AX Agent 코워크·코드 흐름과 컨텍스트 관리를 claude-code 기준으로 대폭 정리

- 코워크·코드 프롬프트, 도구 선택, 문서 생성/검증 흐름을 claude-code 동등 품질 기준으로 재정렬함

- OpenAI/vLLM 경로의 오래된 tool history를 평탄화하고 최근 이력만 구조화해 컨텍스트 직렬화를 경량화함

- AX Agent UI를 테마 기준으로 재구성하고 플랜 승인/오버레이/이벤트 렌더링/명령 입력 상호작용을 개선함

- 파일 후보 제안, 반복 경로 정체 복구, LSP 보강, 문서·PPT 처리 개선, 설정/서비스 인터페이스 정리를 함께 반영함

- README.md 및 docs/DEVELOPMENT.md를 작업 시점별로 갱신함

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
This commit is contained in:
2026-04-12 22:02:14 +09:00
parent b8f4df1892
commit fb0bea41f7
137 changed files with 18532 additions and 1144 deletions

View File

@@ -62,9 +62,9 @@ public partial class LlmService
EnsureOperationModeAllowsLlmService(activeService);
return NormalizeServiceName(activeService) switch
{
"sigmoid" => await SendSigmoidWithToolsAsync(messages, tools, ct),
"gemini" => await SendGeminiWithToolsAsync(messages, tools, ct),
"ollama" or "vllm" => await SendOpenAiWithToolsAsync(messages, tools, ct, forceToolCall, prefetchToolCallAsync),
"sigmoid" => await SendSigmoidWithToolsAsync(messages, tools, ct).ConfigureAwait(false),
"gemini" => await SendGeminiWithToolsAsync(messages, tools, ct).ConfigureAwait(false),
"ollama" or "vllm" => await SendOpenAiWithToolsAsync(messages, tools, ct, forceToolCall, prefetchToolCallAsync).ConfigureAwait(false),
_ => throw new NotSupportedException($"서비스 '{activeService}'는 아직 Function Calling을 지원하지 않습니다.")
};
}
@@ -592,7 +592,7 @@ public partial class LlmService
@"\{\s*""name""\s*:\s*""(\w+)""\s*,\s*""arguments""\s*:\s*(\{[\s\S]*?\})\s*\}",
System.Text.RegularExpressions.RegexOptions.Compiled);
private static List<ContentBlock> TryExtractToolCallsFromText(string text)
internal static List<ContentBlock> TryExtractToolCallsFromText(string text)
{
var results = new List<ContentBlock>();
if (string.IsNullOrWhiteSpace(text)) return results;
@@ -690,9 +690,13 @@ public partial class LlmService
{
var llm = _settings.Settings.Llm;
var msgs = new List<object>();
var structuredHistoryStart = GetStructuredToolHistoryStartIndex(messages);
foreach (var m in messages)
for (var messageIndex = 0; messageIndex < messages.Count; messageIndex++)
{
var m = messages[messageIndex];
var keepStructuredHistory = messageIndex >= structuredHistoryStart;
// tool_result 메시지 → OpenAI tool 응답 형식
if (m.Role == "user" && m.Content.StartsWith("{\"type\":\"tool_result\""))
{
@@ -700,6 +704,16 @@ public partial class LlmService
{
using var doc = JsonDocument.Parse(m.Content);
var root = doc.RootElement;
if (!keepStructuredHistory)
{
msgs.Add(new
{
role = "user",
content = BuildOpenAiToolResultTranscript(root),
});
continue;
}
msgs.Add(new
{
role = "tool",
@@ -718,6 +732,16 @@ public partial class LlmService
{
using var doc = JsonDocument.Parse(m.Content);
var blocksArr = doc.RootElement.GetProperty("_tool_use_blocks");
if (!keepStructuredHistory)
{
msgs.Add(new
{
role = "assistant",
content = BuildOpenAiAssistantTranscript(blocksArr),
});
continue;
}
var textContent = "";
var toolCallsList = new List<object>();
foreach (var b in blocksArr.EnumerateArray())
@@ -766,6 +790,12 @@ public partial class LlmService
}
}
// ── tool_calls ↔ tool 메시지 쌍 검증 ──
// 컨텍스트 압축 후 tool_calls assistant 메시지는 남아있는데
// 대응하는 tool result 메시지가 누락되면 vLLM이 400 에러를 반환함.
// 깨진 tool_calls 메시지를 일반 assistant 텍스트로 평탄화하여 방지.
SanitizeToolCallPairs(msgs);
// OpenAI 도구 정의
var toolDefs = tools.Select(t =>
{
@@ -798,14 +828,20 @@ public partial class LlmService
var isOllama = activeService.Equals("ollama", StringComparison.OrdinalIgnoreCase);
if (isOllama)
{
return new
// Ollama /api/chat 전용 바디 — stream:false로 비스트리밍 응답
// Ollama 0.5.x+ 에서 tool_choice 파라미터 지원 (미지원 버전은 무시됨)
var ollamaBody = new Dictionary<string, object?>
{
model = activeModel,
messages = msgs,
tools = toolDefs,
stream = false,
options = new { temperature = ResolveToolTemperature() }
["model"] = activeModel,
["messages"] = msgs,
["tools"] = toolDefs,
["stream"] = false,
["options"] = new { temperature = ResolveToolTemperature() }
};
// Ollama에도 tool_choice 전달 — 프로파일 ForceInitialToolCall 적용
if (forceToolCall)
ollamaBody["tool_choice"] = "required";
return ollamaBody;
}
var body = new Dictionary<string, object?>
@@ -830,6 +866,26 @@ public partial class LlmService
return body;
}
private static int GetStructuredToolHistoryStartIndex(IReadOnlyList<ChatMessage> messages)
{
const int protectedRecentNonSystemMessages = 8;
var nonSystemMessages = messages
.Select((message, index) => new { message, index })
.Where(x => !string.Equals(x.message.Role, "system", StringComparison.OrdinalIgnoreCase))
.ToList();
if (nonSystemMessages.Count <= protectedRecentNonSystemMessages)
return 0;
var tentativeStart = Math.Max(0, nonSystemMessages.Count - protectedRecentNonSystemMessages);
var adjustedStart = AgentMessageInvariantHelper.AdjustStartIndexForToolPairs(
nonSystemMessages.Select(x => x.message).ToList(),
tentativeStart,
out _);
return nonSystemMessages[Math.Max(0, adjustedStart)].index;
}
/// <summary>IBM 배포형 /text/chat 전용 도구 바디. parameters 래퍼 사용, model 필드 없음.</summary>
private object BuildIbmToolBody(
List<ChatMessage> messages,
@@ -1006,6 +1062,51 @@ public partial class LlmService
: string.Join("\n\n", parts);
}
private static string BuildOpenAiAssistantTranscript(JsonElement blocksArr)
{
var textSegments = new List<string>();
var toolNames = new List<string>();
foreach (var block in blocksArr.EnumerateArray())
{
var blockType = block.GetProperty("type").SafeGetString();
if (string.Equals(blockType, "text", StringComparison.OrdinalIgnoreCase))
{
var text = block.SafeTryGetProperty("text", out var textEl) ? textEl.SafeGetString() ?? "" : "";
if (!string.IsNullOrWhiteSpace(text))
textSegments.Add(text.Trim());
continue;
}
if (!string.Equals(blockType, "tool_use", StringComparison.OrdinalIgnoreCase))
continue;
var name = block.SafeTryGetProperty("name", out var nameEl) ? nameEl.SafeGetString() ?? "" : "";
if (!string.IsNullOrWhiteSpace(name))
toolNames.Add(name.Trim());
}
var parts = new List<string>();
if (textSegments.Count > 0)
parts.Add(string.Join("\n\n", textSegments));
if (toolNames.Count > 0)
parts.Add($"[이전 도구 호출: {string.Join(", ", toolNames.Distinct(StringComparer.OrdinalIgnoreCase))}]");
return parts.Count == 0 ? "[이전 도구 호출]" : string.Join("\n\n", parts);
}
private static string BuildOpenAiToolResultTranscript(JsonElement root)
{
var toolName = root.SafeTryGetProperty("tool_name", out var nameEl) ? nameEl.SafeGetString() ?? "" : "";
var content = root.SafeTryGetProperty("content", out var contentEl) ? contentEl.SafeGetString() ?? "" : "";
var header = string.IsNullOrWhiteSpace(toolName)
? "[이전 도구 결과]"
: $"[이전 도구 결과: {toolName}]";
return string.IsNullOrWhiteSpace(content)
? $"{header}\n(no output)"
: $"{header}\n{content}";
}
private static string BuildIbmToolResultTranscript(JsonElement root)
{
var toolCallId = root.SafeTryGetProperty("tool_use_id", out var idEl) ? idEl.SafeGetString() ?? "" : "";
@@ -1674,6 +1775,93 @@ public partial class LlmService
// ─── 공통 헬퍼 ─────────────────────────────────────────────────────
/// <summary>
/// tool_calls가 포함된 assistant 메시지 뒤에 대응하는 tool 메시지가 없으면
/// 해당 assistant 메시지를 일반 텍스트로 평탄화합니다.
/// 컨텍스트 압축 후 쌍이 깨지는 경우를 방어합니다.
/// </summary>
private static void SanitizeToolCallPairs(List<object> msgs)
{
// ── 1패스: tool_calls assistant 메시지의 쌍 검증 ──
// tool_calls가 있는 assistant 인덱스를 기록하여 2패스에서 고아 tool 판별에 사용
var pairedToolIndices = new HashSet<int>();
for (int i = 0; i < msgs.Count; i++)
{
var msgType = msgs[i].GetType();
var toolCallsProp = msgType.GetProperty("tool_calls");
var roleProp = msgType.GetProperty("role");
if (toolCallsProp == null || roleProp == null) continue;
var role = roleProp.GetValue(msgs[i]) as string;
if (role != "assistant") continue;
var toolCalls = toolCallsProp.GetValue(msgs[i]);
if (toolCalls == null) continue;
int callCount = 0;
if (toolCalls is System.Collections.ICollection col) callCount = col.Count;
else if (toolCalls is System.Collections.IEnumerable en)
{
foreach (var _ in en) callCount++;
}
if (callCount == 0) continue;
// 바로 다음에 tool 역할 메시지가 callCount개 있는지 확인
int foundTools = 0;
for (int j = i + 1; j < msgs.Count && foundTools < callCount; j++)
{
var jType = msgs[j].GetType();
var jRole = jType.GetProperty("role")?.GetValue(msgs[j]) as string;
if (jRole == "tool")
{
foundTools++;
pairedToolIndices.Add(j);
}
else
break;
}
if (foundTools < callCount)
{
// 쌍이 불완전 → assistant를 일반 텍스트로 교체
var contentProp = msgType.GetProperty("content");
var contentText = contentProp?.GetValue(msgs[i]) as string ?? "";
if (string.IsNullOrWhiteSpace(contentText))
contentText = "[이전 도구 호출 — 결과 누락으로 생략됨]";
msgs[i] = new { role = "assistant", content = contentText };
// 이 assistant에 딸린 불완전 tool 메시지도 user로 변환
for (int j = i + 1; j < msgs.Count; j++)
{
var jType = msgs[j].GetType();
var jRole = jType.GetProperty("role")?.GetValue(msgs[j]) as string;
if (jRole != "tool") break;
var jContent = jType.GetProperty("content")?.GetValue(msgs[j]) as string ?? "";
msgs[j] = new { role = "user", content = $"[이전 도구 결과]\n{jContent}" };
pairedToolIndices.Remove(j);
}
LogService.Info($"[ToolUse] tool_calls/tool 쌍 불일치 감지 — assistant 메시지를 평탄화 (index={i})");
}
}
// ── 2패스: 앞에 tool_calls가 없는 고아 tool 메시지를 user로 변환 ──
for (int i = 0; i < msgs.Count; i++)
{
if (pairedToolIndices.Contains(i)) continue;
var msgType = msgs[i].GetType();
var roleProp = msgType.GetProperty("role");
var role = roleProp?.GetValue(msgs[i]) as string;
if (role != "tool") continue;
var content = msgType.GetProperty("content")?.GetValue(msgs[i]) as string ?? "";
msgs[i] = new { role = "user", content = $"[이전 도구 결과]\n{content}" };
LogService.Info($"[ToolUse] 고아 tool 메시지 감지 — user로 변환 (index={i})");
}
}
/// <summary>ToolProperty를 LLM API용 스키마 객체로 변환. array/enum/items 포함.</summary>
private static object BuildPropertySchema(Agent.ToolProperty prop, bool upperCaseType)
{