에이전트 선택적 탐색 구조 개선과 경고 정리 반영
Some checks failed
Release Gate / gate (push) Has been cancelled

- claude-code 선택적 탐색 흐름을 참고해 Cowork/Code 시스템 프롬프트에서 folder_map 상시 선행 지시를 완화하고 glob/grep 기반 좁은 탐색을 우선하도록 조정함

- FolderMapTool 기본 depth를 2로, include_files 기본값을 false로 낮추고 MultiReadTool 최대 파일 수를 8개로 줄여 초기 과탐색 폭을 보수적으로 조정함

- AgentLoopExplorationPolicy partial을 추가해 탐색 범위 분류, broad-scan corrective hint, exploration_breadth 성능 로그를 연결함

- AgentLoopService에 탐색 범위 가이드 주입과 실행 중 탐색 폭 추적을 추가하고, 좁은 질문에서 반복적인 folder_map/대량 multi_read를 교정하도록 정리함

- DocxToHtmlConverter nullable 경고를 수정해 Release 빌드 경고 0 / 오류 0 기준을 다시 충족함

- README와 docs/DEVELOPMENT.md에 2026-04-09 10:36 (KST) 기준 개발 이력을 반영함
This commit is contained in:
2026-04-09 14:27:59 +09:00
parent 7931566212
commit 33c1db4dae
119 changed files with 4453 additions and 6943 deletions

View File

