feat(agent): harden loop recovery and permission hook lifecycle
Some checks failed
Release Gate / gate (push) Has been cancelled

This commit is contained in:
2026-04-03 19:24:08 +09:00
parent 0c3921feb5
commit 5de5c74040
8 changed files with 523 additions and 115 deletions

View File

@@ -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}");
}