에이전트 선택적 탐색 구조 개선과 경고 정리 반영
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

@@ -162,22 +162,22 @@ public partial class LlmService
var root = doc.RootElement;
// 토큰 사용량
if (root.TryGetProperty("usage", out var usage))
if (root.SafeTryGetProperty("usage", out var usage))
TryParseSigmoidUsageFromElement(usage);
// 컨텐츠 블록 파싱
var blocks = new List<ContentBlock>();
if (root.TryGetProperty("content", out var content))
if (root.SafeTryGetProperty("content", out var content))
{
foreach (var block in content.EnumerateArray())
{
var type = block.TryGetProperty("type", out var tp) ? tp.GetString() : "";
var type = block.SafeTryGetProperty("type", out var tp) ? tp.SafeGetString() : "";
if (type == "text")
{
blocks.Add(new ContentBlock
{
Type = "text",
Text = block.TryGetProperty("text", out var txt) ? txt.GetString() ?? "" : ""
Text = block.SafeTryGetProperty("text", out var txt) ? txt.SafeGetString() ?? "" : ""
});
}
else if (type == "tool_use")
@@ -185,9 +185,9 @@ public partial class LlmService
blocks.Add(new ContentBlock
{
Type = "tool_use",
ToolName = block.TryGetProperty("name", out var nm) ? nm.GetString() ?? "" : "",
ToolId = block.TryGetProperty("id", out var bid) ? bid.GetString() ?? "" : "",
ToolInput = block.TryGetProperty("input", out var inp) ? inp.Clone() : null
ToolName = block.SafeTryGetProperty("name", out var nm) ? nm.SafeGetString() ?? "" : "",
ToolId = block.SafeTryGetProperty("id", out var bid) ? bid.SafeGetString() ?? "" : "",
ToolInput = block.SafeTryGetProperty("input", out var inp) ? inp.Clone() : null
});
}
}
@@ -220,8 +220,8 @@ public partial class LlmService
new
{
type = "tool_result",
tool_use_id = root.TryGetProperty("tool_use_id", out var tuid) ? tuid.GetString() : "",
content = root.TryGetProperty("content", out var tcont) ? tcont.GetString() : ""
tool_use_id = root.SafeTryGetProperty("tool_use_id", out var tuid) ? tuid.SafeGetString() : "",
content = root.SafeTryGetProperty("content", out var tcont) ? tcont.SafeGetString() : ""
}
}
});
@@ -236,20 +236,20 @@ public partial class LlmService
try
{
using var doc = JsonDocument.Parse(m.Content);
if (!doc.RootElement.TryGetProperty("_tool_use_blocks", out var blocksArr)) throw new Exception();
if (!doc.RootElement.SafeTryGetProperty("_tool_use_blocks", out var blocksArr)) throw new Exception();
var contentList = new List<object>();
foreach (var b in blocksArr.EnumerateArray())
{
var bType = b.TryGetProperty("type", out var bt) ? bt.GetString() : "";
var bType = b.SafeTryGetProperty("type", out var bt) ? bt.SafeGetString() : "";
if (bType == "text")
contentList.Add(new { type = "text", text = b.TryGetProperty("text", out var tx) ? tx.GetString() ?? "" : "" });
contentList.Add(new { type = "text", text = b.SafeTryGetProperty("text", out var tx) ? tx.SafeGetString() ?? "" : "" });
else if (bType == "tool_use")
contentList.Add(new
{
type = "tool_use",
id = b.TryGetProperty("id", out var bid) ? bid.GetString() ?? "" : "",
name = b.TryGetProperty("name", out var nm) ? nm.GetString() ?? "" : "",
input = b.TryGetProperty("input", out var inp) ? (object)inp.Clone() : new { }
id = b.SafeTryGetProperty("id", out var bid) ? bid.SafeGetString() ?? "" : "",
name = b.SafeTryGetProperty("name", out var nm) ? nm.SafeGetString() ?? "" : "",
input = b.SafeTryGetProperty("input", out var inp) ? (object)inp.Clone() : new { }
});
}
msgs.Add(new { role = "assistant", content = contentList });
@@ -347,26 +347,26 @@ public partial class LlmService
TryParseGeminiUsage(root);
var blocks = new List<ContentBlock>();
if (root.TryGetProperty("candidates", out var candidates) && candidates.GetArrayLength() > 0)
if (root.SafeTryGetProperty("candidates", out var candidates) && candidates.GetArrayLength() > 0)
{
var firstCandidate = candidates[0];
if (firstCandidate.TryGetProperty("content", out var contentObj) &&
contentObj.TryGetProperty("parts", out var parts))
if (firstCandidate.SafeTryGetProperty("content", out var contentObj) &&
contentObj.SafeTryGetProperty("parts", out var parts))
{
foreach (var part in parts.EnumerateArray())
{
if (part.TryGetProperty("text", out var text))
if (part.SafeTryGetProperty("text", out var text))
{
blocks.Add(new ContentBlock { Type = "text", Text = text.GetString() ?? "" });
blocks.Add(new ContentBlock { Type = "text", Text = text.SafeGetString() ?? "" });
}
else if (part.TryGetProperty("functionCall", out var fc))
else if (part.SafeTryGetProperty("functionCall", out var fc))
{
blocks.Add(new ContentBlock
{
Type = "tool_use",
ToolName = fc.TryGetProperty("name", out var fcName) ? fcName.GetString() ?? "" : "",
ToolName = fc.SafeTryGetProperty("name", out var fcName) ? fcName.SafeGetString() ?? "" : "",
ToolId = Guid.NewGuid().ToString("N")[..12],
ToolInput = fc.TryGetProperty("args", out var a) ? a.Clone() : null
ToolInput = fc.SafeTryGetProperty("args", out var a) ? a.Clone() : null
});
}
}
@@ -391,8 +391,8 @@ public partial class LlmService
{
using var doc = JsonDocument.Parse(m.Content);
var root = doc.RootElement;
var toolName = root.TryGetProperty("tool_name", out var tn) ? tn.GetString() ?? "" : "";
var toolContent = root.TryGetProperty("content", out var tc) ? tc.GetString() ?? "" : "";
var toolName = root.SafeTryGetProperty("tool_name", out var tn) ? tn.SafeGetString() ?? "" : "";
var toolContent = root.SafeTryGetProperty("content", out var tc) ? tc.SafeGetString() ?? "" : "";
contents.Add(new
{
role = "function",
@@ -419,21 +419,21 @@ public partial class LlmService
try
{
using var doc = JsonDocument.Parse(m.Content);
if (doc.RootElement.TryGetProperty("_tool_use_blocks", out var blocksArr))
if (doc.RootElement.SafeTryGetProperty("_tool_use_blocks", out var blocksArr))
{
var parts = new List<object>();
foreach (var b in blocksArr.EnumerateArray())
{
var bType = b.TryGetProperty("type", out var bt) ? bt.GetString() : "";
var bType = b.SafeTryGetProperty("type", out var bt) ? bt.SafeGetString() : "";
if (bType == "text")
parts.Add(new { text = b.TryGetProperty("text", out var tx) ? tx.GetString() ?? "" : "" });
parts.Add(new { text = b.SafeTryGetProperty("text", out var tx) ? tx.SafeGetString() ?? "" : "" });
else if (bType == "tool_use")
parts.Add(new
{
functionCall = new
{
name = b.TryGetProperty("name", out var nm) ? nm.GetString() ?? "" : "",
args = b.TryGetProperty("input", out var inp) ? (object)inp.Clone() : new { }
name = b.SafeTryGetProperty("name", out var nm) ? nm.SafeGetString() ?? "" : "",
args = b.SafeTryGetProperty("input", out var inp) ? (object)inp.Clone() : new { }
}
});
}
@@ -639,13 +639,13 @@ public partial class LlmService
json = json[braceStart..(braceEnd + 1)];
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var name = root.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
var name = root.SafeTryGetProperty("name", out var n) ? n.SafeGetString() ?? "" : "";
if (string.IsNullOrEmpty(name)) return null;
JsonElement? args = null;
if (root.TryGetProperty("arguments", out var a))
if (root.SafeTryGetProperty("arguments", out var a))
args = a.Clone();
else if (root.TryGetProperty("parameters", out var p))
else if (root.SafeTryGetProperty("parameters", out var p))
args = p.Clone();
return new ContentBlock
@@ -702,8 +702,8 @@ public partial class LlmService
msgs.Add(new
{
role = "tool",
tool_call_id = root.GetProperty("tool_use_id").GetString(),
content = root.GetProperty("content").GetString(),
tool_call_id = root.GetProperty("tool_use_id").SafeGetString(),
content = root.GetProperty("content").SafeGetString(),
});
continue;
}
@@ -721,19 +721,19 @@ public partial class LlmService
var toolCallsList = new List<object>();
foreach (var b in blocksArr.EnumerateArray())
{
var bType = b.GetProperty("type").GetString();
var bType = b.GetProperty("type").SafeGetString();
if (bType == "text")
textContent = b.GetProperty("text").GetString() ?? "";
textContent = b.GetProperty("text").SafeGetString() ?? "";
else if (bType == "tool_use")
{
var argsJson = b.TryGetProperty("input", out var inp) ? inp.GetRawText() : "{}";
var argsJson = b.SafeTryGetProperty("input", out var inp) ? inp.GetRawText() : "{}";
toolCallsList.Add(new
{
id = b.GetProperty("id").GetString() ?? "",
id = b.GetProperty("id").SafeGetString() ?? "",
type = "function",
function = new
{
name = b.GetProperty("name").GetString() ?? "",
name = b.GetProperty("name").SafeGetString() ?? "",
arguments = argsJson,
}
});
@@ -817,6 +817,8 @@ public partial class LlmService
["max_tokens"] = ResolveOpenAiCompatibleMaxTokens(),
["parallel_tool_calls"] = executionPolicy.EnableParallelReadBatch,
};
// 스트리밍 시 마지막 청크에 토큰 사용량 포함 요청 (vLLM/OpenAI 호환)
body["stream_options"] = new { include_usage = true };
// tool_choice: "required" — 모델이 반드시 도구를 호출하도록 강제
// 아직 한 번도 도구를 호출하지 않은 첫 번째 요청에서만 사용 (chatty 모델 대응)
if (forceToolCall)
@@ -877,8 +879,8 @@ public partial class LlmService
msgs.Add(new
{
role = "tool",
tool_call_id = root.GetProperty("tool_use_id").GetString(),
content = root.GetProperty("content").GetString(),
tool_call_id = root.GetProperty("tool_use_id").SafeGetString(),
content = root.GetProperty("content").SafeGetString(),
});
continue;
}
@@ -896,19 +898,19 @@ public partial class LlmService
var toolCallsList = new List<object>();
foreach (var b in blocksArr.EnumerateArray())
{
var bType = b.GetProperty("type").GetString();
var bType = b.GetProperty("type").SafeGetString();
if (bType == "text")
textContent = b.GetProperty("text").GetString() ?? "";
textContent = b.GetProperty("text").SafeGetString() ?? "";
else if (bType == "tool_use")
{
var argsJson = b.TryGetProperty("input", out var inp) ? inp.GetRawText() : "{}";
var argsJson = b.SafeTryGetProperty("input", out var inp) ? inp.GetRawText() : "{}";
toolCallsList.Add(new
{
id = b.GetProperty("id").GetString() ?? "",
id = b.GetProperty("id").SafeGetString() ?? "",
type = "function",
function = new
{
name = b.GetProperty("name").GetString() ?? "",
name = b.GetProperty("name").SafeGetString() ?? "",
arguments = argsJson,
}
});
@@ -1197,11 +1199,11 @@ public partial class LlmService
TryParseOpenAiUsage(root);
if (usesIbmDeploymentApi &&
root.TryGetProperty("status", out var statusEl) &&
string.Equals(statusEl.GetString(), "error", StringComparison.OrdinalIgnoreCase))
root.SafeTryGetProperty("status", out var statusEl) &&
string.Equals(statusEl.SafeGetString(), "error", StringComparison.OrdinalIgnoreCase))
{
var detail = root.TryGetProperty("message", out var msgEl)
? msgEl.GetString()
var detail = root.SafeTryGetProperty("message", out var msgEl)
? msgEl.SafeGetString()
: "IBM vLLM 도구 호출 응답 오류";
throw new ToolCallNotSupportedException(detail ?? "IBM vLLM 도구 호출 응답 오류");
}
@@ -1223,15 +1225,15 @@ public partial class LlmService
}
if (usesIbmDeploymentApi &&
root.TryGetProperty("results", out var resultsEl) &&
root.SafeTryGetProperty("results", out var resultsEl) &&
resultsEl.ValueKind == JsonValueKind.Array &&
resultsEl.GetArrayLength() > 0)
{
var first = resultsEl[0];
var generatedText = first.TryGetProperty("generated_text", out var generatedTextEl)
? generatedTextEl.GetString()
: first.TryGetProperty("output_text", out var outputTextEl)
? outputTextEl.GetString()
var generatedText = first.SafeTryGetProperty("generated_text", out var generatedTextEl)
? generatedTextEl.SafeGetString()
: first.SafeTryGetProperty("output_text", out var outputTextEl)
? outputTextEl.SafeGetString()
: null;
if (!string.IsNullOrEmpty(generatedText))
{
@@ -1251,18 +1253,18 @@ public partial class LlmService
}
}
if (root.TryGetProperty("choices", out var choicesEl) &&
if (root.SafeTryGetProperty("choices", out var choicesEl) &&
choicesEl.ValueKind == JsonValueKind.Array &&
choicesEl.GetArrayLength() > 0)
{
var firstChoice = choicesEl[0];
if (firstChoice.TryGetProperty("delta", out var deltaEl))
if (firstChoice.SafeTryGetProperty("delta", out var deltaEl))
{
var emittedContent = false;
if (deltaEl.TryGetProperty("content", out var contentEl) &&
if (deltaEl.SafeTryGetProperty("content", out var contentEl) &&
contentEl.ValueKind == JsonValueKind.String)
{
var chunk = contentEl.GetString();
var chunk = contentEl.SafeGetString();
if (!string.IsNullOrEmpty(chunk))
{
yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, chunk);
@@ -1272,20 +1274,20 @@ public partial class LlmService
// Qwen3.5 thinking 모드 폴백: content가 비어있거나 없으면 reasoning_content 사용
// else if가 아닌 독립 if — content 키가 존재하되 빈 문자열("")인 경우도 커버
if (!emittedContent &&
deltaEl.TryGetProperty("reasoning_content", out var reasoningEl) &&
deltaEl.SafeTryGetProperty("reasoning_content", out var reasoningEl) &&
reasoningEl.ValueKind == JsonValueKind.String)
{
var reasoningChunk = reasoningEl.GetString();
var reasoningChunk = reasoningEl.SafeGetString();
if (!string.IsNullOrEmpty(reasoningChunk))
yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, reasoningChunk);
}
if (deltaEl.TryGetProperty("tool_calls", out var toolCallsEl) &&
if (deltaEl.SafeTryGetProperty("tool_calls", out var toolCallsEl) &&
toolCallsEl.ValueKind == JsonValueKind.Array)
{
foreach (var toolCallEl in toolCallsEl.EnumerateArray())
{
var index = toolCallEl.TryGetProperty("index", out var indexEl) &&
var index = toolCallEl.SafeTryGetProperty("index", out var indexEl) &&
indexEl.TryGetInt32(out var parsedIndex)
? parsedIndex
: toolAccumulators.Count;
@@ -1296,18 +1298,18 @@ public partial class LlmService
toolAccumulators[index] = acc;
}
if (toolCallEl.TryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.String)
acc.Id = idEl.GetString() ?? acc.Id;
if (toolCallEl.SafeTryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.String)
acc.Id = idEl.SafeGetString() ?? acc.Id;
if (toolCallEl.TryGetProperty("function", out var functionEl))
if (toolCallEl.SafeTryGetProperty("function", out var functionEl))
{
if (functionEl.TryGetProperty("name", out var nameEl) && nameEl.ValueKind == JsonValueKind.String)
acc.Name = nameEl.GetString() ?? acc.Name;
if (functionEl.SafeTryGetProperty("name", out var nameEl) && nameEl.ValueKind == JsonValueKind.String)
acc.Name = nameEl.SafeGetString() ?? acc.Name;
if (functionEl.TryGetProperty("arguments", out var argumentsEl))
if (functionEl.SafeTryGetProperty("arguments", out var argumentsEl))
{
if (argumentsEl.ValueKind == JsonValueKind.String)
acc.Arguments.Append(argumentsEl.GetString());
acc.Arguments.Append(argumentsEl.SafeGetString());
else if (argumentsEl.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
acc.Arguments.Append(argumentsEl.GetRawText());
}
@@ -1320,7 +1322,7 @@ public partial class LlmService
}
}
if (firstChoice.TryGetProperty("message", out var messageEl))
if (firstChoice.SafeTryGetProperty("message", out var messageEl))
{
if (TryExtractMessageToolBlocks(messageEl, out var messageText2, out var directToolBlocks2))
{
@@ -1358,14 +1360,14 @@ public partial class LlmService
text = "";
toolBlocks = new List<ContentBlock>();
JsonElement message = messageOrRoot;
if (messageOrRoot.TryGetProperty("message", out var nestedMessage))
if (messageOrRoot.SafeTryGetProperty("message", out var nestedMessage))
message = nestedMessage;
var consumed = false;
if (message.TryGetProperty("content", out var contentEl) &&
if (message.SafeTryGetProperty("content", out var contentEl) &&
contentEl.ValueKind == JsonValueKind.String)
{
var parsedText = contentEl.GetString();
var parsedText = contentEl.SafeGetString();
if (!string.IsNullOrWhiteSpace(parsedText))
{
text = parsedText;
@@ -1374,10 +1376,10 @@ public partial class LlmService
}
// Qwen3.5 thinking 모드 폴백: content가 비어있으면 reasoning_content 사용
if (!consumed &&
message.TryGetProperty("reasoning_content", out var reasoningContentEl) &&
message.SafeTryGetProperty("reasoning_content", out var reasoningContentEl) &&
reasoningContentEl.ValueKind == JsonValueKind.String)
{
var reasoningText = reasoningContentEl.GetString();
var reasoningText = reasoningContentEl.SafeGetString();
if (!string.IsNullOrWhiteSpace(reasoningText))
{
text = reasoningText;
@@ -1385,22 +1387,22 @@ public partial class LlmService
}
}
if (message.TryGetProperty("tool_calls", out var toolCallsEl) &&
if (message.SafeTryGetProperty("tool_calls", out var toolCallsEl) &&
toolCallsEl.ValueKind == JsonValueKind.Array)
{
foreach (var tc in toolCallsEl.EnumerateArray())
{
if (!tc.TryGetProperty("function", out var functionEl))
if (!tc.SafeTryGetProperty("function", out var functionEl))
continue;
JsonElement? parsedArgs = null;
if (functionEl.TryGetProperty("arguments", out var argsEl))
if (functionEl.SafeTryGetProperty("arguments", out var argsEl))
{
if (argsEl.ValueKind == JsonValueKind.String)
{
try
{
using var argsDoc = JsonDocument.Parse(argsEl.GetString() ?? "{}");
using var argsDoc = JsonDocument.Parse(argsEl.SafeGetString() ?? "{}");
parsedArgs = argsDoc.RootElement.Clone();
}
catch { parsedArgs = null; }
@@ -1414,8 +1416,8 @@ public partial class LlmService
toolBlocks.Add(new ContentBlock
{
Type = "tool_use",
ToolName = functionEl.TryGetProperty("name", out var nameEl) ? nameEl.GetString() ?? "" : "",
ToolId = tc.TryGetProperty("id", out var idEl) ? idEl.GetString() ?? Guid.NewGuid().ToString("N")[..12] : Guid.NewGuid().ToString("N")[..12],
ToolName = functionEl.SafeTryGetProperty("name", out var nameEl) ? nameEl.SafeGetString() ?? "" : "",
ToolId = tc.SafeTryGetProperty("id", out var idEl) ? idEl.SafeGetString() ?? Guid.NewGuid().ToString("N")[..12] : Guid.NewGuid().ToString("N")[..12],
ToolInput = parsedArgs,
});
}
@@ -1542,12 +1544,12 @@ public partial class LlmService
{
using var doc = JsonDocument.Parse(errBody);
// Ollama: {"error":"..."}
if (doc.RootElement.TryGetProperty("error", out var err))
if (doc.RootElement.SafeTryGetProperty("error", out var err))
{
if (err.ValueKind == JsonValueKind.String)
return err.GetString() ?? errBody;
if (err.ValueKind == JsonValueKind.Object && err.TryGetProperty("message", out var m))
return m.GetString() ?? errBody;
return err.SafeGetString() ?? errBody;
if (err.ValueKind == JsonValueKind.Object && err.SafeTryGetProperty("message", out var m))
return m.SafeGetString() ?? errBody;
}
}
catch { }