diff --git a/README.md b/README.md
index 03c4314..40a24a9 100644
--- a/README.md
+++ b/README.md
@@ -1483,3 +1483,8 @@ MIT License
- OpenAI/vLLM tool calling 바디에 `parallel_tool_calls` 힌트를 추가해 읽기 도구 병렬 실행 성향이 모델 요청 바디에도 반영되도록 보강했습니다.
- Cowork/Code 진행 표시에는 `계획 / 도구 / 검증 / 압축 / 폴백 / 재시도` 같은 단계 메타를 더 직접적으로 붙여, 오래 걸릴 때도 현재 단계가 더 잘 읽히게 했습니다.
- [docs/AX_AGENT_REGRESSION_PROMPTS.md](/E:/AX%20Copilot%20-%20Codex/docs/AX_AGENT_REGRESSION_PROMPTS.md)를 전면 정리해 `tool_call_strict`, `fast_readonly`, `document_heavy`, `reasoning_first` 프로파일별 회귀 시나리오를 고정했습니다.
+- 업데이트: 2026-04-08 11:14 (KST)
+ - IBM 인증 경유 vLLM 도구 호출 경로를 강화했습니다. IBM tool body도 이제 프로파일 기반 `ResolveToolTemperature()`를 사용하고, `tool_call_strict` 프로파일에서는 더 짧고 직접적인 `tool-only` 지시를 추가해 plain text 응답으로 빠지는 경향을 줄였습니다.
+ - IBM 배포형 엔드포인트가 `tool_choice`를 400으로 거부하면, `tool_choice`만 제거하고 동일한 강제 지시를 유지한 채 한 번 더 재시도하는 대체 강제 전략을 넣었습니다.
+ - OpenAI/vLLM tool-use 응답은 이제 `stream=true` 기반 SSE 수신기로 읽으며, `delta.tool_calls`를 부분 조립해 완성된 도구 호출을 더 빨리 감지합니다.
+ - read-only 도구는 조립이 끝나는 즉시 조기 실행을 시작하고, 최종 루프에서는 그 결과를 재사용하도록 바꿔 도구 착수 속도를 끌어올렸습니다.
diff --git a/dist/AxCopilot/AxCopilot.exe b/dist/AxCopilot/AxCopilot.exe
index bd143e9..a1b1a68 100644
Binary files a/dist/AxCopilot/AxCopilot.exe and b/dist/AxCopilot/AxCopilot.exe differ
diff --git a/dist/AxCopilot_Setup.exe b/dist/AxCopilot_Setup.exe
index bc87538..72ecd86 100644
Binary files a/dist/AxCopilot_Setup.exe and b/dist/AxCopilot_Setup.exe differ
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index cf27a15..26167b0 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -5386,3 +5386,18 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎.
- Cowork/Code 진행 카드에 `계획 / 도구 / 검증 / 압축 / 폴백 / 재시도` 단계 메타를 더 직접적으로 붙여, 오래 걸리는 작업도 어느 단계인지 읽기 쉽게 정리했다.
- [AX_AGENT_REGRESSION_PROMPTS.md](/E:/AX%20Copilot%20-%20Codex/docs/AX_AGENT_REGRESSION_PROMPTS.md)
- 회귀 프롬프트 문서를 전면 교체하고, `tool_call_strict`, `fast_readonly`, `document_heavy`, `reasoning_first` 프로파일별 검증 시나리오를 추가했다.
+
+## 2026-04-08 11:14 (KST)
+
+- [LlmService.ToolUse.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/LlmService.ToolUse.cs)
+ - IBM 배포형 도구 호출 바디가 일반 temperature 대신 프로파일 기반 `ResolveToolTemperature()`를 사용하도록 수정했다.
+ - `tool_call_strict` + `forceToolCall` 조합일 때 IBM 경로에 짧고 직접적인 `tool-only` 지시를 추가했다.
+ - IBM 경로가 `tool_choice`를 400으로 거부하면 `tool_choice`만 제거한 대체 강제 요청으로 한 번 더 재시도하도록 보강했다.
+ - OpenAI/vLLM tool-use 응답을 `stream=true` SSE로 읽는 전용 파서를 추가하고, `choices[].delta.tool_calls`를 부분 조립해 완성된 도구 호출을 더 빨리 감지하도록 바꿨다.
+ - SSE에서 완성된 read-only tool call은 즉시 조기 실행 task를 연결하고, 최종 루프에서는 그 결과를 재사용할 수 있도록 `ContentBlock`에 prefetch 메타를 추가했다.
+- [AgentLoopTransitions.Execution.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs)
+ - `SendWithToolsWithRecoveryAsync(...)`에 prefetch callback을 연결해 OpenAI/vLLM tool SSE 파서가 조기 실행을 요청할 수 있게 했다.
+ - read-only 도구 조기 실행 헬퍼를 추가해 `file_read`, `glob`, `grep`, `folder_map`, `multi_read` 등 읽기 위주 도구는 스트리밍 도중 바로 실행을 시작하도록 보강했다.
+- [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)
+ - 메인 루프의 tool-use 요청 경로에 조기 read-only prefetch callback을 연결했다.
+ - 최종 도구 실행 단계에서는 조기 실행이 이미 끝난 도구의 결과를 재사용해 중복 실행을 피하고, transcript에는 `조기 실행 결과 재사용` 힌트를 남기도록 정리했다.
diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs
index f53effb..dac3c84 100644
--- a/src/AxCopilot/App.xaml.cs
+++ b/src/AxCopilot/App.xaml.cs
@@ -75,6 +75,11 @@ public partial class App : System.Windows.Application
var settings = _settings;
settings.Load();
+ // ─── 워크플로우 상세 로그 초기화 ─────────────────────────────────────
+ WorkflowLogService.IsEnabled = settings.Settings.Llm.EnableDetailedLog;
+ WorkflowLogService.RetentionDays = settings.Settings.Llm.DetailedLogRetentionDays > 0
+ ? settings.Settings.Llm.DetailedLogRetentionDays : 3;
+
// ─── 대화 보관/디스크 정리 (제품화 하드닝) ───────────────────────────
try
{
diff --git a/src/AxCopilot/Models/AppSettings.cs b/src/AxCopilot/Models/AppSettings.cs
index 7d90573..7cfb940 100644
--- a/src/AxCopilot/Models/AppSettings.cs
+++ b/src/AxCopilot/Models/AppSettings.cs
@@ -995,6 +995,14 @@ public class LlmSettings
[JsonPropertyName("enableAuditLog")]
public bool EnableAuditLog { get; set; } = true;
+ /// 워크플로우 상세 로그 기록 활성화. 워크플로우 분석기와 함께 사용하면 LLM 요청/응답, 도구 호출 전체 이력을 기록합니다.
+ [JsonPropertyName("enableDetailedLog")]
+ public bool EnableDetailedLog { get; set; } = false;
+
+ /// 상세 로그 보관 기간 (일). 이 기간이 지난 로그는 자동 삭제됩니다. 기본 3일.
+ [JsonPropertyName("detailedLogRetentionDays")]
+ public int DetailedLogRetentionDays { get; set; } = 3;
+
/// 에이전트 메모리 (지속적 학습) 활성화. 기본 true.
[JsonPropertyName("enableAgentMemory")]
public bool EnableAgentMemory { get; set; } = true;
diff --git a/src/AxCopilot/Services/Agent/AgentLoopParallelExecution.cs b/src/AxCopilot/Services/Agent/AgentLoopParallelExecution.cs
index 27d8a09..d45c073 100644
--- a/src/AxCopilot/Services/Agent/AgentLoopParallelExecution.cs
+++ b/src/AxCopilot/Services/Agent/AgentLoopParallelExecution.cs
@@ -143,10 +143,15 @@ public partial class AgentLoopService
using var gate = new SemaphoreSlim(maxConcurrency, maxConcurrency);
var tasks = executableCalls.Select(async call =>
{
- await gate.WaitAsync(ct).ConfigureAwait(false);
- var tool = _tools.Get(call.ToolName);
+ // gate.WaitAsync를 try 안에서 호출: ct 취소 시 WaitAsync가 OperationCanceledException을
+ // 던져도 Release()가 잘못 호출되지 않도록 보호 (SemaphoreFullException 방지)
+ var acquired = false;
try
{
+ await gate.WaitAsync(ct).ConfigureAwait(false);
+ acquired = true;
+
+ var tool = _tools.Get(call.ToolName);
if (tool == null)
return (call, ToolResult.Fail($"알 수 없는 도구: {call.ToolName}"), 0L);
@@ -154,19 +159,32 @@ public partial class AgentLoopService
try
{
var input = call.ToolInput ?? JsonDocument.Parse("{}").RootElement;
- var result = await ExecuteToolWithTimeoutAsync(tool, call.ToolName, input, context, messages, ct);
+ // 병렬 실행 중에는 messages를 null로 전달:
+ // 훅이 messages.Add()를 동시 호출하면 List race condition 발생.
+ // 읽기 전용 도구이므로 hook 추가 컨텍스트는 이 배치 후 순차로 처리됨.
+ var result = await ExecuteToolWithTimeoutAsync(tool, call.ToolName, input, context, null, ct);
sw.Stop();
return (call, result, sw.ElapsedMilliseconds);
}
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ sw.Stop();
+ return (call, ToolResult.Fail($"도구 실행이 취소되었습니다: {call.ToolName}"), sw.ElapsedMilliseconds);
+ }
catch (Exception ex)
{
sw.Stop();
return (call, ToolResult.Fail($"도구 실행 오류: {ex.Message}"), sw.ElapsedMilliseconds);
}
}
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ // WaitAsync 도중 취소됨 — 세마포어 미취득 상태이므로 Release 하지 않음
+ return (call, ToolResult.Fail($"도구 실행 대기 중 취소됨: {call.ToolName}"), 0L);
+ }
finally
{
- gate.Release();
+ if (acquired) gate.Release();
}
}).ToList();
@@ -241,6 +259,11 @@ public partial class AgentLoopService
TruncateOutput(result.Output, 500),
result.FilePath, result.Success);
}
+
+ // 워크플로우 상세 로그: 병렬 도구 실행 결과
+ WorkflowLogService.LogToolResult(_conversationId, _currentRunId, 0,
+ call.ToolName, TruncateOutput(result.Output, 2000),
+ result.Success, 0);
}
}
}
diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs
index 71c7481..3056103 100644
--- a/src/AxCopilot/Services/Agent/AgentLoopService.cs
+++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs
@@ -150,6 +150,10 @@ public partial class AgentLoopService
// 사용자 원본 요청 캡처 (문서 생성 폴백 판단용)
var userQuery = messages.LastOrDefault(m => m.Role == "user")?.Content ?? "";
+
+ // 워크플로우 상세 로그: 에이전트 루프 시작
+ WorkflowLogService.LogAgentLifecycle(_conversationId, _currentRunId, "start",
+ userPrompt: userQuery);
var consecutiveErrors = 0; // Self-Reflection: 연속 오류 카운터
var totalToolCalls = 0; // 복잡도 추정용
string? lastFailedToolSignature = null;
@@ -510,6 +514,7 @@ public partial class AgentLoopService
// LLM에 도구 정의와 함께 요청
List blocks;
+ var llmCallSw = Stopwatch.StartNew();
try
{
var activeTools = GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides);
@@ -521,14 +526,43 @@ public partial class AgentLoopService
// totalToolCalls == 0: 아직 한 번도 도구를 안 불렀으면 tool_choice:"required" 강제
// → chatty 모델(Qwen 등)이 텍스트 설명만 하고 도구를 안 부르는 현상 방지
var forceFirst = totalToolCalls == 0 && executionPolicy.ForceInitialToolCall;
+
+ // IBM/Qwen 등 chatty 모델 대응: 첫 번째 호출 직전 마지막 user 메시지로 도구 호출 강제 reminder 주입.
+ // recovery 메시지가 이미 추가된 경우(NoToolCallLoopRetry > 0)에는 중복 주입하지 않음.
+ // 임시 메시지이므로 실제 messages 목록은 수정하지 않고, 별도 sendMessages로 전달.
+ List sendMessages = messages;
+ if (forceFirst
+ && executionPolicy.InjectPreCallToolReminder
+ && runState.NoToolCallLoopRetry == 0)
+ {
+ sendMessages = [.. messages, new ChatMessage
+ {
+ Role = "user",
+ Content = "[TOOL_REQUIRED] 지금 즉시 도구를 1개 이상 호출하세요. 텍스트만 반환하면 거부됩니다. " +
+ "Call at least one tool RIGHT NOW. Text-only response is rejected."
+ }];
+ }
+
+ // 워크플로우 상세 로그: LLM 요청
+ llmCallSw.Restart();
+ var (_, currentModel) = _llm.GetCurrentModelInfo();
+ WorkflowLogService.LogLlmRequest(_conversationId, _currentRunId, iteration,
+ currentModel, sendMessages.Count, activeTools.Count, forceFirst);
+
blocks = await SendWithToolsWithRecoveryAsync(
- messages,
+ sendMessages,
activeTools,
ct,
$"메인 루프 {iteration}",
runState,
- forceToolCall: forceFirst);
+ forceToolCall: forceFirst,
+ prefetchToolCallAsync: block => TryPrefetchReadOnlyToolAsync(
+ block,
+ activeTools,
+ context,
+ ct));
runState.ContextRecoveryAttempts = 0;
+ llmCallSw.Stop();
runState.TransientLlmErrorRetries = 0;
NotifyPostCompactionTurnIfNeeded(runState);
}
@@ -646,6 +680,13 @@ public partial class AgentLoopService
var textResponse = string.Join("\n", textParts);
consecutiveNoToolResponses = toolCalls.Count == 0 ? consecutiveNoToolResponses + 1 : 0;
+ // 워크플로우 상세 로그: LLM 응답
+ WorkflowLogService.LogLlmResponse(_conversationId, _currentRunId, iteration,
+ textResponse, toolCalls.Count,
+ _llm.LastTokenUsage?.PromptTokens ?? 0,
+ _llm.LastTokenUsage?.CompletionTokens ?? 0,
+ llmCallSw.ElapsedMilliseconds);
+
// Task Decomposition: 첫 번째 텍스트 응답에서 계획 단계 추출
if (!planExtracted && !string.IsNullOrEmpty(textResponse))
{
@@ -1126,22 +1167,27 @@ public partial class AgentLoopService
repeatedUnknownToolCount = 0;
lastDisallowedToolName = null;
repeatedDisallowedToolCount = 0;
- var effectiveCall = string.Equals(call.ToolName, resolvedToolName, StringComparison.OrdinalIgnoreCase)
+ var effectiveToolName = !string.IsNullOrWhiteSpace(call.ResolvedToolName)
+ ? call.ResolvedToolName
+ : resolvedToolName;
+ var effectiveCall = string.Equals(call.ToolName, effectiveToolName, StringComparison.OrdinalIgnoreCase)
? call
: new LlmService.ContentBlock
{
Type = call.Type,
Text = call.Text,
- ToolName = resolvedToolName,
+ ToolName = effectiveToolName,
ToolId = call.ToolId,
ToolInput = call.ToolInput,
+ ResolvedToolName = effectiveToolName,
+ PrefetchedExecutionTask = call.PrefetchedExecutionTask,
};
- if (!string.Equals(call.ToolName, resolvedToolName, StringComparison.OrdinalIgnoreCase))
+ if (!string.Equals(call.ToolName, effectiveToolName, StringComparison.OrdinalIgnoreCase))
{
EmitEvent(
AgentEventType.Thinking,
- resolvedToolName,
- $"도구명 정규화 적용: '{call.ToolName}' → '{resolvedToolName}'");
+ effectiveToolName,
+ $"도구명 정규화 적용: '{call.ToolName}' → '{effectiveToolName}'");
}
var toolCallSignature = BuildToolCallSignature(effectiveCall);
@@ -1279,11 +1325,37 @@ public partial class AgentLoopService
}
ToolResult result;
+ long elapsedMs;
var sw = Stopwatch.StartNew();
try
{
- var input = effectiveCall.ToolInput ?? JsonDocument.Parse("{}").RootElement;
- result = await ExecuteToolWithTimeoutAsync(tool, effectiveCall.ToolName, input, context, messages, ct);
+ if (effectiveCall.PrefetchedExecutionTask != null)
+ {
+ var prefetched = await effectiveCall.PrefetchedExecutionTask.ConfigureAwait(false);
+ if (prefetched != null)
+ {
+ result = prefetched.Result;
+ elapsedMs = prefetched.ElapsedMilliseconds;
+ EmitEvent(
+ AgentEventType.Thinking,
+ effectiveCall.ToolName,
+ $"조기 실행 결과 재사용: {effectiveCall.ToolName}");
+ }
+ else
+ {
+ var input = effectiveCall.ToolInput ?? JsonDocument.Parse("{}").RootElement;
+ result = await ExecuteToolWithTimeoutAsync(tool, effectiveCall.ToolName, input, context, messages, ct);
+ sw.Stop();
+ elapsedMs = sw.ElapsedMilliseconds;
+ }
+ }
+ else
+ {
+ var input = effectiveCall.ToolInput ?? JsonDocument.Parse("{}").RootElement;
+ result = await ExecuteToolWithTimeoutAsync(tool, effectiveCall.ToolName, input, context, messages, ct);
+ sw.Stop();
+ elapsedMs = sw.ElapsedMilliseconds;
+ }
}
catch (OperationCanceledException)
{
@@ -1300,8 +1372,9 @@ public partial class AgentLoopService
catch (Exception ex)
{
result = ToolResult.Fail($"도구 실행 오류: {ex.Message}");
+ sw.Stop();
+ elapsedMs = sw.ElapsedMilliseconds;
}
- sw.Stop();
// ── Post-Hook 실행 ──
if (llm.EnableToolHooks && runtimeHooks.Count > 0)
@@ -1340,7 +1413,7 @@ public partial class AgentLoopService
effectiveCall.ToolName,
TruncateOutput(result.Output, 200),
result.FilePath,
- elapsedMs: sw.ElapsedMilliseconds,
+ elapsedMs: elapsedMs,
inputTokens: tokenUsage?.PromptTokens ?? 0,
outputTokens: tokenUsage?.CompletionTokens ?? 0,
toolInput: effectiveCall.ToolInput?.ToString(),
@@ -1533,6 +1606,10 @@ public partial class AgentLoopService
if (runtimeOverrideApplied)
_llm.PopInferenceOverride();
+ // 워크플로우 상세 로그: 에이전트 루프 종료
+ WorkflowLogService.LogAgentLifecycle(_conversationId, _currentRunId, "end",
+ summary: $"iterations={iteration}, tools={totalToolCalls}, success={statsSuccessCount}, fail={statsFailCount}");
+
IsRunning = false;
_currentRunId = "";
_runPendingPostCompactionTurn = false;
diff --git a/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs b/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs
index 3ebaf96..173f95a 100644
--- a/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs
+++ b/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs
@@ -2,6 +2,8 @@
using AxCopilot.Services;
using System.Text.Json;
+using System.Diagnostics;
+
namespace AxCopilot.Services.Agent;
public partial class AgentLoopService
@@ -375,7 +377,7 @@ public partial class AgentLoopService
return sawDiff;
}
- // 蹂寃??꾧뎄 ?먯껜媛 ?놁쑝硫?diff 寃뚯씠?몃? ?붽뎄?섏? ?딆쓬
+ // 蹂寃??꾧뎄 ?먯껜媛 ?놁쑝硫?diff 寃뚯씠???꾧뎄?몄? ?뺤씤
return true;
}
@@ -395,7 +397,7 @@ public partial class AgentLoopService
"deletions",
"file changed",
"異붽?",
- "??젣");
+ "?낅뜲?댄듃");
}
private bool TryApplyDocumentArtifactGateTransition(
@@ -413,18 +415,18 @@ public partial class AgentLoopService
if (!string.IsNullOrEmpty(textResponse))
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
- var targetHint = string.IsNullOrWhiteSpace(suggestedPath) ? "?붿껌???곗텧臾?臾몄꽌 ?뚯씪" : $"'{suggestedPath}'";
+ var targetHint = string.IsNullOrWhiteSpace(suggestedPath) ? "요청한 출력 문서 파일" : $"'{suggestedPath}'";
messages.Add(new ChatMessage
{
Role = "user",
- Content = "[System:DocumentArtifactGate] 寃곌낵 ?ㅻ챸留뚯쑝濡?醫낅즺?????놁뒿?덈떎. " +
- $"吏湲?利됱떆 {targetHint}???ㅼ젣 ?뚯씪濡??앹꽦?섏꽭?? " +
- "html_create/markdown_create/document_assemble/file_write 以??곸젅???꾧뎄瑜?諛섎뱶???몄텧?섏꽭??"
+ Content = "[System:DocumentArtifactGate] 결과 파일이 누락되었습니다. " +
+ $"즉시 {targetHint} 파일을 실제로 생성하세요. " +
+ "html_create/markdown_create/document_assemble/file_write 중 적절한 도구를 호출하세요."
});
EmitEvent(
AgentEventType.Thinking,
"",
- $"臾몄꽌 ?묒뾽 ?곗텧臾??뚯씪???놁뼱 ?꾧뎄 ?ㅽ뻾???ъ슂泥?빀?덈떎 ({runState.DocumentArtifactGateRetry}/2)");
+ $"문서 생성 결과 파일이 없어 재생성이 필요합니다. ({runState.DocumentArtifactGateRetry}/2)");
return true;
}
@@ -689,7 +691,7 @@ public partial class AgentLoopService
EmitEvent(
AgentEventType.Thinking,
call.ToolName,
- $"臾댁쓽誘명븳 ?쎄린 ?꾧뎄 諛섎났 猷⑦봽瑜?媛먯????ㅻⅨ ?꾨왂?쇰줈 ?꾪솚?⑸땲??({repeatedSameSignatureCount}??");
+ $"臾댁쓽誘명븳 ?쎄린 ?꾧뎄 諛섎났 猷⑦봽媛 媛먯??섏뼱 ?ㅻⅨ ?꾨왂?쇰줈 ?꾪솚?⑸땲??({repeatedSameSignatureCount}??");
return true;
}
@@ -716,7 +718,7 @@ public partial class AgentLoopService
EmitEvent(
AgentEventType.Thinking,
"",
- $"?쎄린 ?꾩슜 ?꾧뎄留??곗냽 {consecutiveReadOnlySuccessTools}???ㅽ뻾?섏뼱 ?뺤껜瑜?媛먯??덉뒿?덈떎. ?ㅽ뻾 ?④퀎濡??꾪솚?⑸땲?? (threshold={threshold})");
+ $"?쎄린 ?꾩슜 ?꾧뎄媛 ?곗냽 {consecutiveReadOnlySuccessTools}???ㅽ뻾?섏뼱 ?뺤껜媛 媛먯??섏뿀?듬땲?? ?ㅽ뻾 ?④퀎濡??꾪솚?⑸땲??(threshold={threshold})");
return true;
}
@@ -750,7 +752,7 @@ public partial class AgentLoopService
EmitEvent(
AgentEventType.Thinking,
"",
- $"?ㅽ뻾 吏꾩쟾???놁뼱 媛뺤젣 蹂듦뎄 ?④퀎瑜??쒖옉?⑸땲??({runState.NoProgressRecoveryRetry}/2)");
+ $"?ㅽ뻾 吏꾩쟾???놁뼱 媛뺤젣 蹂듦뎄 ?④퀎濡??쒖옉?⑸땲??({runState.NoProgressRecoveryRetry}/2)");
return true;
}
@@ -809,8 +811,8 @@ public partial class AgentLoopService
return false;
var lower = content.ToLowerInvariant();
- if (ContainsAny(lower, "success", "succeeded", "passed", "?듦낵", "?깃났", "鍮뚮뱶?덉뒿?덈떎", "tests passed", "build succeeded")
- && !ContainsAny(lower, "fail", "failed", "error", "?ㅻ쪟", "?ㅽ뙣", "exception", "denied", "not found"))
+ if (ContainsAny(lower, "success", "succeeded", "passed", "성공", "tests passed", "build succeeded")
+ && !ContainsAny(lower, "fail", "failed", "error", "오류", "실패", "exception", "denied", "not found"))
return false;
return ContainsAny(
@@ -829,7 +831,7 @@ public partial class AgentLoopService
"?ㅻ쪟",
"?덉쇅",
"?쒓컙 珥덇낵",
- "沅뚰븳",
+ "沅뚰븳 嫄곕?",
"李⑤떒",
"찾을 수");
}
@@ -1103,7 +1105,55 @@ public partial class AgentLoopService
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
- return ToolResult.Fail($"?꾧뎄 ?ㅽ뻾 ??꾩븘??({timeoutMs}ms): {toolName}");
+ return ToolResult.Fail($"도구 실행 시간 초과 ({timeoutMs}ms): {toolName}");
+ }
+ }
+
+ private static readonly HashSet PrefetchableReadOnlyTools = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "file_read", "glob", "grep", "grep_tool", "folder_map", "document_read",
+ "search_codebase", "code_search", "env_tool", "datetime_tool",
+ "dev_env_detect", "memory", "json_tool", "regex_tool", "base64_tool",
+ "hash_tool", "image_analyze", "multi_read"
+ };
+
+ private async Task TryPrefetchReadOnlyToolAsync(
+ LlmService.ContentBlock block,
+ IReadOnlyCollection tools,
+ AgentContext context,
+ CancellationToken ct)
+ {
+ var activeToolNames = tools.Select(t => t.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+ var resolvedToolName = ResolveRequestedToolName(block.ToolName, activeToolNames);
+ block.ResolvedToolName = resolvedToolName;
+
+ if (!PrefetchableReadOnlyTools.Contains(resolvedToolName))
+ return null;
+
+ var tool = _tools.Get(resolvedToolName);
+ if (tool == null)
+ return null;
+
+ EmitEvent(
+ AgentEventType.Thinking,
+ resolvedToolName,
+ $"읽기 도구 조기 실행 준비: {resolvedToolName}");
+
+ var sw = Stopwatch.StartNew();
+ try
+ {
+ var input = block.ToolInput ?? JsonDocument.Parse("{}").RootElement;
+ var result = await ExecuteToolWithTimeoutAsync(tool, resolvedToolName, input, context, null, ct);
+ sw.Stop();
+ return new LlmService.ToolPrefetchResult(result, sw.ElapsedMilliseconds, resolvedToolName);
+ }
+ catch (Exception ex)
+ {
+ sw.Stop();
+ return new LlmService.ToolPrefetchResult(
+ ToolResult.Fail($"조기 실행 오류: {ex.Message}"),
+ sw.ElapsedMilliseconds,
+ resolvedToolName);
}
}
@@ -1113,7 +1163,8 @@ public partial class AgentLoopService
CancellationToken ct,
string phaseLabel,
RunState? runState = null,
- bool forceToolCall = false)
+ bool forceToolCall = false,
+ Func>? prefetchToolCallAsync = null)
{
var transientRetries = runState?.TransientLlmErrorRetries ?? 0;
var contextRecoveryRetries = runState?.ContextRecoveryAttempts ?? 0;
@@ -1121,7 +1172,7 @@ public partial class AgentLoopService
{
try
{
- return await _llm.SendWithToolsAsync(messages, tools, ct, forceToolCall);
+ return await _llm.SendWithToolsAsync(messages, tools, ct, forceToolCall, prefetchToolCallAsync);
}
catch (Exception ex)
{
@@ -1191,6 +1242,11 @@ public partial class AgentLoopService
result.FilePath, result.Success);
}
+ // 워크플로우 상세 로그: 도구 실행 결과
+ WorkflowLogService.LogToolResult(_conversationId, _currentRunId, 0,
+ call.ToolName, TruncateOutput(result.Output, 2000),
+ result.Success, 0);
+
totalToolCalls++;
if (totalToolCalls > 15 && maxIterations < baseMax * 2)
maxIterations = Math.Min(baseMax * 2, 50);
@@ -1294,7 +1350,7 @@ public partial class AgentLoopService
result.Output)
});
}
- EmitEvent(AgentEventType.Thinking, "", "鍮꾩옱?쒕룄 ?ㅽ뙣濡?遺꾨쪟?섏뼱 ?숈씪 ?몄텧 諛섎났??以묐떒?섍퀬 ?고쉶 ?꾨왂?쇰줈 ?꾪솚?⑸땲??");
+ EmitEvent(AgentEventType.Thinking, "", "현재도 실패로 오류 발생, 다시 실행 경로로 전환합니다.");
return true;
}
@@ -1310,7 +1366,7 @@ public partial class AgentLoopService
if (memSvc != null && (app?.SettingsService?.Settings.Llm.EnableAgentMemory ?? false))
{
memSvc.Add("correction",
- $"?꾧뎄 '{call.ToolName}' 諛섎났 ?ㅽ뙣: {TruncateOutput(result.Output, 200)}",
+ $"도구 '{call.ToolName}' 반복 실패: {TruncateOutput(result.Output, 200)}",
$"conv:{_conversationId}", context.WorkFolder);
}
}
@@ -1453,8 +1509,8 @@ public partial class AgentLoopService
taskPolicy)
});
EmitEvent(AgentEventType.Thinking, "", highImpactCodeChange
- ? "怨좎쁺??肄붾뱶 蹂寃쎌씠??李몄“ 寃?됯낵 build/test 寃利앹쓣 ???꾧꺽?섍쾶 ?댁뼱媛묐땲??.."
- : "肄붾뱶 蹂寃???build/test/diff 寃利앹쓣 ?댁뼱媛묐땲??..");
+ ? "怨좎쁺??肄붾뱶 蹂寃쎌쑝濡?李몄“ 寃利앷낵 build/test 寃利앹쓣 ???꾧꺽?섍쾶 ?댁뼱媛묐땲??."
+ : "肄붾뱶 蹂寃???build/test/diff 寃利앹쓣 ?댁뼱媛묐땲??.");
}
else if (HasCodeVerificationEvidenceAfterLastModification(messages, requireHighImpactCodeVerification))
{
@@ -1541,7 +1597,7 @@ public partial class AgentLoopService
if (devShouldContinue)
{
messages.Add(LlmService.CreateToolResultMessage(
- call.ToolId, call.ToolName, devToolResultMessage ?? "[SKIPPED by developer] 사용자가 이 도구 실행을 건너뛰었습니다."));
+ call.ToolId, call.ToolName, devToolResultMessage ?? "[SKIPPED by developer] 媛쒕컻?먭? ???꾧뎄 ?ㅽ뻾??嫄대꼫?곗뿀?듬땲??"));
return (true, null);
}
}
@@ -1562,7 +1618,7 @@ public partial class AgentLoopService
if (scopeShouldContinue)
{
messages.Add(LlmService.CreateToolResultMessage(
- call.ToolId, call.ToolName, scopeToolResultMessage ?? "[SKIPPED] 사용자가 이 작업을 건너뛰었습니다."));
+ call.ToolId, call.ToolName, scopeToolResultMessage ?? "[SKIPPED] 媛쒕컻?먭? ???묒뾽??嫄대꼫?곗뿀?듬땲??"));
return (true, null);
}
}
diff --git a/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs b/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs
index 317ae9d..be331a0 100644
--- a/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs
+++ b/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs
@@ -287,6 +287,22 @@ public sealed class AxAgentExecutionEngine
&& !string.Equals(t, "Resumed", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(t, "SessionStart", StringComparison.OrdinalIgnoreCase);
+ // 완료 메시지로 표시하면 안 되는 Thinking 이벤트 요약 패턴 (내부 진행 상태 문자열)
+ static bool IsInternalStatusSummary(string? summary)
+ {
+ if (string.IsNullOrWhiteSpace(summary)) return false;
+ return summary.StartsWith("LLM에 요청 중", StringComparison.OrdinalIgnoreCase)
+ || summary.StartsWith("도구 미호출 루프", StringComparison.OrdinalIgnoreCase)
+ || summary.StartsWith("강제 실행 유도", StringComparison.OrdinalIgnoreCase)
+ || summary.StartsWith("읽기 전용 도구", StringComparison.OrdinalIgnoreCase)
+ || summary.StartsWith("병렬 실행", StringComparison.OrdinalIgnoreCase)
+ || summary.StartsWith("Self-Reflection", StringComparison.OrdinalIgnoreCase)
+ || summary.StartsWith("일시적 LLM 오류", StringComparison.OrdinalIgnoreCase)
+ || summary.StartsWith("컨텍스트 한도 초과", StringComparison.OrdinalIgnoreCase)
+ || summary.Contains("반복 ") && summary.Contains('/')
+ || summary.Contains("[System:");
+ }
+
var completionLine = runTab switch
{
"Cowork" => "코워크 작업이 완료되었습니다.",
@@ -310,9 +326,11 @@ public sealed class AxAgentExecutionEngine
: fileLine;
}
- // 파일 없으면 가장 최근 의미 있는 이벤트 요약 사용
+ // 파일 없으면 가장 최근 의미 있는 이벤트 요약 사용 (내부 상태 문자열 제외)
var latestSummary = conversation.ExecutionEvents?
- .Where(evt => !string.IsNullOrWhiteSpace(evt.Summary) && IsSignificantEventType(evt.Type))
+ .Where(evt => !string.IsNullOrWhiteSpace(evt.Summary)
+ && IsSignificantEventType(evt.Type)
+ && !IsInternalStatusSummary(evt.Summary))
.OrderByDescending(evt => evt.Timestamp)
.Select(evt => evt.Summary.Trim())
.FirstOrDefault();
diff --git a/src/AxCopilot/Services/Agent/ModelExecutionProfileCatalog.cs b/src/AxCopilot/Services/Agent/ModelExecutionProfileCatalog.cs
index 58f7e0b..242b557 100644
--- a/src/AxCopilot/Services/Agent/ModelExecutionProfileCatalog.cs
+++ b/src/AxCopilot/Services/Agent/ModelExecutionProfileCatalog.cs
@@ -26,7 +26,12 @@ public static class ModelExecutionProfileCatalog
int RecentExecutionGateMaxRetries,
int ExecutionSuccessGateMaxRetries,
int DocumentVerificationGateMaxRetries,
- int TerminalEvidenceGateMaxRetries);
+ int TerminalEvidenceGateMaxRetries,
+ ///
+ /// true이면 첫 번째 LLM 호출 직전 마지막 user 메시지로 도구 호출 강제 reminder를 주입합니다.
+ /// IBM Qwen 등 system prompt instruction을 약하게 따르는 chatty 모델용.
+ ///
+ bool InjectPreCallToolReminder = false);
public static string Normalize(string? key)
{
@@ -51,9 +56,9 @@ public static class ModelExecutionProfileCatalog
ForceToolCallAfterPlan: true,
ToolTemperatureCap: 0.2,
NoToolResponseThreshold: 1,
- NoToolRecoveryMaxRetries: 1,
- PlanExecutionRetryMax: 1,
- DocumentPlanRetryMax: 1,
+ NoToolRecoveryMaxRetries: 4, // IBM/Qwen 등 chatty 모델: 재시도 횟수 늘려 도구 호출 강제
+ PlanExecutionRetryMax: 2,
+ DocumentPlanRetryMax: 2,
PreferAggressiveDocumentFallback: true,
ReduceEarlyMemoryPressure: true,
EnablePostToolVerification: false,
@@ -68,7 +73,8 @@ public static class ModelExecutionProfileCatalog
RecentExecutionGateMaxRetries: 0,
ExecutionSuccessGateMaxRetries: 0,
DocumentVerificationGateMaxRetries: 0,
- TerminalEvidenceGateMaxRetries: 1),
+ TerminalEvidenceGateMaxRetries: 1,
+ InjectPreCallToolReminder: true), // IBM/Qwen: 첫 호출 직전 reminder 주입으로 이중 강제
"reasoning_first" => new ExecutionPolicy(
"reasoning_first",
"추론 우선",
diff --git a/src/AxCopilot/Services/LlmService.ToolUse.cs b/src/AxCopilot/Services/LlmService.ToolUse.cs
index 0e3bcdc..068fedd 100644
--- a/src/AxCopilot/Services/LlmService.ToolUse.cs
+++ b/src/AxCopilot/Services/LlmService.ToolUse.cs
@@ -1,5 +1,6 @@
using System.Net.Http;
using System.Runtime.CompilerServices;
+using System.IO;
using System.Text;
using System.Text.Json;
using AxCopilot.Models;
@@ -22,8 +23,15 @@ public partial class LlmService
public string ToolName { get; init; } = ""; // tool_use 타입일 때
public string ToolId { get; init; } = ""; // tool_use ID
public JsonElement? ToolInput { get; init; } // tool_use 파라미터
+ public string? ResolvedToolName { get; set; }
+ public Task? PrefetchedExecutionTask { get; set; }
}
+ public sealed record ToolPrefetchResult(
+ Agent.ToolResult Result,
+ long ElapsedMilliseconds,
+ string? ResolvedToolName = null);
+
/// 도구 정의를 포함하여 LLM에 요청하고, 텍스트 + tool_use 블록을 파싱하여 반환합니다.
///
/// true이면 tool_choice: "required"를 요청에 추가하여 모델이 반드시 도구를 호출하도록 강제합니다.
@@ -34,7 +42,8 @@ public partial class LlmService
List messages,
IReadOnlyCollection tools,
CancellationToken ct = default,
- bool forceToolCall = false)
+ bool forceToolCall = false,
+ Func>? prefetchToolCallAsync = null)
{
var activeService = ResolveService();
EnsureOperationModeAllowsLlmService(activeService);
@@ -42,7 +51,7 @@ public partial class LlmService
{
"sigmoid" => await SendSigmoidWithToolsAsync(messages, tools, ct),
"gemini" => await SendGeminiWithToolsAsync(messages, tools, ct),
- "ollama" or "vllm" => await SendOpenAiWithToolsAsync(messages, tools, ct, forceToolCall),
+ "ollama" or "vllm" => await SendOpenAiWithToolsAsync(messages, tools, ct, forceToolCall, prefetchToolCallAsync),
_ => throw new NotSupportedException($"서비스 '{activeService}'는 아직 Function Calling을 지원하지 않습니다.")
};
}
@@ -435,7 +444,8 @@ public partial class LlmService
private async Task> SendOpenAiWithToolsAsync(
List messages, IReadOnlyCollection tools, CancellationToken ct,
- bool forceToolCall = false)
+ bool forceToolCall = false,
+ Func>? prefetchToolCallAsync = null)
{
var activeService = ResolveService();
@@ -453,7 +463,7 @@ public partial class LlmService
string url;
if (isIbmDeployment)
- url = BuildIbmDeploymentChatUrl(endpoint, stream: false);
+ url = BuildIbmDeploymentChatUrl(endpoint, stream: true);
else if (activeService.ToLowerInvariant() == "ollama")
url = endpoint.TrimEnd('/') + "/api/chat";
else
@@ -467,13 +477,28 @@ public partial class LlmService
// CP4D 또는 Bearer 인증 적용
await ApplyAuthHeaderAsync(req, ct);
- using var resp = await SendWithTlsAsync(req, allowInsecureTls, ct);
+ using var resp = await SendWithTlsAsync(req, allowInsecureTls, ct, HttpCompletionOption.ResponseHeadersRead);
if (!resp.IsSuccessStatusCode)
{
var errBody = await resp.Content.ReadAsStringAsync(ct);
var detail = ExtractErrorDetail(errBody);
LogService.Warn($"[ToolUse] {activeService} API 오류 ({resp.StatusCode}): {errBody}");
+ if (isIbmDeployment && forceToolCall && (int)resp.StatusCode == 400)
+ {
+ LogService.Warn("[ToolUse] IBM 배포형 경로에서 tool_choice가 거부되어 대체 강제 전략으로 재시도합니다.");
+ var fallbackBody = BuildIbmToolBody(messages, tools, forceToolCall: true, useToolChoice: false);
+ var fallbackJson = JsonSerializer.Serialize(fallbackBody);
+ using var retryReq = new HttpRequestMessage(HttpMethod.Post, url)
+ {
+ Content = new StringContent(fallbackJson, Encoding.UTF8, "application/json")
+ };
+ await ApplyAuthHeaderAsync(retryReq, ct);
+ using var retryResp = await SendWithTlsAsync(retryReq, allowInsecureTls, ct, HttpCompletionOption.ResponseHeadersRead);
+ if (retryResp.IsSuccessStatusCode)
+ return await ReadOpenAiToolBlocksFromStreamAsync(retryResp, true, prefetchToolCallAsync, ct);
+ }
+
// 400 BadRequest → 도구 없이 일반 응답으로 폴백 시도
if ((int)resp.StatusCode == 400)
throw new ToolCallNotSupportedException(
@@ -482,83 +507,103 @@ public partial class LlmService
throw new HttpRequestException($"{activeService} API 오류 ({resp.StatusCode}): {detail}");
}
- var rawResp = await resp.Content.ReadAsStringAsync(ct);
+ return await ReadOpenAiToolBlocksFromStreamAsync(resp, isIbmDeployment, prefetchToolCallAsync, ct);
+ }
- // SSE 형식 응답 사전 처리 (stream:false 요청에도 SSE로 응답하는 경우)
- var respJson = ExtractJsonFromSseIfNeeded(rawResp);
+ ///
+ /// Qwen/vLLM 등이 tool_calls 대신 텍스트로 도구 호출을 출력하는 경우를 파싱합니다.
+ /// 지원 패턴:
+ /// 1. <tool_call>{"name":"...", "arguments":{...}}</tool_call>
+ /// 2. Qwen3 <tool_call>\n{"name":"...", "arguments":{...}}\n</tool_call>
+ /// 3. 여러 건의 연속 tool_call 태그
+ ///
+ private static List TryExtractToolCallsFromText(string text)
+ {
+ var results = new List();
+ if (string.IsNullOrWhiteSpace(text)) return results;
- // 비-JSON 응답(IBM 도구 호출 미지원 등) → ToolCallNotSupportedException으로 폴백 트리거
+ // 패턴 1: ... 태그 (Qwen 계열 기본 출력)
+ var tagPattern = new System.Text.RegularExpressions.Regex(
+ @"<\s*tool_call\s*>\s*(\{[\s\S]*?\})\s*<\s*/\s*tool_call\s*>",
+ System.Text.RegularExpressions.RegexOptions.IgnoreCase);
+
+ foreach (System.Text.RegularExpressions.Match m in tagPattern.Matches(text))
{
- var trimmedResp = respJson.TrimStart();
- if (!trimmedResp.StartsWith('{') && !trimmedResp.StartsWith('['))
- throw new ToolCallNotSupportedException(
- $"vLLM 응답이 JSON이 아닙니다 (도구 호출 미지원 가능성): {respJson[..Math.Min(120, respJson.Length)]}");
+ var block = TryParseToolCallJson(m.Groups[1].Value);
+ if (block != null) results.Add(block);
}
- using var doc = JsonDocument.Parse(respJson);
- var root = doc.RootElement;
-
- TryParseOpenAiUsage(root);
-
- var blocks = new List();
-
- // Ollama 형식: root.message
- // OpenAI 형식: root.choices[0].message
- JsonElement message;
- if (root.TryGetProperty("message", out var ollamaMsg))
- message = ollamaMsg;
- else if (root.TryGetProperty("choices", out var choices) && choices.GetArrayLength() > 0)
- message = choices[0].TryGetProperty("message", out var choiceMsg) ? choiceMsg : default;
- else
- return blocks;
-
- // 텍스트 응답
- if (message.TryGetProperty("content", out var content))
+ // 패턴 2: ✿FUNCTION✿ 또는 <|tool_call|> (일부 Qwen 변형)
+ if (results.Count == 0)
{
- var text = content.GetString();
- if (!string.IsNullOrWhiteSpace(text))
- blocks.Add(new ContentBlock { Type = "text", Text = text });
- }
-
- // 도구 호출 (tool_calls 배열)
- if (message.TryGetProperty("tool_calls", out var toolCalls))
- {
- foreach (var tc in toolCalls.EnumerateArray())
+ var fnPattern = new System.Text.RegularExpressions.Regex(
+ @"✿FUNCTION✿\s*(\w+)\s*\n\s*(\{[\s\S]*?\})\s*(?:✿|$)",
+ System.Text.RegularExpressions.RegexOptions.IgnoreCase);
+ foreach (System.Text.RegularExpressions.Match m in fnPattern.Matches(text))
{
- if (!tc.TryGetProperty("function", out var func)) continue;
-
- // arguments: 표준(OpenAI)은 JSON 문자열, Ollama/qwen 등은 JSON 객체를 직접 반환하기도 함
- JsonElement? parsedArgs = null;
- if (func.TryGetProperty("arguments", out var argsEl))
- {
- if (argsEl.ValueKind == JsonValueKind.String)
- {
- // 표준: 문자열로 감싸진 JSON → 파싱
- try
- {
- using var argsDoc = JsonDocument.Parse(argsEl.GetString() ?? "{}");
- parsedArgs = argsDoc.RootElement.Clone();
- }
- catch { parsedArgs = null; }
- }
- else if (argsEl.ValueKind == JsonValueKind.Object || argsEl.ValueKind == JsonValueKind.Array)
- {
- // Ollama/qwen 방식: 이미 JSON 객체 — 그대로 사용
- parsedArgs = argsEl.Clone();
- }
- }
-
- blocks.Add(new ContentBlock
- {
- Type = "tool_use",
- ToolName = func.TryGetProperty("name", out var fnm) ? fnm.GetString() ?? "" : "",
- ToolId = tc.TryGetProperty("id", out var id) ? id.GetString() ?? Guid.NewGuid().ToString("N")[..12] : Guid.NewGuid().ToString("N")[..12],
- ToolInput = parsedArgs,
- });
+ var block = TryParseToolCallJsonWithName(m.Groups[1].Value, m.Groups[2].Value);
+ if (block != null) results.Add(block);
}
}
- return blocks;
+ // 패턴 3: JSON 객체가 직접 출력된 경우 ({"name":"tool_name","arguments":{...}})
+ if (results.Count == 0)
+ {
+ var jsonPattern = new System.Text.RegularExpressions.Regex(
+ @"\{\s*""name""\s*:\s*""(\w+)""\s*,\s*""arguments""\s*:\s*(\{[\s\S]*?\})\s*\}");
+ foreach (System.Text.RegularExpressions.Match m in jsonPattern.Matches(text))
+ {
+ var block = TryParseToolCallJsonWithName(m.Groups[1].Value, m.Groups[2].Value);
+ if (block != null) results.Add(block);
+ }
+ }
+
+ return results;
+ }
+
+ /// {"name":"...", "arguments":{...}} 형식 JSON을 ContentBlock으로 변환.
+ private static ContentBlock? TryParseToolCallJson(string json)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+ var name = root.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
+ if (string.IsNullOrEmpty(name)) return null;
+
+ JsonElement? args = null;
+ if (root.TryGetProperty("arguments", out var a))
+ args = a.Clone();
+ else if (root.TryGetProperty("parameters", out var p))
+ args = p.Clone();
+
+ return new ContentBlock
+ {
+ Type = "tool_use",
+ ToolName = name,
+ ToolId = $"text_fc_{Guid.NewGuid():N}"[..16],
+ ToolInput = args,
+ };
+ }
+ catch { return null; }
+ }
+
+ /// 이름과 arguments JSON이 별도로 주어진 경우.
+ private static ContentBlock? TryParseToolCallJsonWithName(string name, string argsJson)
+ {
+ if (string.IsNullOrWhiteSpace(name)) return null;
+ try
+ {
+ using var doc = JsonDocument.Parse(argsJson);
+ return new ContentBlock
+ {
+ Type = "tool_use",
+ ToolName = name.Trim(),
+ ToolId = $"text_fc_{Guid.NewGuid():N}"[..16],
+ ToolInput = doc.RootElement.Clone(),
+ };
+ }
+ catch { return null; }
}
private object BuildOpenAiToolBody(List messages, IReadOnlyCollection tools, bool forceToolCall = false)
@@ -688,7 +733,7 @@ public partial class LlmService
["model"] = activeModel,
["messages"] = msgs,
["tools"] = toolDefs,
- ["stream"] = false,
+ ["stream"] = true,
["temperature"] = ResolveToolTemperature(),
["max_tokens"] = ResolveOpenAiCompatibleMaxTokens(),
["parallel_tool_calls"] = executionPolicy.EnableParallelReadBatch,
@@ -704,8 +749,16 @@ public partial class LlmService
}
/// IBM 배포형 /text/chat 전용 도구 바디. parameters 래퍼 사용, model 필드 없음.
- private object BuildIbmToolBody(List messages, IReadOnlyCollection tools, bool forceToolCall = false)
+ private object BuildIbmToolBody(
+ List messages,
+ IReadOnlyCollection tools,
+ bool forceToolCall = false,
+ bool useToolChoice = true)
{
+ var executionPolicy = GetActiveExecutionPolicy();
+ var strictToolOnlyDirective =
+ forceToolCall &&
+ string.Equals(executionPolicy.Key, "tool_call_strict", StringComparison.OrdinalIgnoreCase);
var msgs = new List