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