@@ -4,6 +4,7 @@ using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using AxCopilot.Models;
using AxCopilot.Services.Agent;
namespace AxCopilot.Services;
@@ -403,17 +404,17 @@ public partial class LlmService : IDisposable
try
{
using var doc = JsonDocument.Parse(m.Content);
if (doc.RootElement.TryGetProperty("_tool_use_blocks", out var blocks))
if (doc.RootElement.SafeTryGetProperty("_tool_use_blocks", out var blocks))
{
var parts = new List<string>();
foreach (var block in blocks.EnumerateArray())
{
if (!block.TryGetProperty("type", out var typeEl)) continue;
var type = typeEl.GetString();
if (type == "text" && block.TryGetProperty("text", out var textEl))
parts.Add(textEl.GetString() ?? "");
else if (type == "tool_use" && block.TryGetProperty("name", out var nameEl))
parts.Add($"[도구 호출: {nameEl.GetString()}]");
if (!block.SafeTryGetProperty("type", out var typeEl)) continue;
var type = typeEl.SafeGetString();
if (type == "text" && block.SafeTryGetProperty("text", out var textEl))
parts.Add(textEl.SafeGetString() ?? "");
else if (type == "tool_use" && block.SafeTryGetProperty("name", out var nameEl))
parts.Add($"[도구 호출: {nameEl.SafeGetString()}]");
}
var content = string.Join("\n", parts).Trim();
if (!string.IsNullOrEmpty(content))
@@ -431,8 +432,8 @@ public partial class LlmService : IDisposable
{
using var doc = JsonDocument.Parse(m.Content);
var root = doc.RootElement;
var toolName = root.TryGetProperty("tool_name", out var tn) ? tn.GetString() ?? "tool" : "tool";
var toolContent = root.TryGetProperty("content", out var tc) ? tc.GetString() ?? "" : "";
var toolName = root.SafeTryGetProperty("tool_name", out var tn) ? tn.SafeGetString() ?? "tool" : "tool";
var toolContent = root.SafeTryGetProperty("content", out var tc) ? tc.SafeGetString() ?? "" : "";
msgs.Add(new { role = "user", content = $"[{toolName} 결과]\n{toolContent}" });
continue;
}
@@ -461,41 +462,41 @@ public partial class LlmService : IDisposable
private static string ExtractIbmDeploymentText(JsonElement root)
{
if (root.TryGetProperty("choices", out var choices) && choices.ValueKind == JsonValueKind.Array && choices.GetArrayLength() > 0)
if (root.SafeTryGetProperty("choices", out var choices) && choices.ValueKind == JsonValueKind.Array && choices.GetArrayLength() > 0)
{
var message = choices[0].TryGetProperty("message", out var choiceMessage) ? choiceMessage : default;
var message = choices[0].SafeTryGetProperty("message", out var choiceMessage) ? choiceMessage : default;
if (message.ValueKind == JsonValueKind.Object)
{
if (message.TryGetProperty("content", out var content))
if (message.SafeTryGetProperty("content", out var content))
{
var text = content.GetString();
var text = content.SafeGetString();
if (!string.IsNullOrEmpty(text))
return text;
}
// Qwen3.5 thinking 모드 폴백
if (message.TryGetProperty("reasoning_content", out var reasoning))
if (message.SafeTryGetProperty("reasoning_content", out var reasoning))
{
var text = reasoning.GetString();
var text = reasoning.SafeGetString();
if (!string.IsNullOrEmpty(text))
return text;
}
}
}
if (root.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array && results.GetArrayLength() > 0)
if (root.SafeTryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array && results.GetArrayLength() > 0)
{
var first = results[0];
if (first.TryGetProperty("generated_text", out var generatedText))
return generatedText.GetString() ?? "";
if (first.TryGetProperty("output_text", out var outputText))
return outputText.GetString() ?? "";
if (first.SafeTryGetProperty("generated_text", out var generatedText))
return generatedText.SafeGetString() ?? "";
if (first.SafeTryGetProperty("output_text", out var outputText))
return outputText.SafeGetString() ?? "";
}
if (root.TryGetProperty("generated_text", out var generated))
return generated.GetString() ?? "";
if (root.SafeTryGetProperty("generated_text", out var generated))
return generated.SafeGetString() ?? "";
if (root.TryGetProperty("message", out var messageValue) && messageValue.ValueKind == JsonValueKind.String)
return messageValue.GetString() ?? "";
if (root.SafeTryGetProperty("message", out var messageValue) && messageValue.ValueKind == JsonValueKind.String)
return messageValue.SafeGetString() ?? "";
return "";
}
@@ -719,7 +720,10 @@ public partial class LlmService : IDisposable
return SafeParseJson(resp, root =>
{
TryParseOllamaUsage(root);
return root.GetProperty("message").GetProperty("content").GetString() ?? "";
var msg = root.SafeGetProperty("message");
if (msg == null) return root.SafeGetString() ?? "(빈 응답)";
if (msg.Value.ValueKind == JsonValueKind.String) return msg.Value.SafeGetString() ?? "";
return msg.Value.SafeGetProperty("content")?.SafeGetString() ?? "";
}, "Ollama 응답");
}
@@ -759,10 +763,10 @@ public partial class LlmService : IDisposable
try
{
using var doc = JsonDocument.Parse(line);
if (doc.RootElement.TryGetProperty("message", out var msg) &&
msg.TryGetProperty("content", out var c))
text = c.GetString();
if (doc.RootElement.TryGetProperty("done", out var done) && done.GetBoolean())
if (doc.RootElement.SafeTryGetProperty("message", out var msg) &&
msg.SafeTryGetProperty("content", out var c))
text = c.SafeGetString();
if (doc.RootElement.SafeTryGetProperty("done", out var done) && done.GetBoolean())
TryParseOllamaUsage(doc.RootElement);
}
catch (JsonException ex)
@@ -827,9 +831,15 @@ public partial class LlmService : IDisposable
return string.IsNullOrWhiteSpace(parsed) ? "(빈 응답)" : parsed;
}
var choices = root.GetProperty("choices");
if (choices.GetArrayLength() == 0) return "(빈 응답)";
return choices[0].GetProperty("message").GetProperty("content").GetString() ?? "";
if (!root.SafeTryGetProperty("choices", out var choices)
|| choices.ValueKind != JsonValueKind.Array
|| choices.GetArrayLength() == 0)
return "(빈 응답)";
var firstChoice = choices[0];
var msg = firstChoice.SafeGetProperty("message");
if (msg == null) return firstChoice.SafeGetString() ?? "(빈 응답)";
if (msg.Value.ValueKind == JsonValueKind.String) return msg.Value.SafeGetString() ?? "";
return msg.Value.SafeGetProperty("content")?.SafeGetString() ?? "";
}, "vLLM 응답");
}
@@ -867,27 +877,27 @@ public partial class LlmService : IDisposable
{
using var doc = JsonDocument.Parse(data);
// 스트리밍 청크(delta) → content 누적
if (doc.RootElement.TryGetProperty("choices", out var ch) && ch.GetArrayLength() > 0)
if (doc.RootElement.SafeTryGetProperty("choices", out var ch) && ch.GetArrayLength() > 0)
{
var first = ch[0];
if (first.TryGetProperty("delta", out var delta))
if (first.SafeTryGetProperty("delta", out var delta))
{
string? txt = null;
if (delta.TryGetProperty("content", out var cnt))
txt = cnt.GetString();
if (delta.SafeTryGetProperty("content", out var cnt))
txt = cnt.SafeGetString();
// Qwen3.5 thinking 모드 폴백: content가 비어있으면 reasoning_content 사용
if (string.IsNullOrEmpty(txt) && delta.TryGetProperty("reasoning_content", out var rc))
txt = rc.GetString();
if (string.IsNullOrEmpty(txt) && delta.SafeTryGetProperty("reasoning_content", out var rc))
txt = rc.SafeGetString();
if (!string.IsNullOrEmpty(txt)) { sb.Append(txt); collectingChunks = true; }
}
else if (first.TryGetProperty("message", out _))
else if (first.SafeTryGetProperty("message", out _))
{
// 완성 응답 → 이 JSON을 그대로 사용
return data;
}
}
// IBM results[] 형식
else if (doc.RootElement.TryGetProperty("results", out var res) && res.GetArrayLength() > 0)
else if (doc.RootElement.SafeTryGetProperty("results", out var res) && res.GetArrayLength() > 0)
{
return data;
}
@@ -960,44 +970,67 @@ public partial class LlmService : IDisposable
TryParseOpenAiUsage(doc.RootElement);
if (usesIbmDeploymentApi)
{
if (doc.RootElement.TryGetProperty("status", out var status) &&
string.Equals(status.GetString(), "error", StringComparison.OrdinalIgnoreCase))
if (doc.RootElement.SafeTryGetProperty("status", out var status) &&
string.Equals(status.SafeGetString(), "error", StringComparison.OrdinalIgnoreCase))
{
var detail = doc.RootElement.TryGetProperty("message", out var message)
? message.GetString()
var detail = doc.RootElement.SafeTryGetProperty("message", out var message)
? message.SafeGetString()
: "IBM vLLM 스트리밍 오류";
throw new InvalidOperationException(detail);
}
if (doc.RootElement.TryGetProperty("results", out var results) &&
if (doc.RootElement.SafeTryGetProperty("results", out var results) &&
results.ValueKind == JsonValueKind.Array &&
results.GetArrayLength() > 0)
{
var first = results[0];
if (first.TryGetProperty("generated_text", out var generatedText))
text = generatedText.GetString();
else if (first.TryGetProperty("output_text", out var outputText))
text = outputText.GetString();
if (first.SafeTryGetProperty("generated_text", out var generatedText))
text = generatedText.SafeGetString();
else if (first.SafeTryGetProperty("output_text", out var outputText))
text = outputText.SafeGetString();
}
else if (doc.RootElement.TryGetProperty("choices", out var ibmChoices) && ibmChoices.GetArrayLength() > 0)
else if (doc.RootElement.SafeTryGetProperty("choices", out var ibmChoices)
&& ibmChoices.ValueKind == JsonValueKind.Array
&& ibmChoices.GetArrayLength() > 0)
{
var delta = ibmChoices[0].GetProperty("delta");
if (delta.TryGetProperty("content", out var c))
text = c.GetString();
if (string.IsNullOrEmpty(text) && delta.TryGetProperty("reasoning_content", out var rc))
text = rc.GetString();
var fc = ibmChoices[0];
if (fc.SafeTryGetProperty("delta", out var delta))
{
if (delta.ValueKind == JsonValueKind.String)
text = delta.SafeGetString();
else
{
if (delta.SafeTryGetProperty("content", out var c))
text = c.SafeGetString();
if (string.IsNullOrEmpty(text) && delta.SafeTryGetProperty("reasoning_content", out var rc))
text = rc.SafeGetString();
}
}
else if (fc.ValueKind == JsonValueKind.String)
text = fc.SafeGetString();
}
}
else
{
var choices = doc.RootElement.GetProperty("choices");
if (choices.GetArrayLength() > 0)
if (doc.RootElement.SafeTryGetProperty("choices", out var choices)
&& choices.ValueKind == JsonValueKind.Array
&& choices.GetArrayLength() > 0)
{
var delta = choices[0].GetProperty("delta");
if (delta.TryGetProperty("content", out var c))
text = c.GetString();
if (string.IsNullOrEmpty(text) && delta.TryGetProperty("reasoning_content", out var rc2))
text = rc2.GetString();
var fc = choices[0];
if (fc.SafeTryGetProperty("delta", out var delta))
{
if (delta.ValueKind == JsonValueKind.String)
text = delta.SafeGetString();
else
{
if (delta.SafeTryGetProperty("content", out var c))
text = c.SafeGetString();
if (string.IsNullOrEmpty(text) && delta.SafeTryGetProperty("reasoning_content", out var rc2))
text = rc2.SafeGetString();
}
}
else if (fc.ValueKind == JsonValueKind.String)
text = fc.SafeGetString();
}
}
}
@@ -1021,6 +1054,9 @@ public partial class LlmService : IDisposable
["temperature"] = ResolveTemperature(),
["max_tokens"] = ResolveOpenAiCompatibleMaxTokens()
};
// 스트리밍 시 마지막 청크에 토큰 사용량을 포함하도록 요청 (vLLM/OpenAI 호환)
if (stream)
body["stream_options"] = new { include_usage = true };
var effort = ResolveReasoningEffort();
if (!string.IsNullOrWhiteSpace(effort))
body["reasoning_effort"] = effort;
@@ -1045,11 +1081,16 @@ public partial class LlmService : IDisposable
return SafeParseJson(resp, root =>
{
TryParseGeminiUsage(root);
var candidates = root.GetProperty("candidates");
if (candidates.GetArrayLength() == 0) return "(빈 응답)";
var parts = candidates[0].GetProperty("content").GetProperty("parts");
if (parts.GetArrayLength() == 0) return "(빈 응답)";
return parts[0].GetProperty("text").GetString() ?? "";
if (!root.SafeTryGetProperty("candidates", out var candidates)
|| candidates.ValueKind != JsonValueKind.Array
|| candidates.GetArrayLength() == 0)
return "(빈 응답)";
var first = candidates[0];
var content = first.SafeGetProperty("content");
if (content == null || !content.Value.SafeTryGetProperty("parts", out var parts)
|| parts.ValueKind != JsonValueKind.Array || parts.GetArrayLength() == 0)
return first.SafeGetString() ?? "(빈 응답)";
return parts[0].SafeGetProperty("text")?.SafeGetString() ?? "";
}, "Gemini 응답");
}
@@ -1093,15 +1134,19 @@ public partial class LlmService : IDisposable
{
using var doc = JsonDocument.Parse(data);
TryParseGeminiUsage(doc.RootElement);
var candidates = doc.RootElement.GetProperty("candidates");
if (candidates.GetArrayLength() == 0) continue;
if (!doc.RootElement.SafeTryGetProperty("candidates", out var candidates)
|| candidates.ValueKind != JsonValueKind.Array
|| candidates.GetArrayLength() == 0) continue;
var sb = new StringBuilder();
var parts = candidates[0].GetProperty("content").GetProperty("parts");
var firstCand = candidates[0];
var contentEl = firstCand.SafeGetProperty("content");
if (contentEl == null || !contentEl.Value.SafeTryGetProperty("parts", out var parts)
|| parts.ValueKind != JsonValueKind.Array) continue;
foreach (var part in parts.EnumerateArray())
{
if (part.TryGetProperty("text", out var t))
if (part.SafeTryGetProperty("text", out var t))
{
var text = t.GetString();
var text = t.SafeGetString();
if (!string.IsNullOrEmpty(text)) sb.Append(text);
}
}
@@ -1185,9 +1230,11 @@ public partial class LlmService : IDisposable
return SafeParseJson(respJson, root =>
{
TryParseSigmoidUsage(root);
var content = root.GetProperty("content");
if (content.GetArrayLength() == 0) return "(빈 응답)";
return content[0].GetProperty("text").GetString() ?? "";
if (!root.SafeTryGetProperty("content", out var content)
|| content.ValueKind != JsonValueKind.Array
|| content.GetArrayLength() == 0)
return root.SafeGetString() ?? "(빈 응답)";
return content[0].SafeGetProperty("text")?.SafeGetString() ?? "";
}, "Claude 응답");
}
@@ -1237,20 +1284,20 @@ public partial class LlmService : IDisposable
try
{
using var doc = JsonDocument.Parse(data);
var type = doc.RootElement.GetProperty("type").GetString();
var type = doc.RootElement.SafeGetProperty("type")?.SafeGetString();
if (type == "content_block_delta")
{
var delta = doc.RootElement.GetProperty("delta");
if (delta.TryGetProperty("text", out var t))
text = t.GetString();
if (!doc.RootElement.SafeTryGetProperty("delta", out var delta)) continue;
if (delta.SafeTryGetProperty("text", out var t))
text = t.SafeGetString();
}
else if (type is "message_start" or "message_delta")
{
// message_start: usage in .message.usage, message_delta: usage in .usage
if (doc.RootElement.TryGetProperty("message", out var msg) &&
msg.TryGetProperty("usage", out var u1))
if (doc.RootElement.SafeTryGetProperty("message", out var msg) &&
msg.SafeTryGetProperty("usage", out var u1))
TryParseSigmoidUsageFromElement(u1);
else if (doc.RootElement.TryGetProperty("usage", out var u2))
else if (doc.RootElement.SafeTryGetProperty("usage", out var u2))
TryParseSigmoidUsageFromElement(u2);
}
}
@@ -1434,9 +1481,9 @@ public partial class LlmService : IDisposable
using var doc = JsonDocument.Parse(json);
// API 에러 응답 감지
if (doc.RootElement.TryGetProperty("error", out var error))
if (doc.RootElement.SafeTryGetProperty("error", out var error))
{
var msg = error.TryGetProperty("message", out var m) ? m.GetString() : error.ToString();
var msg = error.SafeTryGetProperty("message", out var m) ? m.SafeGetString() : error.ToString();
throw new HttpRequestException($"[{context}] API 에러: {msg}");
}
@@ -1468,12 +1515,12 @@ public partial class LlmService : IDisposable
try
{
using var doc = JsonDocument.Parse(body);
if (doc.RootElement.TryGetProperty("error", out var err))
if (doc.RootElement.SafeTryGetProperty("error", out var err))
{
if (err.ValueKind == JsonValueKind.Object && err.TryGetProperty("message", out var m))
detail = m.GetString() ?? "";
if (err.ValueKind == JsonValueKind.Object && err.SafeTryGetProperty("message", out var m))
detail = m.SafeGetString() ?? "";
else if (err.ValueKind == JsonValueKind.String)
detail = err.GetString() ?? "";
detail = err.SafeGetString() ?? "";
}
}
catch { }
@@ -1506,8 +1553,8 @@ public partial class LlmService : IDisposable
{
try
{
var prompt = root.TryGetProperty("prompt_eval_count", out var p) ? p.GetInt32() : 0;
var completion = root.TryGetProperty("eval_count", out var e) ? e.GetInt32() : 0;
var prompt = root.SafeTryGetProperty("prompt_eval_count", out var p) ? p.GetInt32() : 0;
var completion = root.SafeTryGetProperty("eval_count", out var e) ? e.GetInt32() : 0;
if (prompt > 0 || completion > 0)
LastTokenUsage = new TokenUsage(prompt, completion);
}
@@ -1518,9 +1565,9 @@ public partial class LlmService : IDisposable
{
try
{
if (!root.TryGetProperty("usage", out var usage)) return;
var prompt = usage.TryGetProperty("prompt_tokens", out var p) ? p.GetInt32() : 0;
var completion = usage.TryGetProperty("completion_tokens", out var c) ? c.GetInt32() : 0;
if (!root.SafeTryGetProperty("usage", out var usage)) return;
var prompt = usage.SafeTryGetProperty("prompt_tokens", out var p) ? p.SafeGetInt32(0) : 0;
var completion = usage.SafeTryGetProperty("completion_tokens", out var c) ? c.SafeGetInt32(0) : 0;
if (prompt > 0 || completion > 0)
LastTokenUsage = new TokenUsage(prompt, completion);
}
@@ -1531,9 +1578,9 @@ public partial class LlmService : IDisposable
{
try
{
if (!root.TryGetProperty("usageMetadata", out var usage)) return;
var prompt = usage.TryGetProperty("promptTokenCount", out var p) ? p.GetInt32() : 0;
var completion = usage.TryGetProperty("candidatesTokenCount", out var c) ? c.GetInt32() : 0;
if (!root.SafeTryGetProperty("usageMetadata", out var usage)) return;
var prompt = usage.SafeTryGetProperty("promptTokenCount", out var p) ? p.GetInt32() : 0;
var completion = usage.SafeTryGetProperty("candidatesTokenCount", out var c) ? c.GetInt32() : 0;
if (prompt > 0 || completion > 0)
LastTokenUsage = new TokenUsage(prompt, completion);
}
@@ -1544,7 +1591,7 @@ public partial class LlmService : IDisposable
{
try
{
if (!root.TryGetProperty("usage", out var usage)) return;
if (!root.SafeTryGetProperty("usage", out var usage)) return;
TryParseSigmoidUsageFromElement(usage);
}
catch { }
@@ -1554,8 +1601,8 @@ public partial class LlmService : IDisposable
{
try
{
var input = usage.TryGetProperty("input_tokens", out var i) ? i.GetInt32() : 0;
var output = usage.TryGetProperty("output_tokens", out var o) ? o.GetInt32() : 0;
var input = usage.SafeTryGetProperty("input_tokens", out var i) ? i.GetInt32() : 0;
var output = usage.SafeTryGetProperty("output_tokens", out var o) ? o.GetInt32() : 0;
if (input > 0 || output > 0)
LastTokenUsage = new TokenUsage(input, output);
}