AX Agent 도구·스킬 정합성 재구성 및 실행 품질 보강
변경 목적: - AX Agent의 도구 이름, 내부 설정, 스킬 정책, 실행 루프 사이의 불일치를 줄이고 전체 동작 품질을 높인다. - claw-code 수준의 일관된 동작 품질을 참고하되 AX 구조에 맞는 고유한 카탈로그·정규화 레이어로 재구성한다. 핵심 수정사항: - 도구 canonical id, legacy alias, 탭 노출, 설정 카테고리, read-only 분류를 중앙 카탈로그로 통합했다. - ToolRegistry, AgentLoopService, 병렬 실행 분류, 권한 처리, 훅 처리, 스킬 allowed-tools 해석이 같은 이름 체계를 사용하도록 정리했다. - Agent 설정/일반 설정/도움말의 도구 카드와 훅 편집기, 스킬 설명을 현재 런타임 구조에 맞게 갱신했다. - 컨텍스트 압축, intent gate, spawn agents, session learning, model prompt adapter, workspace context 관련 변경과 테스트 추가를 함께 반영했다. - 문서 이력과 비교/로드맵 문서를 최신 상태로 갱신했다. 검증 결과: - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify_toolcat\ -p:IntermediateOutputPath=obj\verify_toolcat\ : 경고 0 / 오류 0 - dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter AgentToolCatalogTests -p:OutputPath=bin\verify_toolcat_tests\ -p:IntermediateOutputPath=obj\verify_toolcat_tests\ : 통과 8
This commit is contained in:
@@ -188,12 +188,20 @@ public partial class AgentLoopService
|
||||
|
||||
// 사용자 원본 요청 캡처 (문서 생성 폴백 판단용)
|
||||
var userQuery = messages.LastOrDefault(m => m.Role == "user")?.Content ?? "";
|
||||
// IntentGate: 통합 의도 분류
|
||||
var intentGate = new IntentGateService(_llm);
|
||||
var intentResult = await intentGate.ClassifyAsync(userQuery, ActiveTab, ct).ConfigureAwait(false);
|
||||
|
||||
var explorationState = new ExplorationTrackingState
|
||||
{
|
||||
Scope = ClassifyExplorationScope(userQuery, ActiveTab),
|
||||
Scope = intentResult.SuggestedScope,
|
||||
SelectiveHit = true,
|
||||
};
|
||||
var pathAccessState = new PathAccessTrackingState();
|
||||
// P3: 누적 학습 — 도구 결과에서 자동 학습 포인트 수집
|
||||
var sessionLearnings = (_settings.Settings.Llm.EnableSessionLearnings)
|
||||
? new SessionLearningCollector(_settings.Settings.Llm.MaxSessionLearnings)
|
||||
: null;
|
||||
DateTime? lastToolResultAtUtc = null;
|
||||
string? lastToolResultToolName = null;
|
||||
|
||||
@@ -236,9 +244,10 @@ public partial class AgentLoopService
|
||||
string? documentPlanScaffold = null; // document_plan이 생성한 body 골격 HTML
|
||||
string? lastArtifactFilePath = null; // 생성/수정 산출물 파일 추적
|
||||
var statsModifiedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase); // 수정/생성된 파일 추적
|
||||
var taskType = ClassifyTaskType(userQuery, ActiveTab);
|
||||
var taskType = intentResult.TaskType;
|
||||
var taskPolicy = TaskTypePolicy.FromTaskType(taskType);
|
||||
var executionPolicy = _llm.GetActiveExecutionPolicy();
|
||||
var executionPolicy = ExecutionPolicyMerger.Apply(
|
||||
_llm.GetActiveExecutionPolicy(), intentResult.PolicyOverlay);
|
||||
var consecutiveNoToolResponses = 0;
|
||||
var noToolResponseThreshold = AgentLoopRuntimeThresholds.GetNoToolCallResponseThreshold(executionPolicy.NoToolResponseThreshold);
|
||||
var noToolRecoveryMaxRetries = AgentLoopRuntimeThresholds.GetNoToolCallRecoveryMaxRetries(executionPolicy.NoToolRecoveryMaxRetries);
|
||||
@@ -256,6 +265,19 @@ public partial class AgentLoopService
|
||||
var context = BuildContext();
|
||||
InjectTaskTypeGuidance(messages, taskPolicy);
|
||||
InjectExplorationScopeGuidance(messages, explorationState.Scope);
|
||||
|
||||
// P5: 복합 요청 감지 시 DecompositionHint 주입
|
||||
if (intentResult.IsComplexTask && !string.IsNullOrWhiteSpace(intentResult.DecompositionHint))
|
||||
{
|
||||
messages.Add(new ChatMessage
|
||||
{
|
||||
Role = "user",
|
||||
Content = $"[System:DecompositionHint]\n{intentResult.DecompositionHint}\n" +
|
||||
"Consider using spawn_agents to run independent sub-tasks in parallel.",
|
||||
MetaKind = "decomposition_hint",
|
||||
});
|
||||
}
|
||||
|
||||
var preferredInitialToolSequence = BuildPreferredInitialToolSequence(
|
||||
explorationState,
|
||||
taskPolicy,
|
||||
@@ -370,6 +392,11 @@ public partial class AgentLoopService
|
||||
// PauseAsync가 아직 세마포어를 보유 중이 아닌 경우 — 무시
|
||||
}
|
||||
|
||||
// ── 실행 중 설정 변경 반영 ──
|
||||
// 사용자가 UI에서 권한 모드나 사내/사외 모드를 바꾼 경우 다음 반복부터 즉시 적용.
|
||||
// (현재 진행 중인 LLM 호출이나 도구 실행에는 영향 없음 — 다음 사이클부터)
|
||||
SyncContextFromSettings(context);
|
||||
|
||||
// Context Condenser: 토큰 초과 시 이전 대화 자동 압축
|
||||
// 첫 반복에서도 실행 (이전 대화 복원으로 이미 긴 경우 대비)
|
||||
{
|
||||
@@ -475,6 +502,22 @@ public partial class AgentLoopService
|
||||
}
|
||||
}
|
||||
|
||||
// P3: 누적 학습 메시지 주입 (매 반복 갱신)
|
||||
if (sessionLearnings is { Count: > 0 })
|
||||
{
|
||||
var learningMsg = sessionLearnings.BuildInjectionMessage();
|
||||
if (learningMsg != null)
|
||||
{
|
||||
messages.RemoveAll(m => m.MetaKind == "session_learnings");
|
||||
messages.Insert(0, new ChatMessage
|
||||
{
|
||||
Role = "user",
|
||||
Content = learningMsg,
|
||||
MetaKind = "session_learnings",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// P1: 반복당 1회 캐시 — GetRuntimeActiveTools는 동일 파라미터로 반복 내 여러 번 호출됨
|
||||
var cachedActiveTools = FilterExplorationToolsForCurrentIteration(
|
||||
GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides),
|
||||
@@ -782,9 +825,20 @@ public partial class AgentLoopService
|
||||
? taskPolicy.TaskType is "bugfix" or "feature" or "refactor"
|
||||
: IsDocumentCreationRequest(userQuery);
|
||||
|
||||
// probe 전용 도구(dev_env_detect, memory 등)만 사용된 경우도
|
||||
// 실질적 작업이 이뤄지지 않은 것으로 간주하고 NoToolCallLoop 복구를 유도한다.
|
||||
// (Code/Cowork 양쪽 모두 — 문서 작성 요청도 probe-only로 끝나서는 안 됨)
|
||||
var onlyProbeToolsUsed =
|
||||
requiresConcreteArtifactOrEdit
|
||||
&& totalToolCalls > 0
|
||||
&& !HasSubstantiveCodeToolUsage(statsUsedTools);
|
||||
|
||||
// probe-only 상태는 "1회 no-tool 응답"만으로도 즉시 복구 — 기본 임계(2회)는 느림
|
||||
var effectiveNoToolThreshold = onlyProbeToolsUsed ? 1 : noToolResponseThreshold;
|
||||
|
||||
if (requiresConcreteArtifactOrEdit
|
||||
&& totalToolCalls == 0
|
||||
&& consecutiveNoToolResponses >= noToolResponseThreshold
|
||||
&& (totalToolCalls == 0 || onlyProbeToolsUsed)
|
||||
&& consecutiveNoToolResponses >= effectiveNoToolThreshold
|
||||
&& runState.NoToolCallLoopRetry < noToolRecoveryMaxRetries)
|
||||
{
|
||||
runState.NoToolCallLoopRetry++;
|
||||
@@ -1034,6 +1088,18 @@ public partial class AgentLoopService
|
||||
terminalEvidenceGateRetryMax))
|
||||
continue;
|
||||
|
||||
// 방어: 파이프-래핑 tool_call 토큰 등 원본 마크업이 최종 assistant 응답에 남아있으면 제거
|
||||
// (패턴 4 폴백 파싱도 실패하여 사용자 화면에 "<|tool_call>call;foo{...}<tool_call|>" 가
|
||||
// 그대로 노출되는 증상을 방지)
|
||||
if (!string.IsNullOrEmpty(textResponse))
|
||||
{
|
||||
var cleaned = LlmService.StripToolCallTokens(textResponse);
|
||||
if (!string.Equals(cleaned, textResponse, StringComparison.Ordinal))
|
||||
{
|
||||
LogService.Debug("[AgentLoop] 최종 응답에 미파싱 tool_call 토큰 발견 — 정화");
|
||||
textResponse = cleaned;
|
||||
}
|
||||
}
|
||||
if (!string.IsNullOrEmpty(textResponse))
|
||||
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
|
||||
EmitEvent(AgentEventType.Complete, "",
|
||||
@@ -1513,6 +1579,9 @@ public partial class AgentLoopService
|
||||
lastToolResultAtUtc = DateTime.UtcNow;
|
||||
lastToolResultToolName = effectiveCall.ToolName;
|
||||
|
||||
// P3: 누적 학습 — 도구 결과에서 학습 포인트 추출
|
||||
sessionLearnings?.TryExtract(effectiveCall.ToolName, result.Output ?? "", result.Success);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
failedToolHistogram.TryGetValue(effectiveCall.ToolName, out var failedCount);
|
||||
@@ -1698,7 +1767,7 @@ public partial class AgentLoopService
|
||||
{
|
||||
if (consumedExtraIteration)
|
||||
iteration++;
|
||||
return result.Output;
|
||||
return result.Output ?? "";
|
||||
}
|
||||
|
||||
var consumedVerificationIteration = await TryApplyPostToolVerificationTransitionAsync(
|
||||
@@ -1957,8 +2026,9 @@ public partial class AgentLoopService
|
||||
{
|
||||
foreach (var name in disabledToolNames)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
disabled.Add(name);
|
||||
var canonical = AgentToolCatalog.Canonicalize(name);
|
||||
if (!string.IsNullOrWhiteSpace(canonical))
|
||||
disabled.Add(canonical);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1986,9 +2056,7 @@ public partial class AgentLoopService
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
continue;
|
||||
|
||||
var alias = NormalizeAliasToken(normalized);
|
||||
if (ToolAliasMap.TryGetValue(alias, out var mapped))
|
||||
normalized = mapped;
|
||||
normalized = AgentToolCatalog.Canonicalize(normalized);
|
||||
|
||||
result.Add(normalized);
|
||||
}
|
||||
@@ -2006,7 +2074,7 @@ public partial class AgentLoopService
|
||||
{
|
||||
var normalized = token.Trim().Trim('`', '"', '\'');
|
||||
if (!string.IsNullOrWhiteSpace(normalized))
|
||||
result.Add(normalized);
|
||||
result.Add(AgentToolCatalog.Canonicalize(normalized));
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -2132,7 +2200,7 @@ public partial class AgentLoopService
|
||||
}
|
||||
|
||||
private static bool IsForkCompliantTool(string toolName)
|
||||
=> toolName is "spawn_agent" or "wait_agents";
|
||||
=> toolName is "spawn_agent" or "spawn_agents" or "wait_agents";
|
||||
|
||||
private static bool ShouldEnforceForkExecution(
|
||||
bool enforceForkExecution,
|
||||
@@ -3164,6 +3232,33 @@ public partial class AgentLoopService
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Code 작업에서 "실질적" 도구 사용이 있었는지 판별한다.
|
||||
/// dev_env_detect/memory/notify 같은 probe·메타 도구만 호출된 경우엔 false를 반환하여
|
||||
/// 메인 루프가 NoToolCallLoop 복구를 통해 실제 작업을 유도하도록 한다.
|
||||
/// </summary>
|
||||
private static bool HasSubstantiveCodeToolUsage(IEnumerable<string> usedTools)
|
||||
{
|
||||
// 실질적 진행으로 간주하는 도구(읽기/수정/검색/빌드/테스트/LSP/git 등)
|
||||
var substantive = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"file_read", "file_write", "file_edit", "file_manage",
|
||||
"multi_read", "file_info",
|
||||
"grep", "glob", "code_search", "lsp_code_intel",
|
||||
"git_tool", "build_run", "test_loop",
|
||||
"script_create", "process",
|
||||
"html_create", "markdown_create", "docx_create",
|
||||
"excel_create", "csv_create", "pptx_create", "chart_create",
|
||||
"document_plan", "sub_agent", "wait_agents"
|
||||
};
|
||||
foreach (var t in usedTools)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(t) && substantive.Contains(t))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HasAnyBuildOrTestEvidence(List<ChatMessage> messages)
|
||||
{
|
||||
foreach (var message in messages.AsEnumerable().Reverse())
|
||||
@@ -3338,8 +3433,8 @@ public partial class AgentLoopService
|
||||
return false;
|
||||
if (highImpact && !hasSuccessfulBuildAndTestEvidence)
|
||||
return false;
|
||||
if (string.Equals(taskPolicy.TaskType, "docs", StringComparison.OrdinalIgnoreCase) && !hasDocumentVerificationEvidence)
|
||||
return false;
|
||||
// docs 작업: 검증 도구(document_review 등) 호출이 있으면 그 언급도 필요하지만,
|
||||
// 호출이 없어도 기본 요약(변경+검증 키워드)만으로 FinalReport를 트리거할 수 있게 허용.
|
||||
if (string.Equals(taskPolicy.TaskType, "docs", StringComparison.OrdinalIgnoreCase)
|
||||
&& hasDocumentVerificationEvidence
|
||||
&& !hasDocumentVerificationToolMention)
|
||||
@@ -4062,8 +4157,8 @@ public partial class AgentLoopService
|
||||
string unknownToolName,
|
||||
IReadOnlyCollection<string> activeToolNames)
|
||||
{
|
||||
var normalizedUnknown = NormalizeAliasToken(unknownToolName);
|
||||
var aliasHint = ToolAliasMap.TryGetValue(normalizedUnknown, out var mappedCandidate)
|
||||
var mappedCandidate = AgentToolCatalog.Canonicalize(unknownToolName);
|
||||
var aliasHint = !string.Equals(mappedCandidate, unknownToolName, StringComparison.OrdinalIgnoreCase)
|
||||
&& activeToolNames.Any(name => string.Equals(name, mappedCandidate, StringComparison.OrdinalIgnoreCase))
|
||||
? $"- 자동 매핑 후보: {unknownToolName} → {mappedCandidate}\n"
|
||||
: "";
|
||||
@@ -4133,67 +4228,6 @@ public partial class AgentLoopService
|
||||
"- 다음 실행에서는 허용 도구 예시에서 직접 고를 수 있으면 바로 바꾸고, 그래도 애매할 때만 tool_search를 사용하세요.";
|
||||
}
|
||||
|
||||
private static readonly Dictionary<string, string> ToolAliasMap = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["read"] = "file_read",
|
||||
["readfile"] = "file_read",
|
||||
["read_file"] = "file_read",
|
||||
["write"] = "file_write",
|
||||
["writefile"] = "file_write",
|
||||
["write_file"] = "file_write",
|
||||
["edit"] = "file_edit",
|
||||
["editfile"] = "file_edit",
|
||||
["edit_file"] = "file_edit",
|
||||
["bash"] = "process",
|
||||
["shell"] = "process",
|
||||
["terminal"] = "process",
|
||||
["run"] = "process",
|
||||
["ls"] = "glob",
|
||||
["listfiles"] = "glob",
|
||||
["list_files"] = "glob",
|
||||
["grep"] = "grep",
|
||||
["greptool"] = "grep",
|
||||
["grep_tool"] = "grep",
|
||||
["rg"] = "grep",
|
||||
["ripgrep"] = "grep",
|
||||
["search"] = "grep",
|
||||
["globfiles"] = "glob",
|
||||
["glob_files"] = "glob",
|
||||
// claw-code 계열 도구명 호환
|
||||
["webfetch"] = "http_tool",
|
||||
["websearch"] = "http_tool",
|
||||
["askuserquestion"] = "user_ask",
|
||||
["lsp"] = "lsp_code_intel",
|
||||
["listmcpresourcestool"] = "mcp_list_resources",
|
||||
["readmcpresourcetool"] = "mcp_read_resource",
|
||||
["agent"] = "spawn_agent",
|
||||
["spawnagent"] = "spawn_agent",
|
||||
["task"] = "spawn_agent",
|
||||
["sendmessage"] = "notify_tool",
|
||||
["shellcommand"] = "process",
|
||||
["execute"] = "process",
|
||||
["codesearch"] = "search_codebase",
|
||||
["code_search"] = "search_codebase",
|
||||
["powershell"] = "process",
|
||||
["toolsearch"] = "tool_search",
|
||||
["todowrite"] = "todo_write",
|
||||
["taskcreate"] = "task_create",
|
||||
["taskget"] = "task_get",
|
||||
["tasklist"] = "task_list",
|
||||
["taskupdate"] = "task_update",
|
||||
["taskstop"] = "task_stop",
|
||||
["taskoutput"] = "task_output",
|
||||
["enterworktree"] = "enter_worktree",
|
||||
["exitworktree"] = "exit_worktree",
|
||||
["teamcreate"] = "team_create",
|
||||
["teamdelete"] = "team_delete",
|
||||
["croncreate"] = "cron_create",
|
||||
["crondelete"] = "cron_delete",
|
||||
["cronlist"] = "cron_list",
|
||||
["config"] = "project_rules",
|
||||
["skill"] = "skill_manager",
|
||||
};
|
||||
|
||||
private static string ResolveRequestedToolName(string requestedToolName, IReadOnlyCollection<string> activeToolNames)
|
||||
{
|
||||
var requested = requestedToolName.Trim();
|
||||
@@ -4205,15 +4239,13 @@ public partial class AgentLoopService
|
||||
if (!string.IsNullOrWhiteSpace(direct))
|
||||
return direct;
|
||||
|
||||
var normalizedRequested = NormalizeAliasToken(requested);
|
||||
if (ToolAliasMap.TryGetValue(normalizedRequested, out var mapped))
|
||||
{
|
||||
var mappedDirect = activeToolNames.FirstOrDefault(name =>
|
||||
string.Equals(name, mapped, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(mappedDirect))
|
||||
return mappedDirect;
|
||||
}
|
||||
var canonicalRequested = AgentToolCatalog.Canonicalize(requested);
|
||||
var mappedDirect = activeToolNames.FirstOrDefault(name =>
|
||||
string.Equals(name, canonicalRequested, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(mappedDirect))
|
||||
return mappedDirect;
|
||||
|
||||
var normalizedRequested = NormalizeAliasToken(requested);
|
||||
var normalizedMatch = activeToolNames.FirstOrDefault(name =>
|
||||
string.Equals(NormalizeAliasToken(name), normalizedRequested, StringComparison.Ordinal));
|
||||
if (!string.IsNullOrWhiteSpace(normalizedMatch))
|
||||
@@ -4223,20 +4255,7 @@ public partial class AgentLoopService
|
||||
}
|
||||
|
||||
private static string NormalizeAliasToken(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return "";
|
||||
|
||||
Span<char> buffer = stackalloc char[value.Length];
|
||||
var idx = 0;
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (ch is '_' or '-' or ' ')
|
||||
continue;
|
||||
buffer[idx++] = char.ToLowerInvariant(ch);
|
||||
}
|
||||
return new string(buffer[..idx]);
|
||||
}
|
||||
=> AgentToolCatalog.NormalizeToken(value);
|
||||
|
||||
private static string BuildNoProgressAbortResponse(
|
||||
TaskTypePolicy taskPolicy,
|
||||
@@ -4555,7 +4574,7 @@ public partial class AgentLoopService
|
||||
AskPermission = AskPermissionCallback,
|
||||
UserDecision = UserDecisionCallback,
|
||||
UserAskCallback = UserAskCallback,
|
||||
ToolPermissions = new Dictionary<string, string>(llm.ToolPermissions ?? new(), StringComparer.OrdinalIgnoreCase),
|
||||
ToolPermissions = AgentToolCatalog.CanonicalizePermissionMap(llm.ToolPermissions ?? new Dictionary<string, string>()),
|
||||
ActiveTab = ActiveTab,
|
||||
OperationMode = _settings.Settings.OperationMode,
|
||||
DevMode = llm.DevMode,
|
||||
@@ -4563,6 +4582,57 @@ public partial class AgentLoopService
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 실행 중인 컨텍스트의 권한/운영모드/차단목록을 현재 설정값으로 갱신합니다.
|
||||
/// 에이전트 루프가 실행되는 동안 사용자가 UI에서 권한 모드를 바꾸거나
|
||||
/// 사내/사외 모드를 전환했을 때 다음 도구 호출부터 즉시 반영되도록 합니다.
|
||||
///
|
||||
/// 주의: WorkFolder와 ActiveTab은 실행 중 변경되지 않아야 하므로 건드리지 않음.
|
||||
/// (중간에 바뀌면 이미 진행 중인 도구 호출이 다른 워크스페이스를 바라보게 됨)
|
||||
/// </summary>
|
||||
private void SyncContextFromSettings(AgentContext context, bool emitChangeEvents = true)
|
||||
{
|
||||
if (context == null) return;
|
||||
var llm = _settings.Settings.Llm;
|
||||
|
||||
var newPermission = PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission);
|
||||
var oldPermission = PermissionModeCatalog.NormalizeGlobalMode(context.Permission);
|
||||
if (!string.Equals(newPermission, oldPermission, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
context.Permission = newPermission;
|
||||
if (emitChangeEvents)
|
||||
EmitEvent(AgentEventType.Thinking, "",
|
||||
$"[설정 변경 감지] 권한 모드: {oldPermission} → {newPermission}");
|
||||
// 권한 변경 시 이전 세션 승인 캐시는 유지 (사용자가 이미 허용한 건은 그대로)
|
||||
}
|
||||
|
||||
var newOpMode = AxCopilot.Services.OperationModePolicy.Normalize(_settings.Settings.OperationMode);
|
||||
var oldOpMode = AxCopilot.Services.OperationModePolicy.Normalize(context.OperationMode);
|
||||
if (!string.Equals(newOpMode, oldOpMode, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
context.OperationMode = newOpMode;
|
||||
if (emitChangeEvents)
|
||||
EmitEvent(AgentEventType.Thinking, "",
|
||||
$"[설정 변경 감지] 운영 모드: {oldOpMode} → {newOpMode}");
|
||||
}
|
||||
|
||||
// 차단 경로/확장자는 참조 자체가 설정 객체와 동일할 수 있어 Clear+AddRange 대신 새 리스트로 교체
|
||||
if (!ReferenceEquals(context.BlockedPaths, llm.BlockedPaths))
|
||||
context.BlockedPaths = llm.BlockedPaths ?? new();
|
||||
if (!ReferenceEquals(context.BlockedExtensions, llm.BlockedExtensions))
|
||||
context.BlockedExtensions = llm.BlockedExtensions ?? new();
|
||||
|
||||
// 도구별 권한 오버라이드는 훅이 런타임에 쓰는 경로도 있으므로, 설정값과 훅 값을 병합.
|
||||
// 설정에서 제거된 키는 삭제하되, 훅이 추가한 키(설정에 없는)는 유지.
|
||||
var desired = AgentToolCatalog.CanonicalizePermissionMap(llm.ToolPermissions ?? new Dictionary<string, string>());
|
||||
foreach (var kv in desired)
|
||||
context.ToolPermissions[kv.Key] = kv.Value;
|
||||
// 설정에서 사라진 키 중, 훅이 추가하지 않은 것만 정리하기는 훅 추적 비용이 커서 생략.
|
||||
|
||||
context.DevMode = llm.DevMode;
|
||||
context.DevModeStepApproval = llm.DevModeStepApproval;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 탭(Cowork/Code)별 작업 폴더를 결정합니다.
|
||||
/// 탭 전용 경로가 설정되어 있으면 우선, 아니면 레거시 WorkFolder 폴백.
|
||||
@@ -4661,7 +4731,23 @@ public partial class AgentLoopService
|
||||
|
||||
var primary = TryReadString(input, "path", "filePath", "destination", "url", "command", "project_path", "cwd");
|
||||
if (string.IsNullOrWhiteSpace(primary))
|
||||
{
|
||||
// ── 권한 대상 보정 ──
|
||||
// html_create/markdown_create 등은 path 생략 시 WorkFolder 하위에 자동 생성된다.
|
||||
// 이 경우 권한 검사 대상을 WorkFolder 자체로 간주해야 한다.
|
||||
// 이전에는 toolName("html_create")을 target으로 리턴했는데, IsOutsideWorkspace가
|
||||
// Path.GetFullPath("html_create") = <앱 CWD>/html_create 로 해석하여
|
||||
// "워크스페이스 외부"로 오인 → 사내 모드에서 BypassPermissions여도 강제 승인창이 떴다.
|
||||
if (toolName is "file_write" or "file_edit" or "file_manage"
|
||||
or "html_create" or "markdown_create" or "docx_create"
|
||||
or "excel_create" or "csv_create" or "pptx_create"
|
||||
or "chart_create" or "script_create"
|
||||
&& !string.IsNullOrWhiteSpace(context.WorkFolder))
|
||||
{
|
||||
return context.WorkFolder;
|
||||
}
|
||||
return toolName;
|
||||
}
|
||||
|
||||
if ((toolName is "file_write" or "file_edit" or "file_manage" or "open_external" or "html_create" or "markdown_create" or "docx_create" or "excel_create" or "csv_create" or "pptx_create" or "chart_create" or "script_create")
|
||||
&& !Path.IsPathRooted(primary)
|
||||
@@ -4858,11 +4944,7 @@ public partial class AgentLoopService
|
||||
if (trimmed is "*" or "default")
|
||||
return trimmed;
|
||||
|
||||
var normalizedRequested = NormalizeAliasToken(trimmed);
|
||||
if (ToolAliasMap.TryGetValue(normalizedRequested, out var mapped))
|
||||
return mapped;
|
||||
|
||||
return trimmed;
|
||||
return AgentToolCatalog.Canonicalize(trimmed);
|
||||
}
|
||||
|
||||
private async Task RunPermissionLifecycleHooksAsync(
|
||||
@@ -4907,6 +4989,11 @@ public partial class AgentLoopService
|
||||
AgentContext context,
|
||||
List<ChatMessage>? messages = null)
|
||||
{
|
||||
// 권한 검사 직전 한 번 더 동기화 — 한 iteration 안에서 여러 툴 콜이 있을 때
|
||||
// 사용자가 중간에 권한/운영모드를 바꿨으면 즉시 반영되도록.
|
||||
// 반복 호출이므로 Thinking 이벤트는 조용히 스킵(이미 iteration 시작 시 알림 발생).
|
||||
SyncContextFromSettings(context, emitChangeEvents: false);
|
||||
|
||||
var target = DescribeToolTarget(toolName, input, context);
|
||||
var requestPayload = JsonSerializer.Serialize(new
|
||||
{
|
||||
@@ -5073,8 +5160,10 @@ public partial class AgentLoopService
|
||||
var toolName = call.ToolName ?? "";
|
||||
var input = call.ToolInput;
|
||||
|
||||
// 사외모드 + 권한 건너뛰기: 모든 도구 승인 생략
|
||||
if (!AxCopilot.Services.OperationModePolicy.IsInternal(context.OperationMode))
|
||||
// 권한 건너뛰기: 도구별 승인 생략
|
||||
// 사내모드에서도 동일하게 적용 — 외부 URL 접근, 워크스페이스 외부 경로 접근 등
|
||||
// 실질적인 위험은 OperationModePolicy(IsBlockedAgentToolInInternalMode) 와
|
||||
// IAgentTool.CheckToolPermissionAsync 의 IsOutsideWorkspace 체크에서 이미 방어됨
|
||||
{
|
||||
var effectivePerm = PermissionModeCatalog.NormalizeGlobalMode(context.Permission);
|
||||
if (PermissionModeCatalog.IsBypassPermissions(effectivePerm))
|
||||
|
||||
Reference in New Issue
Block a user