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:
@@ -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}");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user