IBM vLLM 도구 호출 스트리밍과 모델 프로파일 기반 실행 정책 강화
Some checks failed
Release Gate / gate (push) Has been cancelled
Some checks failed
Release Gate / gate (push) Has been cancelled
- IBM 배포형 도구 호출 바디에 프로파일 기반 tool temperature를 적용하고 tool_call_strict 프로파일에서 더 직접적인 tool-only 지시를 추가함 - IBM 경로가 tool_choice를 거부할 때 tool_choice만 제거한 대체 강제 재시도 경로를 추가함 - OpenAI/vLLM tool-use 응답을 SSE로 수신하고 delta.tool_calls를 부분 조립해 도구 호출을 더 빨리 감지하도록 변경함 - read-only 도구 조기 실행과 결과 재사용 경로를 도입해 Cowork/Code 도구 착수 속도를 개선함 - README와 DEVELOPMENT 문서를 2026-04-08 11:14(KST) 기준으로 갱신함 검증 - 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:
@@ -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<T> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<LlmService.ContentBlock> 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<ChatMessage> 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;
|
||||
|
||||
@@ -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<string> 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<LlmService.ToolPrefetchResult?> TryPrefetchReadOnlyToolAsync(
|
||||
LlmService.ContentBlock block,
|
||||
IReadOnlyCollection<IAgentTool> 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<LlmService.ContentBlock, Task<LlmService.ToolPrefetchResult?>>? 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -26,7 +26,12 @@ public static class ModelExecutionProfileCatalog
|
||||
int RecentExecutionGateMaxRetries,
|
||||
int ExecutionSuccessGateMaxRetries,
|
||||
int DocumentVerificationGateMaxRetries,
|
||||
int TerminalEvidenceGateMaxRetries);
|
||||
int TerminalEvidenceGateMaxRetries,
|
||||
/// <summary>
|
||||
/// true이면 첫 번째 LLM 호출 직전 마지막 user 메시지로 도구 호출 강제 reminder를 주입합니다.
|
||||
/// IBM Qwen 등 system prompt instruction을 약하게 따르는 chatty 모델용.
|
||||
/// </summary>
|
||||
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",
|
||||
"추론 우선",
|
||||
|
||||
@@ -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<ToolPrefetchResult?>? PrefetchedExecutionTask { get; set; }
|
||||
}
|
||||
|
||||
public sealed record ToolPrefetchResult(
|
||||
Agent.ToolResult Result,
|
||||
long ElapsedMilliseconds,
|
||||
string? ResolvedToolName = null);
|
||||
|
||||
/// <summary>도구 정의를 포함하여 LLM에 요청하고, 텍스트 + tool_use 블록을 파싱하여 반환합니다.</summary>
|
||||
/// <param name="forceToolCall">
|
||||
/// true이면 <c>tool_choice: "required"</c>를 요청에 추가하여 모델이 반드시 도구를 호출하도록 강제합니다.
|
||||
@@ -34,7 +42,8 @@ public partial class LlmService
|
||||
List<ChatMessage> messages,
|
||||
IReadOnlyCollection<IAgentTool> tools,
|
||||
CancellationToken ct = default,
|
||||
bool forceToolCall = false)
|
||||
bool forceToolCall = false,
|
||||
Func<ContentBlock, Task<ToolPrefetchResult?>>? 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<List<ContentBlock>> SendOpenAiWithToolsAsync(
|
||||
List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools, CancellationToken ct,
|
||||
bool forceToolCall = false)
|
||||
bool forceToolCall = false,
|
||||
Func<ContentBlock, Task<ToolPrefetchResult?>>? 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);
|
||||
/// <summary>
|
||||
/// Qwen/vLLM 등이 tool_calls 대신 텍스트로 도구 호출을 출력하는 경우를 파싱합니다.
|
||||
/// 지원 패턴:
|
||||
/// 1. <tool_call>{"name":"...", "arguments":{...}}</tool_call>
|
||||
/// 2. Qwen3 <tool_call>\n{"name":"...", "arguments":{...}}\n</tool_call>
|
||||
/// 3. 여러 건의 연속 tool_call 태그
|
||||
/// </summary>
|
||||
private static List<ContentBlock> TryExtractToolCallsFromText(string text)
|
||||
{
|
||||
var results = new List<ContentBlock>();
|
||||
if (string.IsNullOrWhiteSpace(text)) return results;
|
||||
|
||||
// 비-JSON 응답(IBM 도구 호출 미지원 등) → ToolCallNotSupportedException으로 폴백 트리거
|
||||
// 패턴 1: <tool_call>...</tool_call> 태그 (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<ContentBlock>();
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/// <summary>{"name":"...", "arguments":{...}} 형식 JSON을 ContentBlock으로 변환.</summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>이름과 arguments JSON이 별도로 주어진 경우.</summary>
|
||||
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<ChatMessage> messages, IReadOnlyCollection<IAgentTool> 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
|
||||
}
|
||||
|
||||
/// <summary>IBM 배포형 /text/chat 전용 도구 바디. parameters 래퍼 사용, model 필드 없음.</summary>
|
||||
private object BuildIbmToolBody(List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools, bool forceToolCall = false)
|
||||
private object BuildIbmToolBody(
|
||||
List<ChatMessage> messages,
|
||||
IReadOnlyCollection<IAgentTool> 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<object>();
|
||||
|
||||
// 시스템 프롬프트
|
||||
@@ -778,6 +831,15 @@ public partial class LlmService
|
||||
msgs.Add(new { role = m.Role == "assistant" ? "assistant" : "user", content = m.Content });
|
||||
}
|
||||
|
||||
if (strictToolOnlyDirective)
|
||||
{
|
||||
msgs.Add(new
|
||||
{
|
||||
role = "user",
|
||||
content = "[TOOL_ONLY] 설명하지 말고 지금 즉시 tools 중 하나 이상을 호출하세요. 평문 응답 금지. 도구 호출만 하세요."
|
||||
});
|
||||
}
|
||||
|
||||
// OpenAI 호환 도구 정의 (형식 동일, watsonx에서 tools 필드 지원)
|
||||
var toolDefs = tools.Select(t =>
|
||||
{
|
||||
@@ -806,7 +868,7 @@ public partial class LlmService
|
||||
// IBM watsonx: parameters 래퍼 사용, model 필드 없음
|
||||
// tool_choice: "required" 지원 여부는 배포 버전마다 다를 수 있으므로
|
||||
// forceToolCall=true일 때 추가하되, 오류 시 상위에서 ToolCallNotSupportedException으로 폴백됨
|
||||
if (forceToolCall)
|
||||
if (forceToolCall && useToolChoice)
|
||||
{
|
||||
return new
|
||||
{
|
||||
@@ -815,7 +877,7 @@ public partial class LlmService
|
||||
tool_choice = "required",
|
||||
parameters = new
|
||||
{
|
||||
temperature = ResolveTemperature(),
|
||||
temperature = ResolveToolTemperature(),
|
||||
max_new_tokens = ResolveOpenAiCompatibleMaxTokens()
|
||||
}
|
||||
};
|
||||
@@ -827,12 +889,314 @@ public partial class LlmService
|
||||
tools = toolDefs,
|
||||
parameters = new
|
||||
{
|
||||
temperature = ResolveTemperature(),
|
||||
temperature = ResolveToolTemperature(),
|
||||
max_new_tokens = ResolveOpenAiCompatibleMaxTokens()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class ToolCallAccumulator
|
||||
{
|
||||
public int Index { get; init; }
|
||||
public string Id { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public StringBuilder Arguments { get; } = new();
|
||||
public bool Emitted { get; set; }
|
||||
}
|
||||
|
||||
private async Task<List<ContentBlock>> ReadOpenAiToolBlocksFromStreamAsync(
|
||||
HttpResponseMessage resp,
|
||||
bool usesIbmDeploymentApi,
|
||||
Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync,
|
||||
CancellationToken ct)
|
||||
{
|
||||
using var stream = await resp.Content.ReadAsStreamAsync(ct);
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
var firstChunkReceived = false;
|
||||
var textBuilder = new StringBuilder();
|
||||
var toolAccumulators = new Dictionary<int, ToolCallAccumulator>();
|
||||
var emittedTools = new List<ContentBlock>();
|
||||
var lastIbmGeneratedText = "";
|
||||
|
||||
while (!reader.EndOfStream && !ct.IsCancellationRequested)
|
||||
{
|
||||
var timeout = firstChunkReceived ? SubsequentChunkTimeout : FirstChunkTimeout;
|
||||
var line = await ReadLineWithTimeoutAsync(reader, ct, timeout);
|
||||
if (line == null)
|
||||
{
|
||||
if (!firstChunkReceived)
|
||||
throw new TimeoutException("도구 호출 응답 첫 청크를 받지 못했습니다.");
|
||||
break;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data: ", StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
firstChunkReceived = true;
|
||||
var data = line["data: ".Length..].Trim();
|
||||
if (string.Equals(data, "[DONE]", StringComparison.OrdinalIgnoreCase))
|
||||
break;
|
||||
|
||||
using var doc = JsonDocument.Parse(data);
|
||||
var root = doc.RootElement;
|
||||
TryParseOpenAiUsage(root);
|
||||
|
||||
if (usesIbmDeploymentApi &&
|
||||
root.TryGetProperty("status", out var statusEl) &&
|
||||
string.Equals(statusEl.GetString(), "error", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var detail = root.TryGetProperty("message", out var msgEl)
|
||||
? msgEl.GetString()
|
||||
: "IBM vLLM 도구 호출 응답 오류";
|
||||
throw new ToolCallNotSupportedException(detail ?? "IBM vLLM 도구 호출 응답 오류");
|
||||
}
|
||||
|
||||
if (TryExtractMessageToolBlocks(root, textBuilder, emittedTools))
|
||||
continue;
|
||||
|
||||
if (usesIbmDeploymentApi &&
|
||||
root.TryGetProperty("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()
|
||||
: null;
|
||||
if (!string.IsNullOrEmpty(generatedText))
|
||||
{
|
||||
if (generatedText.StartsWith(lastIbmGeneratedText, StringComparison.Ordinal))
|
||||
{
|
||||
textBuilder.Append(generatedText[lastIbmGeneratedText.Length..]);
|
||||
lastIbmGeneratedText = generatedText;
|
||||
}
|
||||
else
|
||||
{
|
||||
textBuilder.Clear();
|
||||
textBuilder.Append(generatedText);
|
||||
lastIbmGeneratedText = generatedText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("choices", out var choicesEl) &&
|
||||
choicesEl.ValueKind == JsonValueKind.Array &&
|
||||
choicesEl.GetArrayLength() > 0)
|
||||
{
|
||||
var firstChoice = choicesEl[0];
|
||||
if (firstChoice.TryGetProperty("delta", out var deltaEl))
|
||||
{
|
||||
if (deltaEl.TryGetProperty("content", out var contentEl) &&
|
||||
contentEl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var chunk = contentEl.GetString();
|
||||
if (!string.IsNullOrEmpty(chunk))
|
||||
textBuilder.Append(chunk);
|
||||
}
|
||||
|
||||
if (deltaEl.TryGetProperty("tool_calls", out var toolCallsEl) &&
|
||||
toolCallsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var toolCallEl in toolCallsEl.EnumerateArray())
|
||||
{
|
||||
var index = toolCallEl.TryGetProperty("index", out var indexEl) &&
|
||||
indexEl.TryGetInt32(out var parsedIndex)
|
||||
? parsedIndex
|
||||
: toolAccumulators.Count;
|
||||
|
||||
if (!toolAccumulators.TryGetValue(index, out var acc))
|
||||
{
|
||||
acc = new ToolCallAccumulator { Index = index };
|
||||
toolAccumulators[index] = acc;
|
||||
}
|
||||
|
||||
if (toolCallEl.TryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.String)
|
||||
acc.Id = idEl.GetString() ?? acc.Id;
|
||||
|
||||
if (toolCallEl.TryGetProperty("function", out var functionEl))
|
||||
{
|
||||
if (functionEl.TryGetProperty("name", out var nameEl) && nameEl.ValueKind == JsonValueKind.String)
|
||||
acc.Name = nameEl.GetString() ?? acc.Name;
|
||||
|
||||
if (functionEl.TryGetProperty("arguments", out var argumentsEl))
|
||||
{
|
||||
if (argumentsEl.ValueKind == JsonValueKind.String)
|
||||
acc.Arguments.Append(argumentsEl.GetString());
|
||||
else if (argumentsEl.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
|
||||
acc.Arguments.Append(argumentsEl.GetRawText());
|
||||
}
|
||||
}
|
||||
|
||||
await TryEmitCompletedToolCallAsync(acc, emittedTools, prefetchToolCallAsync).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (firstChoice.TryGetProperty("message", out var messageEl))
|
||||
TryExtractMessageToolBlocks(messageEl, textBuilder, emittedTools);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var acc in toolAccumulators.Values.OrderBy(a => a.Index))
|
||||
await TryEmitCompletedToolCallAsync(acc, emittedTools, prefetchToolCallAsync, forceEmit: true).ConfigureAwait(false);
|
||||
|
||||
var blocks = new List<ContentBlock>();
|
||||
var text = textBuilder.ToString().Trim();
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
blocks.Add(new ContentBlock { Type = "text", Text = text });
|
||||
|
||||
blocks.AddRange(emittedTools);
|
||||
|
||||
if (!blocks.Any(b => b.Type == "tool_use"))
|
||||
{
|
||||
var textBlock = blocks.FirstOrDefault(b => b.Type == "text" && !string.IsNullOrWhiteSpace(b.Text));
|
||||
if (textBlock != null)
|
||||
{
|
||||
var extracted = TryExtractToolCallsFromText(textBlock.Text);
|
||||
if (extracted.Count > 0)
|
||||
{
|
||||
foreach (var block in extracted)
|
||||
{
|
||||
if (prefetchToolCallAsync != null)
|
||||
block.PrefetchedExecutionTask = prefetchToolCallAsync(block);
|
||||
}
|
||||
blocks.AddRange(extracted);
|
||||
LogService.Debug($"[ToolUse] 텍스트에서 도구 호출 {extracted.Count}건 추출 (SSE 폴백 파싱)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private static bool TryExtractMessageToolBlocks(
|
||||
JsonElement messageOrRoot,
|
||||
StringBuilder textBuilder,
|
||||
List<ContentBlock> emittedTools)
|
||||
{
|
||||
JsonElement message = messageOrRoot;
|
||||
if (messageOrRoot.TryGetProperty("message", out var nestedMessage))
|
||||
message = nestedMessage;
|
||||
|
||||
var consumed = false;
|
||||
if (message.TryGetProperty("content", out var contentEl) &&
|
||||
contentEl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var text = contentEl.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
textBuilder.Append(text);
|
||||
consumed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (message.TryGetProperty("tool_calls", out var toolCallsEl) &&
|
||||
toolCallsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var tc in toolCallsEl.EnumerateArray())
|
||||
{
|
||||
if (!tc.TryGetProperty("function", out var functionEl))
|
||||
continue;
|
||||
|
||||
JsonElement? parsedArgs = null;
|
||||
if (functionEl.TryGetProperty("arguments", out var argsEl))
|
||||
{
|
||||
if (argsEl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var argsDoc = JsonDocument.Parse(argsEl.GetString() ?? "{}");
|
||||
parsedArgs = argsDoc.RootElement.Clone();
|
||||
}
|
||||
catch { parsedArgs = null; }
|
||||
}
|
||||
else if (argsEl.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
|
||||
{
|
||||
parsedArgs = argsEl.Clone();
|
||||
}
|
||||
}
|
||||
|
||||
emittedTools.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],
|
||||
ToolInput = parsedArgs,
|
||||
});
|
||||
}
|
||||
|
||||
consumed = true;
|
||||
}
|
||||
|
||||
return consumed;
|
||||
}
|
||||
|
||||
private static bool LooksLikeCompleteJson(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return false;
|
||||
|
||||
json = json.Trim();
|
||||
if (!(json.StartsWith('{') || json.StartsWith('[')))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
using var _ = JsonDocument.Parse(json);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task TryEmitCompletedToolCallAsync(
|
||||
ToolCallAccumulator acc,
|
||||
List<ContentBlock> emittedTools,
|
||||
Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync,
|
||||
bool forceEmit = false)
|
||||
{
|
||||
if (acc.Emitted || string.IsNullOrWhiteSpace(acc.Name))
|
||||
return;
|
||||
|
||||
var argsJson = acc.Arguments.ToString().Trim();
|
||||
JsonElement? parsedArgs = null;
|
||||
if (!string.IsNullOrEmpty(argsJson))
|
||||
{
|
||||
if (!forceEmit && !LooksLikeCompleteJson(argsJson))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
using var argsDoc = JsonDocument.Parse(argsJson);
|
||||
parsedArgs = argsDoc.RootElement.Clone();
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (!forceEmit)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var block = new ContentBlock
|
||||
{
|
||||
Type = "tool_use",
|
||||
ToolName = acc.Name,
|
||||
ToolId = string.IsNullOrWhiteSpace(acc.Id) ? Guid.NewGuid().ToString("N")[..12] : acc.Id,
|
||||
ToolInput = parsedArgs,
|
||||
};
|
||||
|
||||
if (prefetchToolCallAsync != null)
|
||||
block.PrefetchedExecutionTask = prefetchToolCallAsync(block);
|
||||
|
||||
emittedTools.Add(block);
|
||||
acc.Emitted = true;
|
||||
}
|
||||
|
||||
// ─── 공통 헬퍼 ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>ToolProperty를 LLM API용 스키마 객체로 변환. array/enum/items 포함.</summary>
|
||||
|
||||
268
src/AxCopilot/Services/WorkflowLogService.cs
Normal file
268
src/AxCopilot/Services/WorkflowLogService.cs
Normal file
@@ -0,0 +1,268 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 에이전트 워크플로우 상세 로그 서비스.
|
||||
/// 설정에서 EnableDetailedLog가 true이고 WorkflowVisualizer가 활성화된 경우
|
||||
/// LLM 요청/응답, 도구 호출/결과, 에이전트 판단 등 상세 이력을 JSON 파일로 기록합니다.
|
||||
/// 파일 위치: %APPDATA%\AxCopilot\audit\workflow\{yyyy-MM-dd}\{conversationId}.jsonl
|
||||
/// 보관 기간: DetailedLogRetentionDays 설정에 따라 자동 삭제 (기본 3일).
|
||||
/// </summary>
|
||||
public static class WorkflowLogService
|
||||
{
|
||||
private static readonly string WorkflowDir;
|
||||
private static readonly object _lock = new();
|
||||
private static bool _purged;
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
static WorkflowLogService()
|
||||
{
|
||||
WorkflowDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "audit", "workflow");
|
||||
try { Directory.CreateDirectory(WorkflowDir); } catch { }
|
||||
}
|
||||
|
||||
/// <summary>상세 로그가 활성 상태인지 확인합니다.</summary>
|
||||
public static bool IsEnabled { get; set; }
|
||||
|
||||
/// <summary>보관 기간 (일). 이 기간이 지난 로그는 자동 삭제됩니다.</summary>
|
||||
public static int RetentionDays { get; set; } = 3;
|
||||
|
||||
/// <summary>상세 워크플로우 이벤트를 기록합니다.</summary>
|
||||
public static void Log(WorkflowLogEntry entry)
|
||||
{
|
||||
if (!IsEnabled) return;
|
||||
try
|
||||
{
|
||||
var dayDir = Path.Combine(WorkflowDir, DateTime.Now.ToString("yyyy-MM-dd"));
|
||||
Directory.CreateDirectory(dayDir);
|
||||
|
||||
var safeId = SanitizeFileName(entry.ConversationId);
|
||||
var filePath = Path.Combine(dayDir, $"{safeId}.jsonl");
|
||||
var json = JsonSerializer.Serialize(entry, _jsonOpts);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
File.AppendAllText(filePath, json + "\n", Encoding.UTF8);
|
||||
}
|
||||
|
||||
if (!_purged) { _purged = true; PurgeOldLogs(); }
|
||||
}
|
||||
catch { /* 로깅 실패는 무시 */ }
|
||||
}
|
||||
|
||||
/// <summary>LLM 요청을 기록합니다.</summary>
|
||||
public static void LogLlmRequest(string conversationId, string runId, int iteration,
|
||||
string model, int messageCount, int toolCount, bool forceToolCall)
|
||||
{
|
||||
Log(new WorkflowLogEntry
|
||||
{
|
||||
ConversationId = conversationId,
|
||||
RunId = runId,
|
||||
EventType = "llm_request",
|
||||
Iteration = iteration,
|
||||
Details = new Dictionary<string, object?>
|
||||
{
|
||||
["model"] = model,
|
||||
["messageCount"] = messageCount,
|
||||
["toolCount"] = toolCount,
|
||||
["forceToolCall"] = forceToolCall,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>LLM 응답을 기록합니다.</summary>
|
||||
public static void LogLlmResponse(string conversationId, string runId, int iteration,
|
||||
string? textResponse, int toolCallCount, int inputTokens, int outputTokens, long elapsedMs)
|
||||
{
|
||||
Log(new WorkflowLogEntry
|
||||
{
|
||||
ConversationId = conversationId,
|
||||
RunId = runId,
|
||||
EventType = "llm_response",
|
||||
Iteration = iteration,
|
||||
ElapsedMs = elapsedMs,
|
||||
Details = new Dictionary<string, object?>
|
||||
{
|
||||
["textLength"] = textResponse?.Length ?? 0,
|
||||
["textPreview"] = Truncate(textResponse, 500),
|
||||
["toolCallCount"] = toolCallCount,
|
||||
["inputTokens"] = inputTokens,
|
||||
["outputTokens"] = outputTokens,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>도구 호출을 기록합니다.</summary>
|
||||
public static void LogToolCall(string conversationId, string runId, int iteration,
|
||||
string toolName, string? parameters, bool isParallel = false)
|
||||
{
|
||||
Log(new WorkflowLogEntry
|
||||
{
|
||||
ConversationId = conversationId,
|
||||
RunId = runId,
|
||||
EventType = "tool_call",
|
||||
Iteration = iteration,
|
||||
ToolName = toolName,
|
||||
Details = new Dictionary<string, object?>
|
||||
{
|
||||
["parameters"] = Truncate(parameters, 2000),
|
||||
["isParallel"] = isParallel,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>도구 실행 결과를 기록합니다.</summary>
|
||||
public static void LogToolResult(string conversationId, string runId, int iteration,
|
||||
string toolName, string? result, bool success, long elapsedMs)
|
||||
{
|
||||
Log(new WorkflowLogEntry
|
||||
{
|
||||
ConversationId = conversationId,
|
||||
RunId = runId,
|
||||
EventType = "tool_result",
|
||||
Iteration = iteration,
|
||||
ToolName = toolName,
|
||||
ElapsedMs = elapsedMs,
|
||||
Details = new Dictionary<string, object?>
|
||||
{
|
||||
["result"] = Truncate(result, 2000),
|
||||
["success"] = success,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>에이전트 상태 전환을 기록합니다.</summary>
|
||||
public static void LogTransition(string conversationId, string runId, int iteration,
|
||||
string transitionType, string? detail = null)
|
||||
{
|
||||
Log(new WorkflowLogEntry
|
||||
{
|
||||
ConversationId = conversationId,
|
||||
RunId = runId,
|
||||
EventType = "transition",
|
||||
Iteration = iteration,
|
||||
Details = new Dictionary<string, object?>
|
||||
{
|
||||
["type"] = transitionType,
|
||||
["detail"] = detail,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>에이전트 루프 시작/종료를 기록합니다.</summary>
|
||||
public static void LogAgentLifecycle(string conversationId, string runId,
|
||||
string phase, string? userPrompt = null, string? summary = null)
|
||||
{
|
||||
Log(new WorkflowLogEntry
|
||||
{
|
||||
ConversationId = conversationId,
|
||||
RunId = runId,
|
||||
EventType = $"agent_{phase}",
|
||||
Details = new Dictionary<string, object?>
|
||||
{
|
||||
["prompt"] = Truncate(userPrompt, 1000),
|
||||
["summary"] = Truncate(summary, 1000),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>보관 기간을 초과한 로그를 삭제합니다.</summary>
|
||||
public static void PurgeOldLogs()
|
||||
{
|
||||
try
|
||||
{
|
||||
var cutoff = DateTime.Now.Date.AddDays(-RetentionDays);
|
||||
foreach (var dir in Directory.GetDirectories(WorkflowDir))
|
||||
{
|
||||
var dirName = Path.GetFileName(dir);
|
||||
if (DateTime.TryParseExact(dirName, "yyyy-MM-dd",
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.None, out var dirDate))
|
||||
{
|
||||
if (dirDate < cutoff)
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
/// <summary>특정 대화의 워크플로우 로그를 읽습니다.</summary>
|
||||
public static List<WorkflowLogEntry> LoadConversation(string conversationId, int daysBack = 3)
|
||||
{
|
||||
var entries = new List<WorkflowLogEntry>();
|
||||
var safeId = SanitizeFileName(conversationId);
|
||||
|
||||
for (int d = 0; d < daysBack; d++)
|
||||
{
|
||||
var dayDir = Path.Combine(WorkflowDir, DateTime.Now.AddDays(-d).ToString("yyyy-MM-dd"));
|
||||
var filePath = Path.Combine(dayDir, $"{safeId}.jsonl");
|
||||
if (!File.Exists(filePath)) continue;
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var line in File.ReadAllLines(filePath, Encoding.UTF8))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
var entry = JsonSerializer.Deserialize<WorkflowLogEntry>(line, _jsonOpts);
|
||||
if (entry != null) entries.Add(entry);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
return entries.OrderBy(e => e.Timestamp).ToList();
|
||||
}
|
||||
|
||||
/// <summary>워크플로우 로그 폴더 경로.</summary>
|
||||
public static string GetLogFolder() => WorkflowDir;
|
||||
|
||||
private static string Truncate(string? s, int maxLen) =>
|
||||
string.IsNullOrEmpty(s) ? "" : s.Length <= maxLen ? s : s[..maxLen] + "…";
|
||||
|
||||
private static string SanitizeFileName(string name) =>
|
||||
string.IsNullOrEmpty(name) ? "unknown"
|
||||
: string.Join("_", name.Split(Path.GetInvalidFileNameChars()));
|
||||
}
|
||||
|
||||
/// <summary>워크플로우 상세 로그 항목.</summary>
|
||||
public class WorkflowLogEntry
|
||||
{
|
||||
[JsonPropertyName("ts")]
|
||||
public DateTime Timestamp { get; init; } = DateTime.Now;
|
||||
|
||||
[JsonPropertyName("convId")]
|
||||
public string ConversationId { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("event")]
|
||||
public string EventType { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("iter")]
|
||||
public int Iteration { get; init; }
|
||||
|
||||
[JsonPropertyName("tool")]
|
||||
public string? ToolName { get; init; }
|
||||
|
||||
[JsonPropertyName("ms")]
|
||||
public long ElapsedMs { get; init; }
|
||||
|
||||
[JsonPropertyName("details")]
|
||||
public Dictionary<string, object?>? Details { get; init; }
|
||||
}
|
||||
Reference in New Issue
Block a user