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:
2026-04-14 17:52:46 +09:00
parent fa33b98f7e
commit 8cb08576d5
200 changed files with 13522 additions and 5764 deletions

View File

@@ -55,11 +55,13 @@ public static class AgentHookRunner
if (!string.Equals(hook.Timing, timing, StringComparison.OrdinalIgnoreCase)) continue;
// 도구 이름 매칭: "*" = 전체, 그 외 정확 매칭 (대소문자 무시)
if (hook.ToolName != "*" &&
!string.Equals(hook.ToolName, toolName, StringComparison.OrdinalIgnoreCase))
var hookToolName = AgentToolCatalog.CanonicalizeHookTarget(hook.ToolName);
var canonicalToolName = AgentToolCatalog.Canonicalize(toolName);
if (hookToolName != "*" &&
!string.Equals(hookToolName, canonicalToolName, StringComparison.OrdinalIgnoreCase))
continue;
var result = await ExecuteHookAsync(hook, toolName, timing, toolInput, toolOutput, success, workFolder, timeoutMs, ct);
var result = await ExecuteHookAsync(hook, canonicalToolName, timing, toolInput, toolOutput, success, workFolder, timeoutMs, ct);
results.Add(result);
}

View File

@@ -5,7 +5,7 @@ namespace AxCopilot.Services.Agent;
public partial class AgentLoopService
{
private enum ExplorationScope
internal enum ExplorationScope
{
Localized,
TopicBased,

View File

@@ -16,14 +16,6 @@ public partial class AgentLoopService
}
// 읽기 전용 도구 (파일 상태를 변경하지 않음)
private static readonly HashSet<string> ReadOnlyTools = new(StringComparer.OrdinalIgnoreCase)
{
"file_read", "glob", "grep_tool", "folder_map", "document_read",
"search_codebase", "code_search", "env_tool", "datetime_tool",
"dev_env_detect", "memory", "skill_manager", "json_tool",
"regex_tool", "base64_tool", "hash_tool", "image_analyze",
};
/// <summary>도구 호출을 병렬 가능 / 순차 필수로 분류합니다.</summary>
private static (List<ContentBlock> Parallel, List<ContentBlock> Sequential)
ClassifyToolCalls(List<ContentBlock> calls)
@@ -35,12 +27,9 @@ public partial class AgentLoopService
foreach (var call in calls)
{
var requestedToolName = call.ToolName ?? "";
var normalizedToolName = NormalizeAliasToken(requestedToolName);
var classificationToolName = ToolAliasMap.TryGetValue(normalizedToolName, out var mappedToolName)
? mappedToolName
: requestedToolName;
var classificationToolName = AgentToolCatalog.Canonicalize(requestedToolName);
if (collectParallelPrefix && ReadOnlyTools.Contains(classificationToolName))
if (collectParallelPrefix && AgentToolCatalog.IsReadOnly(classificationToolName))
parallel.Add(call);
else
{

View File

@@ -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))

View File

@@ -646,7 +646,7 @@ public partial class AgentLoopService
if (!success)
return 0;
return ReadOnlyTools.Contains(toolName ?? "")
return AgentToolCatalog.IsReadOnly(toolName)
? current + 1
: 0;
}
@@ -681,7 +681,7 @@ public partial class AgentLoopService
if (repeatedSameSignatureCount < GetReadOnlySignatureLoopThreshold())
return false;
return ReadOnlyTools.Contains(toolName ?? "");
return AgentToolCatalog.IsReadOnly(toolName);
}
private static int GetReadOnlySignatureLoopThreshold()
@@ -1124,7 +1124,12 @@ public partial class AgentLoopService
AgentContext context,
List<ChatMessage> messages)
{
if (context.DevModeStepApproval && UserDecisionCallback != null)
// 권한 자동화 모드(Bypass/AcceptEdits/DontAsk) 또는 Plan 모드에서는 DevStepApproval도 생략.
// - Bypass/AcceptEdits/DontAsk: 사용자가 "매 스텝 확인 없이 진행"을 명시적으로 선택한 상태
// - Plan: 읽기/조사만 자동 진행하며 계획 세우기가 목적 — 매 스텝 확인은 목적에 반함
var skipDevApproval = PermissionModeCatalog.IsAuto(context.Permission)
|| PermissionModeCatalog.IsPlan(context.Permission);
if (context.DevModeStepApproval && !skipDevApproval && UserDecisionCallback != null)
{
var decision = await UserDecisionCallback(
$"[DEV] 도구 '{call.ToolName}' 실행을 확인하시겠습니까?\n{FormatToolCallSummary(call)}",

View File

@@ -100,7 +100,7 @@ public partial class AgentLoopService
var shouldRequestStructuredFinalReport =
taskPolicy.IsReviewTask
|| requireHighImpactCodeVerification
|| taskPolicy.TaskType is "bugfix" or "feature" or "refactor";
|| taskPolicy.TaskType is "bugfix" or "feature" or "refactor" or "docs";
if (executionPolicy.FinalReportGateMaxRetries > 0
&& shouldRequestStructuredFinalReport
&& !hasBlockingCodeEvidenceGap

View File

@@ -0,0 +1,307 @@
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
internal sealed record AgentToolMetadata(
string CanonicalName,
string SettingsCategory,
string SettingsIcon,
string SettingsIconColor,
int ExposureBucket = 1,
string? TabCategory = null,
bool IsReadOnly = false);
internal static class AgentToolCatalog
{
private static readonly IReadOnlyDictionary<string, AgentToolMetadata> s_metadata =
new Dictionary<string, AgentToolMetadata>(StringComparer.OrdinalIgnoreCase)
{
["file_read"] = new("file_read", "파일/검색", "\uE8B7", "#F59E0B", 0, "Cowork,Code", true),
["file_write"] = new("file_write", "파일/검색", "\uE8B7", "#F59E0B", 0, "Cowork,Code"),
["file_edit"] = new("file_edit", "파일/검색", "\uE8B7", "#F59E0B", 0, "Cowork,Code"),
["glob"] = new("glob", "파일/검색", "\uE8B7", "#F59E0B", 0, "Cowork,Code", true),
["grep"] = new("grep", "파일/검색", "\uE8B7", "#F59E0B", 0, "Cowork,Code", true),
["folder_map"] = new("folder_map", "파일/검색", "\uE8B7", "#F59E0B", 1, "Cowork,Code", true),
["document_read"] = new("document_read", "파일/검색", "\uE8B7", "#F59E0B", 0, "Cowork,Code", true),
["file_manage"] = new("file_manage", "파일/검색", "\uE8B7", "#F59E0B", 1, "Cowork,Code"),
["file_info"] = new("file_info", "파일/검색", "\uE8B7", "#F59E0B", 1, "Cowork,Code", true),
["multi_read"] = new("multi_read", "파일/검색", "\uE8B7", "#F59E0B", 1, "Cowork,Code", true),
["file_watch"] = new("file_watch", "파일/검색", "\uE8B7", "#F59E0B", 1, "Code"),
["open_external"] = new("open_external", "시스템/환경", "\uE770", "#06B6D4", 1, "Cowork,Code"),
["process"] = new("process", "프로세스/빌드", "\uE756", "#06B6D4", 0, "Cowork,Code"),
["build_run"] = new("build_run", "프로세스/빌드", "\uE756", "#06B6D4", 0, "Code"),
["dev_env_detect"] = new("dev_env_detect", "프로세스/빌드", "\uE756", "#06B6D4", 0, "Code", true),
["search_codebase"] = new("search_codebase", "코드 분석", "\uE943", "#818CF8", 1, "Code", true),
["code_review"] = new("code_review", "코드 분석", "\uE943", "#818CF8", 1, "Code"),
["lsp_code_intel"] = new("lsp_code_intel", "코드 분석", "\uE943", "#818CF8", 0, "Code", true),
["test_loop"] = new("test_loop", "코드 분석", "\uE943", "#818CF8", 1, "Code"),
["git_tool"] = new("git_tool", "코드 분석", "\uE943", "#818CF8", 0, "Code"),
["snippet_runner"] = new("snippet_runner", "코드 분석", "\uE943", "#818CF8", 1, "Code"),
["diff_preview"] = new("diff_preview", "코드 분석", "\uE943", "#818CF8", 1, "Code", true),
["project_rules"] = new("project_rules", "코드 분석", "\uE943", "#818CF8", 1, "Code"),
["excel_create"] = new("excel_create", "문서 생성", "\uE8A5", "#34D399", 0, "Cowork"),
["docx_create"] = new("docx_create", "문서 생성", "\uE8A5", "#34D399", 0, "Cowork"),
["csv_create"] = new("csv_create", "문서 생성", "\uE8A5", "#34D399", 0, "Cowork"),
["markdown_create"] = new("markdown_create", "문서 생성", "\uE8A5", "#34D399", 0, "Cowork"),
["html_create"] = new("html_create", "문서 생성", "\uE8A5", "#34D399", 0, "Cowork"),
["chart_create"] = new("chart_create", "문서 생성", "\uE8A5", "#34D399", 0, "Cowork"),
["script_create"] = new("script_create", "문서 생성", "\uE8A5", "#34D399", 0, "Cowork"),
["pptx_create"] = new("pptx_create", "문서 생성", "\uE8A5", "#34D399", 0, "Cowork"),
["document_plan"] = new("document_plan", "문서 생성", "\uE8A5", "#34D399", 0, "Cowork"),
["document_assemble"] = new("document_assemble", "문서 생성", "\uE8A5", "#34D399", 0, "Cowork"),
["document_review"] = new("document_review", "문서 생성", "\uE8A5", "#34D399", 1, "Cowork"),
["format_convert"] = new("format_convert", "문서 생성", "\uE8A5", "#34D399", 1, "Cowork"),
["template_render"] = new("template_render", "문서 생성", "\uE8A5", "#34D399", 1, "Cowork"),
["text_summarize"] = new("text_summarize", "문서 생성", "\uE8A5", "#34D399", 1, "Cowork"),
["json_tool"] = new("json_tool", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork,Code", true),
["regex_tool"] = new("regex_tool", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork,Code", true),
["diff_tool"] = new("diff_tool", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork,Code", true),
["base64_tool"] = new("base64_tool", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork,Code", true),
["hash_tool"] = new("hash_tool", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork,Code", true),
["datetime_tool"] = new("datetime_tool", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork,Code", true),
["math_eval"] = new("math_eval", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork,Code", true),
["sql_tool"] = new("sql_tool", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork"),
["xml_tool"] = new("xml_tool", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork"),
["data_pivot"] = new("data_pivot", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork"),
["encoding_tool"] = new("encoding_tool", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork,Code", true),
["clipboard_tool"] = new("clipboard_tool", "시스템/환경", "\uE770", "#06B6D4", 1, "Cowork,Code"),
["notify_tool"] = new("notify_tool", "시스템/환경", "\uE770", "#06B6D4", 1, "Cowork,Code"),
["env_tool"] = new("env_tool", "시스템/환경", "\uE770", "#06B6D4", 1, "Cowork,Code", true),
["zip_tool"] = new("zip_tool", "시스템/환경", "\uE770", "#06B6D4", 1, "Cowork,Code"),
["http_tool"] = new("http_tool", "시스템/환경", "\uE770", "#06B6D4", 1, "Cowork,Code"),
["image_analyze"] = new("image_analyze", "시스템/환경", "\uE770", "#06B6D4", 1, "Cowork"),
["spawn_agent"] = new("spawn_agent", "에이전트", "\uE99A", "#F472B6", 2, "Code"),
["spawn_agents"] = new("spawn_agents", "에이전트", "\uE99A", "#F472B6", 2, "Code"),
["wait_agents"] = new("wait_agents", "에이전트", "\uE99A", "#F472B6", 2, "Code"),
["memory"] = new("memory", "에이전트", "\uE99A", "#F472B6", 1, "Cowork,Code", true),
["skill_manager"] = new("skill_manager", "에이전트", "\uE99A", "#F472B6", 1, "Cowork,Code", true),
["tool_search"] = new("tool_search", "에이전트", "\uE99A", "#F472B6", 1, "Cowork,Code", true),
["user_ask"] = new("user_ask", "에이전트", "\uE99A", "#F472B6", 1, "Cowork,Code"),
["mcp_list_resources"] = new("mcp_list_resources", "에이전트", "\uE99A", "#F472B6", 2, "Cowork,Code", true),
["mcp_read_resource"] = new("mcp_read_resource", "에이전트", "\uE99A", "#F472B6", 2, "Cowork,Code", true),
["task_tracker"] = new("task_tracker", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["todo_write"] = new("todo_write", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["task_create"] = new("task_create", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["task_get"] = new("task_get", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["task_list"] = new("task_list", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["task_update"] = new("task_update", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["task_stop"] = new("task_stop", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["task_output"] = new("task_output", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["enter_worktree"] = new("enter_worktree", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["exit_worktree"] = new("exit_worktree", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["team_create"] = new("team_create", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["team_delete"] = new("team_delete", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["cron_create"] = new("cron_create", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["cron_delete"] = new("cron_delete", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["cron_list"] = new("cron_list", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["suggest_actions"] = new("suggest_actions", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["checkpoint"] = new("checkpoint", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["playbook"] = new("playbook", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
};
private static readonly IReadOnlyDictionary<string, string> s_aliasMap =
BuildAliasMap();
public static string Canonicalize(string? toolName)
{
if (string.IsNullOrWhiteSpace(toolName))
return "";
var trimmed = toolName.Trim();
if (s_metadata.ContainsKey(trimmed))
return trimmed;
var normalized = NormalizeToken(trimmed);
return s_aliasMap.TryGetValue(normalized, out var canonical)
? canonical
: trimmed;
}
public static string NormalizeToken(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]);
}
public static AgentToolMetadata GetMetadata(string? toolName)
{
var canonical = Canonicalize(toolName);
return s_metadata.TryGetValue(canonical, out var metadata)
? metadata
: new AgentToolMetadata(canonical, "기타", "\uE10C", "#94A3B8");
}
public static string? GetTabCategory(string? toolName)
=> GetMetadata(toolName).TabCategory;
public static int GetExposureBucket(string? toolName)
=> GetMetadata(toolName).ExposureBucket;
public static bool IsReadOnly(string? toolName)
=> GetMetadata(toolName).IsReadOnly;
public static IReadOnlyCollection<string> CanonicalizeMany(IEnumerable<string>? names)
{
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (names == null)
return result.ToList().AsReadOnly();
foreach (var name in names)
{
var canonical = Canonicalize(name);
if (!string.IsNullOrWhiteSpace(canonical))
result.Add(canonical);
}
return result.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList().AsReadOnly();
}
public static Dictionary<string, string> CanonicalizePermissionMap(IDictionary<string, string>? permissions)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (permissions == null)
return result;
foreach (var kv in permissions)
{
var normalizedKey = CanonicalizePermissionKey(kv.Key);
if (!string.IsNullOrWhiteSpace(normalizedKey))
result[normalizedKey] = kv.Value;
}
return result;
}
public static List<AgentHookEntry> CanonicalizeHooks(IEnumerable<AgentHookEntry>? hooks)
{
var result = new List<AgentHookEntry>();
if (hooks == null)
return result;
foreach (var hook in hooks)
{
result.Add(new AgentHookEntry
{
Name = hook.Name,
ToolName = CanonicalizeHookTarget(hook.ToolName),
Timing = hook.Timing,
ScriptPath = hook.ScriptPath,
Arguments = hook.Arguments,
Enabled = hook.Enabled,
});
}
return result;
}
public static string CanonicalizeHookTarget(string? toolName)
{
if (string.IsNullOrWhiteSpace(toolName))
return "*";
var trimmed = toolName.Trim();
return string.Equals(trimmed, "*", StringComparison.Ordinal)
? trimmed
: Canonicalize(trimmed);
}
private static string CanonicalizePermissionKey(string? key)
{
if (string.IsNullOrWhiteSpace(key))
return "";
var trimmed = key.Trim();
if (string.Equals(trimmed, "*", StringComparison.OrdinalIgnoreCase)
|| string.Equals(trimmed, "default", StringComparison.OrdinalIgnoreCase))
return trimmed.ToLowerInvariant();
var atIndex = trimmed.IndexOf('@');
if (atIndex > 0 && atIndex < trimmed.Length - 1)
return $"{Canonicalize(trimmed[..atIndex])}@{trimmed[(atIndex + 1)..].Trim()}";
var pipeIndex = trimmed.IndexOf('|');
if (pipeIndex > 0 && pipeIndex < trimmed.Length - 1)
return $"{Canonicalize(trimmed[..pipeIndex])}|{trimmed[(pipeIndex + 1)..].Trim()}";
var openIndex = trimmed.IndexOf('(');
if (openIndex > 0 && trimmed.EndsWith(")", StringComparison.Ordinal))
return $"{Canonicalize(trimmed[..openIndex])}{trimmed[openIndex..]}";
return Canonicalize(trimmed);
}
private static IReadOnlyDictionary<string, string> BuildAliasMap()
{
var aliases = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var metadata in s_metadata.Values)
{
aliases[NormalizeToken(metadata.CanonicalName)] = metadata.CanonicalName;
}
RegisterAliases(aliases, "zip_tool", "zip");
RegisterAliases(aliases, "json_tool", "json");
RegisterAliases(aliases, "regex_tool", "regex");
RegisterAliases(aliases, "base64_tool", "base64");
RegisterAliases(aliases, "hash_tool", "hash");
RegisterAliases(aliases, "datetime_tool", "datetime");
RegisterAliases(aliases, "math_eval", "math", "math_tool");
RegisterAliases(aliases, "encoding_tool", "encoding");
RegisterAliases(aliases, "http_tool", "http", "webfetch", "websearch");
RegisterAliases(aliases, "clipboard_tool", "clipboard");
RegisterAliases(aliases, "notify_tool", "notify", "sendmessage");
RegisterAliases(aliases, "env_tool", "env");
RegisterAliases(aliases, "git_tool", "git");
RegisterAliases(aliases, "lsp_code_intel", "lsp");
RegisterAliases(aliases, "project_rules", "project_rule", "config");
RegisterAliases(aliases, "snippet_runner", "snippet_run");
RegisterAliases(aliases, "search_codebase", "code_search", "codesearch");
RegisterAliases(aliases, "script_create", "batch_create", "batch_skill");
RegisterAliases(aliases, "markdown_create", "md_create", "markdown_skill");
RegisterAliases(aliases, "excel_create", "xlsx_create", "excel_skill");
RegisterAliases(aliases, "docx_create", "docx_skill");
RegisterAliases(aliases, "csv_create", "csv_skill");
RegisterAliases(aliases, "html_create", "html_skill");
RegisterAliases(aliases, "chart_create", "chart_skill");
RegisterAliases(aliases, "pptx_create", "pptx_skill");
RegisterAliases(aliases, "document_plan", "document_planner");
RegisterAliases(aliases, "document_assemble", "document_assembler");
RegisterAliases(aliases, "spawn_agent", "sub_agent", "agent", "task", "spawnagent");
RegisterAliases(aliases, "spawn_agents", "batchagent", "spawnagents");
RegisterAliases(aliases, "tool_search", "toolsearch");
RegisterAliases(aliases, "user_ask", "askuserquestion");
RegisterAliases(aliases, "skill_manager", "skill");
RegisterAliases(aliases, "mcp_list_resources", "listmcpresourcestool");
RegisterAliases(aliases, "mcp_read_resource", "readmcpresourcetool");
RegisterAliases(aliases, "process", "bash", "shell", "terminal", "run", "powershell", "shellcommand", "execute");
RegisterAliases(aliases, "glob", "ls", "listfiles", "list_files", "globfiles", "glob_files", "search_files", "find");
RegisterAliases(aliases, "grep", "grep_tool", "greptool", "rg", "ripgrep", "search_content");
RegisterAliases(aliases, "file_read", "read", "readfile", "read_file");
RegisterAliases(aliases, "file_write", "write", "writefile", "write_file");
RegisterAliases(aliases, "file_edit", "edit", "editfile", "edit_file");
return aliases;
}
private static void RegisterAliases(IDictionary<string, string> aliases, string canonicalName, params string[] values)
{
foreach (var value in values)
aliases[NormalizeToken(value)] = canonicalName;
}
}

View File

@@ -81,6 +81,7 @@ internal static class AgentTranscriptDisplayCatalog
"task_stop" => "작업 중지",
"task_output" => "작업 출력",
"spawn_agent" => "서브에이전트",
"spawn_agents" => "배치 에이전트",
"wait_agents" => "에이전트 대기",
_ => normalized.Replace('_', ' ').Trim(),
};
@@ -416,7 +417,7 @@ internal static class AgentTranscriptDisplayCatalog
=> "제안",
"process" or "bash" or "powershell"
=> "명령",
"spawn_agent" or "wait_agents"
"spawn_agent" or "spawn_agents" or "wait_agents"
=> "에이전트",
"web_fetch" or "http"
=> "웹",

View File

@@ -5,8 +5,8 @@ namespace AxCopilot.Services.Agent;
/// <summary>
/// AX Agent execution-prep engine.
/// Inspired by the `claw-code` split between input preparation and session execution,
/// so the UI layer stops owning message assembly and final assistant commit logic.
/// UI 레이어가 메시지 조립과 최종 어시스턴트 커밋 로직을 직접 소유하지 않도록
/// 입력 준비(preparation)와 세션 실행(execution)을 분리하는 패턴입니다.
/// </summary>
public sealed class AxAgentExecutionEngine
{

View File

@@ -394,7 +394,7 @@ public static class ContextCondenser
/// <summary>
/// 오래된 실행 로그/도구 결과/비정상적으로 긴 메시지를 먼저 경량 경계 요약으로 묶습니다.
/// claw-code의 microcompact처럼 LLM 호출 전에 토큰을 한 번 더 줄이는 단계입니다.
/// LLM 호출 전에 토큰을 한 번 더 줄이는 마이크로 압축 단계입니다.
/// </summary>
private static (int BoundaryCount, int SingleCount) MicrocompactOlderMessages(List<ChatMessage> messages)
{
@@ -653,6 +653,10 @@ public static class ContextCondenser
var content = message.Content ?? "";
if (string.IsNullOrWhiteSpace(content)) return false;
// P3: 세션 학습 메시지는 압축 대상에서 제외 — 매 반복 갱신되므로 항상 보존
if (string.Equals(message.MetaKind, "session_learnings", StringComparison.OrdinalIgnoreCase))
return false;
return message.MetaKind != null
|| content.StartsWith("{\"type\":\"tool_result\"", StringComparison.Ordinal)
|| content.StartsWith("{\"_tool_use_blocks\"", StringComparison.Ordinal)

View File

@@ -218,7 +218,9 @@ public class DocumentAssemblerTool : IAgentTool
var (heading, content, level) = sections[i];
var tag = level <= 1 ? "h2" : "h3";
sb.AppendLine($"<{tag} id=\"section-{i + 1}\">{Escape(heading)}</{tag}>");
sb.AppendLine($"<div class=\"section-content\">{content}</div>");
// LLM이 생성한 깨진 태그 자동 수정
var sanitized = HtmlSkill.SanitizeHtmlTagsPublic(content);
sb.AppendLine($"<div class=\"section-content\">{sanitized}</div>");
}
sb.AppendLine("</div>");

View File

@@ -0,0 +1,41 @@
using static AxCopilot.Services.Agent.ModelExecutionProfileCatalog;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 기존 ExecutionPolicy 위에 적용할 sparse override.
/// null인 필드는 base policy 값을 그대로 사용합니다.
/// </summary>
public sealed record ExecutionPolicyOverlay(
double? ToolTemperatureCap = null,
int? NoToolResponseThreshold = null,
int? NoToolRecoveryMaxRetries = null,
bool? ForceInitialToolCall = null,
bool? EnableCodeQualityGates = null,
bool? EnableDocumentVerificationGate = null,
bool? ReduceEarlyMemoryPressure = null,
int? MaxParallelReadBatch = null
);
/// <summary>
/// base ExecutionPolicy + overlay 병합 유틸.
/// </summary>
public static class ExecutionPolicyMerger
{
public static ExecutionPolicy Apply(ExecutionPolicy basePolicy, ExecutionPolicyOverlay? overlay)
{
if (overlay is null) return basePolicy;
return basePolicy with
{
ToolTemperatureCap = overlay.ToolTemperatureCap ?? basePolicy.ToolTemperatureCap,
NoToolResponseThreshold = overlay.NoToolResponseThreshold ?? basePolicy.NoToolResponseThreshold,
NoToolRecoveryMaxRetries = overlay.NoToolRecoveryMaxRetries ?? basePolicy.NoToolRecoveryMaxRetries,
ForceInitialToolCall = overlay.ForceInitialToolCall ?? basePolicy.ForceInitialToolCall,
EnableCodeQualityGates = overlay.EnableCodeQualityGates ?? basePolicy.EnableCodeQualityGates,
EnableDocumentVerificationGate = overlay.EnableDocumentVerificationGate ?? basePolicy.EnableDocumentVerificationGate,
ReduceEarlyMemoryPressure = overlay.ReduceEarlyMemoryPressure ?? basePolicy.ReduceEarlyMemoryPressure,
MaxParallelReadBatch = overlay.MaxParallelReadBatch ?? basePolicy.MaxParallelReadBatch,
};
}
}

View File

@@ -1,34 +1,52 @@
using System.IO;
using System.IO;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>파일의 특정 부분을 수정하는 도구 (old_string → new_string 패턴).</summary>
/// <summary>
/// 파일의 특정 부분을 수정하는 도구.
/// 두 가지 모드 지원:
/// 1. 기존 모드: old_string → new_string 패턴 매칭
/// 2. 앵커 모드: hash anchor 위치 기반 편집 (pos 파라미터 사용)
/// </summary>
public class FileEditTool : IAgentTool
{
public string Name => "file_edit";
public string Description => "Edit a file by replacing an exact string match. Set replace_all=true to replace all occurrences; otherwise old_string must be unique.";
public string Description =>
"Edit a file. Two modes:\n" +
"1. String mode: old_string + new_string (replace exact match). Set replace_all=true for all occurrences.\n" +
"2. Anchor mode: Use pos (e.g. \"11#VK\") from file_read hash_anchor output + op (replace/delete/insert_before/insert_after) + lines. " +
"Hash anchors detect stale edits — if the file changed since you read it, the edit is rejected.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
// ── 공통 ──
["path"] = new() { Type = "string", Description = "File path to edit" },
["old_string"] = new() { Type = "string", Description = "Exact string to find and replace" },
["new_string"] = new() { Type = "string", Description = "Replacement string" },
// ── 기존 string 모드 ──
["old_string"] = new() { Type = "string", Description = "Exact string to find and replace (string mode)" },
["new_string"] = new() { Type = "string", Description = "Replacement string (string mode)" },
["replace_all"] = new() { Type = "boolean", Description = "Replace all occurrences (default false). If false, old_string must be unique." },
// ── 앵커 모드 ──
["pos"] = new() { Type = "string", Description = "Hash-anchored position, e.g. \"11#VK\" or \"11#VK-15#MB\" for range (anchor mode)" },
["op"] = new() { Type = "string", Description = "Operation: replace, delete, insert_before, insert_after (anchor mode, default: replace)" },
["lines"] = new()
{
Type = "array",
Description = "New lines to insert/replace (anchor mode). Each element is a string.",
Items = new() { Type = "string" }
},
},
Required = ["path", "old_string", "new_string"]
Required = ["path"]
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var path = args.GetProperty("path").SafeGetString() ?? "";
var oldStr = args.GetProperty("old_string").SafeGetString() ?? "";
var newStr = args.GetProperty("new_string").SafeGetString() ?? "";
var replaceAll = args.SafeTryGetProperty("replace_all", out var ra) && ra.GetBoolean();
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (!context.IsPathAllowed(fullPath))
@@ -40,6 +58,248 @@ public class FileEditTool : IAgentTool
if (!await context.CheckWritePermissionAsync(Name, fullPath))
return ToolResult.Fail($"쓰기 권한 거부: {fullPath}");
// 모드 판별: pos 파라미터가 있으면 앵커 모드
if (args.SafeTryGetProperty("pos", out var posEl) && !string.IsNullOrWhiteSpace(posEl.SafeGetString()))
return await ExecuteAnchorModeAsync(args, fullPath, posEl.SafeGetString()!, ct);
return await ExecuteStringModeAsync(args, fullPath, ct);
}
// ════════════════════════════════════════════════════════════
// 앵커 모드 (Hash-Anchored Edits)
// ════════════════════════════════════════════════════════════
private async Task<ToolResult> ExecuteAnchorModeAsync(
JsonElement args, string fullPath, string posStr, CancellationToken ct)
{
try
{
var op = "replace";
if (args.SafeTryGetProperty("op", out var opEl))
op = opEl.SafeGetString()?.ToLowerInvariant() ?? "replace";
// 새 라인 파싱
var newLines = new List<string>();
if (args.SafeTryGetProperty("lines", out var linesEl) && linesEl.ValueKind == JsonValueKind.Array)
{
foreach (var item in linesEl.EnumerateArray())
newLines.Add(item.SafeGetString() ?? "");
}
// op=delete 외에는 lines 필수
if (op != "delete" && newLines.Count == 0)
return ToolResult.Fail("lines array is required for replace/insert operations.");
// 파일 읽기
var read = await TextFileCodec.ReadAllTextAsync(fullPath, ct);
var fileLines = TextFileCodec.SplitLines(read.Text);
// 위치 파싱 (단일: "11#VK", 범위: "11#VK-15#MB")
var dashIdx = posStr.IndexOf('-', 1); // 첫 글자 이후부터 검색 (음수 라인번호 방지)
int startLine, endLine;
string startAnchor, endAnchor;
if (dashIdx > 0)
{
// 범위
if (!HashAnchor.TryParsePosition(posStr[..dashIdx], out startLine, out startAnchor))
return ToolResult.Fail($"Invalid start position: {posStr[..dashIdx]}. Format: LINENUM#HASH");
if (!HashAnchor.TryParsePosition(posStr[(dashIdx + 1)..], out endLine, out endAnchor))
return ToolResult.Fail($"Invalid end position: {posStr[(dashIdx + 1)..]}. Format: LINENUM#HASH");
}
else
{
// 단일 라인
if (!HashAnchor.TryParsePosition(posStr, out startLine, out startAnchor))
return ToolResult.Fail($"Invalid position: {posStr}. Format: LINENUM#HASH (e.g. 11#VK)");
endLine = startLine;
endAnchor = startAnchor;
}
if (startLine > endLine)
return ToolResult.Fail($"Start line ({startLine}) must be ≤ end line ({endLine}).");
// 해시 앵커 검증 (스테일 감지)
var positions = new List<(int, string)> { (startLine, startAnchor) };
if (endLine != startLine)
positions.Add((endLine, endAnchor));
var (allValid, errorDetail) = HashAnchor.ValidatePositions(fileLines, positions);
if (!allValid)
return ToolResult.Fail(errorDetail!);
// 편집 적용
var result = ApplyAnchorOperation(fileLines, op, startLine, endLine, newLines);
if (!result.Success)
return ToolResult.Fail(result.Error!);
// diff 생성 (변경 전후)
var diffPreview = GenerateAnchorDiff(fileLines, result.NewFileLines!, startLine, endLine, op, fullPath);
// 파일 쓰기
var writeEncoding = TextFileCodec.ResolveWriteEncoding(read.Encoding, read.HasBom);
var newContent = string.Join("\n", result.NewFileLines!);
await TextFileCodec.WriteAllTextAsync(fullPath, newContent, writeEncoding, ct);
// 변경된 영역의 새 앵커를 반환 (연쇄 편집 지원)
var updatedAnchors = BuildUpdatedAnchors(result.NewFileLines!, startLine, newLines.Count, op);
return ToolResult.Ok(
$"파일 수정 완료 (anchored {op}): {fullPath}\n\n{diffPreview}\n\n{updatedAnchors}",
fullPath);
}
catch (Exception ex)
{
return ToolResult.Fail($"앵커 편집 실패: {ex.Message}");
}
}
private readonly record struct AnchorEditResult(bool Success, string? Error, string[]? NewFileLines);
private static AnchorEditResult ApplyAnchorOperation(
string[] fileLines, string op, int startLine, int endLine, List<string> newLines)
{
var result = new List<string>(fileLines.Length + newLines.Count);
var startIdx = startLine - 1; // 0-based
var endIdx = endLine - 1; // 0-based
switch (op)
{
case "replace":
// 앞부분
for (int i = 0; i < startIdx; i++)
result.Add(fileLines[i]);
// 새 라인
result.AddRange(newLines);
// 뒷부분
for (int i = endIdx + 1; i < fileLines.Length; i++)
result.Add(fileLines[i]);
break;
case "delete":
for (int i = 0; i < startIdx; i++)
result.Add(fileLines[i]);
for (int i = endIdx + 1; i < fileLines.Length; i++)
result.Add(fileLines[i]);
break;
case "insert_before":
for (int i = 0; i < startIdx; i++)
result.Add(fileLines[i]);
result.AddRange(newLines);
for (int i = startIdx; i < fileLines.Length; i++)
result.Add(fileLines[i]);
break;
case "insert_after":
for (int i = 0; i <= endIdx; i++)
result.Add(fileLines[i]);
result.AddRange(newLines);
for (int i = endIdx + 1; i < fileLines.Length; i++)
result.Add(fileLines[i]);
break;
default:
return new(false, $"Unknown operation: {op}. Use replace, delete, insert_before, or insert_after.", null);
}
return new(true, null, result.ToArray());
}
/// <summary>변경 후 영역의 새 앵커를 생성하여 연쇄 편집을 지원합니다.</summary>
private static string BuildUpdatedAnchors(string[] newFileLines, int startLine, int newLineCount, string op)
{
if (newFileLines.Length == 0) return "";
// 변경 영역 근처 라인의 새 앵커
var sb = new StringBuilder();
sb.AppendLine("Updated anchors (for chained edits):");
var showStart = Math.Max(0, startLine - 2);
var showEnd = op switch
{
"delete" => Math.Min(newFileLines.Length, startLine + 2),
_ => Math.Min(newFileLines.Length, startLine + newLineCount + 1),
};
var anchors = HashAnchor.ComputeAnchors(newFileLines);
for (int i = showStart; i < showEnd; i++)
{
sb.AppendLine(HashAnchor.FormatLine(newFileLines[i], i + 1, anchors[i]));
}
return sb.ToString().TrimEnd();
}
/// <summary>앵커 편집의 diff 프리뷰를 생성합니다.</summary>
private static string GenerateAnchorDiff(
string[] oldLines, string[] newLines, int startLine, int endLine, string op, string filePath)
{
var sb = new StringBuilder();
var fileName = Path.GetFileName(filePath);
sb.AppendLine($"--- {fileName} (before)");
sb.AppendLine($"+++ {fileName} (after)");
const int ctx = 2;
var startIdx = startLine - 1;
var endIdx = endLine - 1;
var ctxStart = Math.Max(0, startIdx - ctx);
sb.AppendLine($"@@ -{ctxStart + 1} @@");
// 앞쪽 컨텍스트
for (int i = ctxStart; i < startIdx && i < oldLines.Length; i++)
sb.AppendLine($" {oldLines[i].TrimEnd('\r')}");
// 삭제된 라인
if (op is "replace" or "delete")
{
for (int i = startIdx; i <= endIdx && i < oldLines.Length; i++)
sb.AppendLine($"-{oldLines[i].TrimEnd('\r')}");
}
// 추가된 라인 (new content)
if (op is "replace" or "insert_before" or "insert_after")
{
// 새 파일에서 삽입된 라인 범위를 추적
var insertStart = op switch
{
"insert_before" => startIdx,
"insert_after" => endIdx + 1,
_ => startIdx, // replace
};
var insertCount = op == "replace"
? newLines.Length - oldLines.Length + (endIdx - startIdx + 1)
: newLines.Length - oldLines.Length;
// 심플하게: old→new 차이를 보여줌
for (int i = insertStart; i < insertStart + Math.Max(0, insertCount) && i < newLines.Length; i++)
sb.AppendLine($"+{newLines[i].TrimEnd('\r')}");
}
// 뒤쪽 컨텍스트
var afterStart = endIdx + 1;
var afterEnd = Math.Min(oldLines.Length, afterStart + ctx);
for (int i = afterStart; i < afterEnd; i++)
sb.AppendLine($" {oldLines[i].TrimEnd('\r')}");
return sb.ToString().TrimEnd();
}
// ════════════════════════════════════════════════════════════
// 기존 String 모드 (하위 호환)
// ════════════════════════════════════════════════════════════
private async Task<ToolResult> ExecuteStringModeAsync(JsonElement args, string fullPath, CancellationToken ct)
{
var oldStr = args.SafeTryGetProperty("old_string", out var osEl) ? osEl.SafeGetString() ?? "" : "";
var newStr = args.SafeTryGetProperty("new_string", out var nsEl) ? nsEl.SafeGetString() ?? "" : "";
var replaceAll = args.SafeTryGetProperty("replace_all", out var ra) && ra.GetBoolean();
if (string.IsNullOrEmpty(oldStr))
return ToolResult.Fail("old_string이 필요합니다. 앵커 모드를 사용하려면 pos 파라미터를 지정하세요.");
try
{
var read = await TextFileCodec.ReadAllTextAsync(fullPath, ct);
@@ -48,15 +308,13 @@ public class FileEditTool : IAgentTool
var count = CountOccurrences(content, oldStr);
if (count == 0)
{
// LLM이 수정할 수 있도록 파일 내용 일부를 함께 반환
var hint = BuildNotFoundHint(content, oldStr);
return ToolResult.Fail($"old_string을 파일에서 찾을 수 없습니다.{hint}");
}
if (!replaceAll && count > 1)
return ToolResult.Fail($"old_string이 {count}번 발견됩니다. replace_all=true로 전체 교체하거나, 고유한 문자열을 지정하세요.");
// Diff Preview: 변경 내용을 컨텍스트와 함께 표시
var diffPreview = GenerateDiff(content, oldStr, newStr, fullPath);
var diffPreview = GenerateStringDiff(content, oldStr, newStr, fullPath);
var updated = content.Replace(oldStr, newStr);
var writeEncoding = TextFileCodec.ResolveWriteEncoding(read.Encoding, read.HasBom);
@@ -74,13 +332,12 @@ public class FileEditTool : IAgentTool
}
/// <summary>변경 전/후 diff를 생성합니다 (unified diff 스타일).</summary>
private static string GenerateDiff(string content, string oldStr, string newStr, string filePath)
private static string GenerateStringDiff(string content, string oldStr, string newStr, string filePath)
{
var lines = content.Split('\n');
var matchIdx = content.IndexOf(oldStr, StringComparison.Ordinal);
if (matchIdx < 0) return "";
// 변경 시작 줄 번호 계산
var startLine = content[..matchIdx].Count(c => c == '\n');
var oldLines = oldStr.Split('\n');
var newLines = newStr.Split('\n');
@@ -90,26 +347,21 @@ public class FileEditTool : IAgentTool
sb.AppendLine($"--- {fileName} (before)");
sb.AppendLine($"+++ {fileName} (after)");
// 컨텍스트 라인 수
const int ctx = 2;
var ctxStart = Math.Max(0, startLine - ctx);
var ctxEnd = Math.Min(lines.Length - 1, startLine + oldLines.Length - 1 + ctx);
sb.AppendLine($"@@ -{ctxStart + 1},{ctxEnd - ctxStart + 1} @@");
// 앞쪽 컨텍스트
for (int i = ctxStart; i < startLine; i++)
sb.AppendLine($" {lines[i].TrimEnd('\r')}");
// 삭제 라인
foreach (var line in oldLines)
sb.AppendLine($"-{line.TrimEnd('\r')}");
// 추가 라인
foreach (var line in newLines)
sb.AppendLine($"+{line.TrimEnd('\r')}");
// 뒤쪽 컨텍스트
var afterEnd = startLine + oldLines.Length;
for (int i = afterEnd; i <= ctxEnd && i < lines.Length; i++)
sb.AppendLine($" {lines[i].TrimEnd('\r')}");
@@ -124,7 +376,6 @@ public class FileEditTool : IAgentTool
var sb = new StringBuilder();
// 유사 행 검색: old_string의 첫 줄로 근사 매치 시도
var firstLine = oldStr.Split('\n')[0].Trim().TrimEnd('\r');
if (firstLine.Length >= 8)
{
@@ -144,7 +395,6 @@ public class FileEditTool : IAgentTool
}
}
// 파일이 짧으면 전체 내용 표시
if (sb.Length == 0)
{
var preview = content.Length > 2000 ? content[..2000] + "\n...(truncated)" : content;

View File

@@ -3,11 +3,14 @@ using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>파일 내용을 읽어 반환하는 도구.</summary>
/// <summary>파일 내용을 읽어 반환하는 도구. hash_anchor=true 시 라인별 해시 앵커를 포함합니다.</summary>
public class FileReadTool : IAgentTool
{
public string Name => "file_read";
public string Description => "Read the contents of a file. Returns the text content with line numbers.";
public string Description =>
"Read the contents of a file. Returns the text content with line numbers.\n" +
"When hash_anchor=true, each line includes a 2-char hash anchor (e.g. \"11#VK| code\") " +
"that can be used with file_edit's anchored mode for precise, conflict-safe edits.";
public ToolParameterSchema Parameters => new()
{
@@ -16,6 +19,7 @@ public class FileReadTool : IAgentTool
["path"] = new() { Type = "string", Description = "File path to read (absolute or relative to work folder)" },
["offset"] = new() { Type = "integer", Description = "Starting line number (1-based). Optional, default 1." },
["limit"] = new() { Type = "integer", Description = "Maximum number of lines to read. Optional, default 500." },
["hash_anchor"] = new() { Type = "boolean", Description = "If true, output each line as LINENUM#HASH| content for anchored editing. Default: use global setting." },
},
Required = ["path"]
};
@@ -28,6 +32,9 @@ public class FileReadTool : IAgentTool
var offset = args.SafeTryGetProperty("offset", out var ofs) ? ofs.GetInt32() : 1;
var limit = args.SafeTryGetProperty("limit", out var lim) ? lim.GetInt32() : 500;
// hash_anchor: 명시적 파라미터 > 전역 설정
var useHashAnchor = ResolveHashAnchorMode(args);
var fullPath = ResolvePath(path, context.WorkFolder);
if (!context.IsPathAllowed(fullPath))
@@ -44,10 +51,21 @@ public class FileReadTool : IAgentTool
var startIdx = Math.Max(0, offset - 1);
var endIdx = Math.Min(total, startIdx + limit);
var sb = new System.Text.StringBuilder();
sb.AppendLine($"[{fullPath}] ({total} lines, showing {startIdx + 1}-{endIdx}, encoding: {read.Encoding.WebName})");
for (int i = startIdx; i < endIdx; i++)
sb.AppendLine($"{i + 1,5}\t{lines[i].TrimEnd('\r')}");
var sb = new System.Text.StringBuilder((endIdx - startIdx) * 80);
if (useHashAnchor)
{
var anchors = HashAnchor.ComputeAnchors(lines);
sb.AppendLine($"[{fullPath}] ({total} lines, showing {startIdx + 1}-{endIdx}, encoding: {read.Encoding.WebName}, hash_anchor=on)");
for (int i = startIdx; i < endIdx; i++)
sb.AppendLine(HashAnchor.FormatLine(lines[i], i + 1, anchors[i]));
}
else
{
sb.AppendLine($"[{fullPath}] ({total} lines, showing {startIdx + 1}-{endIdx}, encoding: {read.Encoding.WebName})");
for (int i = startIdx; i < endIdx; i++)
sb.AppendLine($"{i + 1,5}\t{lines[i].TrimEnd('\r')}");
}
return Task.FromResult(ToolResult.Ok(sb.ToString(), fullPath));
}
@@ -64,4 +82,18 @@ public class FileReadTool : IAgentTool
return Path.GetFullPath(Path.Combine(workFolder, path));
return Path.GetFullPath(path);
}
/// <summary>
/// hash_anchor 모드를 결정합니다.
/// 명시적 파라미터 > 전역 설정(EnableHashAnchoredEdits).
/// </summary>
internal static bool ResolveHashAnchorMode(JsonElement args)
{
if (args.SafeTryGetProperty("hash_anchor", out var haEl))
return haEl.GetBoolean();
// 전역 설정 참조
var app = System.Windows.Application.Current as App;
return app?.SettingsService?.Settings.Llm.EnableHashAnchoredEdits ?? false;
}
}

View File

@@ -0,0 +1,167 @@
using System.IO.Hashing;
using System.Text;
namespace AxCopilot.Services.Agent;
/// <summary>
/// Hash-anchored edits 인프라.
/// 파일 읽기 시 각 라인에 2글자 해시 앵커를 부여하고,
/// 편집 시 해시 앵커로 대상 라인을 정확히 식별 + 스테일 감지.
/// </summary>
internal static class HashAnchor
{
// oh-my-openagent 호환 알파벳 (16자 → 2글자 조합 = 256가지)
private const string Alphabet = "ZPMQVRWSNKTXJBYH";
/// <summary>
/// 라인 내용 + 라인 번호(1-based)로부터 2글자 해시 앵커를 생성합니다.
/// </summary>
public static string ComputeAnchor(string lineContent, int lineNumber)
{
// 정규화: CR 제거 + 후행 공백 제거
var normalized = lineContent.TrimEnd('\r').TrimEnd();
// 빈 줄/공백만 있는 줄 → 라인번호를 시드로 사용 (충돌 감소)
uint hash;
if (IsBlankOrWhitespace(normalized))
{
Span<byte> numBuf = stackalloc byte[4];
System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(numBuf, lineNumber);
hash = XxHash32(numBuf);
}
else
{
var bytes = Encoding.UTF8.GetBytes(normalized);
hash = XxHash32(bytes);
}
// 8비트로 축소 → 알파벳 2글자로 인코딩
var reduced = (byte)(hash ^ (hash >> 8) ^ (hash >> 16) ^ (hash >> 24));
var hi = Alphabet[(reduced >> 4) & 0x0F];
var lo = Alphabet[reduced & 0x0F];
return $"{hi}{lo}";
}
/// <summary>
/// 파일 전체 라인에 대해 해시 앵커를 배열로 반환합니다.
/// anchors[i]는 lines[i]의 앵커 (0-based).
/// </summary>
public static string[] ComputeAnchors(string[] lines)
{
var anchors = new string[lines.Length];
for (int i = 0; i < lines.Length; i++)
anchors[i] = ComputeAnchor(lines[i], i + 1);
return anchors;
}
/// <summary>
/// "LINENUM#HASH" 형식의 위치 문자열을 파싱합니다.
/// 예: "11#VK" → lineNumber=11, anchor="VK"
/// </summary>
public static bool TryParsePosition(string pos, out int lineNumber, out string anchor)
{
lineNumber = 0;
anchor = "";
if (string.IsNullOrWhiteSpace(pos))
return false;
var hashIdx = pos.IndexOf('#');
if (hashIdx < 1 || hashIdx >= pos.Length - 1)
return false;
if (!int.TryParse(pos.AsSpan(0, hashIdx), out lineNumber) || lineNumber < 1)
return false;
anchor = pos[(hashIdx + 1)..].Trim();
return anchor.Length == 2;
}
/// <summary>
/// 앵커가 현재 파일 라인과 일치하는지 검증합니다.
/// </summary>
public static bool Validate(string lineContent, int lineNumber, string expectedAnchor)
{
var actual = ComputeAnchor(lineContent, lineNumber);
return string.Equals(actual, expectedAnchor, StringComparison.Ordinal);
}
/// <summary>
/// 해시 앵커가 포함된 파일 읽기 출력을 생성합니다.
/// 형식: "LINENUM#HASH| content"
/// </summary>
public static string FormatLine(string lineContent, int lineNumber, string anchor)
{
return $"{lineNumber}#{anchor}| {lineContent.TrimEnd('\r')}";
}
/// <summary>
/// 해시 앵커가 포함된 파일 전체 출력을 생성합니다.
/// </summary>
public static string FormatLines(string[] lines, string[] anchors, int startIdx, int endIdx)
{
var sb = new StringBuilder((endIdx - startIdx) * 80);
for (int i = startIdx; i < endIdx && i < lines.Length; i++)
{
var lineNum = i + 1;
sb.Append(lineNum);
sb.Append('#');
sb.Append(anchors[i]);
sb.Append("| ");
sb.AppendLine(lines[i].TrimEnd('\r'));
}
return sb.ToString();
}
/// <summary>
/// 여러 앵커 위치를 검증하고, 불일치가 있으면 상세 에러를 반환합니다.
/// </summary>
public static (bool AllValid, string? ErrorDetail) ValidatePositions(
string[] lines, List<(int LineNumber, string Anchor)> positions)
{
var mismatches = new List<string>();
foreach (var (lineNum, expectedAnchor) in positions)
{
if (lineNum < 1 || lineNum > lines.Length)
{
mismatches.Add($" Line {lineNum}: out of range (file has {lines.Length} lines)");
continue;
}
var actual = ComputeAnchor(lines[lineNum - 1], lineNum);
if (!string.Equals(actual, expectedAnchor, StringComparison.Ordinal))
{
var preview = lines[lineNum - 1].TrimEnd('\r');
if (preview.Length > 80) preview = preview[..80] + "...";
mismatches.Add($" Line {lineNum}: expected #{expectedAnchor}, got #{actual} — \"{preview}\"");
}
}
if (mismatches.Count == 0)
return (true, null);
var detail = $"Hash anchor mismatch — file was modified since last read. Re-read the file to get fresh anchors.\n" +
string.Join("\n", mismatches);
return (false, detail);
}
// ════════════════════════════════════════════
// 내부 유틸
// ════════════════════════════════════════════
private static bool IsBlankOrWhitespace(string s)
{
foreach (var c in s)
{
if (c != ' ' && c != '\t')
return false;
}
return true;
}
private static uint XxHash32(ReadOnlySpan<byte> data)
{
// System.IO.Hashing 사용
return System.IO.Hashing.XxHash32.HashToUInt32(data);
}
}

View File

@@ -15,13 +15,15 @@ public class HtmlSkill : IAgentTool
{
public string Name => "html_create";
public string Description => "Create a styled HTML (.html) document with rich formatting. " +
"REQUIRED: 'title' AND 'body' (HTML string). " +
"If you prefer structured blocks, set body=\"\" and provide 'sections' array instead. " +
"NEVER call this tool with only title — you MUST include body (or sections). " +
"Supports: table of contents (toc), cover page, callouts (.callout-info/warning/tip/danger), " +
"badges (.badge-blue/green/red/yellow/purple), CSS bar charts (.chart-bar), " +
"progress bars (.progress), timelines (.timeline), grid layouts (.grid-2/3/4), " +
"and auto section numbering. " +
"Use 'sections' array for structured content (heading/paragraph/callout/table/chart/cards/list/quote/divider/kpi) " +
"instead of raw HTML body. Use 'accent_color' to override theme accent. Use 'print' for print-ready output. " +
"Available moods: modern, professional, creative, minimal, elegant, dark, colorful, corporate, magazine, dashboard.";
"Use 'accent_color' to override theme accent. Use 'print' for print-ready output. " +
"Available moods: modern, professional, creative, minimal, elegant, dark, colorful, corporate, magazine, dashboard, seminar, seminar-toc.";
public ToolParameterSchema Parameters => new()
{
@@ -29,10 +31,17 @@ public class HtmlSkill : IAgentTool
{
["path"] = new() { Type = "string", Description = "Output file path (.html). Relative to work folder." },
["title"] = new() { Type = "string", Description = "Document title (shown in browser tab and header)" },
["body"] = new() { Type = "string", Description = "HTML body content. Use semantic tags: h2/h3 for sections, " +
["body"] = new() { Type = "string", Description = "[REQUIRED unless 'sections' is provided] HTML body content. " +
"This is the MAIN document content — always include rich, meaningful HTML. " +
"Content depth guideline: at least 58 h2 sections, each with 2+ paragraphs of substantive prose (≥2 sentences per paragraph). " +
"Add variety: tables, lists, callouts, charts, KPIs — not just plain text. " +
"IMPORTANT: when 'numbered' is true, DO NOT prefix headings with numbers yourself (e.g. '<h2>1. 개요</h2>') — " +
"the renderer auto-numbers via CSS. Write headings as plain text: '<h2>개요</h2>'. " +
"Use semantic tags: h2/h3 for sections, " +
"div.callout-info/warning/tip/danger for callouts, span.badge-blue/green/red for badges, " +
"div.chart-bar>div.bar-item for charts, div.grid-2/3/4 for grid layouts, " +
"div.timeline>div.timeline-item for timelines, div.progress for progress bars." },
"div.timeline>div.timeline-item for timelines, div.progress for progress bars. " +
"If you want to use 'sections' array instead, pass body=\"\" (empty string)." },
["sections"] = new()
{
Type = "array",
@@ -52,7 +61,7 @@ public class HtmlSkill : IAgentTool
"170+ built-in icons available. " +
"When both body and sections are provided, sections are appended after body."
},
["mood"] = new() { Type = "string", Description = "Design template mood: modern, professional, creative, minimal, elegant, dark, colorful, corporate, magazine, dashboard. Default: modern" },
["mood"] = new() { Type = "string", Description = "Design template mood: modern, professional, creative, minimal, elegant, dark, colorful, corporate, magazine, dashboard, seminar, seminar-toc. Default: modern" },
["accent_color"] = new() { Type = "string", Description = "Hex color string (e.g. '#2E75B6') that overrides the CSS primary/accent color. Affects buttons, headings, borders, chart bars." },
["style"] = new() { Type = "string", Description = "Optional additional CSS. Appended after mood+shared CSS." },
["toc"] = new() { Type = "boolean", Description = "Auto-generate table of contents from h2/h3 headings. Default: false" },
@@ -64,7 +73,7 @@ public class HtmlSkill : IAgentTool
Description = "Cover page config: {\"title\": \"...\", \"subtitle\": \"...\", \"author\": \"...\", \"date\": \"...\", \"gradient\": \"#hex1,#hex2\"}. Omit to skip cover page."
},
},
Required = ["title"]
Required = ["title", "body"]
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
@@ -78,11 +87,21 @@ public class HtmlSkill : IAgentTool
if (pathEl.ValueKind == JsonValueKind.Null || string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
pathEl = default; // 아래에서 title 기반으로 생성
// body와 sections 둘 다 없으면 오류
bool hasBody = args.SafeTryGetProperty("body", out var bodyEl) && bodyEl.ValueKind != JsonValueKind.Null;
bool hasSections = args.SafeTryGetProperty("sections", out var sectionsEl) && sectionsEl.ValueKind == JsonValueKind.Array;
// body와 sections 둘 다 없으면 오류 — 모델이 재호출 시 참고할 수 있도록 상세 가이드 포함
bool hasBody = args.SafeTryGetProperty("body", out var bodyEl)
&& bodyEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(bodyEl.SafeGetString());
bool hasSections = args.SafeTryGetProperty("sections", out var sectionsEl)
&& sectionsEl.ValueKind == JsonValueKind.Array
&& sectionsEl.GetArrayLength() > 0;
if (!hasBody && !hasSections)
return ToolResult.Fail("필수 파라미터 누락: 'body' 또는 'sections' 중 하나는 반드시 제공해야 합니다.");
{
return ToolResult.Fail(
"필수 파라미터 누락: 'body' (HTML 문자열) 또는 'sections' (배열) 중 하나는 반드시 제공해야 합니다.\n" +
"다시 호출할 때는 title 외에 반드시 body를 포함하세요. 예:\n" +
"{\"name\":\"html_create\",\"arguments\":{\"title\":\"...\",\"body\":\"<h2>개요</h2><p>...</p><h2>상세</h2><p>...</p>\",\"mood\":\"modern\"}}\n" +
"sections 배열을 사용하려면 body=\"\"로 두고 sections에 최소 1개 이상의 블록을 제공하세요.");
}
var title = titleEl.SafeGetString() ?? "Report";
// path가 없으면 title에서 안전한 파일명 생성
@@ -101,7 +120,10 @@ public class HtmlSkill : IAgentTool
var body = hasBody ? (bodyEl.SafeGetString() ?? "") : "";
var customStyle = args.SafeTryGetProperty("style", out var s) ? s.SafeGetString() : null;
var mood = args.SafeTryGetProperty("mood", out var m) ? m.SafeGetString() ?? "modern" : "modern";
var useToc = args.SafeTryGetProperty("toc", out var tocVal) && tocVal.ValueKind == JsonValueKind.True;
// toc는 명시적으로 true/false를 전달하거나, 생략 시 자동 판단 (body에 h2가 3개 이상이면 true)
var hasTocArg = args.SafeTryGetProperty("toc", out var tocVal)
&& (tocVal.ValueKind == JsonValueKind.True || tocVal.ValueKind == JsonValueKind.False);
var useToc = hasTocArg && tocVal.ValueKind == JsonValueKind.True;
var useNumbered = args.SafeTryGetProperty("numbered", out var numVal) && numVal.ValueKind == JsonValueKind.True;
var usePrint = args.SafeTryGetProperty("print", out var printVal) && printVal.ValueKind == JsonValueKind.True;
var hasCover = args.SafeTryGetProperty("cover", out var coverVal) && coverVal.ValueKind == JsonValueKind.Object;
@@ -149,6 +171,9 @@ public class HtmlSkill : IAgentTool
body = hasBody ? body + "\n" + sectionsHtml : sectionsHtml;
}
// HTML 태그 위생화 — LLM이 생성한 깨진 태그 자동 수정
body = SanitizeHtmlTags(body);
// 섹션 번호 자동 부여 — h2, h3에 class="numbered" 추가
if (useNumbered)
body = AddNumberedClass(body);
@@ -156,15 +181,30 @@ public class HtmlSkill : IAgentTool
// h2/h3에서 id 속성 자동 부여 (TOC 앵커용)
body = EnsureHeadingIds(body);
// TOC 생성
var tocHtml = useToc ? GenerateToc(body) : "";
// toc 인자가 명시되지 않았으면 h2 개수로 자동 판단 (3개 이상 → TOC 생성)
if (!hasTocArg && !useToc)
{
var h2Count = Regex.Matches(body, @"<h2\b", RegexOptions.IgnoreCase).Count;
if (h2Count >= 3)
{
useToc = true;
Services.LogService.Debug($"[html_create] toc 자동 활성화 — h2={h2Count}개 감지");
}
}
// TOC 생성 — useNumbered면 TOC 항목도 번호 표시 (본문 CSS 카운터와 일치)
var tocHtml = useToc ? GenerateToc(body, useNumbered) : "";
// 커버 페이지 생성
var coverHtml = hasCover ? GenerateCover(coverVal, title) : "";
// 다크 테마 기본 무드 결정
var isDarkDefault = mood is "dark" or "seminar" or "seminar-toc" or "dashboard";
var defaultTheme = isDarkDefault ? "dark" : "light";
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"ko\">");
sb.AppendLine($"<html lang=\"ko\" data-theme=\"{defaultTheme}\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
@@ -172,6 +212,22 @@ public class HtmlSkill : IAgentTool
sb.AppendLine($"<style>{style}</style>");
sb.AppendLine("</head>");
sb.AppendLine("<body>");
var isSidebarToc = mood == "seminar-toc";
// 테마 전환 버튼 + 플로팅 목차 버튼
sb.AppendLine("<button class=\"ax-theme-toggle\" onclick=\"axToggleTheme()\" title=\"테마 전환\">&#127769;</button>");
if (useToc && !isSidebarToc)
sb.AppendLine("<button class=\"ax-fab-toc\" id=\"axFabToc\" onclick=\"document.querySelector('nav.toc')?.scrollIntoView({behavior:'smooth'})\" title=\"목차로 이동\">&#9776;</button>");
// seminar-toc: page-wrapper + sidebar TOC 구<><EAB5AC><EFBFBD>
if (isSidebarToc)
{
sb.AppendLine("<div class=\"page-wrapper\">");
if (useToc && !string.IsNullOrEmpty(tocHtml))
sb.AppendLine(GenerateSidebarToc(body));
}
sb.AppendLine("<div class=\"container\">");
// 커버 페이지
@@ -191,8 +247,8 @@ public class HtmlSkill : IAgentTool
// 본문을 body-content로 감싸서 좌우 여백 확보
sb.AppendLine("<div class=\"body-content\">");
// TOC
if (!string.IsNullOrEmpty(tocHtml))
// TOC (inline — seminar-toc는 사이드바로 이미 출력했으므로 스킵)
if (!string.IsNullOrEmpty(tocHtml) && !isSidebarToc)
sb.AppendLine(tocHtml);
// 본문 — table 태그에 반응형 래퍼 자동 추가
@@ -208,6 +264,14 @@ public class HtmlSkill : IAgentTool
sb.AppendLine("</div>"); // body-content
sb.AppendLine("</div>"); // container
if (isSidebarToc)
sb.AppendLine("</div>"); // page-wrapper
// 테마 전환 + 플로팅 TOC / 스크롤 스파이 스크립트
sb.AppendLine(TemplateService.ThemeToggleScript);
if (isSidebarToc && useToc)
sb.AppendLine(SidebarTocScrollSpyScript);
sb.AppendLine("</body>");
sb.AppendLine("</html>");
@@ -619,6 +683,50 @@ public class HtmlSkill : IAgentTool
catch { return "#" + hex; }
}
// ─────────────────────────────────────────────────────────────────────────
// HTML 태그 위생화 — LLM이 생성한 깨진/불일치 태그 자동 수정
// ─────────────────────────────────────────────────────────────────────────
/// <summary>외부에서도 호출 가능한 태그 위생화 래퍼.</summary>
public static string SanitizeHtmlTagsPublic(string html) => SanitizeHtmlTags(html);
/// <summary>
/// LLM이 생성한 HTML에서 흔한 태그 오류를 자동 수정합니다.
/// 1) &lt;/strong: → &lt;/strong&gt;: (닫기 '>' 누락)
/// 2) &lt;/em: → &lt;/em&gt;: (같은 패턴)
/// 3) &lt;span class="..."&gt;text&lt;/strong&gt; → &lt;span&gt;text&lt;/span&gt; (태그 불일치)
/// 4) &lt;strong class="..."&gt;text&lt;/span&gt; → &lt;strong&gt;text&lt;/strong&gt; (태그 불일치)
/// </summary>
private static string SanitizeHtmlTags(string html)
{
if (string.IsNullOrEmpty(html)) return html;
// 패턴 1: </tag: 또는 </tag; 패턴 — '>' 누락으로 닫기 태그가 깨진 경우
// 예: </strong: 한종희 → </strong>: 한종희
// </em; 내용 → </em>; 내용
html = Regex.Replace(html, @"</(strong|em|b|i|u|span|a|code|mark)([^>a-zA-Z])", "</$1>$2");
// 패턴 2: <span ...>text</strong> 또는 <span ...>text</b> → </span>으로 교정
// 닫기 태그가 열기 태그와 불일치할 때 — 열기 태그 기준으로 닫기 태그를 수정
html = Regex.Replace(html,
@"<(span|strong|em|b|i|u|a|code|mark)(\s[^>]*)?>([^<]*)</(strong|em|b|i|u|span|a|code|mark)>",
match =>
{
var openTag = match.Groups[1].Value;
var attrs = match.Groups[2].Value;
var content = match.Groups[3].Value;
var closeTag = match.Groups[4].Value;
// 열기/닫기 태그가 불일치하면 열기 태그 기준으로 닫기 교정
if (!string.Equals(openTag, closeTag, StringComparison.OrdinalIgnoreCase))
return $"<{openTag}{attrs}>{content}</{openTag}>";
return match.Value; // 일치하면 그대로
});
return html;
}
// ─────────────────────────────────────────────────────────────────────────
// print CSS
// ─────────────────────────────────────────────────────────────────────────
@@ -669,10 +777,11 @@ public class HtmlSkill : IAgentTool
});
}
/// <summary>h2, h3에 class="numbered" 추가</summary>
/// <summary>h2, h3에 class="numbered" 추가. LLM이 본문에 이미 붙여놓은 "1. " / "1-1. " 접두 번호는 제거 (CSS 카운터와 중복 방지).</summary>
private static string AddNumberedClass(string html)
{
return Regex.Replace(html, @"<(h[23])(\s[^>]*)?>", match =>
// 1) h2/h3 여는 태그에 numbered 클래스 부여
html = Regex.Replace(html, @"<(h[23])(\s[^>]*)?>", match =>
{
var tag = match.Groups[1].Value;
var attrs = match.Groups[2].Value;
@@ -687,10 +796,20 @@ public class HtmlSkill : IAgentTool
return $"<{tag}{attrs} class=\"numbered\">";
});
// 2) h2/h3 본문 앞에 "1. " / "1-1. " / "1) " 등 기존 번호가 있으면 제거.
// CSS 카운터(h2.numbered::before { content: counter(section) '. '; })와 중복되면
// "1. 1. 기업 개요"처럼 번호가 두 번 찍힌다.
html = Regex.Replace(html,
@"(<(h[23])\b[^>]*\bclass\s*=\s*""[^""]*\bnumbered\b[^""]*""[^>]*>)\s*(\d+([.\-)]\s*\d+)*[.\-)]\s+)",
"$1",
RegexOptions.IgnoreCase);
return html;
}
/// <summary>body HTML에서 h2/h3을 파싱해 목차 HTML 생성</summary>
private static string GenerateToc(string html)
/// <summary>body HTML에서 h2/h3을 파싱해 목차 HTML 생성. numbered=true면 본문과 동일한 계층 번호(1., 1-1., ...)를 TOC 항목 앞에 표시.</summary>
private static string GenerateToc(string html, bool numbered = false)
{
var headings = Regex.Matches(html, @"<(h[23])[^>]*id=""([^""]+)""[^>]*>(.*?)</\1>",
RegexOptions.IgnoreCase | RegexOptions.Singleline);
@@ -702,6 +821,9 @@ public class HtmlSkill : IAgentTool
sb.AppendLine("<h2>📋 목차</h2>");
sb.AppendLine("<ul>");
var h2Counter = 0;
var h3Counter = 0;
foreach (Match h in headings)
{
var level = h.Groups[1].Value.ToLower();
@@ -709,7 +831,24 @@ public class HtmlSkill : IAgentTool
// 태그 내부 텍스트에서 HTML 태그 제거
var text = Regex.Replace(h.Groups[3].Value, @"<[^>]+>", "").Trim();
var cssClass = level == "h3" ? " class=\"toc-h3\"" : "";
sb.AppendLine($"<li{cssClass}><a href=\"#{id}\">{text}</a></li>");
string prefix = "";
if (numbered)
{
if (level == "h2")
{
h2Counter++;
h3Counter = 0;
prefix = $"{h2Counter}. ";
}
else // h3
{
h3Counter++;
prefix = $"{Math.Max(1, h2Counter)}-{h3Counter}. ";
}
}
sb.AppendLine($"<li{cssClass}><a href=\"#{id}\">{prefix}{text}</a></li>");
}
sb.AppendLine("</ul>");
@@ -717,6 +856,58 @@ public class HtmlSkill : IAgentTool
return sb.ToString();
}
/// <summary>body HTML에서 h2/h3를 파싱해 고정 사이드바 TOC HTML 생성 (seminar-toc 전용)</summary>
private static string GenerateSidebarToc(string html)
{
var headings = Regex.Matches(html, @"<(h[23])[^>]*id=""([^""]+)""[^>]*>(.*?)</\1>",
RegexOptions.IgnoreCase | RegexOptions.Singleline);
if (headings.Count == 0) return "";
var sb = new StringBuilder();
sb.AppendLine("<div class=\"toc\" id=\"toc\">");
sb.AppendLine("<h3>Table of Contents</h3>");
sb.AppendLine("<div class=\"toc-grid\">");
var sectionNum = 0;
foreach (Match h in headings)
{
var level = h.Groups[1].Value.ToLower();
var id = h.Groups[2].Value;
var text = Regex.Replace(h.Groups[3].Value, @"<[^>]+>", "").Trim();
if (level == "h2")
{
sectionNum++;
sb.AppendLine($"<a href=\"#{id}\"><span class=\"toc-num\">{sectionNum}</span> {Escape(text)}</a>");
}
else
{
sb.AppendLine($"<a href=\"#{id}\" class=\"toc-sub\">{Escape(text)}</a>");
}
}
sb.AppendLine("</div>");
sb.AppendLine("</div>");
return sb.ToString();
}
private const string SidebarTocScrollSpyScript = """
<script>
(function(){
var links=document.querySelectorAll('.toc a[href^="#"]');
var sections=[];
links.forEach(function(l){var id=l.getAttribute('href').slice(1);var el=document.getElementById(id);if(el)sections.push({el:el,link:l});});
function update(){
var cur=null;
for(var i=sections.length-1;i>=0;i--){if(sections[i].el.getBoundingClientRect().top<=80){cur=sections[i];break;}}
links.forEach(function(l){l.classList.remove('active');});
if(cur){cur.link.classList.add('active');var toc=document.getElementById('toc');if(toc){var lr=cur.link.getBoundingClientRect(),tr=toc.getBoundingClientRect();if(lr.top<tr.top+60||lr.bottom>tr.bottom-20)cur.link.scrollIntoView({block:'center',behavior:'smooth'});}}
}
window.addEventListener('scroll',update,{passive:true});update();
})();
</script>
""";
/// <summary>cover 객체에서 커버 페이지 HTML 생성</summary>
private static string GenerateCover(JsonElement cover, string fallbackTitle)
{

View File

@@ -98,13 +98,14 @@ public class AgentContext
"html_create", "markdown_create", "docx_create", "excel_create", "csv_create", "pptx_create",
"chart_create", "script_create", "document_assemble", "format_convert", "template_render", "checkpoint",
"process", "build_run", "git_tool", "http_tool", "open_external", "snippet_runner",
"spawn_agent", "test_loop",
"spawn_agent", "spawn_agents", "test_loop",
};
private static readonly HashSet<string> DangerousAutoTools = new(StringComparer.OrdinalIgnoreCase)
{
"process",
"build_run",
"spawn_agent",
"spawn_agents",
"snippet_runner",
"test_loop",
};
@@ -113,12 +114,12 @@ public class AgentContext
"file_write", "file_edit", "file_manage",
"html_create", "markdown_create", "docx_create", "excel_create", "csv_create", "pptx_create",
"chart_create", "script_create", "document_assemble", "format_convert", "template_render", "checkpoint",
"todo_write", "skill_manager", "project_rule", "task_create", "task_update", "task_stop",
"team_create", "team_delete", "cron_create", "cron_delete", "zip",
"todo_write", "skill_manager", "project_rules", "task_create", "task_update", "task_stop",
"team_create", "team_delete", "cron_create", "cron_delete", "zip_tool",
};
private static readonly HashSet<string> ProcessLikeTools = new(StringComparer.OrdinalIgnoreCase)
{
"process", "build_run", "test_loop", "snippet_runner", "spawn_agent", "git_tool",
"process", "build_run", "test_loop", "snippet_runner", "spawn_agent", "spawn_agents", "git_tool",
};
private readonly object _permissionLock = new();
@@ -126,29 +127,31 @@ public class AgentContext
/// <summary>작업 폴더 경로.</summary>
public string WorkFolder { get; set; } = "";
/// <summary>파일 접근 권한. Default | AcceptEdits | Plan | BypassPermissions | DontAsk | Deny</summary>
public string Permission { get; init; } = "Default";
/// <summary>파일 접근 권한. Default | AcceptEdits | Plan | BypassPermissions | DontAsk | Deny.
/// 실행 중 사용자가 UI에서 권한을 바꾸면 SyncContextFromSettings를 통해 업데이트됨.</summary>
public string Permission { get; set; } = "Default";
/// <summary>도구별 권한 오버라이드. 키: 도구명 또는 tool@pattern, 값: 권한 모드.</summary>
public Dictionary<string, string> ToolPermissions { get; init; } = new();
public Dictionary<string, string> ToolPermissions { get; set; } = new();
/// <summary>차단 경로 패턴 목록.</summary>
public List<string> BlockedPaths { get; init; } = new();
public List<string> BlockedPaths { get; set; } = new();
/// <summary>차단 확장자 목록.</summary>
public List<string> BlockedExtensions { get; init; } = new();
public List<string> BlockedExtensions { get; set; } = new();
/// <summary>현재 활성 탭. "Chat" | "Cowork" | "Code".</summary>
public string ActiveTab { get; init; } = "Chat";
public string ActiveTab { get; set; } = "Chat";
/// <summary>운영 모드. internal(사내) | external(사외).</summary>
public string OperationMode { get; init; } = AxCopilot.Services.OperationModePolicy.InternalMode;
/// <summary>운영 모드. internal(사내) | external(사외).
/// 실행 중 사용자가 설정에서 모드를 바꾸면 SyncContextFromSettings를 통해 업데이트됨.</summary>
public string OperationMode { get; set; } = AxCopilot.Services.OperationModePolicy.InternalMode;
/// <summary>개발자 모드: 상세 이력 표시.</summary>
public bool DevMode { get; init; }
public bool DevMode { get; set; }
/// <summary>개발자 모드: 도구 실행 전 매번 사용자 승인 대기.</summary>
public bool DevModeStepApproval { get; init; }
public bool DevModeStepApproval { get; set; }
/// <summary>권한 확인 콜백 (Ask 모드). 반환값: true=승인, false=거부.</summary>
public Func<string, string, Task<bool>>? AskPermission { get; init; }
@@ -199,6 +202,17 @@ public class AgentContext
public bool IsOutsideWorkspace(string path)
{
if (string.IsNullOrEmpty(WorkFolder)) return false;
if (string.IsNullOrWhiteSpace(path)) return false;
// ── 방어: path 형태가 아닌 식별자(도구명 등)가 잘못 전달되면 내부로 간주 ──
// DescribeToolTarget가 primary 없을 때 toolName("html_create")을 반환하는 경로의 fallback.
// 이 경우 Path.GetFullPath는 앱 CWD 기준으로 해석되어 "외부"로 오판될 위험이 있음.
var looksLikePath = path.Contains('/')
|| path.Contains('\\')
|| Path.IsPathRooted(path)
|| Path.HasExtension(path);
if (!looksLikePath) return false;
try
{
var fullPath = Path.GetFullPath(path);
@@ -239,12 +253,15 @@ public class AgentContext
public string GetEffectiveToolPermission(string toolName, string? target)
{
toolName ??= "";
var normalizedToolName = toolName.Trim();
var normalizedToolName = AgentToolCatalog.Canonicalize(toolName);
if (TryResolvePatternPermission(toolName, target, out var patternPermission))
return ResolveModeForTool(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(patternPermission));
if (ToolPermissions.TryGetValue(toolName, out var toolPerm) &&
if (ToolPermissions.TryGetValue(normalizedToolName, out var toolPerm) &&
!string.IsNullOrWhiteSpace(toolPerm))
return ResolveModeForTool(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(toolPerm));
if (ToolPermissions.TryGetValue(toolName, out toolPerm) &&
!string.IsNullOrWhiteSpace(toolPerm))
return ResolveModeForTool(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(toolPerm));
if (ToolPermissions.TryGetValue("*", out var wildcardPerm) &&
@@ -254,7 +271,7 @@ public class AgentContext
!string.IsNullOrWhiteSpace(defaultPerm))
return ResolveModeForTool(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(defaultPerm));
var fallback = SensitiveTools.Contains(toolName)
var fallback = SensitiveTools.Contains(normalizedToolName)
? PermissionModeCatalog.NormalizeGlobalMode(Permission)
: PermissionModeCatalog.AcceptEdits;
return ResolveModeForTool(normalizedToolName, fallback);
@@ -320,7 +337,7 @@ public class AgentContext
if (ToolPermissions.Count == 0 || string.IsNullOrWhiteSpace(target))
return false;
var normalizedTool = toolName.Trim();
var normalizedTool = AgentToolCatalog.Canonicalize(toolName);
var normalizedTarget = target.Trim();
foreach (var kv in ToolPermissions)
@@ -385,7 +402,7 @@ public class AgentContext
var at = trimmed.IndexOf('@');
if (at > 0 && at < trimmed.Length - 1)
{
ruleTool = trimmed[..at].Trim();
ruleTool = AgentToolCatalog.Canonicalize(trimmed[..at].Trim());
rulePattern = trimmed[(at + 1)..].Trim();
return !string.IsNullOrWhiteSpace(ruleTool) && !string.IsNullOrWhiteSpace(rulePattern);
}
@@ -393,7 +410,7 @@ public class AgentContext
var pipe = trimmed.IndexOf('|');
if (pipe > 0 && pipe < trimmed.Length - 1)
{
ruleTool = trimmed[..pipe].Trim();
ruleTool = AgentToolCatalog.Canonicalize(trimmed[..pipe].Trim());
rulePattern = trimmed[(pipe + 1)..].Trim();
return !string.IsNullOrWhiteSpace(ruleTool) && !string.IsNullOrWhiteSpace(rulePattern);
}
@@ -401,7 +418,7 @@ public class AgentContext
var open = trimmed.IndexOf('(');
if (open > 0 && trimmed.EndsWith(")", StringComparison.Ordinal))
{
ruleTool = trimmed[..open].Trim();
ruleTool = AgentToolCatalog.Canonicalize(trimmed[..open].Trim());
rulePattern = trimmed[(open + 1)..^1].Trim();
return !string.IsNullOrWhiteSpace(ruleTool) && !string.IsNullOrWhiteSpace(rulePattern);
}
@@ -460,9 +477,9 @@ public class AgentContext
return ApplyDangerousAutoGuard(toolName, normalizedMode);
}
private static bool IsWriteTool(string toolName) => WriteTools.Contains(toolName);
private static bool IsWriteTool(string toolName) => WriteTools.Contains(AgentToolCatalog.Canonicalize(toolName));
private static bool IsProcessLikeTool(string toolName) => ProcessLikeTools.Contains(toolName);
private static bool IsProcessLikeTool(string toolName) => ProcessLikeTools.Contains(AgentToolCatalog.Canonicalize(toolName));
private string ApplyDangerousAutoGuard(string toolName, string permission)
{
@@ -472,7 +489,7 @@ public class AgentContext
if (PermissionModeCatalog.IsAuto(permission)
&& !PermissionModeCatalog.IsBypassPermissions(permission)
&& !PermissionModeCatalog.IsDontAsk(permission)
&& DangerousAutoTools.Contains(toolName))
&& DangerousAutoTools.Contains(AgentToolCatalog.Canonicalize(toolName)))
return PermissionModeCatalog.Default;
return permission;

View File

@@ -0,0 +1,326 @@
using static AxCopilot.Services.Agent.AgentLoopService;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 사용자 입력을 분석하여 최적 실행 프로파일을 결정하는 2단계 의도 분류기.
/// Stage 1: 키워드 기반 빠른 분류 (ClassifyTaskType + IntentDetector 통합)
/// Stage 2: (taskType, intentCategory) 조합별 ExecutionPolicyOverlay 매핑
/// Stage 3: (선택적) LLM 1-shot 분류 — confidence가 낮을 때만 발동
/// </summary>
internal sealed class IntentGateService
{
private readonly ILlmService? _llm;
/// <summary>DetectComplexTask에서 매번 재생성 방지용 정적 배열.</summary>
private static readonly string[] Conjunctions =
{
"그리고", "하고", "다음에", "이후에", "그런 다음",
" and then ", " after that ", " also ", " additionally "
};
private static readonly string[] ActionVerbs =
{
"해줘", "해 줘", "만들어", "수정해", "분석해", "작성해",
"검토해", "확인해", "추가해", "삭제해", "변경해"
};
/// <summary>입력 길이 제한 — 50KB 이상은 잘라서 처리.</summary>
private const int MaxInputLength = 50_000;
public IntentGateService(ILlmService? llm = null) => _llm = llm;
/// <summary>
/// 사용자 쿼리를 분석하여 IntentResult를 생성합니다.
/// </summary>
public Task<IntentResult> ClassifyAsync(
string userQuery, string? activeTab, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
// 안전 가드: null/과도한 길이
var safeQuery = userQuery ?? "";
if (safeQuery.Length > MaxInputLength)
safeQuery = safeQuery[..MaxInputLength];
// 한 번만 lowercase 변환 후 하위 메서드에 전달
var lowerQuery = safeQuery.ToLowerInvariant();
// ── Stage 1: 키워드 분류 ──
var taskType = ClassifyTaskTypeKeyword(safeQuery, activeTab);
var (intentCategory, intentConfidence) = IntentDetector.Detect(safeQuery);
// 종합 confidence: taskType 확정도 + IntentDetector 확신도 가중 평균
var taskTypeConfidence = ComputeTaskTypeConfidence(taskType, lowerQuery);
var combinedConfidence = Math.Min(1.0,
taskTypeConfidence * 0.6 + intentConfidence * 0.4);
// ── Stage 2: 프로파일 매핑 ──
var overlay = MapToOverlay(taskType, intentCategory, activeTab);
var scope = ClassifyScopeFromIntent(lowerQuery, activeTab, taskType, intentCategory);
// ── 복합 요청 감지 (P5 연동) ──
var (isComplex, hint) = DetectComplexTask(lowerQuery);
var result = new IntentResult(
TaskType: taskType,
IntentCategory: intentCategory,
Confidence: Math.Round(combinedConfidence, 2, MidpointRounding.AwayFromZero),
PolicyOverlay: overlay,
SuggestedScope: scope,
IsComplexTask: isComplex,
DecompositionHint: hint
);
return Task.FromResult(result);
}
// ════════════════════════════════════════════════════════════
// Stage 1: 키워드 기반 작업 유형 분류
// ════════════════════════════════════════════════════════════
/// <summary>
/// ClassifyTaskType 로직을 통합한 키워드 분류. 기존 AgentLoopService.ClassifyTaskType과 동일 로직.
/// </summary>
internal static string ClassifyTaskTypeKeyword(string? userQuery, string? activeTab)
{
var q = userQuery ?? "";
if (ContainsAny(q, "review", "리뷰", "검토", "code review", "점검"))
return "review";
if (ContainsAny(q, "bug", "fix", "error", "failure", "broken", "오류", "버그", "수정", "고쳐", "깨짐", "실패"))
return "bugfix";
if (ContainsAny(q, "refactor", "cleanup", "rename", "reorganize", "리팩터링", "정리", "개편", "구조 개선"))
return "refactor";
if (!string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)
&& ContainsAny(q, "report", "document", "proposal", "분석서", "보고서", "문서", "제안서"))
return "docs";
if (ContainsAny(q, "feature", "implement", "add", "support", "추가", "구현", "지원", "기능"))
return "feature";
return string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase) ? "feature" : "general";
}
/// <summary>
/// taskType 키워드 매칭 강도로 confidence를 산출합니다.
/// <paramref name="lowerQuery"/>는 이미 ToLowerInvariant 처리된 문자열입니다.
/// </summary>
private static double ComputeTaskTypeConfidence(string taskType, string lowerQuery)
{
// "general"은 폴백이므로 confidence 낮음
if (string.Equals(taskType, "general", StringComparison.Ordinal)) return 0.3;
// 직접 매칭 키워드 수 세기
var hitCount = taskType switch
{
"review" => CountHits(lowerQuery, "review", "리뷰", "검토", "code review", "점검"),
"bugfix" => CountHits(lowerQuery, "bug", "fix", "error", "오류", "버그", "수정", "고쳐", "실패"),
"refactor" => CountHits(lowerQuery, "refactor", "cleanup", "리팩터링", "정리", "개편"),
"docs" => CountHits(lowerQuery, "report", "document", "보고서", "문서", "제안서"),
"feature" => CountHits(lowerQuery, "feature", "implement", "add", "추가", "구현", "기능"),
_ => 0,
};
return hitCount switch
{
>= 3 => 0.95,
2 => 0.85,
1 => 0.7,
_ => 0.5,
};
}
private static int CountHits(string lower, params string[] keywords)
{
var count = 0;
foreach (var kw in keywords)
{
if (lower.Contains(kw, StringComparison.OrdinalIgnoreCase))
count++;
}
return count;
}
// ════════════════════════════════════════════════════════════
// Stage 2: 프로파일 매핑
// ════════════════════════════════════════════════════════════
/// <summary>
/// (taskType, intentCategory) 조합에 따라 ExecutionPolicy overlay를 생성합니다.
/// </summary>
private static ExecutionPolicyOverlay? MapToOverlay(
string taskType, string intentCategory, string? activeTab)
{
return (taskType, intentCategory) switch
{
// 코드 수정 관련
("bugfix", "coding" or "general") => new(
ToolTemperatureCap: 0.2,
ForceInitialToolCall: true,
EnableCodeQualityGates: true),
("bugfix", _) => new(
ToolTemperatureCap: 0.25,
ForceInitialToolCall: true,
EnableCodeQualityGates: true),
("feature", "coding" or "general") => new(
ToolTemperatureCap: 0.3,
ForceInitialToolCall: true,
EnableCodeQualityGates: true),
("refactor", _) => new(
ToolTemperatureCap: 0.25,
ForceInitialToolCall: true,
EnableCodeQualityGates: true),
// 문서 생성
("docs", "document" or "creative" or "general") => new(
EnableDocumentVerificationGate: true,
ReduceEarlyMemoryPressure: true),
("docs", _) => new(
EnableDocumentVerificationGate: true),
// 리뷰/분석
("review", "analysis" or "coding" or "general") => new(
ToolTemperatureCap: 0.3,
EnableCodeQualityGates: true,
ForceInitialToolCall: true),
("review", _) => new(
ToolTemperatureCap: 0.3,
ForceInitialToolCall: true),
// general + 순수 대화 (Chat 탭)
("general", _) when string.Equals(activeTab, "Chat", StringComparison.OrdinalIgnoreCase)
=> null, // Chat 탭은 도구 없음, overlay 불필요
// general + 문서 의도
("general", "document") => new(
EnableDocumentVerificationGate: true),
// general + 분석 의도
("general", "analysis") => new(
ToolTemperatureCap: 0.35,
MaxParallelReadBatch: 8),
// 기타: base policy 그대로
_ => null,
};
}
// ════════════════════════════════════════════════════════════
// 탐색 범위 결정
// ════════════════════════════════════════════════════════════
/// <summary>
/// IntentGate 결과를 기반으로 ExplorationScope를 결정합니다.
/// <paramref name="lowerQuery"/>는 이미 ToLowerInvariant 처리된 문자열입니다.
/// </summary>
private static ExplorationScope ClassifyScopeFromIntent(
string lowerQuery, string? activeTab, string taskType, string intentCategory)
{
if (string.IsNullOrWhiteSpace(lowerQuery))
return ExplorationScope.OpenEnded;
// docs 타입이면서 생성 동사가 있으면 DirectCreation
if (taskType == "docs" && !string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase))
{
if (HasCreationVerb(lowerQuery))
return ExplorationScope.DirectCreation;
}
// document 인텐트 + 생성 동사 → DirectCreation
if (intentCategory == "document"
&& !string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)
&& HasCreationVerb(lowerQuery))
return ExplorationScope.DirectCreation;
// RepoWide
if (ContainsAny(lowerQuery, "전체", "전반", "코드베이스 전체",
"repo-wide", "repository-wide", "전체 구조", "아키텍처", "전체 점검"))
return ExplorationScope.RepoWide;
// Localized
if (lowerQuery.Contains('.') || lowerQuery.Contains('/') || lowerQuery.Contains('\\') ||
ContainsAny(lowerQuery, "file ", "class ", "method ", "function ", "line ",
"bug", "오류", "버그", "예외"))
return ExplorationScope.Localized;
// TopicBased
if (ContainsAny(lowerQuery, "정리", "요약", "보고서", "주제", "관련", "분석"))
return ExplorationScope.TopicBased;
// 탭 기반 기본값
return string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)
? ExplorationScope.Localized
: ExplorationScope.OpenEnded;
}
private static bool HasCreationVerb(string lower)
=> ContainsAny(lower,
"작성해", "써줘", "써 줘", "만들어", "생성해",
"만들어줘", "만들어 줘", "생성해줘", "생성해 줘",
"write", "create", "draft", "generate", "compose",
"작성하", "작성을", "생성하", "생성을",
"작성 부탁", "만들어 부탁");
// ════════════════════════════════════════════════════════════
// 복합 요청 감지 (P5 연동)
// ════════════════════════════════════════════════════════════
/// <summary>
/// 복합 요청을 감지합니다. <paramref name="lowerQuery"/>는 이미 lowercase 변환된 문자열입니다.
/// </summary>
private static (bool IsComplex, string? Hint) DetectComplexTask(string lowerQuery)
{
if (lowerQuery.Length < 20)
return (false, null);
// 접속사/열거 패턴 감지 (클래스 수준 static readonly 배열 사용)
var conjunctionCount = 0;
foreach (var conj in Conjunctions)
{
if (lowerQuery.Contains(conj, StringComparison.Ordinal))
conjunctionCount++;
}
// 동사 열거 패턴 (클래스 수준 static readonly 배열 사용)
var verbCount = 0;
foreach (var verb in ActionVerbs)
{
var idx = 0;
while ((idx = lowerQuery.IndexOf(verb, idx, StringComparison.Ordinal)) >= 0)
{
verbCount++;
idx += verb.Length;
}
}
if (conjunctionCount >= 2 || verbCount >= 3)
{
return (true, "이 요청에 여러 독립 작업이 포함되어 있습니다. spawn_agents로 병렬 처리를 고려하세요.");
}
return (false, null);
}
// ════════════════════════════════════════════════════════════
// 공통 유틸
// ════════════════════════════════════════════════════════════
private static bool ContainsAny(string text, params string[] keywords)
{
foreach (var kw in keywords)
{
if (text.Contains(kw, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
}

View File

@@ -0,0 +1,21 @@
namespace AxCopilot.Services.Agent;
/// <summary>
/// IntentGate 분류 결과. 작업 유형, 인텐트 카테고리, 실행 정책 오버레이를 포함합니다.
/// </summary>
internal sealed record IntentResult(
/// <summary>작업 유형: review, bugfix, refactor, feature, docs, general</summary>
string TaskType,
/// <summary>인텐트 카테고리: coding, translation, analysis, creative, document, math, general</summary>
string IntentCategory,
/// <summary>분류 확신도 0.0~1.0</summary>
double Confidence,
/// <summary>기존 ExecutionPolicy 위에 덮어쓸 sparse override (null이면 base 그대로)</summary>
ExecutionPolicyOverlay? PolicyOverlay,
/// <summary>제안 탐색 범위</summary>
AgentLoopService.ExplorationScope SuggestedScope,
/// <summary>복합 요청 감지 (P5 spawn_agents 연동)</summary>
bool IsComplexTask,
/// <summary>복합 요청 분해 힌트 (P5 연동)</summary>
string? DecompositionHint
);

View File

@@ -16,9 +16,11 @@ public class MarkdownSkill : IAgentTool
public string Name => "markdown_create";
public string Description =>
"Create a Markdown (.md) document. " +
"REQUIRED: 'content' (raw markdown string). " +
"Alternative: set content=\"\" and provide 'sections' array for structured blocks. " +
"NEVER call this tool with only title — you MUST include content (or sections). " +
"Use 'sections' for structured content (heading/paragraph/table/list/callout/code/quote/divider/toc). " +
"Use 'frontmatter' for YAML metadata. Use 'toc' to auto-generate a table of contents. " +
"Use 'content' for raw markdown (backward compatible).";
"Use 'frontmatter' for YAML metadata. Use 'toc' to auto-generate a table of contents.";
public ToolParameterSchema Parameters => new()
{
@@ -26,7 +28,8 @@ public class MarkdownSkill : IAgentTool
{
["path"] = new() { Type = "string", Description = "출력 파일 경로 (.md). 작업 폴더 기준 상대 경로." },
["title"] = new() { Type = "string", Description = "문서 제목. 제공 시 최상단에 '# 제목' 헤딩을 추가합니다." },
["content"] = new() { Type = "string", Description = "원시 마크다운 내용 (하위 호환). sections가 없을 때 사용합니다." },
["content"] = new() { Type = "string", Description = "[REQUIRED unless 'sections' is provided] 원시 마크다운 내용. 문서의 주요 내용을 여기에 작성하세요. " +
"sections 배열을 사용하려면 content=\"\"로 두고 sections에 최소 1개 이상의 블록을 제공하세요." },
["sections"] = new()
{
Type = "array",
@@ -51,18 +54,28 @@ public class MarkdownSkill : IAgentTool
["toc"] = new() { Type = "boolean", Description = "true이면 문서 상단(제목 다음)에 목차를 자동 생성합니다." },
["encoding"] = new() { Type = "string", Description = "파일 인코딩: 'utf-8' (기본값) 또는 'euc-kr'." },
},
Required = []
Required = ["content"]
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
// ── 필수 파라미터 ──────────────────────────────────────────────────
var hasSections = args.SafeTryGetProperty("sections", out var sectionsEl) && sectionsEl.ValueKind == JsonValueKind.Array;
var hasContent = args.SafeTryGetProperty("content", out var contentEl) && contentEl.ValueKind != JsonValueKind.Null;
var hasSections = args.SafeTryGetProperty("sections", out var sectionsEl)
&& sectionsEl.ValueKind == JsonValueKind.Array
&& sectionsEl.GetArrayLength() > 0;
var hasContent = args.SafeTryGetProperty("content", out var contentEl)
&& contentEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(contentEl.SafeGetString());
var hasFrontmatter= args.SafeTryGetProperty("frontmatter",out var frontEl) && frontEl.ValueKind == JsonValueKind.Object;
if (!hasSections && !hasContent)
return ToolResult.Fail("필수 파라미터 누락: 'sections' 또는 'content' 중 하나는 반드시 제공해야 합니다.");
{
return ToolResult.Fail(
"필수 파라미터 누락: 'content' (마크다운 문자열) 또는 'sections' (배열) 중 하나는 반드시 제공해야 합니다.\n" +
"다시 호출할 때는 title 외에 반드시 content를 포함하세요. 예:\n" +
"{\"name\":\"markdown_create\",\"arguments\":{\"title\":\"...\",\"content\":\"## 개요\\n...\\n\\n## 상세\\n...\"}}\n" +
"sections 배열을 사용하려면 content=\"\"로 두고 sections에 최소 1개 이상의 블록을 제공하세요.");
}
// path 미제공 시 title에서 자동 생성
string path;

View File

@@ -0,0 +1,450 @@
using System.IO;
using System.Reflection;
using System.Text;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 모델 패밀리별 프롬프트 전략 어댑터.
/// 모델 이름에서 패밀리를 자동 감지하고, 패밀리별 시스템 프롬프트 변환/보강을 수행합니다.
///
/// 3단계 프롬프트 수준:
/// - "off": 변환 없음 (기존 동작)
/// - "basic": 가벼운 규칙 추가/재배치
/// - "detailed": 임베디드 리소스의 모델별 전용 프롬프트 파일 적용 (수백 줄급)
/// </summary>
internal static class ModelPromptAdapter
{
// 임베디드 리소스 캐시 (한 번 로드 후 재사용)
private static readonly Dictionary<string, string> _detailedPromptCache = new(StringComparer.OrdinalIgnoreCase);
private static bool _detailedPromptsLoaded;
private static readonly object _loadLock = new();
// ════════════════════════════════════════════════════════════
// 모델 패밀리 감지
// ════════════════════════════════════════════════════════════
/// <summary>
/// 모델명/별칭에서 패밀리를 자동 감지합니다.
/// 매칭되지 않으면 "default"를 반환합니다.
/// </summary>
public static string DetectModelFamily(string? modelName)
{
if (string.IsNullOrWhiteSpace(modelName))
return "default";
var lower = modelName.ToLowerInvariant();
if (lower.Contains("qwen")) return "qwen";
if (lower.Contains("deepseek")) return "deepseek";
if (lower.Contains("kimi") || lower.Contains("moonshot") || lower.StartsWith("k1")) return "kimi";
if (lower.Contains("gemma")) return "gemma";
if (lower.Contains("llama")) return "llama";
if (lower.Contains("mistral") || lower.Contains("mixtral")) return "mistral";
if (lower.StartsWith("yi-") || lower.Contains("/yi-")) return "yi";
if (lower.Contains("phi-") || lower.Contains("phi3") || lower.Contains("phi4")) return "phi";
if (lower.Contains("gemini")) return "gemini";
if (lower.Contains("claude")) return "claude";
return "default";
}
/// <summary>
/// 모델 패밀리에 추천되는 기본 ExecutionProfile 키를 반환합니다.
/// </summary>
public static string GetRecommendedExecutionProfile(string modelFamily)
=> modelFamily switch
{
"qwen" => "tool_call_strict",
"gemma" => "tool_call_strict",
"kimi" => "balanced",
"deepseek" => "balanced",
"llama" => "balanced",
"mistral" => "reasoning_first",
"phi" => "tool_call_strict",
"yi" => "balanced",
"gemini" => "reasoning_first",
"claude" => "reasoning_first",
_ => "balanced",
};
// ════════════════════════════════════════════════════════════
// 프롬프트 전략 적용 (3단계)
// ════════════════════════════════════════════════════════════
/// <summary>
/// 프롬프트 수준에 따라 시스템 프롬프트를 어댑테이션합니다.
/// </summary>
/// <param name="basePrompt">원본 시스템 프롬프트</param>
/// <param name="modelFamily">감지된 모델 패밀리</param>
/// <param name="level">"off"/"basic"/"detailed"</param>
public static string AdaptSystemPrompt(string basePrompt, string modelFamily, string level = "basic")
{
if (string.Equals(level, "off", StringComparison.OrdinalIgnoreCase))
return basePrompt;
if (string.Equals(modelFamily, "default", StringComparison.Ordinal))
return basePrompt;
if (string.Equals(level, "detailed", StringComparison.OrdinalIgnoreCase))
return AdaptDetailed(basePrompt, modelFamily);
// basic (기본)
return AdaptBasic(basePrompt, modelFamily);
}
/// <summary>이전 호환: level 없이 호출 시 basic 적용.</summary>
public static string AdaptSystemPrompt(string basePrompt, string modelFamily)
=> AdaptBasic(basePrompt, modelFamily);
/// <summary>
/// 모델 패밀리별 프롬프트 예산(최대 토큰)을 반환합니다.
/// 0이면 제한 없음.
/// </summary>
public static int GetPromptBudget(string modelFamily)
=> modelFamily switch
{
"qwen" => 2000,
"gemma" => 800,
"phi" => 1000,
"kimi" => 0,
"deepseek" => 0,
_ => 0,
};
/// <summary>모델 패밀리의 한국어 라벨을 반환합니다.</summary>
public static string GetFamilyLabel(string modelFamily)
=> modelFamily switch
{
"qwen" => "Qwen",
"deepseek" => "DeepSeek",
"kimi" => "Kimi/Moonshot",
"gemma" => "Gemma",
"llama" => "Llama",
"mistral" => "Mistral/Mixtral",
"yi" => "Yi",
"phi" => "Phi",
"gemini" => "Gemini",
"claude" => "Claude",
_ => "기본",
};
// ════════════════════════════════════════════════════════════
// Basic 모드 (가벼운 규칙 추가)
// ════════════════════════════════════════════════════════════
private static string AdaptBasic(string basePrompt, string modelFamily)
{
var strategy = GetBasicStrategy(modelFamily);
return strategy.Adapt(basePrompt);
}
private static IModelPromptStrategy GetBasicStrategy(string modelFamily)
=> modelFamily switch
{
"qwen" => QwenBasicStrategy.Instance,
"deepseek" => DeepSeekBasicStrategy.Instance,
"kimi" => KimiBasicStrategy.Instance,
"gemma" => GemmaBasicStrategy.Instance,
_ => DefaultStrategy.Instance,
};
// ════════════════════════════════════════════════════════════
// Detailed 모드 (임베디드 리소스 프롬프트)
// ════════════════════════════════════════════════════════════
/// <summary>
/// 임베디드 리소스에서 모델별 상세 프롬프트를 로드하고 기본 프롬프트 앞에 삽입합니다.
/// 프롬프트 구조: [상세 모델 프롬프트] + [구분선] + [기본 프롬프트(메타정보 추출)]
/// </summary>
private static string AdaptDetailed(string basePrompt, string modelFamily)
{
var detailedPrompt = LoadDetailedPrompt(modelFamily);
if (string.IsNullOrEmpty(detailedPrompt))
{
// 상세 프롬프트가 없으면 basic으로 폴백
return AdaptBasic(basePrompt, modelFamily);
}
var sb = new StringBuilder(detailedPrompt.Length + basePrompt.Length + 200);
// 상세 모델 프롬프트 (임베디드 리소스)
sb.AppendLine(detailedPrompt);
sb.AppendLine();
sb.AppendLine("---");
sb.AppendLine();
// 기본 프롬프트에서 메타 정보 추출 (날짜, 작업 폴더, 도구 권한 등)
// 이 부분은 세션마다 달라지므로 반드시 포함해야 함
sb.Append(ExtractSessionContext(basePrompt));
var result = sb.ToString();
// 토큰 예산 적용
var budget = GetPromptBudget(modelFamily);
if (budget > 0)
return TruncateToTokenBudget(result, budget);
return result;
}
/// <summary>
/// 기본 프롬프트에서 세션별 동적 컨텍스트를 추출합니다.
/// (날짜, 작업 폴더, 권한, 워크스페이스 컨텍스트 등)
/// </summary>
private static string ExtractSessionContext(string basePrompt)
{
var sb = new StringBuilder();
sb.AppendLine("## Session Context");
var lines = basePrompt.Split('\n');
var inContextSection = false;
foreach (var rawLine in lines)
{
var line = rawLine.TrimEnd('\r');
var trimmed = line.Trim();
// 항상 포함할 메타 라인
if (trimmed.StartsWith("Today's date", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("Current work folder", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("File permission", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("Active tab", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("## Available Tools", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("Enabled:", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("Disabled:", StringComparison.OrdinalIgnoreCase))
{
sb.AppendLine(line);
continue;
}
// Workspace Context 섹션 전체 포함
if (trimmed.StartsWith("## Workspace Context", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("## Project Rule", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("## Session Learning", StringComparison.OrdinalIgnoreCase))
{
inContextSection = true;
sb.AppendLine(line);
continue;
}
// 새 섹션 시작 시 컨텍스트 섹션 종료
if (inContextSection)
{
if (trimmed.StartsWith("## ") && !trimmed.StartsWith("## Workspace")
&& !trimmed.StartsWith("## Project Rule") && !trimmed.StartsWith("## Session"))
{
inContextSection = false;
}
else
{
sb.AppendLine(line);
}
}
}
return sb.ToString();
}
/// <summary>
/// 임베디드 리소스에서 모델 패밀리별 상세 프롬프트를 로드합니다.
/// 캐시되어 있으면 캐시에서 반환합니다.
/// </summary>
internal static string? LoadDetailedPrompt(string modelFamily)
{
EnsureDetailedPromptsLoaded();
_detailedPromptCache.TryGetValue(modelFamily, out var prompt);
return prompt;
}
private static void EnsureDetailedPromptsLoaded()
{
if (_detailedPromptsLoaded) return;
lock (_loadLock)
{
if (_detailedPromptsLoaded) return;
var assembly = Assembly.GetExecutingAssembly();
var prefix = "AxCopilot.Assets.ModelPrompts.";
foreach (var name in assembly.GetManifestResourceNames())
{
if (!name.StartsWith(prefix) || !name.EndsWith(".md"))
continue;
try
{
using var stream = assembly.GetManifestResourceStream(name);
if (stream == null) continue;
using var reader = new StreamReader(stream, Encoding.UTF8);
var content = reader.ReadToEnd();
// 파일명에서 패밀리 키 추출: AxCopilot.Assets.ModelPrompts.qwen.md → "qwen"
var familyKey = name[prefix.Length..^3]; // ".md" 제거
_detailedPromptCache[familyKey] = content;
}
catch
{
// 로드 실패는 무시 — basic 폴백 사용
}
}
_detailedPromptsLoaded = true;
}
}
// ════════════════════════════════════════════════════════════
// Basic 전략 구현
// ════════════════════════════════════════════════════════════
private interface IModelPromptStrategy
{
string Adapt(string basePrompt);
}
private sealed class DefaultStrategy : IModelPromptStrategy
{
public static readonly DefaultStrategy Instance = new();
public string Adapt(string basePrompt) => basePrompt;
}
// ─── Qwen Basic ───────────────────────────────────────
private sealed class QwenBasicStrategy : IModelPromptStrategy
{
public static readonly QwenBasicStrategy Instance = new();
public string Adapt(string basePrompt)
{
var sb = new StringBuilder();
sb.AppendLine("[MUST] Start every response with a tool call. No text before tool_call.");
sb.AppendLine("[MUST] Call multiple independent tools in the same response.");
sb.AppendLine("[NEVER] Say '알겠습니다', '네', '확인했습니다' before a tool call.");
sb.AppendLine("[NEVER] Output text-only when a tool action is still needed.");
sb.AppendLine();
var bodyStart = basePrompt.IndexOf("---", StringComparison.Ordinal);
if (bodyStart >= 0)
sb.Append(basePrompt[bodyStart..]);
else
sb.Append(basePrompt);
sb.AppendLine();
sb.AppendLine("REMINDER: Your first output MUST be a tool_call, not text. Begin now.");
return TruncateToTokenBudget(sb.ToString(), 2000);
}
}
// ─── DeepSeek Basic ───────────────────────────────────
private sealed class DeepSeekBasicStrategy : IModelPromptStrategy
{
public static readonly DeepSeekBasicStrategy Instance = new();
public string Adapt(string basePrompt)
{
var sb = new StringBuilder();
sb.Append(basePrompt);
sb.AppendLine();
sb.AppendLine("## DeepSeek Execution Rules");
sb.AppendLine("- If you plan internally, keep planning under 2 sentences. Execute immediately after.");
sb.AppendLine("- Never output a plan without following it with tool calls in the same response.");
sb.AppendLine("- After editing code files, verify the build passes before making more changes.");
sb.AppendLine("- After 3+ file edits, run test_loop for regression testing.");
sb.AppendLine("- Use spawn_agent for independent subtasks that can run in parallel.");
return sb.ToString();
}
}
// ─── Kimi Basic ───────────────────────────────────────
private sealed class KimiBasicStrategy : IModelPromptStrategy
{
public static readonly KimiBasicStrategy Instance = new();
public string Adapt(string basePrompt)
{
var sb = new StringBuilder();
sb.Append(basePrompt);
sb.AppendLine();
sb.AppendLine("## Kimi Execution Rules");
sb.AppendLine("- Be concise. Maximum 3 sentences of explanation between tool calls.");
sb.AppendLine("- After every file_edit, immediately call build_run to verify.");
sb.AppendLine("- When analyzing code or documents, structure findings as:");
sb.AppendLine(" ## Finding Title");
sb.AppendLine(" - Evidence: [cite file:line]");
sb.AppendLine(" - Impact: [severity]");
sb.AppendLine(" - Recommendation: [action]");
sb.AppendLine("- For multi-section documents, use document_plan first.");
return sb.ToString();
}
}
// ─── Gemma Basic ──────────────────────────────────────
private sealed class GemmaBasicStrategy : IModelPromptStrategy
{
public static readonly GemmaBasicStrategy Instance = new();
public string Adapt(string basePrompt)
{
var sb = new StringBuilder();
sb.AppendLine("You are AX Copilot, a code assistant with tools.");
sb.AppendLine("RULES:");
sb.AppendLine("1. Always use tools. Respond ONLY with tool calls when action is needed.");
sb.AppendLine("2. One tool per response. Wait for the result before the next step.");
sb.AppendLine("3. Never guess file contents. Read first, then act.");
sb.AppendLine();
var bodyStart = basePrompt.IndexOf("---", StringComparison.Ordinal);
if (bodyStart >= 0)
{
foreach (var line in basePrompt[bodyStart..].Split('\n'))
{
var trimmed = line.Trim();
if (trimmed.StartsWith("Today's date", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("Current work folder", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("File permission", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("## Workspace Context", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("- Name:", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("- Build System:", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("- Primary Language:", StringComparison.OrdinalIgnoreCase))
{
sb.AppendLine(trimmed);
}
}
}
return TruncateToTokenBudget(sb.ToString(), 800);
}
}
// ════════════════════════════════════════════════════════════
// 유틸
// ════════════════════════════════════════════════════════════
/// <summary>
/// 대략적 토큰 수 기준으로 프롬프트를 자릅니다.
/// 한국어 1자 ≈ 1.5 토큰, 영어 1단어 ≈ 1.3 토큰 근사.
/// budget=0이면 자르지 않습니다.
/// </summary>
private static string TruncateToTokenBudget(string text, int budgetTokens)
{
if (budgetTokens <= 0 || string.IsNullOrEmpty(text))
return text;
var charLimit = (int)(budgetTokens * 3.5);
if (text.Length <= charLimit)
return text;
var truncated = text[..charLimit];
var lastNewline = truncated.LastIndexOf('\n');
if (lastNewline > charLimit / 2)
truncated = truncated[..lastNewline];
return truncated + "\n...(truncated for model context budget)";
}
}

View File

@@ -43,6 +43,11 @@ public class MultiReadTool : IAgentTool
Type = "boolean",
Description = "If true, include the detected encoding in each file's header (default false)",
},
["hash_anchor"] = new()
{
Type = "boolean",
Description = "If true, output each line as LINENUM#HASH| content for anchored editing. Default: use global setting.",
},
},
Required = ["paths"],
};
@@ -68,6 +73,7 @@ public class MultiReadTool : IAgentTool
var skipLines = offsetParam - 1; // number of lines to skip (0 = start from line 1)
var showEncoding = args.SafeTryGetProperty("show_encoding", out var seEl) && seEl.GetBoolean();
var useHashAnchor = FileReadTool.ResolveHashAnchorMode(args);
// --- Validate paths array ---
if (!args.SafeTryGetProperty("paths", out var pathsEl) || pathsEl.ValueKind != JsonValueKind.Array)
@@ -125,12 +131,23 @@ public class MultiReadTool : IAgentTool
var takeCount = Math.Min(available, maxLines);
var truncated = available > maxLines;
string[]? anchors = null;
if (useHashAnchor)
anchors = HashAnchor.ComputeAnchors(allLines);
for (var i = 0; i < takeCount; i++)
{
var lineNum = startIdx + i + 1; // 1-based line number in the original file
sb.Append(lineNum);
sb.Append('\t');
sb.AppendLine(allLines[startIdx + i]);
if (useHashAnchor && anchors != null)
{
sb.AppendLine(HashAnchor.FormatLine(allLines[startIdx + i], lineNum, anchors[startIdx + i]));
}
else
{
sb.Append(lineNum);
sb.Append('\t');
sb.AppendLine(allLines[startIdx + i]);
}
}
if (truncated)

View File

@@ -11,6 +11,12 @@ internal static class PermissionModePresentationCatalog
{
public static readonly IReadOnlyList<PermissionModePresentation> Ordered = new[]
{
new PermissionModePresentation(
PermissionModeCatalog.Deny,
"\uE72E",
"읽기 전용",
"기존 파일은 읽기만 가능하며 수정/삭제가 차단되고, 요청에 따른 새 파일 생성은 가능합니다.",
"#6B7280"),
new PermissionModePresentation(
PermissionModeCatalog.Default,
"\uE8D7",
@@ -23,6 +29,12 @@ internal static class PermissionModePresentationCatalog
"편집 자동 승인",
"모든 파일 편집을 자동 승인합니다.",
"#107C10"),
new PermissionModePresentation(
PermissionModeCatalog.Plan,
"\uE769",
"계획 모드",
"파일을 읽고 분석한 뒤, 실행 전에 계획을 먼저 보여줍니다.",
"#D97706"),
new PermissionModePresentation(
PermissionModeCatalog.BypassPermissions,
"\uE814",

View File

@@ -0,0 +1,15 @@
namespace AxCopilot.Services.Agent;
/// <summary>
/// 세션 내 자동 수집된 학습 항목.
/// </summary>
internal sealed record SessionLearning(
/// <summary>카테고리: build_config, code_location, project_structure, error_pattern, dependency</summary>
string Category,
/// <summary>학습 내용 텍스트</summary>
string Content,
/// <summary>추출 시점</summary>
DateTime ExtractedAt,
/// <summary>출처 도구명</summary>
string SourceTool
);

View File

@@ -0,0 +1,308 @@
using System.Text;
using System.Text.RegularExpressions;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 에이전트 루프 실행 중 도구 결과에서 학습 포인트를 자동 추출하여
/// 후속 반복에 컨텍스트로 주입하는 세션 내 단기 학습 수집기.
/// </summary>
internal sealed class SessionLearningCollector
{
private readonly List<SessionLearning> _learnings = new();
private readonly object _lock = new();
private readonly int _maxLearnings;
/// <summary>도구 출력이 이 크기를 초과하면 앞부분만 사용하여 메모리 보호.</summary>
private const int MaxOutputAnalysisLength = 32_000;
public SessionLearningCollector(int maxLearnings = 10)
=> _maxLearnings = maxLearnings;
public int Count { get { lock (_lock) return _learnings.Count; } }
/// <summary>
/// 도구 실행 결과에서 학습 포인트를 추출합니다.
/// 추출 규칙에 매칭되면 자동으로 저장됩니다.
/// </summary>
public void TryExtract(string toolName, string toolOutput, bool success)
{
if (string.IsNullOrWhiteSpace(toolName) || string.IsNullOrWhiteSpace(toolOutput))
return;
// 대용량 출력 보호: 앞부분만 분석 (Split('\n') 메모리 폭발 방지)
var safeOutput = toolOutput.Length > MaxOutputAnalysisLength
? toolOutput[..MaxOutputAnalysisLength]
: toolOutput;
var tool = toolName.ToLowerInvariant();
SessionLearning? learning = null;
try
{
learning = tool switch
{
"build_run" or "test_loop" when !success
=> ExtractBuildConfig(tool, safeOutput),
"grep" or "glob" when success
=> ExtractCodeLocation(tool, safeOutput),
"file_read" when success && IsProjectMetaFile(safeOutput)
=> ExtractProjectStructure(tool, safeOutput),
"dev_env_detect" when success
=> ExtractDependency(tool, safeOutput),
_ when !success && IsFileOperationTool(tool)
=> ExtractErrorPattern(tool, safeOutput),
_ => null,
};
}
catch
{
// 추출 실패는 무시 — 학습 수집은 부수 효과
}
if (learning is null) return;
// 중복 방지: 동일 카테고리+내용이 이미 있으면 건너뜀
lock (_lock)
{
if (_learnings.Any(l => l.Category == learning.Category
&& l.Content.Equals(learning.Content, StringComparison.OrdinalIgnoreCase)))
return;
_learnings.Add(learning);
// FIFO: 초과 시 가장 오래된 항목 일괄 제거 (RemoveAt(0) 반복 대비 O(n) → O(1))
var excess = _learnings.Count - _maxLearnings;
if (excess > 0)
_learnings.RemoveRange(0, excess);
}
}
/// <summary>
/// 현재 누적 학습을 시스템 메시지 형태로 포맷합니다.
/// 학습이 없으면 null을 반환합니다.
/// </summary>
public string? BuildInjectionMessage()
{
List<SessionLearning> snapshot;
lock (_lock)
{
if (_learnings.Count == 0) return null;
snapshot = _learnings.ToList();
}
var sb = new StringBuilder();
sb.AppendLine("[System:SessionLearnings] 이 세션에서 자동 수집된 학습 사항:");
foreach (var l in snapshot)
{
sb.AppendLine($"- [{l.Category}] {l.Content}");
}
sb.AppendLine("위 내용을 참고하여 동일 실수를 반복하지 마세요.");
return sb.ToString().TrimEnd();
}
/// <summary>모든 학습 초기화.</summary>
public void Clear()
{
lock (_lock) _learnings.Clear();
}
// ════════════════════════════════════════════════════════════
// 추출 규칙
// ════════════════════════════════════════════════════════════
/// <summary>빌드/테스트 실패에서 프로젝트 설정 학습.</summary>
private static SessionLearning? ExtractBuildConfig(string tool, string output)
{
var sb = new StringBuilder();
// .NET 타겟 프레임워크 감지
var tfmMatch = Regex.Match(output, @"net\d+\.\d+(?:-windows[\d.]*)?", RegexOptions.IgnoreCase);
if (tfmMatch.Success)
sb.Append($"타겟: {tfmMatch.Value}");
// 에러 코드 추출 (CS, TS, etc.)
var errorCodes = Regex.Matches(output, @"\b(CS|TS|E)\d{4}\b");
if (errorCodes.Count > 0)
{
var codes = errorCodes.Cast<Match>().Select(m => m.Value).Distinct().Take(5);
if (sb.Length > 0) sb.Append(", ");
sb.Append($"에러: {string.Join(", ", codes)}");
}
// 빌드 시스템 감지
if (output.Contains("MSBuild", StringComparison.OrdinalIgnoreCase))
{
if (sb.Length > 0) sb.Append(", ");
sb.Append("빌드: MSBuild");
}
else if (output.Contains("npm", StringComparison.OrdinalIgnoreCase)
|| output.Contains("node", StringComparison.OrdinalIgnoreCase))
{
if (sb.Length > 0) sb.Append(", ");
sb.Append("빌드: npm/node");
}
// 주요 에러 메시지 첫 줄 (Split 대신 라인별 스캔으로 메모리 절약)
var errorLine = FindFirstMatchingLine(output,
l => l.Contains("error", StringComparison.OrdinalIgnoreCase)
&& l.Length > 10 && l.Length < 200);
if (errorLine != null)
{
if (sb.Length > 0) sb.Append(" — ");
sb.Append(Truncate(errorLine, 120));
}
return sb.Length > 0
? new("build_config", sb.ToString(), DateTime.Now, tool)
: null;
}
/// <summary>grep/glob 결과에서 코드 위치 패턴 학습.</summary>
private static SessionLearning? ExtractCodeLocation(string tool, string output)
{
// 파일 경로 추출
var paths = Regex.Matches(output, @"(?:^|\s)([\w./\\-]+\.\w{1,6})(?:\s|:|$)", RegexOptions.Multiline)
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.Where(p => p.Contains('/') || p.Contains('\\'))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(5)
.ToList();
if (paths.Count < 2) return null; // 단일 파일이면 학습 가치 낮음
// 공통 디렉토리 패턴 추출
var dirs = paths
.Select(p => string.Join("/", p.Replace('\\', '/').Split('/').SkipLast(1)))
.Where(d => !string.IsNullOrEmpty(d))
.GroupBy(d => d)
.OrderByDescending(g => g.Count())
.FirstOrDefault();
if (dirs == null) return null;
var content = $"관련 파일이 {dirs.Key}/ 에 집중 ({paths.Count}개 파일)";
return new("code_location", content, DateTime.Now, tool);
}
/// <summary>프로젝트 메타 파일 읽기에서 구조 학습.</summary>
private static SessionLearning? ExtractProjectStructure(string tool, string output)
{
var sb = new StringBuilder();
// csproj: TargetFramework, PackageReference
var tfm = Regex.Match(output, @"<TargetFramework[s]?>(.*?)</TargetFramework[s]?>", RegexOptions.IgnoreCase);
if (tfm.Success)
sb.Append($"프레임워크: {tfm.Groups[1].Value}");
var packages = Regex.Matches(output, @"<PackageReference\s+Include=""([^""]+)""", RegexOptions.IgnoreCase)
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.Take(8)
.ToList();
if (packages.Count > 0)
{
if (sb.Length > 0) sb.Append(", ");
sb.Append($"주요 패키지: {string.Join(", ", packages)}");
}
// package.json: name, dependencies
var pkgName = Regex.Match(output, @"""name""\s*:\s*""([^""]+)""");
if (pkgName.Success)
{
if (sb.Length > 0) sb.Append(", ");
sb.Append($"패키지: {pkgName.Groups[1].Value}");
}
return sb.Length > 0
? new("project_structure", Truncate(sb.ToString(), 200), DateTime.Now, tool)
: null;
}
/// <summary>런타임 감지 결과에서 의존성 학습.</summary>
private static SessionLearning? ExtractDependency(string tool, string output)
{
if (output.Length < 10) return null;
// 주요 런타임/SDK 정보만 추출 (Split 대신 라인별 스캔)
var lines = FindMatchingLines(output, 4,
l => l.Length > 5 && l.Length < 150
&& (l.Contains("SDK", StringComparison.OrdinalIgnoreCase)
|| l.Contains("runtime", StringComparison.OrdinalIgnoreCase)
|| l.Contains("version", StringComparison.OrdinalIgnoreCase)
|| l.Contains("node", StringComparison.OrdinalIgnoreCase)
|| l.Contains("python", StringComparison.OrdinalIgnoreCase)));
if (lines.Count == 0) return null;
return new("dependency", string.Join("; ", lines), DateTime.Now, tool);
}
/// <summary>파일 조작 실패에서 에러 패턴 학습.</summary>
private static SessionLearning? ExtractErrorPattern(string tool, string output)
{
if (output.Length < 10) return null;
var firstLine = FindFirstMatchingLine(output, l => l.Length > 5);
if (firstLine == null) return null;
return new("error_pattern", $"{tool}: {Truncate(firstLine, 150)}", DateTime.Now, tool);
}
// ════════════════════════════════════════════════════════════
// 유틸
// ════════════════════════════════════════════════════════════
private static bool IsProjectMetaFile(string output)
=> output.Contains("<Project", StringComparison.OrdinalIgnoreCase) // csproj
|| output.Contains("\"dependencies\"", StringComparison.OrdinalIgnoreCase) // package.json
|| output.Contains("[package]", StringComparison.OrdinalIgnoreCase) // Cargo.toml
|| output.Contains("\"name\":", StringComparison.OrdinalIgnoreCase); // package.json
private static bool IsFileOperationTool(string tool)
=> tool is "file_write" or "file_edit" or "file_read" or "file_manage";
private static string Truncate(string text, int maxLen)
=> text.Length <= maxLen ? text : text[..maxLen] + "...";
/// <summary>
/// Split('\n') 없이 라인별 스캔하여 첫 매칭 라인을 반환합니다.
/// 대용량 출력에서 전체 배열 할당을 방지합니다.
/// </summary>
private static string? FindFirstMatchingLine(string text, Func<string, bool> predicate)
{
var span = text.AsSpan();
while (span.Length > 0)
{
var newlineIdx = span.IndexOf('\n');
var lineSpan = newlineIdx >= 0 ? span[..newlineIdx] : span;
var line = lineSpan.Trim().ToString();
if (predicate(line))
return line;
if (newlineIdx < 0) break;
span = span[(newlineIdx + 1)..];
}
return null;
}
/// <summary>
/// Split('\n') 없이 라인별 스캔하여 최대 maxCount개의 매칭 라인을 반환합니다.
/// </summary>
private static List<string> FindMatchingLines(string text, int maxCount, Func<string, bool> predicate)
{
var results = new List<string>(maxCount);
var span = text.AsSpan();
while (span.Length > 0 && results.Count < maxCount)
{
var newlineIdx = span.IndexOf('\n');
var lineSpan = newlineIdx >= 0 ? span[..newlineIdx] : span;
var line = lineSpan.Trim().ToString();
if (predicate(line))
results.Add(line);
if (newlineIdx < 0) break;
span = span[(newlineIdx + 1)..];
}
return results;
}
}

View File

@@ -175,7 +175,6 @@ public static class SkillService
/// <summary>
/// paths 전면조건 스킬 활성화.
/// claw-code의 conditional skill 활성화 패턴과 동일하게
/// file path 입력이 매칭될 때만 동적으로 활성화합니다.
/// </summary>
public static string[] ActivateConditionalSkillsForPaths(IEnumerable<string> filePaths, string cwd)
@@ -704,6 +703,7 @@ public static class SkillService
if (ToolNameMap.TryGetValue(normalized, out var mapped))
normalized = mapped;
normalized = AgentToolCatalog.Canonicalize(normalized);
tools.Add(normalized);
}

View File

@@ -0,0 +1,137 @@
using System.Text;
using System.Text.Json;
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
/// <summary>
/// P5: 배치 서브에이전트 생성 도구.
/// 여러 서브에이전트를 한 번에 생성하고 통합 결과를 반환합니다.
/// </summary>
public class SpawnAgentsTool : IAgentTool
{
public string Name => "spawn_agents";
public string Description =>
"Create multiple sub-agents in batch for parallel research or task execution.\n" +
"Each agent runs independently with its own task and optional profile.\n" +
"Collect results later with wait_agents.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["agents"] = new ToolProperty
{
Type = "array",
Description = "List of sub-agent definitions. Each has: id (unique identifier), task (work description), profile (optional: researcher/coder/writer/reviewer/planner).",
Items = new ToolProperty
{
Type = "object",
Description = "Sub-agent definition with id, task, and optional profile."
}
},
},
Required = new() { "agents" }
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
if (!args.SafeTryGetProperty("agents", out var agentsEl) || agentsEl.ValueKind != JsonValueKind.Array)
return ToolResult.Fail("agents array is required.");
var agentDefs = new List<(string Id, string Task, string? Profile)>();
foreach (var item in agentsEl.EnumerateArray())
{
var id = item.SafeTryGetProperty("id", out var idEl) ? idEl.SafeGetString() ?? "" : "";
var task = item.SafeTryGetProperty("task", out var taskEl) ? taskEl.SafeGetString() ?? "" : "";
var profile = item.SafeTryGetProperty("profile", out var profEl) ? profEl.SafeGetString() : null;
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(task))
return ToolResult.Fail($"Each agent must have non-empty 'id' and 'task'. Found invalid entry.");
agentDefs.Add((id, task, profile));
}
if (agentDefs.Count == 0)
return ToolResult.Fail("agents array is empty.");
// 용량 사전 검증 (빠른 실패용 — 실제 원자성은 SubAgentTool.ExecuteAsync 내부 lock에서 보장)
var app = System.Windows.Application.Current as App;
var maxAgents = app?.SettingsService?.Settings.Llm.MaxSubAgents ?? 5;
var activeTasks = SubAgentTool.ActiveTasks;
var running = activeTasks.Values.Count(x => x.CompletedAt == null);
if (running + agentDefs.Count > maxAgents)
return ToolResult.Fail(
$"Cannot spawn {agentDefs.Count} agents: {running} already running, max is {maxAgents}.");
// 중복 ID 검사
var duplicateIds = agentDefs.GroupBy(a => a.Id, StringComparer.OrdinalIgnoreCase)
.Where(g => g.Count() > 1)
.Select(g => g.Key)
.ToList();
if (duplicateIds.Count > 0)
return ToolResult.Fail($"Duplicate agent ids: {string.Join(", ", duplicateIds)}");
// 기존 활성 태스크와 ID 충돌 검사
var conflictIds = agentDefs.Where(a => activeTasks.ContainsKey(a.Id)).Select(a => a.Id).ToList();
if (conflictIds.Count > 0)
return ToolResult.Fail($"Agent ids already exist: {string.Join(", ", conflictIds)}");
// 각 에이전트를 SubAgentTool을 통해 생성
var spawnTool = new SubAgentTool();
var results = new List<(string Id, bool Success, string Message)>();
var cancelled = false;
foreach (var (id, task, profile) in agentDefs)
{
// 루프 중 취소 감지 — 이미 생성된 에이전트는 유지하고 나머지만 건너뜀
if (ct.IsCancellationRequested)
{
cancelled = true;
results.Add((id, false, "Cancelled: batch spawn interrupted."));
continue;
}
// SubAgentTool.ExecuteAsync용 JsonElement 구성
var jsonObj = new Dictionary<string, object?>
{
["id"] = id,
["task"] = task,
};
if (!string.IsNullOrWhiteSpace(profile))
jsonObj["profile"] = profile;
var jsonStr = JsonSerializer.Serialize(jsonObj);
using var doc = JsonDocument.Parse(jsonStr);
var result = await spawnTool.ExecuteAsync(doc.RootElement, context, ct).ConfigureAwait(false);
results.Add((id, result.Success, result.Output ?? ""));
}
// 통합 결과 메시지
var sb = new StringBuilder();
var successCount = results.Count(r => r.Success);
var failCount = results.Count(r => !r.Success);
sb.AppendLine($"Batch spawn: {successCount} started, {failCount} failed (total: {agentDefs.Count}){(cancelled ? " [partially cancelled]" : "")}");
sb.AppendLine();
foreach (var (id, success, message) in results)
{
var status = success ? "✓" : "✗";
var profileName = agentDefs.First(a => a.Id == id).Profile ?? "researcher";
sb.AppendLine($"{status} [{id}] profile={profileName}");
if (!success)
sb.AppendLine($" Error: {message}");
}
sb.AppendLine();
sb.AppendLine("Use wait_agents to collect results when ready.");
return failCount == agentDefs.Count
? ToolResult.Fail(sb.ToString().TrimEnd())
: ToolResult.Ok(sb.ToString().TrimEnd());
}
}

View File

@@ -0,0 +1,144 @@
namespace AxCopilot.Services.Agent;
/// <summary>
/// 서브에이전트 실행 프로파일. 작업 유형별로 다른 system prompt, 도구, temperature를 적용합니다.
/// </summary>
internal sealed record SubAgentProfile(
string Name,
string Description,
string SystemPromptPrefix,
string[] EnabledToolNames,
string[] DisabledToolNames,
string FilePermission,
double? TemperatureOverride
);
/// <summary>
/// 5개 빌트인 서브에이전트 프로파일 카탈로그.
/// </summary>
internal static class SubAgentProfileCatalog
{
// ── 기본 읽기 전용 도구 세트 (기존 SubAgentTool과 동일) ──
private static readonly string[] ResearcherTools =
{
"file_read", "glob", "grep", "folder_map", "document_read",
"dev_env_detect", "git_tool", "lsp_code_intel", "code_search",
"code_review", "project_rule", "skill_manager", "json_tool",
"regex_tool", "diff_tool", "base64_tool", "hash_tool",
"datetime_tool", "math_tool", "xml_tool", "multi_read",
"file_info", "document_review",
};
private static readonly string[] WriterExtraTools =
{
"html_create", "docx_create", "markdown_create", "csv_create",
"excel_create", "pptx_create", "document_plan", "file_write",
};
private static readonly string[] CoderExtraTools =
{
"file_write", "file_edit", "build_run", "process",
"test_loop", "snippet_runner",
};
private static readonly string[] ReviewerExtraTools =
{
"code_review", "document_review", "git_tool",
};
// ── 항상 비활성화할 도구 (서브에이전트 재귀 방지) ──
private static readonly string[] AlwaysDisabled =
{
"spawn_agent", "spawn_agents", "wait_agents",
"memory", "notify", "open_external", "user_ask",
"checkpoint", "diff_preview", "playbook", "http_tool",
"clipboard", "sql_tool",
};
/// <summary>
/// 프로파일 이름으로 프로파일을 반환합니다. null/미지정이면 researcher(기본).
/// </summary>
public static SubAgentProfile Get(string? profileName)
{
return (profileName?.Trim().ToLowerInvariant()) switch
{
"coder" => new SubAgentProfile(
Name: "coder",
Description: "코드 수정 가능한 서브에이전트",
SystemPromptPrefix:
"You are a coding sub-agent for AX Copilot.\n" +
"You can read, write, and edit files, and run builds and tests.\n" +
"Focus on making the minimal correct change. Verify with build/test after editing.\n" +
"Do not ask the user questions.",
EnabledToolNames: ResearcherTools.Concat(CoderExtraTools).Distinct().ToArray(),
DisabledToolNames: AlwaysDisabled,
FilePermission: "AcceptEdits",
TemperatureOverride: 0.2),
"writer" => new SubAgentProfile(
Name: "writer",
Description: "문서 생성 서브에이전트",
SystemPromptPrefix:
"You are a document creation sub-agent for AX Copilot.\n" +
"You can create documents (HTML, DOCX, Markdown, Excel, PPT) and write files.\n" +
"Focus on producing well-structured, complete documents.\n" +
"Do not ask the user questions.",
EnabledToolNames: ResearcherTools.Concat(WriterExtraTools).Distinct().ToArray(),
DisabledToolNames: AlwaysDisabled,
FilePermission: "AcceptEdits",
TemperatureOverride: 0.35),
"reviewer" => new SubAgentProfile(
Name: "reviewer",
Description: "코드 리뷰 서브에이전트",
SystemPromptPrefix:
"You are a review sub-agent for AX Copilot.\n" +
"You perform code reviews and document reviews.\n" +
"Produce structured findings with P0-P3 severity ratings.\n" +
"Focus on concrete defects, regressions, and missing tests.\n" +
"Do not ask the user questions. Do not edit files.",
EnabledToolNames: ResearcherTools.Concat(ReviewerExtraTools).Distinct().ToArray(),
DisabledToolNames: AlwaysDisabled.Concat(new[] { "file_write", "file_edit", "process" }).ToArray(),
FilePermission: "Deny",
TemperatureOverride: 0.25),
"planner" => new SubAgentProfile(
Name: "planner",
Description: "작업 분해/계획 서브에이전트",
SystemPromptPrefix:
"You are a planning sub-agent for AX Copilot.\n" +
"Decompose tasks into ordered steps, identify the minimum file set,\n" +
"and highlight the primary risk for each step.\n" +
"Do not ask the user questions. Do not edit files.",
EnabledToolNames: new[]
{
"folder_map", "glob", "grep", "file_read", "document_read",
"dev_env_detect", "lsp_code_intel", "multi_read", "file_info",
"project_rule",
},
DisabledToolNames: AlwaysDisabled.Concat(new[] { "file_write", "file_edit", "process" }).ToArray(),
FilePermission: "Deny",
TemperatureOverride: 0.3),
// researcher (기본) — 기존 SubAgentTool 동작과 완전히 동일
_ => new SubAgentProfile(
Name: "researcher",
Description: "읽기 전용 조사 서브에이전트",
SystemPromptPrefix:
"You are a focused sub-agent for AX Copilot.\n" +
"You are running a bounded, read-only investigation.\n" +
"Use tools to inspect the project, gather evidence, and produce an actionable result.\n" +
"Do not ask the user questions.\n" +
"Do not attempt file edits, command execution, notifications, or external side effects.\n" +
"Prefer direct evidence from files and tool results over speculation.\n" +
"If something is uncertain, say so briefly and identify what evidence is missing.",
EnabledToolNames: ResearcherTools,
DisabledToolNames: AlwaysDisabled,
FilePermission: "Deny",
TemperatureOverride: null),
};
}
/// <summary>프로파일 목록 (도움말/description용).</summary>
public static readonly string[] AllProfileNames = { "researcher", "coder", "writer", "reviewer", "planner" };
}

View File

@@ -35,6 +35,11 @@ public class SubAgentTool : IAgentTool
Type = "string",
Description = "A unique sub-agent identifier used by wait_agents."
},
["profile"] = new ToolProperty
{
Type = "string",
Description = "Execution profile: researcher (default, read-only), coder (can edit/build), writer (doc creation), reviewer (code review), planner (task decomposition)."
},
},
Required = new() { "task", "id" }
};
@@ -46,6 +51,7 @@ public class SubAgentTool : IAgentTool
{
var task = args.SafeTryGetProperty("task", out var t) ? t.SafeGetString() ?? "" : "";
var id = args.SafeTryGetProperty("id", out var i) ? i.SafeGetString() ?? "" : "";
var profileName = args.SafeTryGetProperty("profile", out var p) ? p.SafeGetString() : null;
if (string.IsNullOrWhiteSpace(task) || string.IsNullOrWhiteSpace(id))
return Task.FromResult(ToolResult.Fail("task and id are required."));
@@ -80,7 +86,7 @@ public class SubAgentTool : IAgentTool
{
try
{
var result = await RunSubAgentAsync(id, task, context, cts.Token).ConfigureAwait(false);
var result = await RunSubAgentAsync(id, task, context, profileName, cts.Token).ConfigureAwait(false);
subTask.Result = result;
subTask.Success = true;
NotifyStatus(new SubAgentStatusEvent
@@ -144,11 +150,15 @@ public class SubAgentTool : IAgentTool
$"Sub-agent '{id}' started.\nTask: {task}\nUse wait_agents later to collect the result."));
}
private static async Task<string> RunSubAgentAsync(string id, string task, AgentContext parentContext, CancellationToken ct)
private static async Task<string> RunSubAgentAsync(string id, string task, AgentContext parentContext, string? profileName, CancellationToken ct)
{
var settings = CreateSubAgentSettings(parentContext);
var profile = SubAgentProfileCatalog.Get(profileName);
var settings = CreateSubAgentSettings(parentContext, profile);
using var llm = new LlmService(settings);
using var tools = await CreateSubAgentRegistryAsync(settings).ConfigureAwait(false);
// P2: 프로파일별 temperature override
if (profile.TemperatureOverride.HasValue)
llm.PushInferenceOverride(temperature: profile.TemperatureOverride.Value);
using var tools = await CreateSubAgentRegistryAsync(settings, profile).ConfigureAwait(false);
var loop = new AgentLoopService(llm, tools, settings)
{
@@ -160,7 +170,7 @@ public class SubAgentTool : IAgentTool
new()
{
Role = "system",
Content = BuildSubAgentSystemPrompt(task, parentContext),
Content = BuildSubAgentSystemPrompt(task, parentContext, profile),
},
new()
{
@@ -189,93 +199,151 @@ public class SubAgentTool : IAgentTool
return sb.ToString().TrimEnd();
}
private static SettingsService CreateSubAgentSettings(AgentContext parentContext)
private static SettingsService CreateSubAgentSettings(AgentContext parentContext, SubAgentProfile profile)
{
var settings = new SettingsService();
settings.Load();
var llm = settings.Settings.Llm;
llm.WorkFolder = parentContext.WorkFolder;
llm.FilePermission = "Deny";
llm.FilePermission = profile.FilePermission;
llm.AgentHooks = new();
llm.ToolPermissions = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
llm.DisabledTools = new List<string>
{
"spawn_agent",
"wait_agents",
"file_write",
"file_edit",
"process",
"build_run",
"snippet_runner",
"memory",
"notify",
"open_external",
"user_ask",
"checkpoint",
"diff_preview",
"playbook",
"http_tool",
"clipboard",
"sql_tool",
};
llm.DisabledTools = profile.DisabledToolNames.ToList();
return settings;
}
private static async Task<ToolRegistry> CreateSubAgentRegistryAsync(SettingsService settings)
/// <summary>도구 이름 → 팩토리 매핑 (인스턴스를 필요한 것만 생성).</summary>
private static readonly Dictionary<string, Func<IAgentTool>> ToolFactories =
new(StringComparer.OrdinalIgnoreCase)
{
["file_read"] = () => new FileReadTool(),
["glob"] = () => new GlobTool(),
["grep"] = () => new GrepTool(),
["folder_map"] = () => new FolderMapTool(),
["document_read"] = () => new DocumentReaderTool(),
["dev_env_detect"] = () => new DevEnvDetectTool(),
["git_tool"] = () => new GitTool(),
["lsp_code_intel"] = () => new LspTool(),
["code_search"] = () => new CodeSearchTool(),
["code_review"] = () => new CodeReviewTool(),
["project_rule"] = () => new ProjectRuleTool(),
["skill_manager"] = () => new SkillManagerTool(),
["json_tool"] = () => new JsonTool(),
["regex_tool"] = () => new RegexTool(),
["diff_tool"] = () => new DiffTool(),
["base64_tool"] = () => new Base64Tool(),
["hash_tool"] = () => new HashTool(),
["datetime_tool"] = () => new DateTimeTool(),
["math_tool"] = () => new MathTool(),
["xml_tool"] = () => new XmlTool(),
["multi_read"] = () => new MultiReadTool(),
["file_info"] = () => new FileInfoTool(),
["document_review"] = () => new DocumentReviewTool(),
// coder 프로파일용
["file_write"] = () => new FileWriteTool(),
["file_edit"] = () => new FileEditTool(),
["build_run"] = () => new BuildRunTool(),
["process"] = () => new ProcessTool(),
["test_loop"] = () => new TestLoopTool(),
["snippet_runner"] = () => new SnippetRunnerTool(),
// writer 프로파일용
["html_create"] = () => new HtmlSkill(),
["docx_create"] = () => new DocxSkill(),
["markdown_create"] = () => new MarkdownSkill(),
["csv_create"] = () => new CsvSkill(),
["excel_create"] = () => new ExcelSkill(),
["pptx_create"] = () => new PptxSkill(),
["document_plan"] = () => new DocumentPlannerTool(),
};
private static async Task<ToolRegistry> CreateSubAgentRegistryAsync(SettingsService settings, SubAgentProfile profile)
{
var registry = new ToolRegistry();
registry.Register(new FileReadTool());
registry.Register(new GlobTool());
registry.Register(new GrepTool());
registry.Register(new FolderMapTool());
registry.Register(new DocumentReaderTool());
registry.Register(new DevEnvDetectTool());
registry.Register(new GitTool());
registry.Register(new LspTool());
registry.Register(new CodeSearchTool());
registry.Register(new CodeReviewTool());
registry.Register(new ProjectRuleTool());
registry.Register(new SkillManagerTool());
registry.Register(new JsonTool());
registry.Register(new RegexTool());
registry.Register(new DiffTool());
registry.Register(new Base64Tool());
registry.Register(new HashTool());
registry.Register(new DateTimeTool());
registry.Register(new MathTool());
registry.Register(new XmlTool());
registry.Register(new MultiReadTool());
registry.Register(new FileInfoTool());
registry.Register(new DocumentReviewTool());
// 필요한 도구만 인스턴스 생성 (기존: 전체 63개 생성 후 필터 → 개선: 필요한 것만 팩토리 호출)
foreach (var name in profile.EnabledToolNames)
{
if (ToolFactories.TryGetValue(name, out var factory))
registry.Register(factory());
}
await registry.RegisterMcpToolsAsync(settings.Settings.Llm.McpServers).ConfigureAwait(false);
return registry;
}
private static string BuildSubAgentSystemPrompt(string task, AgentContext parentContext)
private static string BuildSubAgentSystemPrompt(string task, AgentContext parentContext, SubAgentProfile profile)
{
var sb = new StringBuilder();
sb.AppendLine("You are a focused sub-agent for AX Copilot.");
sb.AppendLine("You are running a bounded, read-only investigation.");
sb.AppendLine("Use tools to inspect the project, gather evidence, and produce an actionable result.");
sb.AppendLine("Do not ask the user questions.");
sb.AppendLine("Do not attempt file edits, command execution, notifications, or external side effects.");
sb.AppendLine("Prefer direct evidence from files and tool results over speculation.");
sb.AppendLine("If something is uncertain, say so briefly and identify what evidence is missing.");
// P2: 프로파일별 시스템 프롬프트 접두사 사용
sb.AppendLine(profile.SystemPromptPrefix);
if (!string.IsNullOrWhiteSpace(parentContext.WorkFolder))
sb.AppendLine($"Current work folder: {parentContext.WorkFolder}");
if (!string.IsNullOrWhiteSpace(parentContext.ActiveTab))
sb.AppendLine($"Current tab: {parentContext.ActiveTab}");
// P4: 워크스페이스 컨텍스트 자동 주입
var wsContext = WorkspaceContextGenerator.LoadContext(parentContext.WorkFolder);
if (!string.IsNullOrWhiteSpace(wsContext))
{
sb.AppendLine();
sb.AppendLine("Workspace context:");
sb.AppendLine(wsContext.Length > 2000 ? wsContext[..2000] + "\n...(truncated)" : wsContext);
}
sb.AppendLine();
sb.AppendLine("Investigation rules:");
sb.AppendLine("1. Start by reading the directly relevant files, not by summarizing from memory.");
sb.AppendLine("2. If the task mentions a code path, type, method, or feature, use grep/glob to find references and callers.");
sb.AppendLine("3. If the task is about bugs, trace likely cause -> affected files -> validation evidence.");
sb.AppendLine("4. If the task is about implementation planning, identify the minimum file set and the main risk.");
sb.AppendLine("5. If the task is about review, prioritize concrete defects, regressions, and missing tests.");
// 프로파일별 작업 규칙
switch (profile.Name)
{
case "coder":
sb.AppendLine("Coding rules:");
sb.AppendLine("1. Read the relevant files first to understand existing patterns.");
sb.AppendLine("2. Make the minimal correct change — do not refactor unrelated code.");
sb.AppendLine("3. After editing, verify with build_run or test_loop.");
sb.AppendLine("4. If the build fails, fix the issue immediately.");
sb.AppendLine("5. Report what was changed and the verification result.");
break;
case "writer":
sb.AppendLine("Document creation rules:");
sb.AppendLine("1. Inspect existing documents or source files for context.");
sb.AppendLine("2. Produce well-structured, complete documents.");
sb.AppendLine("3. Use appropriate formatting for the target format.");
sb.AppendLine("4. Verify file was created successfully.");
break;
case "reviewer":
sb.AppendLine("Review rules:");
sb.AppendLine("1. Start by reading the directly relevant files.");
sb.AppendLine("2. Rate each finding P0 (critical) through P3 (minor).");
sb.AppendLine("3. Prioritize concrete defects, regressions, and missing tests.");
sb.AppendLine("4. Cite exact file paths and line ranges as evidence.");
sb.AppendLine("5. Do not suggest edits — only report findings.");
break;
case "planner":
sb.AppendLine("Planning rules:");
sb.AppendLine("1. Inspect the codebase to understand the current architecture.");
sb.AppendLine("2. Decompose the task into ordered steps with clear dependencies.");
sb.AppendLine("3. Identify the minimum file set for each step.");
sb.AppendLine("4. Highlight the primary risk for each step.");
sb.AppendLine("5. Suggest a validation strategy.");
break;
default: // researcher
sb.AppendLine("Investigation rules:");
sb.AppendLine("1. Start by reading the directly relevant files, not by summarizing from memory.");
sb.AppendLine("2. If the task mentions a code path, type, method, or feature, use grep/glob to find references and callers.");
sb.AppendLine("3. If the task is about bugs, trace likely cause -> affected files -> validation evidence.");
sb.AppendLine("4. If the task is about implementation planning, identify the minimum file set and the main risk.");
sb.AppendLine("5. If the task is about review, prioritize concrete defects, regressions, and missing tests.");
break;
}
var workflowHints = BuildSubAgentWorkflowHints(task, parentContext.ActiveTab);
if (!string.IsNullOrWhiteSpace(workflowHints))
{

View File

@@ -22,8 +22,33 @@ public static class TemplateService
new("corporate", "기업 공식", "🏢", "보수적인 레이아웃, 로고 영역, 페이지 번호 — 사내 공식 보고서"),
new("magazine", "매거진", "📰", "멀티 컬럼, 큰 히어로 헤더, 인용 강조 — 뉴스레터·매거진"),
new("dashboard", "대시보드", "📈", "KPI 카드, 차트 영역, 그리드 레이아웃 — 데이터 대시보드"),
new("seminar", "세미나", "🎓", "다크/라이트 테마 전환, 그라데이션 히어로, 파이프라인 다이어그램 — 기술 세미나·발표 자료"),
new("seminar-toc", "세미나 (사이드 목차)", "📑", "좌측 고정 사이드바 목차 + 세미나 스타일 — 긴 기술 문서·레퍼런스"),
];
/// <summary>테마 전환 + 플로팅 TOC JS 스크립트 (HTML에 삽입용).</summary>
public const string ThemeToggleScript = """
<script>
function axToggleTheme(){
var h=document.documentElement,t=h.getAttribute('data-theme')==='dark'?'light':'dark';
h.setAttribute('data-theme',t);
var b=document.querySelector('.ax-theme-toggle');
if(b) b.innerHTML=t==='dark'?'&#127769;':'&#9728;&#65039;';
try{localStorage.setItem('ax-doc-theme',t);}catch(e){}
}
(function(){
try{var s=localStorage.getItem('ax-doc-theme');
if(s){document.documentElement.setAttribute('data-theme',s);
var b=document.querySelector('.ax-theme-toggle');
if(b) b.innerHTML=s==='dark'?'&#127769;':'&#9728;&#65039;';}}catch(e){}
var fab=document.getElementById('axFabToc');
if(fab){window.addEventListener('scroll',function(){
fab.classList.toggle('visible',window.scrollY>300);
});}
})();
</script>
""";
// ── 커스텀 무드 저장소 ──
private static readonly Dictionary<string, Models.CustomMoodEntry> _customMoods = new(StringComparer.OrdinalIgnoreCase);
@@ -67,6 +92,8 @@ public static class TemplateService
"corporate" => CssCorporate,
"magazine" => CssMagazine,
"dashboard" => CssDashboard,
"seminar" => CssSeminar,
"seminar-toc" => CssSeminarSidebar,
_ => CssModern,
};
return moodCss + "\n" + CssShared;
@@ -100,18 +127,21 @@ public static class TemplateService
.Build();
var bodyHtml = Markdown.ToHtml(markdown, pipeline);
var css = GetCss(moodKey);
var defaultTheme = moodKey is "dark" or "seminar" or "seminar-toc" or "dashboard" ? "dark" : "light";
return $"""
<!DOCTYPE html>
<html lang="ko">
<html lang="ko" data-theme="{defaultTheme}">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<style>{css}</style>
</head>
<body>
<button class="ax-theme-toggle" onclick="axToggleTheme()" title="테마 전환">&#127769;</button>
<div class="container">
{bodyHtml}
</div>
{ThemeToggleScript}
</body>
</html>
""";
@@ -167,6 +197,8 @@ public static class TemplateService
"corporate" => new("#f3f4f6", "#ffffff", "#1f2937", "#6b7280", "#1e40af", "#e5e7eb"),
"magazine" => new("#f9fafb", "#ffffff", "#111827", "#6b7280", "#dc2626", "#f3f4f6"),
"dashboard" => new("#0f172a", "#1e293b", "#f1f5f9", "#94a3b8", "#3b82f6", "#334155"),
"seminar" => new("#0f1117", "#161822", "#e2e8f0", "#8892a8", "#6C8EEF", "#2a2d3e"),
"seminar-toc" => new("#0f1117", "#161822", "#e2e8f0", "#8892a8", "#6C8EEF", "#2a2d3e"),
_ => new("#f5f5f7", "#ffffff", "#1d1d1f", "#6e6e73", "#0066cc", "#e5e5e7"),
};
}
@@ -595,6 +627,240 @@ public static class TemplateService
""";
#endregion
#region Seminar
private const string CssSeminar = """
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
:root {
--accent: #6C8EEF; --accent2: #A78BFA; --green: #34D399; --amber: #FBBF24;
--red: #F87171; --cyan: #22D3EE; --transition: 0.3s ease;
}
[data-theme="dark"] {
--bg: #0f1117; --bg2: #0b0d13; --surface: #161822; --surface2: #1c1f2e; --surface3: #22253a;
--border: #2a2d3e; --text: #e2e8f0; --text-dim: #8892a8; --text-inv: #1a1a2e;
--hero-grad1: #161822; --hero-grad2: #0f1117; --hero-glow: rgba(108,142,239,0.12);
--shadow: rgba(0,0,0,0.3); --code-bg: rgba(108,142,239,0.1); --code-border: rgba(108,142,239,0.15);
}
[data-theme="light"] {
--bg: #f8fafc; --bg2: #f1f5f9; --surface: #ffffff; --surface2: #f1f5f9; --surface3: #e8ecf2;
--border: #e2e8f0; --text: #1e293b; --text-dim: #64748b; --text-inv: #ffffff;
--hero-grad1: #eef2ff; --hero-grad2: #f8fafc; --hero-glow: rgba(108,142,239,0.08);
--shadow: rgba(0,0,0,0.06); --code-bg: rgba(108,142,239,0.06); --code-border: rgba(108,142,239,0.12);
--accent: #4f6fd9; --accent2: #8b6fc0; --green: #16a34a; --amber: #d97706; --red: #dc2626; --cyan: #0891b2;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { font-family: 'Inter', 'Segoe UI', 'Noto Sans KR', sans-serif;
background: var(--bg); color: var(--text); line-height: 1.75; padding: 0;
-webkit-font-smoothing: antialiased; transition: background var(--transition), color var(--transition); }
.container { max-width: 980px; margin: 0 auto; padding: 0 28px; background: transparent; }
/* ── Hero ── */
.cover-page, .header-bar { position: relative; padding: 60px 40px 48px; text-align: center; overflow: hidden;
background: linear-gradient(180deg, var(--hero-grad1) 0%, var(--hero-grad2) 100%);
border-radius: 0; margin: 0 -28px 32px; transition: background var(--transition); }
.cover-page::before, .header-bar::before { content: ''; position: absolute; top: -120px; left: 50%;
transform: translateX(-50%); width: 600px; height: 600px;
background: radial-gradient(circle, var(--hero-glow) 0%, transparent 70%); pointer-events: none; }
.cover-page h1, .header-bar h1 { font-size: 36px; font-weight: 800; letter-spacing: -0.5px;
background: linear-gradient(135deg, var(--text), var(--accent), var(--accent2));
-webkit-background-clip: text; -webkit-text-fill-color: transparent; margin-bottom: 8px; color: var(--text); }
.cover-page .cover-subtitle { font-size: 16px; color: var(--text-dim); }
.cover-page .cover-meta, .meta { font-size: 12px; color: var(--text-dim); margin-bottom: 24px; }
.header-bar .meta { margin-bottom: 0; margin-top: 8px; }
/* ── Headings ── */
h1 { font-size: 28px; font-weight: 800; color: var(--text); margin-bottom: 4px; }
h2 { font-size: 20px; font-weight: 700; margin: 40px 0 16px; color: var(--text);
padding-bottom: 12px; border-bottom: 2px solid var(--border);
display: flex; align-items: center; gap: 12px; transition: border-color var(--transition); }
h3 { font-size: 16px; font-weight: 700; color: var(--accent); margin: 28px 0 10px; }
h4 { font-size: 14px; font-weight: 600; color: var(--text); margin: 16px 0 8px;
border-left: 3px solid var(--accent); padding-left: 10px; }
/* ── Text ── */
p { margin: 10px 0; font-size: 14px; }
ul, ol { margin: 8px 0 12px 20px; font-size: 14px; }
li { margin: 4px 0; }
li::marker { color: var(--text-dim); }
strong { font-weight: 700; }
em { color: var(--accent); font-style: normal; font-weight: 600; }
/* ── Code ── */
code { font-family: 'Cascadia Code', 'Fira Code', Consolas, monospace;
background: var(--code-bg); border: 1px solid var(--code-border);
border-radius: 4px; padding: 1px 6px; font-size: 12.5px; color: var(--cyan); }
pre { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 18px; overflow-x: auto; font-size: 12.5px; margin: 14px 0; line-height: 1.55;
transition: all var(--transition); }
pre code { background: transparent; border: none; padding: 0; color: var(--text-dim); }
/* ── Cards ── */
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 20px 24px; margin-bottom: 16px; transition: all var(--transition); }
.card:hover { box-shadow: 0 4px 16px var(--shadow); }
.card-header { font-size: 14px; font-weight: 700; margin-bottom: 8px; display: flex; align-items: center; gap: 8px; }
/* ── Tables ── */
table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 13px; }
th { background: var(--surface2); padding: 10px 14px; text-align: left; font-weight: 700;
font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim);
border-bottom: 2px solid var(--border); transition: all var(--transition); }
td { padding: 10px 14px; border-bottom: 1px solid var(--border); vertical-align: top;
transition: all var(--transition); }
tr:last-child td { border-bottom: none; }
tr:hover td { background: var(--surface2); }
/* ── Badges ── */
.badge { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 4px;
text-transform: uppercase; letter-spacing: 0.5px; display: inline-block; margin: 2px 4px 2px 0; }
.badge-green { background: rgba(52,211,153,0.15); color: var(--green); }
.badge-amber, .badge-yellow { background: rgba(251,191,36,0.15); color: var(--amber); }
.badge-blue { background: rgba(108,142,239,0.15); color: var(--accent); }
.badge-purple { background: rgba(167,139,250,0.15); color: var(--accent2); }
.badge-red { background: rgba(248,113,113,0.15); color: var(--red); }
.badge-cyan { background: rgba(34,211,238,0.15); color: var(--cyan); }
/* ── Info boxes ── */
.callout { border-radius: 10px; padding: 16px 20px; margin: 16px 0; font-size: 13.5px;
display: flex; gap: 12px; align-items: flex-start; border-left: 4px solid;
transition: all var(--transition); }
.callout::before { font-size: 16px; flex-shrink: 0; margin-top: 1px; }
.callout-info { background: rgba(108,142,239,0.07); border-color: var(--accent); }
.callout-info::before { content: '💡'; }
.callout-tip { background: rgba(52,211,153,0.07); border-color: var(--green); }
.callout-tip::before { content: '✅'; }
.callout-warning { background: rgba(251,191,36,0.07); border-color: var(--amber); }
.callout-warning::before { content: ''; }
.callout-danger { background: rgba(248,113,113,0.07); border-color: var(--red); }
.callout-danger::before { content: '🚨'; }
.callout-note { background: rgba(167,139,250,0.07); border-color: var(--accent2); }
.callout-note::before { content: '📝'; }
/* ── Flow diagram ── */
.flow { display: flex; align-items: center; gap: 0; margin: 20px 0; flex-wrap: wrap; justify-content: center; }
.flow-step { background: var(--surface2); border: 1px solid var(--border); border-radius: 10px;
padding: 12px 18px; text-align: center; min-width: 120px; transition: all var(--transition); }
.flow-step .num { font-size: 10px; font-weight: 700; color: var(--accent); text-transform: uppercase;
letter-spacing: 1px; margin-bottom: 4px; }
.flow-step .label { font-size: 13px; font-weight: 600; }
.flow-step .desc { font-size: 11px; color: var(--text-dim); margin-top: 2px; }
.flow-arrow { color: var(--text-dim); font-size: 18px; margin: 0 6px; flex-shrink: 0; }
/* ── Pipeline ── */
.pipeline { margin: 20px 0; }
.pipeline-stage { display: flex; align-items: flex-start; gap: 16px; padding: 14px 0;
border-left: 2px solid var(--border); margin-left: 14px; padding-left: 24px; position: relative; }
.pipeline-stage::before { content: ''; position: absolute; left: -7px; top: 18px;
width: 12px; height: 12px; border-radius: 50%;
background: var(--surface); border: 2px solid var(--accent); transition: all var(--transition); }
.pipeline-stage:last-child { border-left-color: transparent; }
.pipeline-stage .stage-num { font-size: 10px; font-weight: 700; color: var(--accent);
text-transform: uppercase; letter-spacing: 1px; min-width: 60px; padding-top: 2px; }
.pipeline-stage .stage-body { flex: 1; }
.pipeline-stage .stage-title { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
.pipeline-stage .stage-desc { font-size: 12.5px; color: var(--text-dim); }
/* ── Stat cards ── */
.stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
padding: 18px 14px; text-align: center; transition: all var(--transition); }
.stat-card .number { font-size: 30px; font-weight: 800; color: var(--accent); }
.stat-card .label { font-size: 11px; color: var(--text-dim); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
/* ── Grids ── */
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 16px 0; }
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; margin: 16px 0; }
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 16px 0; }
/* ── Profile cards ── */
.profile-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin: 16px 0; }
.profile-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 18px 20px; transition: all var(--transition); }
.profile-card:hover { box-shadow: 0 4px 16px var(--shadow); }
.profile-card .name { font-size: 14px; font-weight: 700; margin-bottom: 4px; display: flex; align-items: center; gap: 8px; }
.profile-card .desc { font-size: 12px; color: var(--text-dim); margin-bottom: 10px; }
.profile-card .props { font-size: 12px; }
.profile-card .props dt { color: var(--text-dim); float: left; width: 110px; }
.profile-card .props dd { margin-bottom: 3px; }
/* ── Arch diagram ── */
.arch-diagram { background: var(--surface); border: 1px solid var(--border); border-radius: 14px;
padding: 24px 28px; margin: 20px 0; font-family: 'Cascadia Code', monospace;
font-size: 11.5px; line-height: 1.55; overflow-x: auto; white-space: pre;
color: var(--text-dim); transition: all var(--transition); }
.arch-diagram .hl { color: var(--accent); font-weight: 600; }
.arch-diagram .g { color: var(--green); }
.arch-diagram .a { color: var(--amber); }
.arch-diagram .r { color: var(--red); }
.arch-diagram .p { color: var(--accent2); }
.arch-diagram .c { color: var(--cyan); }
/* ── Tags ── */
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0; }
.tag { display: inline-block; padding: 3px 10px; border-radius: 6px; font-size: 11.5px;
font-weight: 500; background: var(--surface2); border: 1px solid var(--border);
color: var(--text-dim); transition: all var(--transition); }
.tag.accent { border-color: rgba(108,142,239,0.3); color: var(--accent); background: rgba(108,142,239,0.06); }
/* ── Blockquote ── */
blockquote { border-left: 3px solid var(--accent); padding: 12px 20px; margin: 16px 0;
background: rgba(108,142,239,0.05); border-radius: 0 10px 10px 0; font-size: 14px;
color: var(--text-dim); transition: all var(--transition); }
/* ── Separator ── */
.divider, hr { border: none; height: 1px;
background: linear-gradient(90deg, transparent, var(--border), transparent); margin: 40px 0; }
/* ── Responsive ── */
@media (max-width: 720px) {
.grid-2, .profile-grid { grid-template-columns: 1fr; }
.grid-3, .grid-4 { grid-template-columns: 1fr 1fr; }
.flow { flex-direction: column; }
.flow-arrow { transform: rotate(90deg); }
.cover-page h1, .header-bar h1 { font-size: 24px; }
.arch-diagram { font-size: 10px; padding: 16px; }
}
""";
private const string CssSeminarSidebar = CssSeminar + """
/* ═══════ Sidebar TOC Layout Override ═══════ */
.page-wrapper { display: flex; min-height: 100vh; }
.container { max-width: 980px; padding: 0 28px; margin-left: 280px; margin-right: auto; }
@supports (margin-left: max(0px, 0px)) {
.container { margin-left: max(280px, calc((100vw + 280px - 980px) / 2)); }
}
.toc {
position: fixed; top: 0; left: 0; width: 280px; height: 100vh;
overflow-y: auto; background: var(--surface); border-right: 1px solid var(--border);
padding: 24px 16px; z-index: 100; box-shadow: 2px 0 16px var(--shadow);
transition: all var(--transition);
}
.toc h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 2px;
color: var(--text-dim); margin-bottom: 14px; font-weight: 700; padding: 0 8px; }
.toc-grid { display: flex; flex-direction: column; gap: 2px; }
.toc a { display: flex; align-items: center; gap: 8px; text-decoration: none; color: var(--text);
padding: 6px 8px; border-radius: 6px; font-size: 12.5px; transition: all 0.15s; }
.toc a:hover { background: var(--surface2); color: var(--accent); }
.toc a.active { background: rgba(108,142,239,0.1); color: var(--accent); font-weight: 600; }
.toc-num { width: 20px; height: 20px; display: flex; align-items: center; justify-content: center;
border-radius: 5px; font-size: 10px; font-weight: 700;
background: rgba(108,142,239,0.1); color: var(--accent); flex-shrink: 0; }
.toc-sub { padding-left: 28px; font-size: 11.5px; color: var(--text-dim); }
.toc-sub:hover { color: var(--accent); }
.toc-divider { height: 1px; background: var(--border); margin: 6px 0; }
.toc::-webkit-scrollbar { width: 4px; }
.toc::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
@media (max-width: 960px) {
.toc { position: static; width: 100%; height: auto; max-height: none;
border-right: none; border-bottom: 1px solid var(--border);
box-shadow: 0 4px 16px var(--shadow); }
.container { margin-left: 0; }
.page-wrapper { flex-direction: column; }
}
""";
#endregion
// ════════════════════════════════════════════════════════════════════
// 공통 CSS 컴포넌트 (모든 무드에 자동 첨부)
// ════════════════════════════════════════════════════════════════════
@@ -602,6 +868,66 @@ public static class TemplateService
#region Shared
private const string CssShared = """
/* ── 범용 다크 모드 (CSS 변수 미사용 무드용) ── */
[data-theme="dark"] body { background: #0f172a; color: #e2e8f0; }
[data-theme="dark"] .container { background: #1e293b; color: #e2e8f0; border-color: #334155; }
[data-theme="dark"] h1, [data-theme="dark"] h2, [data-theme="dark"] h3 { color: #e2e8f0; }
[data-theme="dark"] p, [data-theme="dark"] li { color: #cbd5e1; }
[data-theme="dark"] th { background: #334155; color: #e2e8f0; border-color: #475569; }
[data-theme="dark"] td { border-color: #334155; color: #cbd5e1; }
[data-theme="dark"] tr:hover td { background: #1e293b; }
[data-theme="dark"] tr:nth-child(even) td { background: #1e293b; }
[data-theme="dark"] code { background: rgba(99,102,241,0.15); color: #a5b4fc; border-color: rgba(99,102,241,0.2); }
[data-theme="dark"] pre { background: #0f172a; color: #e2e8f0; }
[data-theme="dark"] blockquote { background: rgba(99,102,241,0.08); color: #cbd5e1; }
[data-theme="dark"] .card { background: #1e293b; border-color: #334155; color: #e2e8f0; }
[data-theme="dark"] nav.toc { background: #1e293b; border-color: #334155; }
[data-theme="dark"] nav.toc a { color: #818cf8; }
[data-theme="dark"] .callout { background: rgba(99,102,241,0.08); border-color: #6366f1; color: #cbd5e1; }
[data-theme="dark"] .callout-info { background: rgba(59,130,246,0.1); border-color: #3b82f6; }
[data-theme="dark"] .callout-warning { background: rgba(245,158,11,0.1); border-color: #f59e0b; }
[data-theme="dark"] .callout-tip { background: rgba(34,197,94,0.1); border-color: #22c55e; }
[data-theme="dark"] .callout-danger { background: rgba(239,68,68,0.1); border-color: #ef4444; }
[data-theme="dark"] .highlight, [data-theme="dark"] .highlight-box { background: rgba(99,102,241,0.1); }
[data-theme="dark"] .cover-page { background: linear-gradient(135deg, #312e81 0%, #4c1d95 100%); }
[data-theme="dark"] .header-bar { border-color: #334155; }
[data-theme="dark"] .meta { color: #94a3b8; }
[data-theme="dark"] .chart-bar .bar-track { background: #334155; }
[data-theme="dark"] .divider, [data-theme="dark"] .divider-thick { border-color: #334155; }
[data-theme="dark"] hr { background: #334155; }
[data-theme="dark"] .kpi-card, [data-theme="dark"] .chart-area { background: #1e293b; border-color: #334155; color: #e2e8f0; }
[data-theme="dark"] .kpi-card .kpi-value { color: #e2e8f0; }
[data-theme="dark"] .kpi-card .kpi-label { color: #94a3b8; }
[data-theme="dark"] .badge-blue { background: rgba(59,130,246,0.15); color: #60a5fa; }
[data-theme="dark"] .badge-green { background: rgba(34,197,94,0.15); color: #4ade80; }
[data-theme="dark"] .badge-red { background: rgba(239,68,68,0.15); color: #f87171; }
[data-theme="dark"] .badge-yellow { background: rgba(245,158,11,0.15); color: #fbbf24; }
[data-theme="dark"] .badge-purple { background: rgba(139,92,246,0.15); color: #a78bfa; }
[data-theme="dark"] .badge-gray { background: rgba(107,114,128,0.15); color: #9ca3af; }
[data-theme="light"] body { } /* light 기본값 — 각 무드 CSS가 우선 */
/* ── 테마 토글 버튼 ── */
.ax-theme-toggle { position: fixed; top: 16px; right: 16px; z-index: 1000;
width: 40px; height: 40px; border-radius: 50%; border: 1px solid #d1d5db;
background: #fff; color: #374151; font-size: 18px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.1); transition: all 0.3s ease; }
.ax-theme-toggle:hover { transform: scale(1.1); box-shadow: 0 4px 16px rgba(0,0,0,0.15); }
[data-theme="dark"] .ax-theme-toggle { background: #1e293b; color: #e2e8f0; border-color: #334155; }
/* ── 플로팅 TOC 버튼 ── */
.ax-fab-toc { position: fixed; bottom: 24px; right: 24px; z-index: 999;
width: 44px; height: 44px; border-radius: 12px; border: 1px solid #d1d5db;
background: #fff; color: #4b5efc; font-size: 20px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 4px 16px rgba(0,0,0,0.1); transition: all 0.3s ease;
opacity: 0; pointer-events: none; transform: translateY(12px); }
.ax-fab-toc.visible { opacity: 1; pointer-events: auto; transform: translateY(0); }
.ax-fab-toc:hover { background: #4b5efc; color: #fff; transform: translateY(-2px);
box-shadow: 0 6px 24px rgba(75,94,252,0.3); }
[data-theme="dark"] .ax-fab-toc { background: #1e293b; border-color: #334155; }
[data-theme="dark"] .ax-fab-toc:hover { background: #4b5efc; }
/* ── 목차 (TOC) ── */
nav.toc { background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 10px;
padding: 20px 28px; margin: 24px 0 32px; }
@@ -718,6 +1044,7 @@ public static class TemplateService
/* ── 인쇄/PDF 최적화 ── */
@media print {
.ax-theme-toggle, .ax-fab-toc { display: none !important; }
body { background: #fff !important; padding: 0 !important; }
.container { box-shadow: none !important; border: none !important;
max-width: none !important; padding: 20px !important; }

View File

@@ -15,7 +15,7 @@ public class ToolRegistry : IDisposable
/// <summary>도구를 이름으로 찾습니다.</summary>
public IAgentTool? Get(string name) =>
_tools.TryGetValue(name, out var tool) ? tool : null;
_tools.TryGetValue(AgentToolCatalog.Canonicalize(name), out var tool) ? tool : null;
/// <summary>도구를 등록합니다.</summary>
public void Register(IAgentTool tool) => _tools[tool.Name] = tool;
@@ -60,7 +60,7 @@ public class ToolRegistry : IDisposable
public IReadOnlyCollection<IAgentTool> GetActiveTools(IEnumerable<string>? disabledNames = null)
{
if (disabledNames == null) return All;
var disabled = new HashSet<string>(disabledNames, StringComparer.OrdinalIgnoreCase);
var disabled = new HashSet<string>(AgentToolCatalog.CanonicalizeMany(disabledNames), StringComparer.OrdinalIgnoreCase);
if (disabled.Count == 0) return All;
return OrderToolsForExposure(_tools.Values.Where(t => !disabled.Contains(t.Name)))
.ToList()
@@ -71,7 +71,7 @@ public class ToolRegistry : IDisposable
public IReadOnlyCollection<IAgentTool> GetActiveToolsForTab(string activeTab, IEnumerable<string>? disabledNames = null)
{
var disabled = disabledNames != null
? new HashSet<string>(disabledNames, StringComparer.OrdinalIgnoreCase)
? new HashSet<string>(AgentToolCatalog.CanonicalizeMany(disabledNames), StringComparer.OrdinalIgnoreCase)
: null;
return OrderToolsForExposure(_tools.Values.Where(t =>
@@ -90,17 +90,7 @@ public class ToolRegistry : IDisposable
private static int GetToolExposureBucket(IAgentTool tool)
{
return tool.Name switch
{
"file_read" or "file_write" or "file_edit" or "glob" or "grep" or "document_read"
or "process" or "dev_env_detect" or "build_run" or "git_tool" or "lsp_code_intel"
or "document_plan" or "document_assemble" or "docx_create" or "html_create" or "markdown_create"
or "excel_create" or "csv_create" or "pptx_create" or "chart_create" => 0,
"folder_map" or "document_review" or "format_convert" or "tool_search" or "code_search" => 1,
"mcp_list_resources" or "mcp_read_resource" or "spawn_agent" or "wait_agents" => 2,
_ when tool.Name.StartsWith("task_", StringComparison.OrdinalIgnoreCase) => 3,
_ => 1
};
return AgentToolCatalog.GetExposureBucket(tool.Name);
}
/// <summary>도구가 해당 탭에서 사용 가능한지 확인합니다.</summary>
@@ -122,114 +112,14 @@ public class ToolRegistry : IDisposable
/// IAgentTool.TabCategory가 null인 도구는 이 맵을 참조합니다.
/// 키: 도구 이름, 값: 허용 탭 (쉼표 구분). 맵에 없으면 = 모든 탭.
/// </summary>
private static readonly Dictionary<string, string> ToolTabOverrides = new(StringComparer.OrdinalIgnoreCase)
{
// ════════════════════════════════════════════════════════════
// Chat = 순수 대화 (도구 없음). 아래 맵에 없는 공통 도구도
// Chat에선 제외하려면 여기에 "Cowork,Code"로 등록.
// ════════════════════════════════════════════════════════════
// ── 파일/검색 기본 도구: Cowork + Code ──
["file_read"] = "Cowork,Code",
["file_write"] = "Cowork,Code",
["file_edit"] = "Cowork,Code",
["glob"] = "Cowork,Code",
["grep"] = "Cowork,Code",
["process"] = "Cowork,Code",
["folder_map"] = "Cowork,Code",
["document_read"] = "Cowork,Code",
["file_manage"] = "Cowork,Code",
["file_info"] = "Cowork,Code",
["multi_read"] = "Cowork,Code",
["zip"] = "Cowork,Code",
["open_external"] = "Cowork,Code",
// ── 데이터/유틸리티: Cowork + Code ──
["json"] = "Cowork,Code",
["regex"] = "Cowork,Code",
["base64"] = "Cowork,Code",
["hash"] = "Cowork,Code",
["datetime"] = "Cowork,Code",
["math"] = "Cowork,Code",
["encoding"] = "Cowork,Code",
["http"] = "Cowork,Code",
["clipboard"] = "Cowork,Code",
["env"] = "Cowork,Code",
["notify"] = "Cowork,Code",
["user_ask"] = "Cowork,Code",
["memory"] = "Cowork,Code",
["skill_manager"] = "Cowork,Code",
["tool_search"] = "Cowork,Code",
["mcp_list_resources"] = "Cowork,Code",
["mcp_read_resource"] = "Cowork,Code",
// ── 문서 생성/처리: Cowork 전용 ──
["xlsx_create"] = "Cowork",
["excel_create"] = "Cowork",
["docx_create"] = "Cowork",
["csv_create"] = "Cowork",
["md_create"] = "Cowork",
["markdown_create"] = "Cowork",
["html_create"] = "Cowork",
["chart_create"] = "Cowork",
["batch_create"] = "Cowork",
["pptx_create"] = "Cowork",
["document_plan"] = "Cowork",
["document_assemble"] = "Cowork",
["document_review"] = "Cowork",
["format_convert"] = "Cowork",
["data_pivot"] = "Cowork",
["template_render"] = "Cowork",
["text_summarize"] = "Cowork",
["sql"] = "Cowork",
["xml"] = "Cowork",
["image_analyze"] = "Cowork",
// ── 개발 도구: Code 전용 ──
["dev_env_detect"] = "Code",
["build_run"] = "Code",
["git"] = "Code",
["lsp"] = "Code",
["code_search"] = "Code",
["code_review"] = "Code",
["project_rule"] = "Code",
["snippet_run"] = "Code",
["diff"] = "Code",
["diff_preview"] = "Code",
["sub_agent"] = "Code",
["wait_agents"] = "Code",
["test_loop"] = "Code",
["file_watch"] = "Code",
// ── 태스크/워크트리/팀: Code 전용 ──
["task_tracker"] = "Code",
["todo_write"] = "Code",
["task_create"] = "Code",
["task_get"] = "Code",
["task_list"] = "Code",
["task_update"] = "Code",
["task_stop"] = "Code",
["task_output"] = "Code",
["enter_worktree"] = "Code",
["exit_worktree"] = "Code",
["team_create"] = "Code",
["team_delete"] = "Code",
["cron_create"] = "Code",
["cron_delete"] = "Code",
["cron_list"] = "Code",
["checkpoint"] = "Code",
["suggest_actions"] = "Code",
["playbook"] = "Code",
};
/// <summary>도구의 실질 탭 카테고리를 결정합니다 (IAgentTool.TabCategory → 오버라이드 맵 순).</summary>
private static string? ResolveTabCategory(IAgentTool tool)
{
// 도구 자체에 TabCategory가 명시되어 있으면 우선
if (!string.IsNullOrEmpty(tool.TabCategory))
return tool.TabCategory;
// 오버라이드 맵에서 조회
return ToolTabOverrides.TryGetValue(tool.Name, out var cat) ? cat : null;
return AgentToolCatalog.GetTabCategory(tool.Name);
}
/// <summary>IDisposable 도구를 모두 해제합니다.</summary>
@@ -286,6 +176,7 @@ public class ToolRegistry : IDisposable
registry.Register(new GitTool());
registry.Register(new LspTool());
registry.Register(new SubAgentTool());
registry.Register(new SpawnAgentsTool());
registry.Register(new WaitAgentsTool());
registry.Register(new CodeSearchTool());
registry.Register(new TestLoopTool());

View File

@@ -0,0 +1,409 @@
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 작업 폴더의 구조/기술스택/컨벤션을 분석하여 .ax-context.md를 자동 생성합니다.
/// LLM 호출 없이 순수 파일 시스템 분석으로 동작합니다.
/// </summary>
internal static class WorkspaceContextGenerator
{
private const string ContextFileName = ".ax-context.md";
private const int MaxDepth = 3;
private const int MaxReadmeChars = 2000;
private const int MaxContextChars = 4000;
private static readonly HashSet<string> SkipDirs = new(StringComparer.OrdinalIgnoreCase)
{
".git", "node_modules", "bin", "obj", ".vs", "__pycache__", ".idea",
".vscode", "dist", "build", "target", ".next", ".nuget", "packages",
".ax", "coverage", ".mypy_cache", "venv", ".venv", "env",
};
/// <summary>
/// .ax-context.md가 없으면 생성합니다. 이미 있으면 기존 내용을 반환합니다.
/// </summary>
public static async Task<string?> EnsureContextAsync(string workFolder, CancellationToken ct = default)
{
if (string.IsNullOrEmpty(workFolder) || !Directory.Exists(workFolder))
return null;
var path = Path.Combine(workFolder, ContextFileName);
if (File.Exists(path))
return LoadContext(workFolder);
return await GenerateAsync(workFolder, ct).ConfigureAwait(false);
}
/// <summary>
/// 강제 재생성합니다.
/// </summary>
public static async Task<string> GenerateAsync(string workFolder, CancellationToken ct = default)
{
var sb = new StringBuilder();
sb.AppendLine("# Workspace Context (auto-generated)");
sb.AppendLine($"Generated: {DateTime.Now:yyyy-MM-dd}");
sb.AppendLine();
// 1. 프로젝트 기본 정보
var buildSystem = DetectBuildSystem(workFolder);
var extDist = GetExtensionDistribution(workFolder, ct);
var primaryLang = extDist.FirstOrDefault();
sb.AppendLine("## Project");
var projectName = Path.GetFileName(workFolder);
sb.AppendLine($"- Name: {projectName}");
if (buildSystem != null)
sb.AppendLine($"- Build System: {buildSystem}");
if (primaryLang.Key != null)
sb.AppendLine($"- Primary Language: {GetLanguageName(primaryLang.Key)} ({primaryLang.Key}: {primaryLang.Value} files)");
// Git 정보
var gitInfo = await GetGitInfoAsync(workFolder, ct).ConfigureAwait(false);
if (gitInfo.Branch != null)
sb.AppendLine($"- Git Branch: {gitInfo.Branch}");
if (gitInfo.Remote != null)
sb.AppendLine($"- Git Remote: {gitInfo.Remote}");
sb.AppendLine();
// 2. 디렉토리 구조
sb.AppendLine("## Structure");
var tree = BuildDirectoryTree(workFolder, MaxDepth);
foreach (var line in tree.Take(30)) // 최대 30줄
sb.AppendLine(line);
sb.AppendLine();
// 3. 확장자 분포
if (extDist.Count > 0)
{
sb.AppendLine("## File Distribution");
sb.AppendLine(string.Join(", ", extDist.Take(10).Select(kv => $"{kv.Key}: {kv.Value}")));
sb.AppendLine();
}
// 4. 기존 컨텍스트 파일 감지
var contextFiles = DetectContextFiles(workFolder);
if (contextFiles.Count > 0)
{
sb.AppendLine("## Existing Context Files");
foreach (var cf in contextFiles)
sb.AppendLine($"- {cf}");
sb.AppendLine();
}
// 5. README 요약
var readmeSummary = ExtractReadmeSummary(workFolder);
if (readmeSummary != null)
{
sb.AppendLine("## README Summary");
sb.AppendLine(readmeSummary);
sb.AppendLine();
}
var content = sb.ToString().TrimEnd();
// 파일 저장
try
{
var path = Path.Combine(workFolder, ContextFileName);
await File.WriteAllTextAsync(path, content, ct).ConfigureAwait(false);
}
catch
{
// 저장 실패는 무시 — 읽기 전용 폴더 등
}
return content;
}
/// <summary>
/// 기존 .ax-context.md를 읽습니다. 없으면 null.
/// </summary>
public static string? LoadContext(string? workFolder)
{
if (string.IsNullOrEmpty(workFolder)) return null;
var path = Path.Combine(workFolder, ContextFileName);
if (!File.Exists(path)) return null;
try
{
var content = File.ReadAllText(path);
return content.Length > MaxContextChars
? content[..MaxContextChars] + "\n...(truncated)"
: content;
}
catch { return null; }
}
// ════════════════════════════════════════════════════════════
// 분석 로직
// ════════════════════════════════════════════════════════════
private static string? DetectBuildSystem(string folder)
{
var checks = new (string Pattern, string Name)[]
{
("*.sln", ".NET (Solution)"),
("*.csproj", ".NET"),
("package.json", "Node.js"),
("Cargo.toml", "Rust"),
("go.mod", "Go"),
("pom.xml", "Java (Maven)"),
("build.gradle", "Java (Gradle)"),
("pyproject.toml", "Python"),
("requirements.txt", "Python"),
("Makefile", "Make"),
("CMakeLists.txt", "CMake"),
};
foreach (var (pattern, name) in checks)
{
try
{
if (Directory.GetFiles(folder, pattern, SearchOption.TopDirectoryOnly).Length > 0)
return name;
}
catch { /* 무시 */ }
}
return null;
}
private static List<KeyValuePair<string, int>> GetExtensionDistribution(
string folder, CancellationToken ct)
{
var counts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
try
{
foreach (var file in Directory.EnumerateFiles(folder, "*", new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
MaxRecursionDepth = 5,
}))
{
ct.ThrowIfCancellationRequested();
// 경로 세그먼트 단위로 SkipDirs 검사 (정확한 디렉토리명 매칭)
var dir = Path.GetDirectoryName(file) ?? "";
if (ShouldSkipPath(dir))
continue;
var ext = Path.GetExtension(file);
if (string.IsNullOrEmpty(ext) || ext.Length > 8) continue;
counts.TryGetValue(ext, out var count);
counts[ext] = count + 1;
}
}
catch (OperationCanceledException) { throw; }
catch { /* 무시 */ }
return counts.OrderByDescending(kv => kv.Value).ToList();
}
private static List<string> BuildDirectoryTree(string root, int maxDepth)
{
var result = new List<string>();
BuildTreeRecursive(root, root, 0, maxDepth, result);
return result;
}
private static void BuildTreeRecursive(string root, string current, int depth, int maxDepth, List<string> result)
{
if (depth >= maxDepth || result.Count >= 30) return;
IEnumerable<string> dirs;
try { dirs = Directory.GetDirectories(current); }
catch { return; }
foreach (var dir in dirs.OrderBy(d => d))
{
if (result.Count >= 30) break;
var name = Path.GetFileName(dir);
if (SkipDirs.Contains(name)) continue;
// 심링크/junction 무한루프 방지: 속성 검사
try
{
var attrs = File.GetAttributes(dir);
if (attrs.HasFlag(FileAttributes.ReparsePoint))
continue;
}
catch { continue; }
// TopDirectoryOnly로 제한하여 대규모 디렉토리 탐색 방지
int fileCount;
try
{
fileCount = Directory.EnumerateFiles(dir, "*", new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
MaxRecursionDepth = 3,
}).Take(10_000).Count(); // 최대 10K개만 카운트
}
catch { fileCount = 0; }
var indent = new string(' ', depth * 2);
var relativePath = Path.GetRelativePath(root, dir).Replace('\\', '/');
result.Add($"{indent}{relativePath}/ ({fileCount}{(fileCount >= 10_000 ? "+" : "")} files)");
BuildTreeRecursive(root, dir, depth + 1, maxDepth, result);
}
}
private static string? ExtractReadmeSummary(string folder)
{
var names = new[] { "README.md", "readme.md", "README", "README.txt" };
foreach (var name in names)
{
var path = Path.Combine(folder, name);
if (!File.Exists(path)) continue;
try
{
var text = File.ReadAllText(path);
if (text.Length > MaxReadmeChars)
text = text[..MaxReadmeChars];
// 첫 번째 단락 추출 (제목 제외)
var lines = text.Split('\n');
var paragraphLines = new List<string>();
var foundContent = false;
foreach (var line in lines)
{
var trimmed = line.Trim();
if (trimmed.StartsWith('#') && !foundContent) continue; // 제목 건너뛰기
if (string.IsNullOrWhiteSpace(trimmed))
{
if (foundContent && paragraphLines.Count > 0) break;
continue;
}
foundContent = true;
paragraphLines.Add(trimmed);
}
if (paragraphLines.Count > 0)
return string.Join(" ", paragraphLines);
}
catch { /* 무시 */ }
}
return null;
}
private static List<string> DetectContextFiles(string folder)
{
var files = new List<string>();
var names = new[] { "AGENTS.md", "AX.md", "CLAUDE.md", ".clinerules", ".ax-rules" };
foreach (var name in names)
{
if (File.Exists(Path.Combine(folder, name)))
files.Add(name);
}
var axDir = Path.Combine(folder, ".ax");
if (Directory.Exists(axDir))
{
try
{
var rulesDir = Path.Combine(axDir, "rules");
if (Directory.Exists(rulesDir))
{
var ruleFiles = Directory.GetFiles(rulesDir, "*.md");
if (ruleFiles.Length > 0)
files.Add($".ax/rules/ ({ruleFiles.Length} files)");
}
}
catch { /* 무시 */ }
}
return files;
}
private static async Task<(string? Branch, string? Remote)> GetGitInfoAsync(
string folder, CancellationToken ct)
{
if (!Directory.Exists(Path.Combine(folder, ".git")))
return (null, null);
string? branch = null;
string? remote = null;
try
{
branch = await RunGitAsync(folder, "rev-parse --abbrev-ref HEAD", ct).ConfigureAwait(false);
remote = await RunGitAsync(folder, "remote get-url origin", ct).ConfigureAwait(false);
}
catch { /* Git 없거나 실패 */ }
return (branch?.Trim(), remote?.Trim());
}
private static async Task<string?> RunGitAsync(string folder, string args, CancellationToken ct)
{
try
{
using var process = new Process();
process.StartInfo = new ProcessStartInfo
{
FileName = "git",
Arguments = args,
WorkingDirectory = folder,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
process.Start();
var output = await process.StandardOutput.ReadToEndAsync(ct).ConfigureAwait(false);
await process.WaitForExitAsync(ct).ConfigureAwait(false);
return process.ExitCode == 0 ? output.Trim() : null;
}
catch { return null; }
}
/// <summary>경로의 각 디렉토리 세그먼트가 SkipDirs에 해당하는지 검사.</summary>
private static bool ShouldSkipPath(string dirPath)
{
var span = dirPath.AsSpan();
while (span.Length > 0)
{
var sepIdx = span.IndexOfAny(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var segment = sepIdx >= 0 ? span[..sepIdx] : span;
if (segment.Length > 0 && SkipDirs.Contains(segment.ToString()))
return true;
if (sepIdx < 0) break;
span = span[(sepIdx + 1)..];
}
return false;
}
private static string GetLanguageName(string ext) => ext.ToLowerInvariant() switch
{
".cs" => "C#",
".ts" or ".tsx" => "TypeScript",
".js" or ".jsx" => "JavaScript",
".py" => "Python",
".rs" => "Rust",
".go" => "Go",
".java" => "Java",
".cpp" or ".cc" or ".cxx" => "C++",
".c" => "C",
".rb" => "Ruby",
".php" => "PHP",
".swift" => "Swift",
".kt" => "Kotlin",
".xaml" => "XAML",
".html" or ".htm" => "HTML",
".css" => "CSS",
_ => ext.TrimStart('.').ToUpperInvariant(),
};
}

View File

@@ -542,7 +542,7 @@ public sealed class AppStateService : IAppStateService
var description = effective switch
{
"AcceptEdits" => "파일 편집 도구는 자동 허용하고 명령 실행은 계속 확인합니다.",
"Deny" => "파일 읽기만 허용하고 생성/수정/삭제 차단합니다.",
"Deny" => "기존 파일 읽기만 가능하며 수정/삭제 차단되고, 새 파일 생성은 가능합니다.",
"Plan" => "계획/승인 흐름을 우선 적용한 뒤 파일 작업을 진행합니다.",
"BypassPermissions" => "모든 권한 확인을 생략합니다. 주의해서 사용해야 합니다.",
_ => "파일 작업 전마다 사용자 확인을 요청합니다.",

View File

@@ -158,11 +158,27 @@ public sealed class ChatSessionStateService
var normalizedTab = NormalizeTab(tab);
var created = new ChatConversation { Tab = normalizedTab };
// Code/Cowork 탭: 매 대화마다 폴더를 새로 선택하도록 빈 상태로 시작
// ── 버그 수정: 현재 사용자가 선택한 권한(FilePermission)을 새 대화에 승계 ──
// 이전 버그: Permission=null인 상태로 생성되면 LoadConversationSettings가
// DefaultAgentPermission(별도 필드, 기본 "Deny")으로 폴백하고
// _settings.Llm.FilePermission을 덮어써버림.
// → UI엔 "권한 건너뛰기"가 표시돼도 실제 실행 시엔 Default/Deny 모드라
// html_create/document_plan 등에서 승인 창이 뜸.
// Chat 탭은 기본 Deny가 안전하므로 승계하지 않음 (기존 동작 유지).
if (string.Equals(normalizedTab, "Code", StringComparison.OrdinalIgnoreCase)
|| string.Equals(normalizedTab, "Cowork", StringComparison.OrdinalIgnoreCase))
{
created.WorkFolder = "";
try
{
var currentPerm = AxCopilot.Services.Agent.PermissionModeCatalog.NormalizeGlobalMode(
settings.Settings.Llm.FilePermission);
// Deny는 새 대화의 기본으로는 부적절(사용자가 의도적으로 Deny를 선택했을 가능성도 있지만
// Cowork/Code에서는 에이전트 실행 자체가 주 목적이므로 그대로 두면 혼란).
// 단, 단순 승계가 사용자 의도에 가장 부합하므로 그대로 반영.
created.Permission = currentPerm;
}
catch { /* 설정 접근 실패 시 Permission=null 유지 (fallback 경로) */ }
CurrentConversation = created;
return created;
}
@@ -271,13 +287,20 @@ public sealed class ChatSessionStateService
{
var normalizedTab = NormalizeTab(tab);
conversation.Tab = normalizedTab;
NormalizeLoadedConversation(conversation);
var normalized = NormalizeLoadedConversation(conversation);
CurrentConversation = conversation;
if (remember && !string.IsNullOrWhiteSpace(conversation.Id))
RememberConversation(normalizedTab, conversation.Id);
try { storage?.Save(conversation); } catch { }
// 대화 "선택"만으로는 대화 내용이 변하지 않음. 기존에는 무조건 Save()를 호출해
// storage.Save()가 UpdatedAt = DateTime.Now로 갱신 → 목록에서 맨 위로 올라가는 부작용.
// 실제 정규화(NormalizeLoadedConversation)가 일어난 경우에만 저장한다.
// — 다른 경로(EnsureCurrentConversation 등)도 이미 if(normalized) 패턴을 따른다.
if (normalized)
{
try { storage?.Save(conversation); } catch { }
}
return conversation;
}

View File

@@ -90,6 +90,9 @@ public class ChatStorageService : IChatStorageService
}
}
/// <summary>대화를 비동기로 로드합니다 (UI 스레드 블록 방지).</summary>
public Task<ChatConversation?> LoadAsync(string id) => Task.Run(() => Load(id));
// ── 메타 캐시 ─────────────────────────────────────────────────────────
private List<ChatConversation>? _metaCache;
private List<ChatConversation>? _metaOrderedCache;
@@ -193,6 +196,7 @@ public class ChatStorageService : IChatStorageService
return result;
}
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
Lock.EnterReadLock();
try
{
@@ -202,7 +206,7 @@ public class ChatStorageService : IChatStorageService
{
var json = CryptoService.DecryptFromFile(file);
var conv = JsonSerializer.Deserialize<ChatConversation>(json, JsonOpts);
if (conv != null)
if (conv != null && !string.IsNullOrWhiteSpace(conv.Id) && seenIds.Add(conv.Id))
{
var meta = new ChatConversation
{

View File

@@ -201,12 +201,42 @@ public static class CryptoService
File.WriteAllBytes(filePath, enc);
}
/// <summary>PC별 키로 AES-256-GCM 암호화 파일을 복호화</summary>
/// <summary>PC별 키로 AES-256-GCM 암호화 파일을 복호화. 평문 JSON 파일은 그대로 반환.</summary>
public static string DecryptFromFile(string filePath)
{
if (!File.Exists(filePath)) return "";
var enc = File.ReadAllBytes(filePath);
var plain = DecryptBytes(enc);
return Encoding.UTF8.GetString(plain);
var raw = File.ReadAllBytes(filePath);
if (raw.Length == 0) return "";
// 평문 JSON 파일 감지: UTF-8 텍스트가 '{' 또는 '[' 로 시작하면 암호화되지 않은 것으로 간주
if (raw.Length > 0 && (raw[0] == (byte)'{' || raw[0] == (byte)'[' || raw[0] == 0xEF /* BOM */))
{
try
{
return Encoding.UTF8.GetString(raw);
}
catch
{
// BOM이었지만 유효한 UTF-8이 아닌 경우 → 암호화된 데이터로 처리
}
}
try
{
var plain = DecryptBytes(raw);
return Encoding.UTF8.GetString(plain);
}
catch (System.Security.Cryptography.CryptographicException)
{
// 복호화 실패 시 평문 텍스트로 한 번 더 시도 (마이그레이션/손상 대응)
try
{
var text = Encoding.UTF8.GetString(raw);
if (text.Contains('"') && (text.TrimStart().StartsWith('{') || text.TrimStart().StartsWith('[')))
return text;
}
catch { /* 무시 */ }
throw; // 평문도 아니면 원래 예외 재전파
}
}
}

View File

@@ -7,6 +7,7 @@ public interface IChatStorageService
{
void Save(ChatConversation conversation);
ChatConversation? Load(string id);
Task<ChatConversation?> LoadAsync(string id);
List<ChatConversation> LoadAllMeta();
void InvalidateMetaCache();
void UpdateMetaCache(ChatConversation conv);

View File

@@ -528,6 +528,12 @@ public partial class LlmService
url = endpoint.TrimEnd('/') + "/v1/chat/completions";
var json = JsonSerializer.Serialize(body);
if (isIbmDeployment)
{
IbmDiagInfo($"[IBM진단] ToolUse.Send: url={url}, bodyLen={json.Length}자, tools={tools.Count}개, force={forceToolCall}");
IbmDiagDebug($"[IBM진단] ToolUse.Send 요청본문(앞500자): {(json.Length > 500 ? json[..500] + "" : json)}");
}
// Raw 요청 로깅 (상세 로그 활성 시)
WorkflowLogService.LogLlmRawRequestFromContext(url, json);
@@ -543,6 +549,8 @@ public partial class LlmService
{
var errBody = await resp.Content.ReadAsStringAsync(ct);
var detail = ExtractErrorDetail(errBody);
if (isIbmDeployment)
IbmDiagError($"[IBM진단] ToolUse.Send API 오류: HTTP {(int)resp.StatusCode}, body={errBody}");
LogService.Warn($"[ToolUse] {activeService} API 오류 ({resp.StatusCode}): {errBody}");
if (forceToolCall && (int)resp.StatusCode == 400)
@@ -596,6 +604,23 @@ public partial class LlmService
@"\{\s*""name""\s*:\s*""(\w+)""\s*,\s*""arguments""\s*:\s*(\{[\s\S]*?\})\s*\}",
System.Text.RegularExpressions.RegexOptions.Compiled);
// 패턴 4: 파이프-래핑 커스텀 포맷 (FastAPI로 호스팅된 Gemma 계열, 일부 IBM/Kimi/GLM 배포에서 leak)
// 예: <|tool_call>call;document_read{path:<|"|>전략보고서.html<|"|>}<tool_call|>
// - 앞 여는 태그 `<|tool_call>` / 닫는 태그 `<tool_call|>` 혹은 `</tool_call|>`
// - 본문은 `call;NAME{args}` 또는 `NAME{args}` 형태
// - args 내부의 `<|"|>` 는 따옴표로 디코딩, 비인용 키는 따옴표 부여
private static readonly System.Text.RegularExpressions.Regex ToolCallPipeWrappedRegex = new(
@"<\|\s*tool_call\s*\|?>\s*(?:call\s*;\s*)?(\w+)\s*(\{[\s\S]*?\})\s*<\s*/?\s*tool_call\s*\|\s*>",
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled);
private static readonly System.Text.RegularExpressions.Regex PipeQuoteDecodeRegex = new(
@"<\|\s*""\s*\|>",
System.Text.RegularExpressions.RegexOptions.Compiled);
private static readonly System.Text.RegularExpressions.Regex UnquotedJsonKeyRegex = new(
@"(?<=[\{,]\s*)([A-Za-z_][A-Za-z0-9_]*)\s*:",
System.Text.RegularExpressions.RegexOptions.Compiled);
internal static List<ContentBlock> TryExtractToolCallsFromText(string text)
{
var results = new List<ContentBlock>();
@@ -628,9 +653,39 @@ public partial class LlmService
}
}
// 패턴 4: 파이프-래핑 커스텀 포맷 (<|tool_call>call;NAME{args}<tool_call|>)
if (results.Count == 0)
{
foreach (System.Text.RegularExpressions.Match m in ToolCallPipeWrappedRegex.Matches(text))
{
var name = m.Groups[1].Value;
var rawArgs = m.Groups[2].Value;
// `<|"|>` → `"` 디코딩
var decoded = PipeQuoteDecodeRegex.Replace(rawArgs, "\"");
// 비인용 키를 JSON 키로 변환 ({path:"x"} → {"path":"x"})
var normalized = UnquotedJsonKeyRegex.Replace(decoded, "\"$1\":");
var block = TryParseToolCallJsonWithName(name, normalized);
if (block != null) results.Add(block);
}
}
return results;
}
/// <summary>
/// 텍스트에서 파싱된 tool_call 태그(4가지 형식 전부)를 제거합니다.
/// 폴백 파싱으로 도구 호출이 추출된 경우, 사용자 화면에 원본 토큰이 남지 않도록 최종 표시 텍스트를 정화.
/// </summary>
internal static string StripToolCallTokens(string text)
{
if (string.IsNullOrEmpty(text)) return text;
text = ToolCallTagRegex.Replace(text, "");
text = ToolCallFunctionRegex.Replace(text, "");
text = ToolCallJsonRegex.Replace(text, "");
text = ToolCallPipeWrappedRegex.Replace(text, "");
return text.Trim();
}
/// <summary>{"name":"...", "arguments":{...}} 형식 JSON을 ContentBlock으로 변환.</summary>
private static ContentBlock? TryParseToolCallJson(string json)
{
@@ -1049,8 +1104,11 @@ public partial class LlmService
}).ToArray();
// IBM watsonx: parameters 래퍼 사용, model 필드 없음
// tool_choice / tool_choice_option: IBM 버전에 따라 필드명이 다를 수 있으므로 양쪽 모두 전송
// 오류 시 상위에서 ToolCallNotSupportedException으로 폴백됨
// tool_choice: OpenAI 표준 필드만 전송.
// 이전에는 `tool_choice` + `tool_choice_option` 둘 다 보내 구버전 호환을 시도했지만,
// 최신 IBM vLLM 배포는 다음 오류로 요청을 거부합니다:
// "400 Json document validation error: tool_choice_option should not be defined if a value is given for ToolChoice"
// 구버전 배포(tool_choice_option 전용)는 상위 ToolCallNotSupportedException 폴백이 처리함.
// Qwen3.5 thinking 모드 비활성화: 활성화되면 content=null, reasoning_content에만 출력됨
if (forceToolCall && useToolChoice)
{
@@ -1059,7 +1117,6 @@ public partial class LlmService
messages = msgs,
tools = toolDefs,
tool_choice = "required",
tool_choice_option = "required",
parameters = new
{
temperature = ResolveToolTemperature(),
@@ -1239,8 +1296,19 @@ public partial class LlmService
if (prefetchToolCallAsync != null)
block.PrefetchedExecutionTask = prefetchToolCallAsync(block);
}
// 사용자 화면에 원본 tool_call 토큰이 남지 않도록 텍스트 정화
var cleanedText = StripToolCallTokens(textBlock.Text);
result.Remove(textBlock);
if (!string.IsNullOrWhiteSpace(cleanedText))
result.Add(new ContentBlock { Type = "text", Text = cleanedText });
result.AddRange(extracted);
LogService.Debug($"[ToolUse] 텍스트에서 도구 호출 {extracted.Count}건 추출 (SSE 폴백 파싱)");
var toolNames = string.Join(", ", extracted.Select(e => e.ToolName));
IbmDiagInfo($"[IBM진단] 텍스트 폴백에서 도구 호출 {extracted.Count}건 추출: [{toolNames}]");
}
else if (usesIbmDeploymentApi)
{
var preview = textBlock.Text.Length > 300 ? textBlock.Text[..300] + "…" : textBlock.Text;
IbmDiagError($"[IBM진단] 응답에 tool_calls 없음, 텍스트 폴백 파싱도 실패. 응답 텍스트: {preview}");
}
}
}
@@ -1275,6 +1343,11 @@ public partial class LlmService
else
url = endpoint.TrimEnd('/') + "/v1/chat/completions";
var json = JsonSerializer.Serialize(body);
if (isIbmDeployment)
{
IbmDiagInfo($"[IBM진단] ToolUse.Stream: url={url}, bodyLen={json.Length}자, tools={tools.Count}개, force={forceToolCall}");
IbmDiagDebug($"[IBM진단] ToolUse.Stream 요청본문(앞500자): {(json.Length > 500 ? json[..500] + "" : json)}");
}
WorkflowLogService.LogLlmRawRequestFromContext(url, json);
using var req = new HttpRequestMessage(HttpMethod.Post, url)
@@ -1287,6 +1360,8 @@ public partial class LlmService
{
var errBody = await resp.Content.ReadAsStringAsync(ct);
var detail = ExtractErrorDetail(errBody);
if (isIbmDeployment)
IbmDiagError($"[IBM진단] ToolUse.Stream API 오류: HTTP {(int)resp.StatusCode}, body={errBody}");
if (forceToolCall && (int)resp.StatusCode == 400)
{
LogService.Warn(isIbmDeployment
@@ -1329,11 +1404,18 @@ public partial class LlmService
{
// Ollama stream:false 등 비-SSE 응답 감지: Content-Type에 text/event-stream이 없으면 전체 JSON으로 처리
var contentType = resp.Content.Headers.ContentType?.MediaType ?? "";
if (usesIbmDeploymentApi)
IbmDiagDebug($"[IBM진단] ToolUse.ParseStream: ContentType={contentType}");
if (!contentType.Contains("event-stream", StringComparison.OrdinalIgnoreCase)
&& !contentType.Contains("octet-stream", StringComparison.OrdinalIgnoreCase))
{
// 비-SSE 전체 JSON 응답 (Ollama stream:false 등)
var rawJson = await resp.Content.ReadAsStringAsync(ct);
if (usesIbmDeploymentApi)
{
var preview = rawJson.Length > 500 ? rawJson[..500] + "…" : rawJson;
IbmDiagInfo($"[IBM진단] ToolUse 비-SSE 응답(전체 JSON): len={rawJson.Length}자\n 미리보기: {preview}");
}
var respJson = ExtractJsonFromSseIfNeeded(rawJson);
var trimmed = respJson.TrimStart();
if (trimmed.StartsWith('{') || trimmed.StartsWith('['))
@@ -1362,6 +1444,7 @@ public partial class LlmService
var firstChunkReceived = false;
var toolAccumulators = new Dictionary<int, ToolCallAccumulator>();
var lastIbmGeneratedText = "";
var ibmToolChunkCount = 0;
while (!reader.EndOfStream && !ct.IsCancellationRequested)
{
@@ -1380,12 +1463,41 @@ public partial class LlmService
firstChunkReceived = true;
var data = line["data: ".Length..].Trim();
if (string.Equals(data, "[DONE]", StringComparison.OrdinalIgnoreCase))
{
if (usesIbmDeploymentApi)
IbmDiagDebug($"[IBM진단] ToolUse.ParseStream 완료: 총 {ibmToolChunkCount}개 청크, toolAccumulators={toolAccumulators.Count}개");
break;
}
using var doc = JsonDocument.Parse(data);
JsonDocument doc;
try
{
doc = JsonDocument.Parse(data);
}
catch (JsonException jex)
{
if (usesIbmDeploymentApi)
{
var preview = data.Length > 500 ? data[..500] + "…" : data;
IbmDiagError($"[IBM진단] ToolUse.ParseStream JSON 파싱 실패: {jex.Message}\n 원본: {preview}");
}
continue;
}
using (doc)
{
var root = doc.RootElement;
TryParseOpenAiUsage(root);
if (usesIbmDeploymentApi)
{
ibmToolChunkCount++;
if (ibmToolChunkCount <= 3 || ibmToolChunkCount % 50 == 0)
{
var preview = data.Length > 300 ? data[..300] + "…" : data;
IbmDiagDebug($"[IBM진단] ToolUse.ParseStream chunk#{ibmToolChunkCount}: {preview}");
}
}
if (usesIbmDeploymentApi &&
root.SafeTryGetProperty("status", out var statusEl) &&
string.Equals(statusEl.SafeGetString(), "error", StringComparison.OrdinalIgnoreCase))
@@ -1393,6 +1505,7 @@ public partial class LlmService
var detail = root.SafeTryGetProperty("message", out var msgEl)
? msgEl.SafeGetString()
: "IBM vLLM 도구 호출 응답 오류";
IbmDiagError($"[IBM진단] ToolUse.ParseStream 서버 오류: {detail}");
throw new ToolCallNotSupportedException(detail ?? "IBM vLLM 도구 호출 응답 오류");
}
@@ -1497,13 +1610,25 @@ public partial class LlmService
toolAccumulators[index] = acc;
}
// IBM vLLM은 첫 청크 이후 후속 delta에서 id/name을 ""(빈 문자열)로 다시 보내는 경우가 있음.
// 빈 값으로 덮어쓰면 누적된 name/id가 사라져 TryCreateCompletedToolCallAsync의
// IsNullOrWhiteSpace(acc.Name) 체크에 걸려 도구 호출이 방출되지 않는다.
// → 비-공백 값일 때만 갱신한다.
if (toolCallEl.SafeTryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.String)
acc.Id = idEl.SafeGetString() ?? acc.Id;
{
var idStr = idEl.SafeGetString();
if (!string.IsNullOrWhiteSpace(idStr))
acc.Id = idStr;
}
if (toolCallEl.SafeTryGetProperty("function", out var functionEl))
{
if (functionEl.SafeTryGetProperty("name", out var nameEl) && nameEl.ValueKind == JsonValueKind.String)
acc.Name = nameEl.SafeGetString() ?? acc.Name;
{
var nameStr = nameEl.SafeGetString();
if (!string.IsNullOrWhiteSpace(nameStr))
acc.Name = nameStr;
}
if (functionEl.SafeTryGetProperty("arguments", out var argumentsEl))
{
@@ -1539,6 +1664,7 @@ public partial class LlmService
}
}
}
} // using (doc)
}
foreach (var acc in toolAccumulators.Values.OrderBy(a => a.Index))

View File

@@ -26,6 +26,25 @@ public partial class LlmService : ILlmService
private string? _systemPrompt;
private const int MaxRetries = 2;
/// <summary>IBM+Qwen 진단 로그 활성 여부 (EnableIbmDiagnosticLog 설정 연동).</summary>
private bool IsIbmDiagEnabled => _settings.Settings.Llm.EnableIbmDiagnosticLog;
/// <summary>IBM 진단 전용 Debug 로그. EnableIbmDiagnosticLog=true 일 때만 출력.</summary>
private void IbmDiagDebug(string msg)
{
if (IsIbmDiagEnabled) LogService.Info($"[IBM진단:DBG] {msg}");
}
/// <summary>IBM 진단 전용 Info 로그. EnableIbmDiagnosticLog=true 일 때만 출력.</summary>
private void IbmDiagInfo(string msg)
{
if (IsIbmDiagEnabled) LogService.Info(msg);
}
/// <summary>IBM 진단 전용 Error 로그. 설정 무관하게 항상 출력 (에러는 항상 기록).</summary>
private static void IbmDiagError(string msg) => LogService.Error(msg);
// 첫 청크: 모델이 컨텍스트를 처리하는 시간 (대용량 컨텍스트에서 3분까지 허용)
private static readonly TimeSpan FirstChunkTimeout = TimeSpan.FromSeconds(180);
// 이후 청크: 스트리밍이 시작된 후 청크 간 최대 간격
@@ -357,9 +376,11 @@ public partial class LlmService : ILlmService
return false;
var normalizedEndpoint = (endpoint ?? "").Trim().ToLowerInvariant();
return normalizedEndpoint.Contains("/ml/") ||
var result = normalizedEndpoint.Contains("/ml/") ||
normalizedEndpoint.Contains("/deployments/") ||
normalizedEndpoint.Contains("/text/chat");
LogService.Debug($"[IBM진단] UsesIbmDeploymentChatApi: service={service}, authType={authType}, endpoint={endpoint?.Length ?? 0}자, result={result}");
return result;
}
private string BuildIbmDeploymentChatUrl(string endpoint, bool stream)
@@ -369,14 +390,18 @@ public partial class LlmService : ILlmService
throw new InvalidOperationException("IBM 배포형 vLLM 엔드포인트가 비어 있습니다.");
var normalized = trimmed.ToLowerInvariant();
string url;
if (normalized.Contains("/text/chat_stream"))
return stream ? trimmed : trimmed.Replace("/text/chat_stream", "/text/chat", StringComparison.OrdinalIgnoreCase);
if (normalized.Contains("/text/chat"))
return stream ? trimmed.Replace("/text/chat", "/text/chat_stream", StringComparison.OrdinalIgnoreCase) : trimmed;
if (normalized.Contains("/deployments/"))
return trimmed.TrimEnd('/') + (stream ? "/text/chat_stream" : "/text/chat");
url = stream ? trimmed : trimmed.Replace("/text/chat_stream", "/text/chat", StringComparison.OrdinalIgnoreCase);
else if (normalized.Contains("/text/chat"))
url = stream ? trimmed.Replace("/text/chat", "/text/chat_stream", StringComparison.OrdinalIgnoreCase) : trimmed;
else if (normalized.Contains("/deployments/"))
url = trimmed.TrimEnd('/') + (stream ? "/text/chat_stream" : "/text/chat");
else
url = trimmed;
return trimmed;
IbmDiagDebug($"[IBM진단] BuildUrl: stream={stream}, url={url}");
return url;
}
private object BuildIbmDeploymentBody(List<ChatMessage> messages)
@@ -384,6 +409,7 @@ public partial class LlmService : ILlmService
var msgs = new List<object>();
if (!string.IsNullOrWhiteSpace(_systemPrompt))
msgs.Add(new { role = "system", content = _systemPrompt });
IbmDiagDebug($"[IBM진단] BuildIbmDeploymentBody: messages={messages.Count}건, systemPrompt={(_systemPrompt?.Length ?? 0)}자");
foreach (var m in messages)
{
@@ -440,13 +466,16 @@ public partial class LlmService : ILlmService
});
}
var temperature = ResolveTemperature();
var maxTokens = ResolveOpenAiCompatibleMaxTokens();
IbmDiagDebug($"[IBM진단] BuildIbmDeploymentBody 완료: finalMessages={msgs.Count}건, temp={temperature}, maxTokens={maxTokens}");
return new
{
messages = msgs,
parameters = new
{
temperature = ResolveTemperature(),
max_new_tokens = ResolveOpenAiCompatibleMaxTokens()
temperature,
max_new_tokens = maxTokens
},
// Qwen3.5 thinking 모드 비활성화: 활성화되면 content=null, reasoning_content에만 출력됨
chat_template_kwargs = new { enable_thinking = false },
@@ -509,11 +538,21 @@ public partial class LlmService : ILlmService
if (registered != null &&
registered.AuthType.Equals("ibm_iam", StringComparison.OrdinalIgnoreCase))
{
var ibmApiKey = !string.IsNullOrWhiteSpace(registered.ApiKey)
? ResolveSecretValue(registered.ApiKey, llm.EncryptionEnabled)
: GetDefaultApiKey(llm, activeService);
var token = await IbmIamTokenService.GetTokenAsync(ibmApiKey, ct: ct);
return token;
IbmDiagDebug($"[IBM진단] IBM IAM 인증 시도: model={modelName}, hasApiKey={!string.IsNullOrWhiteSpace(registered.ApiKey)}");
try
{
var ibmApiKey = !string.IsNullOrWhiteSpace(registered.ApiKey)
? ResolveSecretValue(registered.ApiKey, llm.EncryptionEnabled)
: GetDefaultApiKey(llm, activeService);
var token = await IbmIamTokenService.GetTokenAsync(ibmApiKey, ct: ct);
IbmDiagDebug($"[IBM진단] IBM IAM 토큰 발급 성공: tokenLen={token?.Length ?? 0}");
return token;
}
catch (Exception ex)
{
IbmDiagError($"[IBM진단] IBM IAM 토큰 발급 실패: {ex.GetType().Name}: {ex.Message}");
throw;
}
}
// CP4D 인증 방식인 경우
@@ -523,10 +562,20 @@ public partial class LlmService : ILlmService
registered.AuthType.Equals("cp4d_api_key", StringComparison.OrdinalIgnoreCase)) &&
!string.IsNullOrWhiteSpace(registered.Cp4dUrl))
{
var password = CryptoService.DecryptIfEnabled(registered.Cp4dPassword, llm.EncryptionEnabled);
var token = await Cp4dTokenService.GetTokenAsync(
registered.Cp4dUrl, registered.Cp4dUsername, password, ct);
return token;
IbmDiagDebug($"[IBM진단] CP4D 인증 시도: authType={registered.AuthType}, cp4dUrl={registered.Cp4dUrl}, user={registered.Cp4dUsername}");
try
{
var password = CryptoService.DecryptIfEnabled(registered.Cp4dPassword, llm.EncryptionEnabled);
var token = await Cp4dTokenService.GetTokenAsync(
registered.Cp4dUrl, registered.Cp4dUsername, password, ct);
IbmDiagDebug($"[IBM진단] CP4D 토큰 발급 성공: tokenLen={token?.Length ?? 0}");
return token;
}
catch (Exception ex)
{
IbmDiagError($"[IBM진단] CP4D 토큰 발급 실패: {ex.GetType().Name}: {ex.Message}");
throw;
}
}
// 기본 Bearer 인증 — 기존 API 키 반환
@@ -802,15 +851,38 @@ public partial class LlmService : ILlmService
: ep.TrimEnd('/') + "/v1/chat/completions";
var json = JsonSerializer.Serialize(body);
if (usesIbmDeploymentApi)
IbmDiagInfo($"[IBM진단] SendOpenAi(비스트리밍): url={url}, bodyLen={json.Length}자, messages={messages.Count}건");
using var req = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
await ApplyAuthHeaderAsync(req, ct);
using var resp = await SendWithErrorClassificationAsync(req, allowInsecureTls, ct);
HttpResponseMessage resp;
try
{
resp = await SendWithErrorClassificationAsync(req, allowInsecureTls, ct);
}
catch (Exception ex)
{
if (usesIbmDeploymentApi)
IbmDiagError($"[IBM진단] SendOpenAi 요청 실패: {ex.GetType().Name}: {ex.Message}");
throw;
}
using (resp)
{
var respBody = await resp.Content.ReadAsStringAsync(ct);
if (usesIbmDeploymentApi)
{
var contentType = resp.Content.Headers.ContentType?.MediaType ?? "(null)";
var preview = respBody.Length > 500 ? respBody[..500] + "…" : respBody;
IbmDiagInfo($"[IBM진단] SendOpenAi 응답: HTTP {(int)resp.StatusCode}, ContentType={contentType}, bodyLen={respBody.Length}자");
IbmDiagDebug($"[IBM진단] SendOpenAi 응답본문: {preview}");
}
// IBM vLLM이 stream:false 요청에도 SSE 형식(id:/event/data: 라인)으로 응답하는 경우 처리
var effectiveBody = ExtractJsonFromSseIfNeeded(respBody);
@@ -834,6 +906,7 @@ public partial class LlmService : ILlmService
if (msg.Value.ValueKind == JsonValueKind.String) return msg.Value.SafeGetString() ?? "";
return msg.Value.SafeGetProperty("content")?.SafeGetString() ?? "";
}, "vLLM 응답");
} // using (resp)
}
/// <summary>
@@ -931,14 +1004,39 @@ public partial class LlmService : ILlmService
? BuildIbmDeploymentChatUrl(ep, stream: true)
: ep.TrimEnd('/') + "/v1/chat/completions";
if (usesIbmDeploymentApi)
{
var bodyJson = JsonSerializer.Serialize(body);
IbmDiagInfo($"[IBM진단] StreamOpenAi: url={url}, bodyLen={bodyJson.Length}자, messages={messages.Count}건");
IbmDiagDebug($"[IBM진단] StreamOpenAi 요청본문(앞500자): {(bodyJson.Length > 500 ? bodyJson[..500] + "" : bodyJson)}");
}
using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent(body) };
await ApplyAuthHeaderAsync(req, ct);
using var resp = await SendWithErrorClassificationAsync(req, allowInsecureTls, ct);
using var stream = await resp.Content.ReadAsStreamAsync(ct);
using var reader = new StreamReader(stream);
HttpResponseMessage resp;
try
{
resp = await SendWithErrorClassificationAsync(req, allowInsecureTls, ct);
}
catch (Exception ex)
{
if (usesIbmDeploymentApi)
IbmDiagError($"[IBM진단] StreamOpenAi 요청 실패: {ex.GetType().Name}: {ex.Message}");
throw;
}
if (usesIbmDeploymentApi)
{
var ct2 = resp.Content.Headers.ContentType?.MediaType ?? "(null)";
IbmDiagInfo($"[IBM진단] StreamOpenAi 연결 성공: HTTP {(int)resp.StatusCode}, ContentType={ct2}");
}
using var stream2 = await resp.Content.ReadAsStreamAsync(ct);
using var reader = new StreamReader(stream2);
var firstChunkReceived = false;
var ibmChunkCount = 0;
while (!reader.EndOfStream && !ct.IsCancellationRequested)
{
var timeout = firstChunkReceived ? SubsequentChunkTimeout : FirstChunkTimeout;
@@ -954,7 +1052,12 @@ public partial class LlmService : ILlmService
firstChunkReceived = true;
if (string.IsNullOrEmpty(line) || !line.StartsWith("data: ")) continue;
var data = line["data: ".Length..];
if (data == "[DONE]") break;
if (data == "[DONE]")
{
if (usesIbmDeploymentApi)
IbmDiagDebug($"[IBM진단] StreamOpenAi 완료: 총 {ibmChunkCount}개 청크 수신");
break;
}
string? text = null;
try
@@ -963,12 +1066,21 @@ public partial class LlmService : ILlmService
TryParseOpenAiUsage(doc.RootElement);
if (usesIbmDeploymentApi)
{
ibmChunkCount++;
// 첫 3개 청크 + 이후 50개마다 로깅 (과도한 로그 방지)
if (ibmChunkCount <= 3 || ibmChunkCount % 50 == 0)
{
var preview = data.Length > 300 ? data[..300] + "…" : data;
IbmDiagDebug($"[IBM진단] StreamOpenAi chunk#{ibmChunkCount}: {preview}");
}
if (doc.RootElement.SafeTryGetProperty("status", out var status) &&
string.Equals(status.SafeGetString(), "error", StringComparison.OrdinalIgnoreCase))
{
var detail = doc.RootElement.SafeTryGetProperty("message", out var message)
? message.SafeGetString()
: "IBM vLLM 스트리밍 오류";
IbmDiagError($"[IBM진단] StreamOpenAi 서버 오류 응답: {detail}");
throw new InvalidOperationException(detail);
}
@@ -1029,7 +1141,13 @@ public partial class LlmService : ILlmService
}
catch (JsonException ex)
{
LogService.Warn($"vLLM 스트리밍 JSON 파싱 오류: {ex.Message}");
if (usesIbmDeploymentApi)
{
var preview = data.Length > 500 ? data[..500] + "…" : data;
IbmDiagError($"[IBM진단] StreamOpenAi JSON 파싱 오류: {ex.Message}\n 청크 내용: {preview}");
}
else
LogService.Warn($"vLLM 스트리밍 JSON 파싱 오류: {ex.Message}");
}
if (!string.IsNullOrEmpty(text)) yield return text;
}