feat(agent): harden loop recovery and permission hook lifecycle
Some checks failed
Release Gate / gate (push) Has been cancelled
Some checks failed
Release Gate / gate (push) Has been cancelled
This commit is contained in:
@@ -1864,6 +1864,42 @@ public class AgentLoopCodeQualityTests
|
||||
delay2.Should().BeLessThan(1850);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsContextOverflowError_DetectsMaxOutputTokenPatterns()
|
||||
{
|
||||
var maxOutput = InvokePrivateStatic<bool>(
|
||||
"IsContextOverflowError",
|
||||
"Request failed: maximum output tokens exceeded");
|
||||
var truncated = InvokePrivateStatic<bool>(
|
||||
"IsContextOverflowError",
|
||||
"response was truncated due to token limit");
|
||||
var unrelated = InvokePrivateStatic<bool>(
|
||||
"IsContextOverflowError",
|
||||
"invalid api key");
|
||||
|
||||
maxOutput.Should().BeTrue();
|
||||
truncated.Should().BeTrue();
|
||||
unrelated.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsLikelyWithheldOrOverflowResponse_DetectsWithheldAndLimitHints()
|
||||
{
|
||||
var withheld = InvokePrivateStatic<bool>(
|
||||
"IsLikelyWithheldOrOverflowResponse",
|
||||
"output withheld because prompt too long");
|
||||
var maxOutput = InvokePrivateStatic<bool>(
|
||||
"IsLikelyWithheldOrOverflowResponse",
|
||||
"max_output_tokens reached");
|
||||
var normal = InvokePrivateStatic<bool>(
|
||||
"IsLikelyWithheldOrOverflowResponse",
|
||||
"completed successfully");
|
||||
|
||||
withheld.Should().BeTrue();
|
||||
maxOutput.Should().BeTrue();
|
||||
normal.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetToolExecutionTimeoutMs_UsesDefaultWhenEnvMissing()
|
||||
{
|
||||
|
||||
@@ -207,6 +207,22 @@ public class TaskRunServiceTests
|
||||
service.ActiveTasks.Should().Contain(t => t.Kind == "permission" && t.Status == "waiting");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Suite", "ReplayStability")]
|
||||
public void RestoreRecentFromExecutionEvents_RebuildsSkillCallAsActiveTask()
|
||||
{
|
||||
var service = new TaskRunService();
|
||||
var now = DateTime.Now;
|
||||
|
||||
service.RestoreRecentFromExecutionEvents(
|
||||
[
|
||||
new Models.ChatExecutionEvent { Timestamp = now.AddSeconds(-2), RunId = "run-s1", Type = "SkillCall", ToolName = "review_skill", Summary = "skill running", Iteration = 1 },
|
||||
new Models.ChatExecutionEvent { Timestamp = now.AddSeconds(-1), RunId = "run-s1", Type = "Thinking", Summary = "thinking", Iteration = 2 },
|
||||
]);
|
||||
|
||||
service.ActiveTasks.Should().Contain(t => t.Kind == "skill" && t.Title == "review_skill" && t.Status == "running");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestoreRecentFromExecutionEvents_RemovesActiveTaskWhenTerminalEventAppears()
|
||||
{
|
||||
|
||||
@@ -154,7 +154,7 @@ public partial class AgentLoopService
|
||||
try
|
||||
{
|
||||
var input = call.ToolInput ?? JsonDocument.Parse("{}").RootElement;
|
||||
var result = await ExecuteToolWithTimeoutAsync(tool, call.ToolName, input, context, ct);
|
||||
var result = await ExecuteToolWithTimeoutAsync(tool, call.ToolName, input, context, messages, ct);
|
||||
sw.Stop();
|
||||
return (call, result, sw.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
@@ -178,6 +178,7 @@ public partial class AgentLoopService
|
||||
var runtimeOverrides = ResolveSkillRuntimeOverrides(messages);
|
||||
var runtimeHooks = GetRuntimeHooks(llm.AgentHooks, runtimeOverrides);
|
||||
var runtimeOverrideApplied = false;
|
||||
string? lastUserPromptHookFingerprint = null;
|
||||
var enforceForkExecution =
|
||||
runtimeOverrides?.RequireForkExecution == true &&
|
||||
llm.EnableForkSkillDelegationEnforcement;
|
||||
@@ -203,8 +204,54 @@ public partial class AgentLoopService
|
||||
: ""));
|
||||
}
|
||||
|
||||
async Task RunRuntimeHooksAsync(
|
||||
string hookToolName,
|
||||
string timing,
|
||||
string? inputJson = null,
|
||||
string? output = null,
|
||||
bool success = true)
|
||||
{
|
||||
if (!llm.EnableToolHooks || runtimeHooks.Count == 0)
|
||||
return;
|
||||
try
|
||||
{
|
||||
var selected = GetRuntimeHooksForCall(runtimeHooks, runtimeOverrides, hookToolName, timing);
|
||||
var results = await AgentHookRunner.RunAsync(
|
||||
selected,
|
||||
hookToolName,
|
||||
timing,
|
||||
toolInput: inputJson,
|
||||
toolOutput: output,
|
||||
success: success,
|
||||
workFolder: context.WorkFolder,
|
||||
timeoutMs: llm.ToolHookTimeoutMs,
|
||||
ct: ct);
|
||||
foreach (var pr in results)
|
||||
{
|
||||
EmitEvent(AgentEventType.HookResult, hookToolName, $"[Hook:{pr.HookName}] {pr.Output}", successOverride: pr.Success);
|
||||
ApplyHookAdditionalContext(messages, pr);
|
||||
ApplyHookPermissionUpdates(context, pr, llm.EnableHookPermissionUpdate);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// lifecycle hook failure is non-blocking
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
EmitEvent(AgentEventType.SessionStart, "", "에이전트 세션 시작");
|
||||
await RunRuntimeHooksAsync(
|
||||
"__session_start__",
|
||||
"pre",
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
runId = _currentRunId,
|
||||
tab = ActiveTab,
|
||||
workFolder = context.WorkFolder
|
||||
}));
|
||||
|
||||
// ── 플랜 모드 "always": 첫 번째 호출은 계획만 생성 (도구 없이) ──
|
||||
if (planMode == "always")
|
||||
{
|
||||
@@ -382,6 +429,26 @@ public partial class AgentLoopService
|
||||
await Task.Delay(delaySec * 1000, ct);
|
||||
}
|
||||
|
||||
var latestUserPrompt = messages.LastOrDefault(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase))?.Content;
|
||||
if (!string.IsNullOrWhiteSpace(latestUserPrompt))
|
||||
{
|
||||
var fingerprint = $"{latestUserPrompt.Length}:{latestUserPrompt.GetHashCode()}";
|
||||
if (!string.Equals(fingerprint, lastUserPromptHookFingerprint, StringComparison.Ordinal))
|
||||
{
|
||||
lastUserPromptHookFingerprint = fingerprint;
|
||||
EmitEvent(AgentEventType.UserPromptSubmit, "", "사용자 프롬프트 제출");
|
||||
await RunRuntimeHooksAsync(
|
||||
"__user_prompt_submit__",
|
||||
"pre",
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
prompt = TruncateOutput(latestUserPrompt, 4000),
|
||||
runId = _currentRunId,
|
||||
tab = ActiveTab
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// LLM에 도구 정의와 함께 요청
|
||||
List<LlmService.ContentBlock> blocks;
|
||||
try
|
||||
@@ -453,7 +520,7 @@ public partial class AgentLoopService
|
||||
mood = "professional",
|
||||
cover = new { title = documentPlanTitle ?? userQuery, author = "AX Copilot Agent" }
|
||||
});
|
||||
var htmlResult = await EnforceToolPermissionAsync(htmlTool.Name, argsJson, context)
|
||||
var htmlResult = await EnforceToolPermissionAsync(htmlTool.Name, argsJson, context, messages)
|
||||
?? await htmlTool.ExecuteAsync(argsJson, context, ct);
|
||||
if (htmlResult.Success)
|
||||
{
|
||||
@@ -602,6 +669,9 @@ public partial class AgentLoopService
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryHandleWithheldResponseTransition(textResponse, messages, runState))
|
||||
continue;
|
||||
|
||||
// 계획이 있고 도구가 아직 한 번도 실행되지 않은 경우 → LLM이 도구 대신 텍스트로만 응답한 것
|
||||
// "계획이 승인됐으니 도구를 호출하라"는 메시지를 추가하여 재시도 (최대 2회)
|
||||
if (planSteps.Count > 0 && totalToolCalls == 0 && planExecutionRetry < planExecutionRetryMax)
|
||||
@@ -672,7 +742,7 @@ public partial class AgentLoopService
|
||||
mood = "professional",
|
||||
cover = new { title = documentPlanTitle ?? userQuery, author = "AX Copilot Agent" }
|
||||
});
|
||||
var htmlResult = await EnforceToolPermissionAsync(htmlTool.Name, argsJson, context)
|
||||
var htmlResult = await EnforceToolPermissionAsync(htmlTool.Name, argsJson, context, messages)
|
||||
?? await htmlTool.ExecuteAsync(argsJson, context, ct);
|
||||
if (htmlResult.Success)
|
||||
{
|
||||
@@ -1091,10 +1161,17 @@ public partial class AgentLoopService
|
||||
try
|
||||
{
|
||||
var input = effectiveCall.ToolInput ?? JsonDocument.Parse("{}").RootElement;
|
||||
result = await ExecuteToolWithTimeoutAsync(tool, effectiveCall.ToolName, input, context, ct);
|
||||
result = await ExecuteToolWithTimeoutAsync(tool, effectiveCall.ToolName, input, context, messages, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
EmitEvent(AgentEventType.StopRequested, "", "사용자가 작업 중단을 요청했습니다");
|
||||
await RunRuntimeHooksAsync(
|
||||
"__stop_requested__",
|
||||
"post",
|
||||
JsonSerializer.Serialize(new { runId = _currentRunId, tool = effectiveCall.ToolName }),
|
||||
"cancelled",
|
||||
success: false);
|
||||
EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다.");
|
||||
return "사용자가 작업을 취소했습니다.";
|
||||
}
|
||||
@@ -1313,6 +1390,16 @@ public partial class AgentLoopService
|
||||
messages);
|
||||
}
|
||||
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
EmitEvent(AgentEventType.StopRequested, "", "사용자가 작업 중단을 요청했습니다");
|
||||
await RunRuntimeHooksAsync(
|
||||
"__stop_requested__",
|
||||
"post",
|
||||
JsonSerializer.Serialize(new { runId = _currentRunId, reason = "loop_cancelled" }),
|
||||
"cancelled",
|
||||
success: false);
|
||||
}
|
||||
return "(취소됨)";
|
||||
}
|
||||
finally
|
||||
@@ -3688,7 +3775,7 @@ public partial class AgentLoopService
|
||||
try
|
||||
{
|
||||
var input = tc.ToolInput ?? System.Text.Json.JsonDocument.Parse("{}").RootElement;
|
||||
var verifyResult = await ExecuteToolWithTimeoutAsync(tool, tc.ToolName, input, context, ct);
|
||||
var verifyResult = await ExecuteToolWithTimeoutAsync(tool, tc.ToolName, input, context, messages, ct);
|
||||
var toolMsg = LlmService.CreateToolResultMessage(
|
||||
tc.ToolId, tc.ToolName, TruncateOutput(verifyResult.Output, 4000));
|
||||
messages.Add(toolMsg);
|
||||
@@ -3914,9 +4001,64 @@ public partial class AgentLoopService
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private async Task<ToolResult?> EnforceToolPermissionAsync(string toolName, JsonElement input, AgentContext context)
|
||||
private async Task RunPermissionLifecycleHooksAsync(
|
||||
string hookToolName,
|
||||
string timing,
|
||||
string payload,
|
||||
AgentContext context,
|
||||
List<ChatMessage>? messages,
|
||||
bool success = true)
|
||||
{
|
||||
var llm = _settings.Settings.Llm;
|
||||
if (!llm.EnableToolHooks || llm.AgentHooks.Count == 0)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var hookResults = await AgentHookRunner.RunAsync(
|
||||
llm.AgentHooks,
|
||||
hookToolName,
|
||||
timing,
|
||||
toolInput: payload,
|
||||
success: success,
|
||||
workFolder: context.WorkFolder,
|
||||
timeoutMs: llm.ToolHookTimeoutMs);
|
||||
foreach (var pr in hookResults)
|
||||
{
|
||||
EmitEvent(AgentEventType.HookResult, hookToolName, $"[Hook:{pr.HookName}] {pr.Output}", successOverride: pr.Success);
|
||||
if (messages != null)
|
||||
ApplyHookAdditionalContext(messages, pr);
|
||||
ApplyHookPermissionUpdates(context, pr, llm.EnableHookPermissionUpdate);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// permission lifecycle hook failure must not block tool permission flow
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ToolResult?> EnforceToolPermissionAsync(
|
||||
string toolName,
|
||||
JsonElement input,
|
||||
AgentContext context,
|
||||
List<ChatMessage>? messages = null)
|
||||
{
|
||||
var target = DescribeToolTarget(toolName, input, context);
|
||||
var requestPayload = JsonSerializer.Serialize(new
|
||||
{
|
||||
runId = _currentRunId,
|
||||
tool = toolName,
|
||||
target,
|
||||
permission = context.GetEffectiveToolPermission(toolName)
|
||||
});
|
||||
await RunPermissionLifecycleHooksAsync(
|
||||
"__permission_request__",
|
||||
"pre",
|
||||
requestPayload,
|
||||
context,
|
||||
messages,
|
||||
success: true);
|
||||
|
||||
var effectivePerm = context.GetEffectiveToolPermission(toolName);
|
||||
|
||||
if (string.Equals(effectivePerm, "Ask", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -3927,6 +4069,20 @@ public partial class AgentLoopService
|
||||
{
|
||||
if (string.Equals(effectivePerm, "Ask", StringComparison.OrdinalIgnoreCase))
|
||||
EmitEvent(AgentEventType.PermissionGranted, toolName, $"권한 승인됨 · 대상: {target}");
|
||||
await RunPermissionLifecycleHooksAsync(
|
||||
"__permission_granted__",
|
||||
"post",
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
runId = _currentRunId,
|
||||
tool = toolName,
|
||||
target,
|
||||
permission = effectivePerm,
|
||||
granted = true
|
||||
}),
|
||||
context,
|
||||
messages,
|
||||
success: true);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3934,6 +4090,21 @@ public partial class AgentLoopService
|
||||
? $"도구 권한이 Deny로 설정되어 차단되었습니다: {toolName}"
|
||||
: $"사용자 승인 없이 실행할 수 없는 도구입니다: {toolName}";
|
||||
EmitEvent(AgentEventType.PermissionDenied, toolName, $"{reason}\n대상: {target}");
|
||||
await RunPermissionLifecycleHooksAsync(
|
||||
"__permission_denied__",
|
||||
"post",
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
runId = _currentRunId,
|
||||
tool = toolName,
|
||||
target,
|
||||
permission = effectivePerm,
|
||||
granted = false,
|
||||
reason
|
||||
}),
|
||||
context,
|
||||
messages,
|
||||
success: false);
|
||||
return ToolResult.Fail($"{reason}\n대상: {target}");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.Services;
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -21,10 +21,40 @@ public partial class AgentLoopService
|
||||
"",
|
||||
recovered
|
||||
? $"컨텍스트 초과를 감지해 자동 복구 후 재시도합니다 ({runState.ContextRecoveryAttempts}/2)"
|
||||
: $"컨텍스트 초과를 감지했지만 복구 가능한 이전 맥락이 부족합니다 ({runState.ContextRecoveryAttempts}/2)");
|
||||
: $"컨텍스트 초과를 감지했지만 복구할 충분한 이력이 없어 재시도하지 못했습니다 ({runState.ContextRecoveryAttempts}/2)");
|
||||
return recovered;
|
||||
}
|
||||
|
||||
private bool TryHandleWithheldResponseTransition(
|
||||
string? textResponse,
|
||||
List<ChatMessage> messages,
|
||||
RunState runState)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(textResponse))
|
||||
return false;
|
||||
if (!IsLikelyWithheldOrOverflowResponse(textResponse))
|
||||
return false;
|
||||
if (runState.WithheldRecoveryAttempts >= 2)
|
||||
return false;
|
||||
|
||||
runState.WithheldRecoveryAttempts++;
|
||||
var recovered = ForceContextRecovery(messages);
|
||||
messages.Add(new ChatMessage
|
||||
{
|
||||
Role = "user",
|
||||
Content = "[System:WithheldRecovery] 직전 응답이 길이/토큰 한계로 중단된 것으로 보입니다. " +
|
||||
"컨텍스트를 정리했으니 직전 작업을 이어서 진행하세요. " +
|
||||
"필요하면 먼저 핵심만 요약하고 다음 도구 호출로 진행하세요."
|
||||
});
|
||||
EmitEvent(
|
||||
AgentEventType.Thinking,
|
||||
"",
|
||||
recovered
|
||||
? $"withheld/길이 제한 응답을 감지해 컨텍스트를 복구하고 재시도합니다 ({runState.WithheldRecoveryAttempts}/2)"
|
||||
: $"withheld/길이 제한 응답을 감지해 최소 지시로 재시도합니다 ({runState.WithheldRecoveryAttempts}/2)");
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryApplyCodeCompletionGateTransition(
|
||||
List<ChatMessage> messages,
|
||||
string? textResponse,
|
||||
@@ -48,12 +78,12 @@ public partial class AgentLoopService
|
||||
{
|
||||
Role = "user",
|
||||
Content = requireHighImpactCodeVerification
|
||||
? "[System:CodeQualityGate] 공용/핵심 코드 수정 이후 검증 근거가 부족합니다. 종료하지 말고 file_read, grep/glob, git diff, build/test까지 확보한 뒤에만 마무리하세요."
|
||||
: "[System:CodeQualityGate] 마지막 코드 수정 이후 build/test/file_read/diff 근거가 부족합니다. 종료하지 말고 검증 근거를 확보한 뒤에만 마무리하세요."
|
||||
? "[System:CodeQualityGate] 怨듭슜/?듭떖 肄붾뱶 ?섏젙 ?댄썑 寃利?洹쇨굅媛 遺議깊빀?덈떎. 醫낅즺?섏? 留먭퀬 file_read, grep/glob, git diff, build/test源뚯? ?뺣낫???ㅼ뿉留?留덈Т由ы븯?몄슂."
|
||||
: "[System:CodeQualityGate] 留덉?留?肄붾뱶 ?섏젙 ?댄썑 build/test/file_read/diff 洹쇨굅媛 遺議깊빀?덈떎. 醫낅즺?섏? 留먭퀬 寃利?洹쇨굅瑜??뺣낫???ㅼ뿉留?留덈Т由ы븯?몄슂."
|
||||
});
|
||||
EmitEvent(AgentEventType.Thinking, "", requireHighImpactCodeVerification
|
||||
? "고영향 코드 변경의 검증 근거가 부족해 추가 검증을 진행합니다..."
|
||||
: "코드 결과 검증 근거가 부족해 추가 검증을 진행합니다...");
|
||||
? "怨좎쁺??肄붾뱶 蹂寃쎌쓽 寃利?洹쇨굅媛 遺議깊빐 異붽? 寃利앹쓣 吏꾪뻾?⑸땲??.."
|
||||
: "肄붾뱶 寃곌낵 寃利?洹쇨굅媛 遺議깊빐 異붽? 寃利앹쓣 吏꾪뻾?⑸땲??..");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -67,10 +97,10 @@ public partial class AgentLoopService
|
||||
messages.Add(new ChatMessage
|
||||
{
|
||||
Role = "user",
|
||||
Content = "[System:HighImpactBuildTestGate] 고영향 코드 변경입니다. " +
|
||||
"종료하지 말고 build_run과 test_loop를 모두 실행해 성공 근거를 확보한 뒤에만 마무리하세요."
|
||||
Content = "[System:HighImpactBuildTestGate] 怨좎쁺??肄붾뱶 蹂寃쎌엯?덈떎. " +
|
||||
"醫낅즺?섏? 留먭퀬 build_run怨?test_loop瑜?紐⑤몢 ?ㅽ뻾???깃났 洹쇨굅瑜??뺣낫???ㅼ뿉留?留덈Т由ы븯?몄슂."
|
||||
});
|
||||
EmitEvent(AgentEventType.Thinking, "", "고영향 변경이라 build+test 성공 근거를 모두 확보할 때까지 진행합니다...");
|
||||
EmitEvent(AgentEventType.Thinking, "", "怨좎쁺??蹂寃쎌씠??build+test ?깃났 洹쇨굅瑜?紐⑤몢 ?뺣낫???뚭퉴吏 吏꾪뻾?⑸땲??..");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -85,7 +115,7 @@ public partial class AgentLoopService
|
||||
Role = "user",
|
||||
Content = BuildFinalReportQualityPrompt(taskPolicy, requireHighImpactCodeVerification)
|
||||
});
|
||||
EmitEvent(AgentEventType.Thinking, "", "최종 보고에 변경/검증/리스크 요약이 부족해 한 번 더 정리합니다...");
|
||||
EmitEvent(AgentEventType.Thinking, "", "理쒖쥌 蹂닿퀬??蹂寃?寃利?由ъ뒪???붿빟??遺議깊빐 ??踰????뺣━?⑸땲??..");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -112,11 +142,11 @@ public partial class AgentLoopService
|
||||
messages.Add(new ChatMessage
|
||||
{
|
||||
Role = "user",
|
||||
Content = "[System:CodeDiffGate] 코드 변경 이후 diff 근거가 부족합니다. " +
|
||||
"git_tool 도구로 변경 파일과 핵심 diff를 먼저 확인하고 요약하세요. " +
|
||||
"지금 즉시 git_tool 도구를 호출하세요."
|
||||
Content = "[System:CodeDiffGate] 肄붾뱶 蹂寃??댄썑 diff 洹쇨굅媛 遺議깊빀?덈떎. " +
|
||||
"git_tool ?꾧뎄濡?蹂寃??뚯씪怨??듭떖 diff瑜?癒쇱? ?뺤씤?섍퀬 ?붿빟?섏꽭?? " +
|
||||
"吏湲?利됱떆 git_tool ?꾧뎄瑜??몄텧?섏꽭??"
|
||||
});
|
||||
EmitEvent(AgentEventType.Thinking, "", "코드 diff 근거가 부족해 git diff 검증을 추가합니다...");
|
||||
EmitEvent(AgentEventType.Thinking, "", "肄붾뱶 diff 洹쇨굅媛 遺議깊빐 git diff 寃利앹쓣 異붽??⑸땲??..");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -146,7 +176,7 @@ public partial class AgentLoopService
|
||||
Role = "user",
|
||||
Content = BuildRecentExecutionEvidencePrompt(taskPolicy)
|
||||
});
|
||||
EmitEvent(AgentEventType.Thinking, "", "최근 수정 이후 실행 근거가 부족해 build/test 재검증을 수행합니다...");
|
||||
EmitEvent(AgentEventType.Thinking, "", "理쒓렐 ?섏젙 ?댄썑 ?ㅽ뻾 洹쇨굅媛 遺議깊빐 build/test ?ш?利앹쓣 ?섑뻾?⑸땲??..");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -176,7 +206,7 @@ public partial class AgentLoopService
|
||||
Role = "user",
|
||||
Content = BuildExecutionSuccessGatePrompt(taskPolicy)
|
||||
});
|
||||
EmitEvent(AgentEventType.Thinking, "", "실패한 실행 근거만 있어 build/test를 다시 성공시켜 검증합니다...");
|
||||
EmitEvent(AgentEventType.Thinking, "", "?ㅽ뙣???ㅽ뻾 洹쇨굅留??덉뼱 build/test瑜??ㅼ떆 ?깃났?쒖폒 寃利앺빀?덈떎...");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -213,7 +243,7 @@ public partial class AgentLoopService
|
||||
EmitEvent(
|
||||
AgentEventType.Thinking,
|
||||
"",
|
||||
$"종료 전 실행 증거가 부족해 보강 단계를 진행합니다 ({runState.TerminalEvidenceGateRetry}/{retryMax})");
|
||||
$"醫낅즺 ???ㅽ뻾 利앷굅媛 遺議깊빐 蹂닿컯 ?④퀎瑜?吏꾪뻾?⑸땲??({runState.TerminalEvidenceGateRetry}/{retryMax})");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -275,27 +305,27 @@ public partial class AgentLoopService
|
||||
{
|
||||
if (string.Equals(taskPolicy.TaskType, "docs", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var fileHint = string.IsNullOrWhiteSpace(lastArtifactFilePath) ? "결과 문서 파일" : $"'{lastArtifactFilePath}'";
|
||||
return "[System:TerminalEvidenceGate] 현재 종료 응답에는 실행 결과 증거가 부족합니다. " +
|
||||
$"{fileHint}을 실제로 생성/갱신하고 file_read 또는 document_read로 검증한 근거를 남긴 뒤 종료하세요.";
|
||||
var fileHint = string.IsNullOrWhiteSpace(lastArtifactFilePath) ? "寃곌낵 臾몄꽌 ?뚯씪" : $"'{lastArtifactFilePath}'";
|
||||
return "[System:TerminalEvidenceGate] ?꾩옱 醫낅즺 ?묐떟?먮뒗 ?ㅽ뻾 寃곌낵 利앷굅媛 遺議깊빀?덈떎. " +
|
||||
$"{fileHint}???ㅼ젣濡??앹꽦/媛깆떊?섍퀬 file_read ?먮뒗 document_read濡?寃利앺븳 洹쇨굅瑜??④릿 ??醫낅즺?섏꽭??";
|
||||
}
|
||||
|
||||
return "[System:TerminalEvidenceGate] 현재 종료 응답에는 실행 결과 증거가 부족합니다. " +
|
||||
"최소 1개 이상의 진행 도구(수정/실행/생성)를 성공시키고, 그 결과를 근거로 최종 응답을 다시 작성하세요.";
|
||||
return "[System:TerminalEvidenceGate] ?꾩옱 醫낅즺 ?묐떟?먮뒗 ?ㅽ뻾 寃곌낵 利앷굅媛 遺議깊빀?덈떎. " +
|
||||
"理쒖냼 1媛??댁긽??吏꾪뻾 ?꾧뎄(?섏젙/?ㅽ뻾/?앹꽦)瑜??깃났?쒗궎怨? 洹?寃곌낵瑜?洹쇨굅濡?理쒖쥌 ?묐떟???ㅼ떆 ?묒꽦?섏꽭??";
|
||||
}
|
||||
|
||||
private static string BuildRecentExecutionEvidencePrompt(TaskTypePolicy taskPolicy)
|
||||
{
|
||||
var taskHint = taskPolicy.TaskType switch
|
||||
{
|
||||
"bugfix" => "재현 경로 기준으로 수정 지점이 실제로 해결됐는지 검증하세요.",
|
||||
"feature" => "신규 동작 경로의 정상/오류 케이스를 최소 1개 이상 확인하세요.",
|
||||
"refactor" => "기존 동작 보존 여부를 회귀 관점으로 확인하세요.",
|
||||
_ => "수정 영향 범위를 기준으로 실행 검증을 진행하세요."
|
||||
"bugfix" => "?ы쁽 寃쎈줈 湲곗??쇰줈 ?섏젙 吏?먯씠 ?ㅼ젣濡??닿껐?먮뒗吏 寃利앺븯?몄슂.",
|
||||
"feature" => "?좉퇋 ?숈옉 寃쎈줈???뺤긽/?ㅻ쪟 耳?댁뒪瑜?理쒖냼 1媛??댁긽 ?뺤씤?섏꽭??",
|
||||
"refactor" => "湲곗〈 ?숈옉 蹂댁〈 ?щ?瑜??뚭? 愿?먯쑝濡??뺤씤?섏꽭??",
|
||||
_ => "?섏젙 ?곹뼢 踰붿쐞瑜?湲곗??쇰줈 ?ㅽ뻾 寃利앹쓣 吏꾪뻾?섏꽭??"
|
||||
};
|
||||
|
||||
return "[System:RecentExecutionGate] 마지막 코드 수정 이후 build/test 실행 근거가 없습니다. " +
|
||||
"지금 즉시 build_run 또는 test_loop를 호출해 최신 상태를 검증하고 결과를 남기세요. " +
|
||||
return "[System:RecentExecutionGate] 留덉?留?肄붾뱶 ?섏젙 ?댄썑 build/test ?ㅽ뻾 洹쇨굅媛 ?놁뒿?덈떎. " +
|
||||
"吏湲?利됱떆 build_run ?먮뒗 test_loop瑜??몄텧??理쒖떊 ?곹깭瑜?寃利앺븯怨?寃곌낵瑜??④린?몄슂. " +
|
||||
taskHint;
|
||||
}
|
||||
|
||||
@@ -303,14 +333,14 @@ public partial class AgentLoopService
|
||||
{
|
||||
var taskHint = taskPolicy.TaskType switch
|
||||
{
|
||||
"bugfix" => "실패 원인을 먼저 수정한 뒤 동일 재현 경로 기준으로 테스트를 다시 통과시키세요.",
|
||||
"feature" => "핵심 신규 동작 경로를 포함해 build/test를 통과시키세요.",
|
||||
"refactor" => "회귀 여부를 확인할 수 있는 테스트를 통과시키세요.",
|
||||
_ => "수정 영향 범위를 확인할 수 있는 실행 검증을 통과시키세요."
|
||||
"bugfix" => "?ㅽ뙣 ?먯씤??癒쇱? ?섏젙?????숈씪 ?ы쁽 寃쎈줈 湲곗??쇰줈 ?뚯뒪?몃? ?ㅼ떆 ?듦낵?쒗궎?몄슂.",
|
||||
"feature" => "?듭떖 ?좉퇋 ?숈옉 寃쎈줈瑜??ы븿??build/test瑜??듦낵?쒗궎?몄슂.",
|
||||
"refactor" => "?뚭? ?щ?瑜??뺤씤?????덈뒗 ?뚯뒪?몃? ?듦낵?쒗궎?몄슂.",
|
||||
_ => "?섏젙 ?곹뼢 踰붿쐞瑜??뺤씤?????덈뒗 ?ㅽ뻾 寃利앹쓣 ?듦낵?쒗궎?몄슂."
|
||||
};
|
||||
|
||||
return "[System:ExecutionSuccessGate] 현재 실행 근거가 실패 결과뿐입니다. " +
|
||||
"종료하지 말고 build_run 또는 test_loop를 호출해 성공 결과를 확보하세요. " +
|
||||
return "[System:ExecutionSuccessGate] ?꾩옱 ?ㅽ뻾 洹쇨굅媛 ?ㅽ뙣 寃곌낵肉먯엯?덈떎. " +
|
||||
"醫낅즺?섏? 留먭퀬 build_run ?먮뒗 test_loop瑜??몄텧???깃났 寃곌낵瑜??뺣낫?섏꽭?? " +
|
||||
taskHint;
|
||||
}
|
||||
|
||||
@@ -338,7 +368,7 @@ public partial class AgentLoopService
|
||||
return sawDiff;
|
||||
}
|
||||
|
||||
// 변경 도구 자체가 없으면 diff 게이트를 요구하지 않음
|
||||
// 蹂寃??꾧뎄 ?먯껜媛 ?놁쑝硫?diff 寃뚯씠?몃? ?붽뎄?섏? ?딆쓬
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -356,9 +386,9 @@ public partial class AgentLoopService
|
||||
"changed files",
|
||||
"insertions",
|
||||
"deletions",
|
||||
"파일 변경",
|
||||
"추가",
|
||||
"삭제");
|
||||
"file changed",
|
||||
"異붽?",
|
||||
"??젣");
|
||||
}
|
||||
|
||||
private bool TryApplyDocumentArtifactGateTransition(
|
||||
@@ -376,18 +406,18 @@ public partial class AgentLoopService
|
||||
if (!string.IsNullOrEmpty(textResponse))
|
||||
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
|
||||
|
||||
var targetHint = string.IsNullOrWhiteSpace(suggestedPath) ? "요청한 산출물 문서 파일" : $"'{suggestedPath}'";
|
||||
var targetHint = string.IsNullOrWhiteSpace(suggestedPath) ? "?붿껌???곗텧臾?臾몄꽌 ?뚯씪" : $"'{suggestedPath}'";
|
||||
messages.Add(new ChatMessage
|
||||
{
|
||||
Role = "user",
|
||||
Content = "[System:DocumentArtifactGate] 결과 설명만으로 종료할 수 없습니다. " +
|
||||
$"지금 즉시 {targetHint}을 실제 파일로 생성하세요. " +
|
||||
"html_create/markdown_create/document_assemble/file_write 중 적절한 도구를 반드시 호출하세요."
|
||||
Content = "[System:DocumentArtifactGate] 寃곌낵 ?ㅻ챸留뚯쑝濡?醫낅즺?????놁뒿?덈떎. " +
|
||||
$"吏湲?利됱떆 {targetHint}???ㅼ젣 ?뚯씪濡??앹꽦?섏꽭?? " +
|
||||
"html_create/markdown_create/document_assemble/file_write 以??곸젅???꾧뎄瑜?諛섎뱶???몄텧?섏꽭??"
|
||||
});
|
||||
EmitEvent(
|
||||
AgentEventType.Thinking,
|
||||
"",
|
||||
$"문서 작업 산출물 파일이 없어 도구 실행을 재요청합니다 ({runState.DocumentArtifactGateRetry}/2)");
|
||||
$"臾몄꽌 ?묒뾽 ?곗텧臾??뚯씪???놁뼱 ?꾧뎄 ?ㅽ뻾???ъ슂泥?빀?덈떎 ({runState.DocumentArtifactGateRetry}/2)");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -407,13 +437,13 @@ public partial class AgentLoopService
|
||||
messages.Add(new ChatMessage
|
||||
{
|
||||
Role = "user",
|
||||
Content = "[System:DocumentVerificationGate] 문서 파일은 생성되었지만 검증 근거가 부족합니다. " +
|
||||
"file_read 또는 document_read로 방금 생성된 파일을 다시 읽고, 문제 없음/수정 필요를 명시해 보고하세요."
|
||||
Content = "[System:DocumentVerificationGate] 臾몄꽌 ?뚯씪? ?앹꽦?섏뿀吏留?寃利?洹쇨굅媛 遺議깊빀?덈떎. " +
|
||||
"file_read ?먮뒗 document_read濡?諛⑷툑 ?앹꽦???뚯씪???ㅼ떆 ?쎄퀬, 臾몄젣 ?놁쓬/?섏젙 ?꾩슂瑜?紐낆떆??蹂닿퀬?섏꽭??"
|
||||
});
|
||||
EmitEvent(
|
||||
AgentEventType.Thinking,
|
||||
"",
|
||||
$"문서 검증 근거가 부족해 재검증을 요청합니다 ({runState.DocumentVerificationGateRetry}/1)");
|
||||
$"臾몄꽌 寃利?洹쇨굅媛 遺議깊빐 ?ш?利앹쓣 ?붿껌?⑸땲??({runState.DocumentVerificationGateRetry}/1)");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -497,11 +527,27 @@ public partial class AgentLoopService
|
||||
|| lower.Contains("context window")
|
||||
|| lower.Contains("too many tokens")
|
||||
|| lower.Contains("max tokens")
|
||||
|| lower.Contains("max_output_tokens")
|
||||
|| lower.Contains("maximum output tokens")
|
||||
|| lower.Contains("token limit")
|
||||
|| lower.Contains("input is too long")
|
||||
|| lower.Contains("response was truncated")
|
||||
|| lower.Contains("maximum context");
|
||||
}
|
||||
|
||||
private static bool IsLikelyWithheldOrOverflowResponse(string response)
|
||||
{
|
||||
var lower = response.ToLowerInvariant();
|
||||
return lower.Contains("withheld")
|
||||
|| lower.Contains("prompt too long")
|
||||
|| lower.Contains("context window")
|
||||
|| lower.Contains("max_output_tokens")
|
||||
|| lower.Contains("maximum output tokens")
|
||||
|| lower.Contains("token limit")
|
||||
|| lower.Contains("response was truncated")
|
||||
|| lower.Contains("input is too long");
|
||||
}
|
||||
|
||||
private static bool ForceContextRecovery(List<ChatMessage> messages)
|
||||
{
|
||||
if (messages.Count < 10)
|
||||
@@ -524,9 +570,9 @@ public partial class AgentLoopService
|
||||
{
|
||||
var content = m.Content ?? "";
|
||||
if (content.StartsWith("{\"_tool_use_blocks\""))
|
||||
content = "[도구 호출 요약]";
|
||||
content = "[?꾧뎄 ?몄텧 ?붿빟]";
|
||||
else if (content.StartsWith("{\"type\":\"tool_result\""))
|
||||
content = "[도구 실행 결과 요약]";
|
||||
content = "[?꾧뎄 ?ㅽ뻾 寃곌낵 ?붿빟]";
|
||||
else if (content.Length > 180)
|
||||
content = content[..180] + "...";
|
||||
return $"- [{m.Role}] {content}";
|
||||
@@ -535,7 +581,7 @@ public partial class AgentLoopService
|
||||
|
||||
var summary = summaryLines.Count > 0
|
||||
? string.Join("\n", summaryLines)
|
||||
: "- 이전 대화 맥락이 자동 축약되었습니다.";
|
||||
: "- ?댁쟾 ???留λ씫???먮룞 異뺤빟?섏뿀?듬땲??";
|
||||
|
||||
var tail = nonSystem.Skip(Math.Max(0, nonSystem.Count - keepTailCount)).ToList();
|
||||
|
||||
@@ -546,7 +592,7 @@ public partial class AgentLoopService
|
||||
{
|
||||
Role = "user",
|
||||
Timestamp = DateTime.Now,
|
||||
Content = $"[시스템 자동 맥락 축약]\n아래는 이전 대화의 핵심 요약입니다.\n{summary}"
|
||||
Content = $"[?쒖뒪???먮룞 留λ씫 異뺤빟]\n?꾨옒???댁쟾 ??붿쓽 ?듭떖 ?붿빟?낅땲??\n{summary}"
|
||||
});
|
||||
messages.AddRange(tail);
|
||||
return true;
|
||||
@@ -590,7 +636,7 @@ public partial class AgentLoopService
|
||||
EmitEvent(
|
||||
AgentEventType.Thinking,
|
||||
call.ToolName,
|
||||
$"동일 도구/파라미터 반복 실패 패턴 감지 - 다른 접근으로 전환합니다 ({repeatedFailedToolSignatureCount}/{maxRetry})");
|
||||
$"?숈씪 ?꾧뎄/?뚮씪誘명꽣 諛섎났 ?ㅽ뙣 ?⑦꽩 媛먯? - ?ㅻⅨ ?묎렐?쇰줈 ?꾪솚?⑸땲??({repeatedFailedToolSignatureCount}/{maxRetry})");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -611,7 +657,7 @@ public partial class AgentLoopService
|
||||
messages.Add(LlmService.CreateToolResultMessage(
|
||||
call.ToolId,
|
||||
call.ToolName,
|
||||
$"[NO_PROGRESS_LOOP_GUARD] 동일한 읽기 도구 호출이 {repeatedSameSignatureCount}회 반복되었습니다: {toolCallSignature}\n" +
|
||||
$"[NO_PROGRESS_LOOP_GUARD] ?숈씪???쎄린 ?꾧뎄 ?몄텧??{repeatedSameSignatureCount}??諛섎났?섏뿀?듬땲?? {toolCallSignature}\n" +
|
||||
"Stop repeating the same read-only call and switch to a concrete next action."));
|
||||
messages.Add(new ChatMessage
|
||||
{
|
||||
@@ -625,7 +671,7 @@ public partial class AgentLoopService
|
||||
EmitEvent(
|
||||
AgentEventType.Thinking,
|
||||
call.ToolName,
|
||||
$"무의미한 읽기 도구 반복 루프를 감지해 다른 전략으로 전환합니다 ({repeatedSameSignatureCount}회)");
|
||||
$"臾댁쓽誘명븳 ?쎄린 ?꾧뎄 諛섎났 猷⑦봽瑜?媛먯????ㅻⅨ ?꾨왂?쇰줈 ?꾪솚?⑸땲??({repeatedSameSignatureCount}??");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -652,7 +698,7 @@ public partial class AgentLoopService
|
||||
EmitEvent(
|
||||
AgentEventType.Thinking,
|
||||
"",
|
||||
$"읽기 전용 도구만 연속 {consecutiveReadOnlySuccessTools}회 실행되어 정체를 감지했습니다. 실행 단계로 전환합니다. (threshold={threshold})");
|
||||
$"?쎄린 ?꾩슜 ?꾧뎄留??곗냽 {consecutiveReadOnlySuccessTools}???ㅽ뻾?섏뼱 ?뺤껜瑜?媛먯??덉뒿?덈떎. ?ㅽ뻾 ?④퀎濡??꾪솚?⑸땲?? (threshold={threshold})");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -686,7 +732,7 @@ public partial class AgentLoopService
|
||||
EmitEvent(
|
||||
AgentEventType.Thinking,
|
||||
"",
|
||||
$"실행 진전이 없어 강제 복구 단계를 시작합니다 ({runState.NoProgressRecoveryRetry}/2)");
|
||||
$"?ㅽ뻾 吏꾩쟾???놁뼱 媛뺤젣 蹂듦뎄 ?④퀎瑜??쒖옉?⑸땲??({runState.NoProgressRecoveryRetry}/2)");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -712,9 +758,9 @@ public partial class AgentLoopService
|
||||
{
|
||||
if (string.Equals(taskPolicy.TaskType, "docs", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var fileHint = string.IsNullOrWhiteSpace(documentPlanPath) ? "결과 문서 파일" : $"'{documentPlanPath}'";
|
||||
return "[System:NoProgressExecutionRecovery] 읽기/분석만 반복되고 있습니다. " +
|
||||
$"이제 설명을 멈추고 {fileHint}을 실제로 생성하는 도구(html_create/markdown_create/document_assemble/file_write)를 즉시 호출하세요.";
|
||||
var fileHint = string.IsNullOrWhiteSpace(documentPlanPath) ? "寃곌낵 臾몄꽌 ?뚯씪" : $"'{documentPlanPath}'";
|
||||
return "[System:NoProgressExecutionRecovery] ?쎄린/遺꾩꽍留?諛섎났?섍퀬 ?덉뒿?덈떎. " +
|
||||
$"?댁젣 ?ㅻ챸??硫덉텛怨?{fileHint}???ㅼ젣濡??앹꽦?섎뒗 ?꾧뎄(html_create/markdown_create/document_assemble/file_write)瑜?利됱떆 ?몄텧?섏꽭??";
|
||||
}
|
||||
|
||||
return BuildFailureNextToolPriorityPrompt(
|
||||
@@ -745,8 +791,8 @@ public partial class AgentLoopService
|
||||
return false;
|
||||
|
||||
var lower = content.ToLowerInvariant();
|
||||
if (ContainsAny(lower, "success", "succeeded", "passed", "통과", "성공", "빌드했습니다", "tests passed", "build succeeded")
|
||||
&& !ContainsAny(lower, "fail", "failed", "error", "오류", "실패", "exception", "denied", "not found"))
|
||||
if (ContainsAny(lower, "success", "succeeded", "passed", "?듦낵", "?깃났", "鍮뚮뱶?덉뒿?덈떎", "tests passed", "build succeeded")
|
||||
&& !ContainsAny(lower, "fail", "failed", "error", "?ㅻ쪟", "?ㅽ뙣", "exception", "denied", "not found"))
|
||||
return false;
|
||||
|
||||
return ContainsAny(
|
||||
@@ -761,13 +807,13 @@ public partial class AgentLoopService
|
||||
"forbidden",
|
||||
"not found",
|
||||
"invalid",
|
||||
"실패",
|
||||
"오류",
|
||||
"예외",
|
||||
"시간 초과",
|
||||
"권한",
|
||||
"차단",
|
||||
"찾을 수 없");
|
||||
"?ㅽ뙣",
|
||||
"?ㅻ쪟",
|
||||
"?덉쇅",
|
||||
"?쒓컙 珥덇낵",
|
||||
"沅뚰븳",
|
||||
"李⑤떒",
|
||||
"찾을 수");
|
||||
}
|
||||
|
||||
private static int UpdateConsecutiveReadOnlySuccessTools(
|
||||
@@ -915,9 +961,9 @@ public partial class AgentLoopService
|
||||
{
|
||||
if (string.Equals(taskPolicy.TaskType, "docs", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "[System:NoProgressRecovery] 동일한 읽기 호출이 반복되었습니다. " +
|
||||
"이제 문서 생성/수정 도구(html_create/markdown_create/document_assemble/file_write) 중 하나를 실제로 호출해 진행하세요. " +
|
||||
"설명만 하지 말고 즉시 도구를 호출하세요.";
|
||||
return "[System:NoProgressRecovery] ?숈씪???쎄린 ?몄텧??諛섎났?섏뿀?듬땲?? " +
|
||||
"?댁젣 臾몄꽌 ?앹꽦/?섏젙 ?꾧뎄(html_create/markdown_create/document_assemble/file_write) 以??섎굹瑜??ㅼ젣濡??몄텧??吏꾪뻾?섏꽭?? " +
|
||||
"?ㅻ챸留??섏? 留먭퀬 利됱떆 ?꾧뎄瑜??몄텧?섏꽭??";
|
||||
}
|
||||
|
||||
return BuildFailureNextToolPriorityPrompt(
|
||||
@@ -940,7 +986,7 @@ public partial class AgentLoopService
|
||||
EmitEvent(
|
||||
AgentEventType.Thinking,
|
||||
"",
|
||||
$"일시적 LLM 오류로 재시도합니다 ({runState.TransientLlmErrorRetries}/3, {delayMs}ms 대기)");
|
||||
$"?쇱떆??LLM ?ㅻ쪟濡??ъ떆?꾪빀?덈떎 ({runState.TransientLlmErrorRetries}/3, {delayMs}ms ?湲?");
|
||||
await Task.Delay(delayMs, ct);
|
||||
return true;
|
||||
}
|
||||
@@ -1023,6 +1069,7 @@ public partial class AgentLoopService
|
||||
string toolName,
|
||||
JsonElement input,
|
||||
AgentContext context,
|
||||
List<ChatMessage>? messages,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var timeoutMs = GetToolExecutionTimeoutMs();
|
||||
@@ -1031,12 +1078,12 @@ public partial class AgentLoopService
|
||||
|
||||
try
|
||||
{
|
||||
return await EnforceToolPermissionAsync(toolName, input, context)
|
||||
return await EnforceToolPermissionAsync(toolName, input, context, messages)
|
||||
?? await tool.ExecuteAsync(input, context, timeoutCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
return ToolResult.Fail($"도구 실행 타임아웃 ({timeoutMs}ms): {toolName}");
|
||||
return ToolResult.Fail($"?꾧뎄 ?ㅽ뻾 ??꾩븘??({timeoutMs}ms): {toolName}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1058,7 +1105,7 @@ public partial class AgentLoopService
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (IsContextOverflowError(ex.Message)
|
||||
&& contextRecoveryRetries < 1
|
||||
&& contextRecoveryRetries < 2
|
||||
&& ForceContextRecovery(messages))
|
||||
{
|
||||
contextRecoveryRetries++;
|
||||
@@ -1067,7 +1114,7 @@ public partial class AgentLoopService
|
||||
EmitEvent(
|
||||
AgentEventType.Thinking,
|
||||
"",
|
||||
$"{phaseLabel}: 컨텍스트 초과를 감지해 복구 후 재시도합니다 ({contextRecoveryRetries}/1)");
|
||||
$"{phaseLabel}: 而⑦뀓?ㅽ듃 珥덇낵瑜?媛먯???蹂듦뎄 ???ъ떆?꾪빀?덈떎 ({contextRecoveryRetries}/2)");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1080,7 +1127,7 @@ public partial class AgentLoopService
|
||||
EmitEvent(
|
||||
AgentEventType.Thinking,
|
||||
"",
|
||||
$"{phaseLabel}: 일시적 LLM 오류로 재시도합니다 ({transientRetries}/3, {delayMs}ms 대기)");
|
||||
$"{phaseLabel}: ?쇱떆??LLM ?ㅻ쪟濡??ъ떆?꾪빀?덈떎 ({transientRetries}/3, {delayMs}ms ?湲?");
|
||||
await Task.Delay(delayMs, ct);
|
||||
continue;
|
||||
}
|
||||
@@ -1200,7 +1247,7 @@ public partial class AgentLoopService
|
||||
result.Output)
|
||||
});
|
||||
}
|
||||
EmitEvent(AgentEventType.Thinking, "", $"Self-Reflection: 실패 분석 후 재시도 ({consecutiveErrors}/{maxRetry})");
|
||||
EmitEvent(AgentEventType.Thinking, "", $"Self-Reflection: ?ㅽ뙣 遺꾩꽍 ???ъ떆??({consecutiveErrors}/{maxRetry})");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1223,7 +1270,7 @@ public partial class AgentLoopService
|
||||
result.Output)
|
||||
});
|
||||
}
|
||||
EmitEvent(AgentEventType.Thinking, "", "비재시도 실패로 분류되어 동일 호출 반복을 중단하고 우회 전략으로 전환합니다.");
|
||||
EmitEvent(AgentEventType.Thinking, "", "鍮꾩옱?쒕룄 ?ㅽ뙣濡?遺꾨쪟?섏뼱 ?숈씪 ?몄텧 諛섎났??以묐떒?섍퀬 ?고쉶 ?꾨왂?쇰줈 ?꾪솚?⑸땲??");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1239,7 +1286,7 @@ public partial class AgentLoopService
|
||||
if (memSvc != null && (app?.SettingsService?.Settings.Llm.EnableAgentMemory ?? false))
|
||||
{
|
||||
memSvc.Add("correction",
|
||||
$"도구 '{call.ToolName}' 반복 실패: {TruncateOutput(result.Output, 200)}",
|
||||
$"?꾧뎄 '{call.ToolName}' 諛섎났 ?ㅽ뙣: {TruncateOutput(result.Output, 200)}",
|
||||
$"conv:{_conversationId}", context.WorkFolder);
|
||||
}
|
||||
}
|
||||
@@ -1285,12 +1332,12 @@ public partial class AgentLoopService
|
||||
if (pm.Success) documentPlanPath = pm.Groups[1].Value;
|
||||
var tm = System.Text.RegularExpressions.Regex.Match(po, @"title:\s*""([^""]+)""");
|
||||
if (tm.Success) documentPlanTitle = tm.Groups[1].Value;
|
||||
var bs = po.IndexOf("--- body 시작 ---", StringComparison.Ordinal);
|
||||
var be = po.IndexOf("--- body 끝 ---", StringComparison.Ordinal);
|
||||
var bs = po.IndexOf("--- body ?쒖옉 ---", StringComparison.Ordinal);
|
||||
var be = po.IndexOf("--- body ??---", StringComparison.Ordinal);
|
||||
if (bs >= 0 && be > bs)
|
||||
documentPlanScaffold = po[(bs + "--- body 시작 ---".Length)..be].Trim();
|
||||
documentPlanScaffold = po[(bs + "--- body ?쒖옉 ---".Length)..be].Trim();
|
||||
|
||||
if (!result.Output.Contains("즉시 실행:", StringComparison.Ordinal))
|
||||
if (!result.Output.Contains("利됱떆 ?ㅽ뻾:", StringComparison.Ordinal))
|
||||
return;
|
||||
|
||||
var toolHint = result.Output.Contains("html_create", StringComparison.OrdinalIgnoreCase) ? "html_create" :
|
||||
@@ -1299,12 +1346,12 @@ public partial class AgentLoopService
|
||||
messages.Add(new ChatMessage
|
||||
{
|
||||
Role = "user",
|
||||
Content = $"document_plan이 완료되었습니다. " +
|
||||
$"위 결과의 body/sections의 [내용...] 부분을 실제 상세 내용으로 모두 채워서 " +
|
||||
$"{toolHint} 도구를 지금 즉시 호출하세요. " +
|
||||
$"각 섹션마다 반드시 충분한 내용을 작성하고, 설명 없이 도구를 바로 호출하세요."
|
||||
Content = $"document_plan???꾨즺?섏뿀?듬땲?? " +
|
||||
$"??寃곌낵??body/sections??[?댁슜...] 遺遺꾩쓣 ?ㅼ젣 ?곸꽭 ?댁슜?쇰줈 紐⑤몢 梨꾩썙??" +
|
||||
$"{toolHint} ?꾧뎄瑜?吏湲?利됱떆 ?몄텧?섏꽭?? " +
|
||||
$"媛??뱀뀡留덈떎 諛섎뱶??異⑸텇???댁슜???묒꽦?섍퀬, ?ㅻ챸 ?놁씠 ?꾧뎄瑜?諛붾줈 ?몄텧?섏꽭??"
|
||||
});
|
||||
EmitEvent(AgentEventType.Thinking, "", $"문서 개요 완성 — {toolHint} 호출 중...");
|
||||
EmitEvent(AgentEventType.Thinking, "", $"臾몄꽌 媛쒖슂 ?꾩꽦 ??{toolHint} ?몄텧 以?..");
|
||||
}
|
||||
|
||||
private void ApplyCodeQualityFollowUpTransition(
|
||||
@@ -1331,8 +1378,8 @@ public partial class AgentLoopService
|
||||
taskPolicy)
|
||||
});
|
||||
EmitEvent(AgentEventType.Thinking, "", highImpactCodeChange
|
||||
? "고영향 코드 변경이라 참조 검색과 build/test 검증을 더 엄격하게 이어갑니다..."
|
||||
: "코드 변경 후 build/test/diff 검증을 이어갑니다...");
|
||||
? "怨좎쁺??肄붾뱶 蹂寃쎌씠??李몄“ 寃?됯낵 build/test 寃利앹쓣 ???꾧꺽?섍쾶 ?댁뼱媛묐땲??.."
|
||||
: "肄붾뱶 蹂寃???build/test/diff 寃利앹쓣 ?댁뼱媛묐땲??..");
|
||||
}
|
||||
else if (HasCodeVerificationEvidenceAfterLastModification(messages, requireHighImpactCodeVerification))
|
||||
{
|
||||
@@ -1365,7 +1412,7 @@ public partial class AgentLoopService
|
||||
consumedExtraIteration = true;
|
||||
}
|
||||
|
||||
EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료");
|
||||
EmitEvent(AgentEventType.Complete, "", "?먯씠?꾪듃 ?묒뾽 ?꾨즺");
|
||||
return (true, consumedExtraIteration);
|
||||
}
|
||||
|
||||
@@ -1401,19 +1448,19 @@ public partial class AgentLoopService
|
||||
if (context.DevModeStepApproval && UserDecisionCallback != null)
|
||||
{
|
||||
var decision = await UserDecisionCallback(
|
||||
$"[DEV] 도구 '{call.ToolName}' 실행을 승인하시겠습니까?\n{FormatToolCallSummary(call)}",
|
||||
new List<string> { "승인", "건너뛰기", "중단" });
|
||||
$"[DEV] ?꾧뎄 '{call.ToolName}' ?ㅽ뻾???뱀씤?섏떆寃좎뒿?덇퉴?\n{FormatToolCallSummary(call)}",
|
||||
new List<string> { "?뱀씤", "嫄대꼫?곌린", "以묐떒" });
|
||||
var (devShouldContinue, devTerminalResponse, devToolResultMessage) =
|
||||
EvaluateDevStepDecision(decision);
|
||||
if (!string.IsNullOrEmpty(devTerminalResponse))
|
||||
{
|
||||
EmitEvent(AgentEventType.Complete, "", "[DEV] 사용자가 실행을 중단했습니다");
|
||||
EmitEvent(AgentEventType.Complete, "", "[DEV] ?ъ슜?먭? ?ㅽ뻾??以묐떒?덉뒿?덈떎");
|
||||
return (false, devTerminalResponse);
|
||||
}
|
||||
if (devShouldContinue)
|
||||
{
|
||||
messages.Add(LlmService.CreateToolResultMessage(
|
||||
call.ToolId, call.ToolName, devToolResultMessage ?? "[SKIPPED by developer] 사용자가 이 도구 실행을 건너뛰었습니다."));
|
||||
call.ToolId, call.ToolName, devToolResultMessage ?? "[SKIPPED by developer] ?ъ슜?먭? ???꾧뎄 ?ㅽ뻾??嫄대꼫?곗뿀?듬땲??"));
|
||||
return (true, null);
|
||||
}
|
||||
}
|
||||
@@ -1423,18 +1470,18 @@ public partial class AgentLoopService
|
||||
{
|
||||
var decision = await UserDecisionCallback(
|
||||
decisionRequired,
|
||||
new List<string> { "승인", "건너뛰기", "취소" });
|
||||
new List<string> { "?뱀씤", "嫄대꼫?곌린", "痍⑥냼" });
|
||||
var (scopeShouldContinue, scopeTerminalResponse, scopeToolResultMessage) =
|
||||
EvaluateScopeDecision(decision);
|
||||
if (!string.IsNullOrEmpty(scopeTerminalResponse))
|
||||
{
|
||||
EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다");
|
||||
EmitEvent(AgentEventType.Complete, "", "?ъ슜?먭? ?묒뾽??痍⑥냼?덉뒿?덈떎");
|
||||
return (false, scopeTerminalResponse);
|
||||
}
|
||||
if (scopeShouldContinue)
|
||||
{
|
||||
messages.Add(LlmService.CreateToolResultMessage(
|
||||
call.ToolId, call.ToolName, scopeToolResultMessage ?? "[SKIPPED] 사용자가 이 작업을 건너뛰었습니다."));
|
||||
call.ToolId, call.ToolName, scopeToolResultMessage ?? "[SKIPPED] ?ъ슜?먭? ???묒뾽??嫄대꼫?곗뿀?듬땲??"));
|
||||
return (true, null);
|
||||
}
|
||||
}
|
||||
@@ -1445,6 +1492,7 @@ public partial class AgentLoopService
|
||||
private sealed class RunState
|
||||
{
|
||||
public int ContextRecoveryAttempts;
|
||||
public int WithheldRecoveryAttempts;
|
||||
public int NoToolCallLoopRetry;
|
||||
public int CodeDiffGateRetry;
|
||||
public int RecentExecutionGateRetry;
|
||||
@@ -1459,3 +1507,6 @@ public partial class AgentLoopService
|
||||
public int TerminalEvidenceGateRetry;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -281,6 +281,9 @@ public enum AgentEventType
|
||||
Error, // 오류
|
||||
Complete, // 완료
|
||||
Decision, // 사용자 의사결정 대기
|
||||
SessionStart, // 세션 시작
|
||||
UserPromptSubmit, // 사용자 프롬프트 제출
|
||||
StopRequested, // 중단 요청
|
||||
Paused, // 에이전트 일시정지
|
||||
Resumed, // 에이전트 재개
|
||||
}
|
||||
|
||||
@@ -580,12 +580,10 @@ public sealed class ChatSessionStateService
|
||||
conversation.AgentRunHistory ??= new List<ChatAgentRunRecord>();
|
||||
conversation.DraftQueueItems ??= new List<DraftQueueItem>();
|
||||
|
||||
var orderedEvents = conversation.ExecutionEvents
|
||||
.OrderBy(evt => evt.Timestamp == default ? DateTime.MinValue : evt.Timestamp)
|
||||
.ToList();
|
||||
if (!conversation.ExecutionEvents.SequenceEqual(orderedEvents))
|
||||
var normalizedEvents = NormalizeExecutionEventsForResume(conversation.ExecutionEvents);
|
||||
if (!conversation.ExecutionEvents.SequenceEqual(normalizedEvents))
|
||||
{
|
||||
conversation.ExecutionEvents = orderedEvents;
|
||||
conversation.ExecutionEvents = normalizedEvents;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
@@ -640,6 +638,55 @@ public sealed class ChatSessionStateService
|
||||
return delta <= TimeSpan.FromSeconds(2);
|
||||
}
|
||||
|
||||
private static List<ChatExecutionEvent> NormalizeExecutionEventsForResume(IEnumerable<ChatExecutionEvent>? source)
|
||||
{
|
||||
if (source == null)
|
||||
return new List<ChatExecutionEvent>();
|
||||
|
||||
var ordered = source
|
||||
.Where(evt => evt != null)
|
||||
.OrderBy(evt => evt.Timestamp == default ? DateTime.MinValue : evt.Timestamp)
|
||||
.ThenBy(evt => evt.Iteration)
|
||||
.ThenBy(evt => evt.ElapsedMs)
|
||||
.ToList();
|
||||
|
||||
var result = new List<ChatExecutionEvent>(ordered.Count);
|
||||
foreach (var evt in ordered)
|
||||
{
|
||||
if (result.Count == 0)
|
||||
{
|
||||
result.Add(evt);
|
||||
continue;
|
||||
}
|
||||
|
||||
var last = result[^1];
|
||||
if (!IsNearDuplicateExecutionEvent(last, evt))
|
||||
{
|
||||
result.Add(evt);
|
||||
continue;
|
||||
}
|
||||
|
||||
last.Timestamp = evt.Timestamp;
|
||||
last.ElapsedMs = Math.Max(last.ElapsedMs, evt.ElapsedMs);
|
||||
last.InputTokens = Math.Max(last.InputTokens, evt.InputTokens);
|
||||
last.OutputTokens = Math.Max(last.OutputTokens, evt.OutputTokens);
|
||||
last.StepCurrent = Math.Max(last.StepCurrent, evt.StepCurrent);
|
||||
last.StepTotal = Math.Max(last.StepTotal, evt.StepTotal);
|
||||
last.Iteration = Math.Max(last.Iteration, evt.Iteration);
|
||||
if (string.IsNullOrWhiteSpace(last.FilePath))
|
||||
last.FilePath = evt.FilePath;
|
||||
if (!string.IsNullOrWhiteSpace(evt.Summary) && evt.Summary.Length >= (last.Summary?.Length ?? 0))
|
||||
last.Summary = evt.Summary;
|
||||
if (evt.Steps is { Count: > 0 })
|
||||
last.Steps = evt.Steps.ToList();
|
||||
if (!string.IsNullOrWhiteSpace(evt.ToolInput))
|
||||
last.ToolInput = evt.ToolInput;
|
||||
last.Success = last.Success && evt.Success;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void TouchConversation(ChatStorageService? storage, string tab)
|
||||
{
|
||||
var conv = EnsureCurrentConversation(tab);
|
||||
|
||||
@@ -168,13 +168,11 @@ public sealed class TaskRunService
|
||||
var restored = new List<TaskRunStore.TaskRun>();
|
||||
var active = new Dictionary<string, TaskRunStore.TaskRun>(StringComparer.OrdinalIgnoreCase);
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var normalizedHistory = NormalizeReplayHistory(history);
|
||||
|
||||
if (history != null)
|
||||
if (normalizedHistory.Count > 0)
|
||||
{
|
||||
foreach (var item in history
|
||||
.OrderBy(x => x.Timestamp)
|
||||
.ThenBy(x => x.Iteration)
|
||||
.ThenBy(x => x.ElapsedMs))
|
||||
foreach (var item in normalizedHistory)
|
||||
{
|
||||
var scopedId = TryGetScopedId(item);
|
||||
if (string.IsNullOrWhiteSpace(scopedId))
|
||||
@@ -193,7 +191,7 @@ public sealed class TaskRunService
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var item in history
|
||||
foreach (var item in normalizedHistory
|
||||
.OrderByDescending(x => x.Timestamp)
|
||||
.ThenByDescending(GetReplayPriority)
|
||||
.ThenByDescending(x => x.Iteration)
|
||||
@@ -511,6 +509,22 @@ public sealed class TaskRunService
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.Equals(item.Type, nameof(Agent.AgentEventType.SkillCall), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
task = new TaskRunStore.TaskRun
|
||||
{
|
||||
Id = BuildScopedId("tool", item),
|
||||
Kind = "skill",
|
||||
Title = string.IsNullOrWhiteSpace(item.ToolName) ? "skill" : item.ToolName,
|
||||
Summary = item.Summary,
|
||||
Status = "running",
|
||||
StartedAt = item.Timestamp,
|
||||
UpdatedAt = item.Timestamp,
|
||||
FilePath = item.FilePath,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.Equals(item.Type, nameof(Agent.AgentEventType.Thinking), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(item.Type, nameof(Agent.AgentEventType.Planning), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -588,6 +602,7 @@ public sealed class TaskRunService
|
||||
return BuildScopedId("permission", item);
|
||||
|
||||
if (string.Equals(item.Type, nameof(Agent.AgentEventType.ToolCall), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(item.Type, nameof(Agent.AgentEventType.SkillCall), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(item.Type, nameof(Agent.AgentEventType.ToolResult), StringComparison.OrdinalIgnoreCase))
|
||||
return BuildScopedId("tool", item);
|
||||
|
||||
@@ -613,4 +628,73 @@ public sealed class TaskRunService
|
||||
? $"{prefix}:{suffix}"
|
||||
: $"{prefix}:{evt.RunId}:{suffix}";
|
||||
}
|
||||
|
||||
private static IReadOnlyList<Models.ChatExecutionEvent> NormalizeReplayHistory(
|
||||
IEnumerable<Models.ChatExecutionEvent>? history)
|
||||
{
|
||||
if (history == null)
|
||||
return Array.Empty<Models.ChatExecutionEvent>();
|
||||
|
||||
var ordered = history
|
||||
.Where(x => x != null)
|
||||
.OrderBy(x => x.Timestamp == default ? DateTime.MinValue : x.Timestamp)
|
||||
.ThenBy(x => x.Iteration)
|
||||
.ThenBy(x => x.ElapsedMs)
|
||||
.ToList();
|
||||
|
||||
var result = new List<Models.ChatExecutionEvent>(ordered.Count);
|
||||
foreach (var item in ordered)
|
||||
{
|
||||
if (result.Count == 0)
|
||||
{
|
||||
result.Add(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
var last = result[^1];
|
||||
if (!IsNearDuplicateReplayEvent(last, item))
|
||||
{
|
||||
result.Add(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
last.Timestamp = item.Timestamp;
|
||||
last.ElapsedMs = Math.Max(last.ElapsedMs, item.ElapsedMs);
|
||||
last.InputTokens = Math.Max(last.InputTokens, item.InputTokens);
|
||||
last.OutputTokens = Math.Max(last.OutputTokens, item.OutputTokens);
|
||||
last.StepCurrent = Math.Max(last.StepCurrent, item.StepCurrent);
|
||||
last.StepTotal = Math.Max(last.StepTotal, item.StepTotal);
|
||||
last.Iteration = Math.Max(last.Iteration, item.Iteration);
|
||||
if (string.IsNullOrWhiteSpace(last.FilePath))
|
||||
last.FilePath = item.FilePath;
|
||||
if (!string.IsNullOrWhiteSpace(item.Summary) && item.Summary.Length >= (last.Summary?.Length ?? 0))
|
||||
last.Summary = item.Summary;
|
||||
if (item.Steps is { Count: > 0 })
|
||||
last.Steps = item.Steps.ToList();
|
||||
if (!string.IsNullOrWhiteSpace(item.ToolInput))
|
||||
last.ToolInput = item.ToolInput;
|
||||
last.Success = last.Success && item.Success;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool IsNearDuplicateReplayEvent(Models.ChatExecutionEvent left, Models.ChatExecutionEvent right)
|
||||
{
|
||||
if (!string.Equals(left.RunId, right.RunId, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
if (!string.Equals(left.Type, right.Type, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
if (!string.Equals(left.ToolName, right.ToolName, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
if (!string.Equals(left.Summary?.Trim(), right.Summary?.Trim(), StringComparison.Ordinal))
|
||||
return false;
|
||||
if (!string.Equals(left.FilePath ?? "", right.FilePath ?? "", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
if (left.Success != right.Success)
|
||||
return false;
|
||||
|
||||
var delta = (right.Timestamp - left.Timestamp).Duration();
|
||||
return delta <= TimeSpan.FromSeconds(2);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user