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:
@@ -1483,3 +1483,8 @@ MIT License
|
|||||||
- OpenAI/vLLM tool calling 바디에 `parallel_tool_calls` 힌트를 추가해 읽기 도구 병렬 실행 성향이 모델 요청 바디에도 반영되도록 보강했습니다.
|
- OpenAI/vLLM tool calling 바디에 `parallel_tool_calls` 힌트를 추가해 읽기 도구 병렬 실행 성향이 모델 요청 바디에도 반영되도록 보강했습니다.
|
||||||
- Cowork/Code 진행 표시에는 `계획 / 도구 / 검증 / 압축 / 폴백 / 재시도` 같은 단계 메타를 더 직접적으로 붙여, 오래 걸릴 때도 현재 단계가 더 잘 읽히게 했습니다.
|
- 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` 프로파일별 회귀 시나리오를 고정했습니다.
|
- [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 도구는 조립이 끝나는 즉시 조기 실행을 시작하고, 최종 루프에서는 그 결과를 재사용하도록 바꿔 도구 착수 속도를 끌어올렸습니다.
|
||||||
|
|||||||
BIN
dist/AxCopilot/AxCopilot.exe
vendored
BIN
dist/AxCopilot/AxCopilot.exe
vendored
Binary file not shown.
BIN
dist/AxCopilot_Setup.exe
vendored
BIN
dist/AxCopilot_Setup.exe
vendored
Binary file not shown.
@@ -5386,3 +5386,18 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎.
|
|||||||
- Cowork/Code 진행 카드에 `계획 / 도구 / 검증 / 압축 / 폴백 / 재시도` 단계 메타를 더 직접적으로 붙여, 오래 걸리는 작업도 어느 단계인지 읽기 쉽게 정리했다.
|
- Cowork/Code 진행 카드에 `계획 / 도구 / 검증 / 압축 / 폴백 / 재시도` 단계 메타를 더 직접적으로 붙여, 오래 걸리는 작업도 어느 단계인지 읽기 쉽게 정리했다.
|
||||||
- [AX_AGENT_REGRESSION_PROMPTS.md](/E:/AX%20Copilot%20-%20Codex/docs/AX_AGENT_REGRESSION_PROMPTS.md)
|
- [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` 프로파일별 검증 시나리오를 추가했다.
|
- 회귀 프롬프트 문서를 전면 교체하고, `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에는 `조기 실행 결과 재사용` 힌트를 남기도록 정리했다.
|
||||||
|
|||||||
@@ -75,6 +75,11 @@ public partial class App : System.Windows.Application
|
|||||||
var settings = _settings;
|
var settings = _settings;
|
||||||
settings.Load();
|
settings.Load();
|
||||||
|
|
||||||
|
// ─── 워크플로우 상세 로그 초기화 ─────────────────────────────────────
|
||||||
|
WorkflowLogService.IsEnabled = settings.Settings.Llm.EnableDetailedLog;
|
||||||
|
WorkflowLogService.RetentionDays = settings.Settings.Llm.DetailedLogRetentionDays > 0
|
||||||
|
? settings.Settings.Llm.DetailedLogRetentionDays : 3;
|
||||||
|
|
||||||
// ─── 대화 보관/디스크 정리 (제품화 하드닝) ───────────────────────────
|
// ─── 대화 보관/디스크 정리 (제품화 하드닝) ───────────────────────────
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -995,6 +995,14 @@ public class LlmSettings
|
|||||||
[JsonPropertyName("enableAuditLog")]
|
[JsonPropertyName("enableAuditLog")]
|
||||||
public bool EnableAuditLog { get; set; } = true;
|
public bool EnableAuditLog { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>워크플로우 상세 로그 기록 활성화. 워크플로우 분석기와 함께 사용하면 LLM 요청/응답, 도구 호출 전체 이력을 기록합니다.</summary>
|
||||||
|
[JsonPropertyName("enableDetailedLog")]
|
||||||
|
public bool EnableDetailedLog { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>상세 로그 보관 기간 (일). 이 기간이 지난 로그는 자동 삭제됩니다. 기본 3일.</summary>
|
||||||
|
[JsonPropertyName("detailedLogRetentionDays")]
|
||||||
|
public int DetailedLogRetentionDays { get; set; } = 3;
|
||||||
|
|
||||||
/// <summary>에이전트 메모리 (지속적 학습) 활성화. 기본 true.</summary>
|
/// <summary>에이전트 메모리 (지속적 학습) 활성화. 기본 true.</summary>
|
||||||
[JsonPropertyName("enableAgentMemory")]
|
[JsonPropertyName("enableAgentMemory")]
|
||||||
public bool EnableAgentMemory { get; set; } = true;
|
public bool EnableAgentMemory { get; set; } = true;
|
||||||
|
|||||||
@@ -143,10 +143,15 @@ public partial class AgentLoopService
|
|||||||
using var gate = new SemaphoreSlim(maxConcurrency, maxConcurrency);
|
using var gate = new SemaphoreSlim(maxConcurrency, maxConcurrency);
|
||||||
var tasks = executableCalls.Select(async call =>
|
var tasks = executableCalls.Select(async call =>
|
||||||
{
|
{
|
||||||
await gate.WaitAsync(ct).ConfigureAwait(false);
|
// gate.WaitAsync를 try 안에서 호출: ct 취소 시 WaitAsync가 OperationCanceledException을
|
||||||
var tool = _tools.Get(call.ToolName);
|
// 던져도 Release()가 잘못 호출되지 않도록 보호 (SemaphoreFullException 방지)
|
||||||
|
var acquired = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
await gate.WaitAsync(ct).ConfigureAwait(false);
|
||||||
|
acquired = true;
|
||||||
|
|
||||||
|
var tool = _tools.Get(call.ToolName);
|
||||||
if (tool == null)
|
if (tool == null)
|
||||||
return (call, ToolResult.Fail($"알 수 없는 도구: {call.ToolName}"), 0L);
|
return (call, ToolResult.Fail($"알 수 없는 도구: {call.ToolName}"), 0L);
|
||||||
|
|
||||||
@@ -154,19 +159,32 @@ public partial class AgentLoopService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var input = call.ToolInput ?? JsonDocument.Parse("{}").RootElement;
|
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();
|
sw.Stop();
|
||||||
return (call, result, sw.ElapsedMilliseconds);
|
return (call, result, sw.ElapsedMilliseconds);
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
sw.Stop();
|
||||||
|
return (call, ToolResult.Fail($"도구 실행이 취소되었습니다: {call.ToolName}"), sw.ElapsedMilliseconds);
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
sw.Stop();
|
sw.Stop();
|
||||||
return (call, ToolResult.Fail($"도구 실행 오류: {ex.Message}"), sw.ElapsedMilliseconds);
|
return (call, ToolResult.Fail($"도구 실행 오류: {ex.Message}"), sw.ElapsedMilliseconds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// WaitAsync 도중 취소됨 — 세마포어 미취득 상태이므로 Release 하지 않음
|
||||||
|
return (call, ToolResult.Fail($"도구 실행 대기 중 취소됨: {call.ToolName}"), 0L);
|
||||||
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
gate.Release();
|
if (acquired) gate.Release();
|
||||||
}
|
}
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
@@ -241,6 +259,11 @@ public partial class AgentLoopService
|
|||||||
TruncateOutput(result.Output, 500),
|
TruncateOutput(result.Output, 500),
|
||||||
result.FilePath, result.Success);
|
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 ?? "";
|
var userQuery = messages.LastOrDefault(m => m.Role == "user")?.Content ?? "";
|
||||||
|
|
||||||
|
// 워크플로우 상세 로그: 에이전트 루프 시작
|
||||||
|
WorkflowLogService.LogAgentLifecycle(_conversationId, _currentRunId, "start",
|
||||||
|
userPrompt: userQuery);
|
||||||
var consecutiveErrors = 0; // Self-Reflection: 연속 오류 카운터
|
var consecutiveErrors = 0; // Self-Reflection: 연속 오류 카운터
|
||||||
var totalToolCalls = 0; // 복잡도 추정용
|
var totalToolCalls = 0; // 복잡도 추정용
|
||||||
string? lastFailedToolSignature = null;
|
string? lastFailedToolSignature = null;
|
||||||
@@ -510,6 +514,7 @@ public partial class AgentLoopService
|
|||||||
|
|
||||||
// LLM에 도구 정의와 함께 요청
|
// LLM에 도구 정의와 함께 요청
|
||||||
List<LlmService.ContentBlock> blocks;
|
List<LlmService.ContentBlock> blocks;
|
||||||
|
var llmCallSw = Stopwatch.StartNew();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var activeTools = GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides);
|
var activeTools = GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides);
|
||||||
@@ -521,14 +526,43 @@ public partial class AgentLoopService
|
|||||||
// totalToolCalls == 0: 아직 한 번도 도구를 안 불렀으면 tool_choice:"required" 강제
|
// totalToolCalls == 0: 아직 한 번도 도구를 안 불렀으면 tool_choice:"required" 강제
|
||||||
// → chatty 모델(Qwen 등)이 텍스트 설명만 하고 도구를 안 부르는 현상 방지
|
// → chatty 모델(Qwen 등)이 텍스트 설명만 하고 도구를 안 부르는 현상 방지
|
||||||
var forceFirst = totalToolCalls == 0 && executionPolicy.ForceInitialToolCall;
|
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(
|
blocks = await SendWithToolsWithRecoveryAsync(
|
||||||
messages,
|
sendMessages,
|
||||||
activeTools,
|
activeTools,
|
||||||
ct,
|
ct,
|
||||||
$"메인 루프 {iteration}",
|
$"메인 루프 {iteration}",
|
||||||
runState,
|
runState,
|
||||||
forceToolCall: forceFirst);
|
forceToolCall: forceFirst,
|
||||||
|
prefetchToolCallAsync: block => TryPrefetchReadOnlyToolAsync(
|
||||||
|
block,
|
||||||
|
activeTools,
|
||||||
|
context,
|
||||||
|
ct));
|
||||||
runState.ContextRecoveryAttempts = 0;
|
runState.ContextRecoveryAttempts = 0;
|
||||||
|
llmCallSw.Stop();
|
||||||
runState.TransientLlmErrorRetries = 0;
|
runState.TransientLlmErrorRetries = 0;
|
||||||
NotifyPostCompactionTurnIfNeeded(runState);
|
NotifyPostCompactionTurnIfNeeded(runState);
|
||||||
}
|
}
|
||||||
@@ -646,6 +680,13 @@ public partial class AgentLoopService
|
|||||||
var textResponse = string.Join("\n", textParts);
|
var textResponse = string.Join("\n", textParts);
|
||||||
consecutiveNoToolResponses = toolCalls.Count == 0 ? consecutiveNoToolResponses + 1 : 0;
|
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: 첫 번째 텍스트 응답에서 계획 단계 추출
|
// Task Decomposition: 첫 번째 텍스트 응답에서 계획 단계 추출
|
||||||
if (!planExtracted && !string.IsNullOrEmpty(textResponse))
|
if (!planExtracted && !string.IsNullOrEmpty(textResponse))
|
||||||
{
|
{
|
||||||
@@ -1126,22 +1167,27 @@ public partial class AgentLoopService
|
|||||||
repeatedUnknownToolCount = 0;
|
repeatedUnknownToolCount = 0;
|
||||||
lastDisallowedToolName = null;
|
lastDisallowedToolName = null;
|
||||||
repeatedDisallowedToolCount = 0;
|
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
|
? call
|
||||||
: new LlmService.ContentBlock
|
: new LlmService.ContentBlock
|
||||||
{
|
{
|
||||||
Type = call.Type,
|
Type = call.Type,
|
||||||
Text = call.Text,
|
Text = call.Text,
|
||||||
ToolName = resolvedToolName,
|
ToolName = effectiveToolName,
|
||||||
ToolId = call.ToolId,
|
ToolId = call.ToolId,
|
||||||
ToolInput = call.ToolInput,
|
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(
|
EmitEvent(
|
||||||
AgentEventType.Thinking,
|
AgentEventType.Thinking,
|
||||||
resolvedToolName,
|
effectiveToolName,
|
||||||
$"도구명 정규화 적용: '{call.ToolName}' → '{resolvedToolName}'");
|
$"도구명 정규화 적용: '{call.ToolName}' → '{effectiveToolName}'");
|
||||||
}
|
}
|
||||||
|
|
||||||
var toolCallSignature = BuildToolCallSignature(effectiveCall);
|
var toolCallSignature = BuildToolCallSignature(effectiveCall);
|
||||||
@@ -1279,11 +1325,37 @@ public partial class AgentLoopService
|
|||||||
}
|
}
|
||||||
|
|
||||||
ToolResult result;
|
ToolResult result;
|
||||||
|
long elapsedMs;
|
||||||
var sw = Stopwatch.StartNew();
|
var sw = Stopwatch.StartNew();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var input = effectiveCall.ToolInput ?? JsonDocument.Parse("{}").RootElement;
|
if (effectiveCall.PrefetchedExecutionTask != null)
|
||||||
result = await ExecuteToolWithTimeoutAsync(tool, effectiveCall.ToolName, input, context, messages, ct);
|
{
|
||||||
|
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)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -1300,8 +1372,9 @@ public partial class AgentLoopService
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
result = ToolResult.Fail($"도구 실행 오류: {ex.Message}");
|
result = ToolResult.Fail($"도구 실행 오류: {ex.Message}");
|
||||||
|
sw.Stop();
|
||||||
|
elapsedMs = sw.ElapsedMilliseconds;
|
||||||
}
|
}
|
||||||
sw.Stop();
|
|
||||||
|
|
||||||
// ── Post-Hook 실행 ──
|
// ── Post-Hook 실행 ──
|
||||||
if (llm.EnableToolHooks && runtimeHooks.Count > 0)
|
if (llm.EnableToolHooks && runtimeHooks.Count > 0)
|
||||||
@@ -1340,7 +1413,7 @@ public partial class AgentLoopService
|
|||||||
effectiveCall.ToolName,
|
effectiveCall.ToolName,
|
||||||
TruncateOutput(result.Output, 200),
|
TruncateOutput(result.Output, 200),
|
||||||
result.FilePath,
|
result.FilePath,
|
||||||
elapsedMs: sw.ElapsedMilliseconds,
|
elapsedMs: elapsedMs,
|
||||||
inputTokens: tokenUsage?.PromptTokens ?? 0,
|
inputTokens: tokenUsage?.PromptTokens ?? 0,
|
||||||
outputTokens: tokenUsage?.CompletionTokens ?? 0,
|
outputTokens: tokenUsage?.CompletionTokens ?? 0,
|
||||||
toolInput: effectiveCall.ToolInput?.ToString(),
|
toolInput: effectiveCall.ToolInput?.ToString(),
|
||||||
@@ -1533,6 +1606,10 @@ public partial class AgentLoopService
|
|||||||
if (runtimeOverrideApplied)
|
if (runtimeOverrideApplied)
|
||||||
_llm.PopInferenceOverride();
|
_llm.PopInferenceOverride();
|
||||||
|
|
||||||
|
// 워크플로우 상세 로그: 에이전트 루프 종료
|
||||||
|
WorkflowLogService.LogAgentLifecycle(_conversationId, _currentRunId, "end",
|
||||||
|
summary: $"iterations={iteration}, tools={totalToolCalls}, success={statsSuccessCount}, fail={statsFailCount}");
|
||||||
|
|
||||||
IsRunning = false;
|
IsRunning = false;
|
||||||
_currentRunId = "";
|
_currentRunId = "";
|
||||||
_runPendingPostCompactionTurn = false;
|
_runPendingPostCompactionTurn = false;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
using AxCopilot.Services;
|
using AxCopilot.Services;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
namespace AxCopilot.Services.Agent;
|
namespace AxCopilot.Services.Agent;
|
||||||
|
|
||||||
public partial class AgentLoopService
|
public partial class AgentLoopService
|
||||||
@@ -375,7 +377,7 @@ public partial class AgentLoopService
|
|||||||
return sawDiff;
|
return sawDiff;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 蹂寃??꾧뎄 ?먯껜媛 ?놁쑝硫?diff 寃뚯씠?몃? ?붽뎄?섏? ?딆쓬
|
// 蹂寃??꾧뎄 ?먯껜媛 ?놁쑝硫?diff 寃뚯씠???꾧뎄?몄? ?뺤씤
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,7 +397,7 @@ public partial class AgentLoopService
|
|||||||
"deletions",
|
"deletions",
|
||||||
"file changed",
|
"file changed",
|
||||||
"異붽?",
|
"異붽?",
|
||||||
"??젣");
|
"?낅뜲?댄듃");
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool TryApplyDocumentArtifactGateTransition(
|
private bool TryApplyDocumentArtifactGateTransition(
|
||||||
@@ -413,18 +415,18 @@ public partial class AgentLoopService
|
|||||||
if (!string.IsNullOrEmpty(textResponse))
|
if (!string.IsNullOrEmpty(textResponse))
|
||||||
messages.Add(new ChatMessage { Role = "assistant", Content = 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
|
messages.Add(new ChatMessage
|
||||||
{
|
{
|
||||||
Role = "user",
|
Role = "user",
|
||||||
Content = "[System:DocumentArtifactGate] 寃곌낵 ?ㅻ챸留뚯쑝濡?醫낅즺?????놁뒿?덈떎. " +
|
Content = "[System:DocumentArtifactGate] 결과 파일이 누락되었습니다. " +
|
||||||
$"吏湲?利됱떆 {targetHint}???ㅼ젣 ?뚯씪濡??앹꽦?섏꽭?? " +
|
$"즉시 {targetHint} 파일을 실제로 생성하세요. " +
|
||||||
"html_create/markdown_create/document_assemble/file_write 以??곸젅???꾧뎄瑜?諛섎뱶???몄텧?섏꽭??"
|
"html_create/markdown_create/document_assemble/file_write 중 적절한 도구를 호출하세요."
|
||||||
});
|
});
|
||||||
EmitEvent(
|
EmitEvent(
|
||||||
AgentEventType.Thinking,
|
AgentEventType.Thinking,
|
||||||
"",
|
"",
|
||||||
$"臾몄꽌 ?묒뾽 ?곗텧臾??뚯씪???놁뼱 ?꾧뎄 ?ㅽ뻾???ъ슂泥?빀?덈떎 ({runState.DocumentArtifactGateRetry}/2)");
|
$"문서 생성 결과 파일이 없어 재생성이 필요합니다. ({runState.DocumentArtifactGateRetry}/2)");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -689,7 +691,7 @@ public partial class AgentLoopService
|
|||||||
EmitEvent(
|
EmitEvent(
|
||||||
AgentEventType.Thinking,
|
AgentEventType.Thinking,
|
||||||
call.ToolName,
|
call.ToolName,
|
||||||
$"臾댁쓽誘명븳 ?쎄린 ?꾧뎄 諛섎났 猷⑦봽瑜?媛먯????ㅻⅨ ?꾨왂?쇰줈 ?꾪솚?⑸땲??({repeatedSameSignatureCount}??");
|
$"臾댁쓽誘명븳 ?쎄린 ?꾧뎄 諛섎났 猷⑦봽媛 媛먯??섏뼱 ?ㅻⅨ ?꾨왂?쇰줈 ?꾪솚?⑸땲??({repeatedSameSignatureCount}??");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -716,7 +718,7 @@ public partial class AgentLoopService
|
|||||||
EmitEvent(
|
EmitEvent(
|
||||||
AgentEventType.Thinking,
|
AgentEventType.Thinking,
|
||||||
"",
|
"",
|
||||||
$"?쎄린 ?꾩슜 ?꾧뎄留??곗냽 {consecutiveReadOnlySuccessTools}???ㅽ뻾?섏뼱 ?뺤껜瑜?媛먯??덉뒿?덈떎. ?ㅽ뻾 ?④퀎濡??꾪솚?⑸땲?? (threshold={threshold})");
|
$"?쎄린 ?꾩슜 ?꾧뎄媛 ?곗냽 {consecutiveReadOnlySuccessTools}???ㅽ뻾?섏뼱 ?뺤껜媛 媛먯??섏뿀?듬땲?? ?ㅽ뻾 ?④퀎濡??꾪솚?⑸땲??(threshold={threshold})");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,7 +752,7 @@ public partial class AgentLoopService
|
|||||||
EmitEvent(
|
EmitEvent(
|
||||||
AgentEventType.Thinking,
|
AgentEventType.Thinking,
|
||||||
"",
|
"",
|
||||||
$"?ㅽ뻾 吏꾩쟾???놁뼱 媛뺤젣 蹂듦뎄 ?④퀎瑜??쒖옉?⑸땲??({runState.NoProgressRecoveryRetry}/2)");
|
$"?ㅽ뻾 吏꾩쟾???놁뼱 媛뺤젣 蹂듦뎄 ?④퀎濡??쒖옉?⑸땲??({runState.NoProgressRecoveryRetry}/2)");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -809,8 +811,8 @@ public partial class AgentLoopService
|
|||||||
return false;
|
return false;
|
||||||
|
|
||||||
var lower = content.ToLowerInvariant();
|
var lower = content.ToLowerInvariant();
|
||||||
if (ContainsAny(lower, "success", "succeeded", "passed", "?듦낵", "?깃났", "鍮뚮뱶?덉뒿?덈떎", "tests passed", "build succeeded")
|
if (ContainsAny(lower, "success", "succeeded", "passed", "성공", "tests passed", "build succeeded")
|
||||||
&& !ContainsAny(lower, "fail", "failed", "error", "?ㅻ쪟", "?ㅽ뙣", "exception", "denied", "not found"))
|
&& !ContainsAny(lower, "fail", "failed", "error", "오류", "실패", "exception", "denied", "not found"))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
return ContainsAny(
|
return ContainsAny(
|
||||||
@@ -829,7 +831,7 @@ public partial class AgentLoopService
|
|||||||
"?ㅻ쪟",
|
"?ㅻ쪟",
|
||||||
"?덉쇅",
|
"?덉쇅",
|
||||||
"?쒓컙 珥덇낵",
|
"?쒓컙 珥덇낵",
|
||||||
"沅뚰븳",
|
"沅뚰븳 嫄곕?",
|
||||||
"李⑤떒",
|
"李⑤떒",
|
||||||
"찾을 수");
|
"찾을 수");
|
||||||
}
|
}
|
||||||
@@ -1103,7 +1105,55 @@ public partial class AgentLoopService
|
|||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
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,
|
CancellationToken ct,
|
||||||
string phaseLabel,
|
string phaseLabel,
|
||||||
RunState? runState = null,
|
RunState? runState = null,
|
||||||
bool forceToolCall = false)
|
bool forceToolCall = false,
|
||||||
|
Func<LlmService.ContentBlock, Task<LlmService.ToolPrefetchResult?>>? prefetchToolCallAsync = null)
|
||||||
{
|
{
|
||||||
var transientRetries = runState?.TransientLlmErrorRetries ?? 0;
|
var transientRetries = runState?.TransientLlmErrorRetries ?? 0;
|
||||||
var contextRecoveryRetries = runState?.ContextRecoveryAttempts ?? 0;
|
var contextRecoveryRetries = runState?.ContextRecoveryAttempts ?? 0;
|
||||||
@@ -1121,7 +1172,7 @@ public partial class AgentLoopService
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await _llm.SendWithToolsAsync(messages, tools, ct, forceToolCall);
|
return await _llm.SendWithToolsAsync(messages, tools, ct, forceToolCall, prefetchToolCallAsync);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -1191,6 +1242,11 @@ public partial class AgentLoopService
|
|||||||
result.FilePath, result.Success);
|
result.FilePath, result.Success);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 워크플로우 상세 로그: 도구 실행 결과
|
||||||
|
WorkflowLogService.LogToolResult(_conversationId, _currentRunId, 0,
|
||||||
|
call.ToolName, TruncateOutput(result.Output, 2000),
|
||||||
|
result.Success, 0);
|
||||||
|
|
||||||
totalToolCalls++;
|
totalToolCalls++;
|
||||||
if (totalToolCalls > 15 && maxIterations < baseMax * 2)
|
if (totalToolCalls > 15 && maxIterations < baseMax * 2)
|
||||||
maxIterations = Math.Min(baseMax * 2, 50);
|
maxIterations = Math.Min(baseMax * 2, 50);
|
||||||
@@ -1294,7 +1350,7 @@ public partial class AgentLoopService
|
|||||||
result.Output)
|
result.Output)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
EmitEvent(AgentEventType.Thinking, "", "鍮꾩옱?쒕룄 ?ㅽ뙣濡?遺꾨쪟?섏뼱 ?숈씪 ?몄텧 諛섎났??以묐떒?섍퀬 ?고쉶 ?꾨왂?쇰줈 ?꾪솚?⑸땲??");
|
EmitEvent(AgentEventType.Thinking, "", "현재도 실패로 오류 발생, 다시 실행 경로로 전환합니다.");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1310,7 +1366,7 @@ public partial class AgentLoopService
|
|||||||
if (memSvc != null && (app?.SettingsService?.Settings.Llm.EnableAgentMemory ?? false))
|
if (memSvc != null && (app?.SettingsService?.Settings.Llm.EnableAgentMemory ?? false))
|
||||||
{
|
{
|
||||||
memSvc.Add("correction",
|
memSvc.Add("correction",
|
||||||
$"?꾧뎄 '{call.ToolName}' 諛섎났 ?ㅽ뙣: {TruncateOutput(result.Output, 200)}",
|
$"도구 '{call.ToolName}' 반복 실패: {TruncateOutput(result.Output, 200)}",
|
||||||
$"conv:{_conversationId}", context.WorkFolder);
|
$"conv:{_conversationId}", context.WorkFolder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1453,8 +1509,8 @@ public partial class AgentLoopService
|
|||||||
taskPolicy)
|
taskPolicy)
|
||||||
});
|
});
|
||||||
EmitEvent(AgentEventType.Thinking, "", highImpactCodeChange
|
EmitEvent(AgentEventType.Thinking, "", highImpactCodeChange
|
||||||
? "怨좎쁺??肄붾뱶 蹂寃쎌씠??李몄“ 寃?됯낵 build/test 寃利앹쓣 ???꾧꺽?섍쾶 ?댁뼱媛묐땲??.."
|
? "怨좎쁺??肄붾뱶 蹂寃쎌쑝濡?李몄“ 寃利앷낵 build/test 寃利앹쓣 ???꾧꺽?섍쾶 ?댁뼱媛묐땲??."
|
||||||
: "肄붾뱶 蹂寃???build/test/diff 寃利앹쓣 ?댁뼱媛묐땲??..");
|
: "肄붾뱶 蹂寃???build/test/diff 寃利앹쓣 ?댁뼱媛묐땲??.");
|
||||||
}
|
}
|
||||||
else if (HasCodeVerificationEvidenceAfterLastModification(messages, requireHighImpactCodeVerification))
|
else if (HasCodeVerificationEvidenceAfterLastModification(messages, requireHighImpactCodeVerification))
|
||||||
{
|
{
|
||||||
@@ -1541,7 +1597,7 @@ public partial class AgentLoopService
|
|||||||
if (devShouldContinue)
|
if (devShouldContinue)
|
||||||
{
|
{
|
||||||
messages.Add(LlmService.CreateToolResultMessage(
|
messages.Add(LlmService.CreateToolResultMessage(
|
||||||
call.ToolId, call.ToolName, devToolResultMessage ?? "[SKIPPED by developer] 사용자가 이 도구 실행을 건너뛰었습니다."));
|
call.ToolId, call.ToolName, devToolResultMessage ?? "[SKIPPED by developer] 媛쒕컻?먭? ???꾧뎄 ?ㅽ뻾??嫄대꼫?곗뿀?듬땲??"));
|
||||||
return (true, null);
|
return (true, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1562,7 +1618,7 @@ public partial class AgentLoopService
|
|||||||
if (scopeShouldContinue)
|
if (scopeShouldContinue)
|
||||||
{
|
{
|
||||||
messages.Add(LlmService.CreateToolResultMessage(
|
messages.Add(LlmService.CreateToolResultMessage(
|
||||||
call.ToolId, call.ToolName, scopeToolResultMessage ?? "[SKIPPED] 사용자가 이 작업을 건너뛰었습니다."));
|
call.ToolId, call.ToolName, scopeToolResultMessage ?? "[SKIPPED] 媛쒕컻?먭? ???묒뾽??嫄대꼫?곗뿀?듬땲??"));
|
||||||
return (true, null);
|
return (true, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -287,6 +287,22 @@ public sealed class AxAgentExecutionEngine
|
|||||||
&& !string.Equals(t, "Resumed", StringComparison.OrdinalIgnoreCase)
|
&& !string.Equals(t, "Resumed", StringComparison.OrdinalIgnoreCase)
|
||||||
&& !string.Equals(t, "SessionStart", 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
|
var completionLine = runTab switch
|
||||||
{
|
{
|
||||||
"Cowork" => "코워크 작업이 완료되었습니다.",
|
"Cowork" => "코워크 작업이 완료되었습니다.",
|
||||||
@@ -310,9 +326,11 @@ public sealed class AxAgentExecutionEngine
|
|||||||
: fileLine;
|
: fileLine;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 파일 없으면 가장 최근 의미 있는 이벤트 요약 사용
|
// 파일 없으면 가장 최근 의미 있는 이벤트 요약 사용 (내부 상태 문자열 제외)
|
||||||
var latestSummary = conversation.ExecutionEvents?
|
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)
|
.OrderByDescending(evt => evt.Timestamp)
|
||||||
.Select(evt => evt.Summary.Trim())
|
.Select(evt => evt.Summary.Trim())
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
|
|||||||
@@ -26,7 +26,12 @@ public static class ModelExecutionProfileCatalog
|
|||||||
int RecentExecutionGateMaxRetries,
|
int RecentExecutionGateMaxRetries,
|
||||||
int ExecutionSuccessGateMaxRetries,
|
int ExecutionSuccessGateMaxRetries,
|
||||||
int DocumentVerificationGateMaxRetries,
|
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)
|
public static string Normalize(string? key)
|
||||||
{
|
{
|
||||||
@@ -51,9 +56,9 @@ public static class ModelExecutionProfileCatalog
|
|||||||
ForceToolCallAfterPlan: true,
|
ForceToolCallAfterPlan: true,
|
||||||
ToolTemperatureCap: 0.2,
|
ToolTemperatureCap: 0.2,
|
||||||
NoToolResponseThreshold: 1,
|
NoToolResponseThreshold: 1,
|
||||||
NoToolRecoveryMaxRetries: 1,
|
NoToolRecoveryMaxRetries: 4, // IBM/Qwen 등 chatty 모델: 재시도 횟수 늘려 도구 호출 강제
|
||||||
PlanExecutionRetryMax: 1,
|
PlanExecutionRetryMax: 2,
|
||||||
DocumentPlanRetryMax: 1,
|
DocumentPlanRetryMax: 2,
|
||||||
PreferAggressiveDocumentFallback: true,
|
PreferAggressiveDocumentFallback: true,
|
||||||
ReduceEarlyMemoryPressure: true,
|
ReduceEarlyMemoryPressure: true,
|
||||||
EnablePostToolVerification: false,
|
EnablePostToolVerification: false,
|
||||||
@@ -68,7 +73,8 @@ public static class ModelExecutionProfileCatalog
|
|||||||
RecentExecutionGateMaxRetries: 0,
|
RecentExecutionGateMaxRetries: 0,
|
||||||
ExecutionSuccessGateMaxRetries: 0,
|
ExecutionSuccessGateMaxRetries: 0,
|
||||||
DocumentVerificationGateMaxRetries: 0,
|
DocumentVerificationGateMaxRetries: 0,
|
||||||
TerminalEvidenceGateMaxRetries: 1),
|
TerminalEvidenceGateMaxRetries: 1,
|
||||||
|
InjectPreCallToolReminder: true), // IBM/Qwen: 첫 호출 직전 reminder 주입으로 이중 강제
|
||||||
"reasoning_first" => new ExecutionPolicy(
|
"reasoning_first" => new ExecutionPolicy(
|
||||||
"reasoning_first",
|
"reasoning_first",
|
||||||
"추론 우선",
|
"추론 우선",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.IO;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using AxCopilot.Models;
|
using AxCopilot.Models;
|
||||||
@@ -22,8 +23,15 @@ public partial class LlmService
|
|||||||
public string ToolName { get; init; } = ""; // tool_use 타입일 때
|
public string ToolName { get; init; } = ""; // tool_use 타입일 때
|
||||||
public string ToolId { get; init; } = ""; // tool_use ID
|
public string ToolId { get; init; } = ""; // tool_use ID
|
||||||
public JsonElement? ToolInput { get; init; } // tool_use 파라미터
|
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>
|
/// <summary>도구 정의를 포함하여 LLM에 요청하고, 텍스트 + tool_use 블록을 파싱하여 반환합니다.</summary>
|
||||||
/// <param name="forceToolCall">
|
/// <param name="forceToolCall">
|
||||||
/// true이면 <c>tool_choice: "required"</c>를 요청에 추가하여 모델이 반드시 도구를 호출하도록 강제합니다.
|
/// true이면 <c>tool_choice: "required"</c>를 요청에 추가하여 모델이 반드시 도구를 호출하도록 강제합니다.
|
||||||
@@ -34,7 +42,8 @@ public partial class LlmService
|
|||||||
List<ChatMessage> messages,
|
List<ChatMessage> messages,
|
||||||
IReadOnlyCollection<IAgentTool> tools,
|
IReadOnlyCollection<IAgentTool> tools,
|
||||||
CancellationToken ct = default,
|
CancellationToken ct = default,
|
||||||
bool forceToolCall = false)
|
bool forceToolCall = false,
|
||||||
|
Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync = null)
|
||||||
{
|
{
|
||||||
var activeService = ResolveService();
|
var activeService = ResolveService();
|
||||||
EnsureOperationModeAllowsLlmService(activeService);
|
EnsureOperationModeAllowsLlmService(activeService);
|
||||||
@@ -42,7 +51,7 @@ public partial class LlmService
|
|||||||
{
|
{
|
||||||
"sigmoid" => await SendSigmoidWithToolsAsync(messages, tools, ct),
|
"sigmoid" => await SendSigmoidWithToolsAsync(messages, tools, ct),
|
||||||
"gemini" => await SendGeminiWithToolsAsync(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을 지원하지 않습니다.")
|
_ => throw new NotSupportedException($"서비스 '{activeService}'는 아직 Function Calling을 지원하지 않습니다.")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -435,7 +444,8 @@ public partial class LlmService
|
|||||||
|
|
||||||
private async Task<List<ContentBlock>> SendOpenAiWithToolsAsync(
|
private async Task<List<ContentBlock>> SendOpenAiWithToolsAsync(
|
||||||
List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools, CancellationToken ct,
|
List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools, CancellationToken ct,
|
||||||
bool forceToolCall = false)
|
bool forceToolCall = false,
|
||||||
|
Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync = null)
|
||||||
{
|
{
|
||||||
var activeService = ResolveService();
|
var activeService = ResolveService();
|
||||||
|
|
||||||
@@ -453,7 +463,7 @@ public partial class LlmService
|
|||||||
|
|
||||||
string url;
|
string url;
|
||||||
if (isIbmDeployment)
|
if (isIbmDeployment)
|
||||||
url = BuildIbmDeploymentChatUrl(endpoint, stream: false);
|
url = BuildIbmDeploymentChatUrl(endpoint, stream: true);
|
||||||
else if (activeService.ToLowerInvariant() == "ollama")
|
else if (activeService.ToLowerInvariant() == "ollama")
|
||||||
url = endpoint.TrimEnd('/') + "/api/chat";
|
url = endpoint.TrimEnd('/') + "/api/chat";
|
||||||
else
|
else
|
||||||
@@ -467,13 +477,28 @@ public partial class LlmService
|
|||||||
// CP4D 또는 Bearer 인증 적용
|
// CP4D 또는 Bearer 인증 적용
|
||||||
await ApplyAuthHeaderAsync(req, ct);
|
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)
|
if (!resp.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var errBody = await resp.Content.ReadAsStringAsync(ct);
|
var errBody = await resp.Content.ReadAsStringAsync(ct);
|
||||||
var detail = ExtractErrorDetail(errBody);
|
var detail = ExtractErrorDetail(errBody);
|
||||||
LogService.Warn($"[ToolUse] {activeService} API 오류 ({resp.StatusCode}): {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 → 도구 없이 일반 응답으로 폴백 시도
|
// 400 BadRequest → 도구 없이 일반 응답으로 폴백 시도
|
||||||
if ((int)resp.StatusCode == 400)
|
if ((int)resp.StatusCode == 400)
|
||||||
throw new ToolCallNotSupportedException(
|
throw new ToolCallNotSupportedException(
|
||||||
@@ -482,83 +507,103 @@ public partial class LlmService
|
|||||||
throw new HttpRequestException($"{activeService} API 오류 ({resp.StatusCode}): {detail}");
|
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로 응답하는 경우)
|
/// <summary>
|
||||||
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 태그
|
||||||
|
/// </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();
|
var block = TryParseToolCallJson(m.Groups[1].Value);
|
||||||
if (!trimmedResp.StartsWith('{') && !trimmedResp.StartsWith('['))
|
if (block != null) results.Add(block);
|
||||||
throw new ToolCallNotSupportedException(
|
|
||||||
$"vLLM 응답이 JSON이 아닙니다 (도구 호출 미지원 가능성): {respJson[..Math.Min(120, respJson.Length)]}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
using var doc = JsonDocument.Parse(respJson);
|
// 패턴 2: ✿FUNCTION✿ 또는 <|tool_call|> (일부 Qwen 변형)
|
||||||
var root = doc.RootElement;
|
if (results.Count == 0)
|
||||||
|
|
||||||
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))
|
|
||||||
{
|
{
|
||||||
var text = content.GetString();
|
var fnPattern = new System.Text.RegularExpressions.Regex(
|
||||||
if (!string.IsNullOrWhiteSpace(text))
|
@"✿FUNCTION✿\s*(\w+)\s*\n\s*(\{[\s\S]*?\})\s*(?:✿|$)",
|
||||||
blocks.Add(new ContentBlock { Type = "text", Text = text });
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
}
|
foreach (System.Text.RegularExpressions.Match m in fnPattern.Matches(text))
|
||||||
|
|
||||||
// 도구 호출 (tool_calls 배열)
|
|
||||||
if (message.TryGetProperty("tool_calls", out var toolCalls))
|
|
||||||
{
|
|
||||||
foreach (var tc in toolCalls.EnumerateArray())
|
|
||||||
{
|
{
|
||||||
if (!tc.TryGetProperty("function", out var func)) continue;
|
var block = TryParseToolCallJsonWithName(m.Groups[1].Value, m.Groups[2].Value);
|
||||||
|
if (block != null) results.Add(block);
|
||||||
// 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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
private object BuildOpenAiToolBody(List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools, bool forceToolCall = false)
|
||||||
@@ -688,7 +733,7 @@ public partial class LlmService
|
|||||||
["model"] = activeModel,
|
["model"] = activeModel,
|
||||||
["messages"] = msgs,
|
["messages"] = msgs,
|
||||||
["tools"] = toolDefs,
|
["tools"] = toolDefs,
|
||||||
["stream"] = false,
|
["stream"] = true,
|
||||||
["temperature"] = ResolveToolTemperature(),
|
["temperature"] = ResolveToolTemperature(),
|
||||||
["max_tokens"] = ResolveOpenAiCompatibleMaxTokens(),
|
["max_tokens"] = ResolveOpenAiCompatibleMaxTokens(),
|
||||||
["parallel_tool_calls"] = executionPolicy.EnableParallelReadBatch,
|
["parallel_tool_calls"] = executionPolicy.EnableParallelReadBatch,
|
||||||
@@ -704,8 +749,16 @@ public partial class LlmService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>IBM 배포형 /text/chat 전용 도구 바디. parameters 래퍼 사용, model 필드 없음.</summary>
|
/// <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>();
|
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 });
|
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 필드 지원)
|
// OpenAI 호환 도구 정의 (형식 동일, watsonx에서 tools 필드 지원)
|
||||||
var toolDefs = tools.Select(t =>
|
var toolDefs = tools.Select(t =>
|
||||||
{
|
{
|
||||||
@@ -806,7 +868,7 @@ public partial class LlmService
|
|||||||
// IBM watsonx: parameters 래퍼 사용, model 필드 없음
|
// IBM watsonx: parameters 래퍼 사용, model 필드 없음
|
||||||
// tool_choice: "required" 지원 여부는 배포 버전마다 다를 수 있으므로
|
// tool_choice: "required" 지원 여부는 배포 버전마다 다를 수 있으므로
|
||||||
// forceToolCall=true일 때 추가하되, 오류 시 상위에서 ToolCallNotSupportedException으로 폴백됨
|
// forceToolCall=true일 때 추가하되, 오류 시 상위에서 ToolCallNotSupportedException으로 폴백됨
|
||||||
if (forceToolCall)
|
if (forceToolCall && useToolChoice)
|
||||||
{
|
{
|
||||||
return new
|
return new
|
||||||
{
|
{
|
||||||
@@ -815,7 +877,7 @@ public partial class LlmService
|
|||||||
tool_choice = "required",
|
tool_choice = "required",
|
||||||
parameters = new
|
parameters = new
|
||||||
{
|
{
|
||||||
temperature = ResolveTemperature(),
|
temperature = ResolveToolTemperature(),
|
||||||
max_new_tokens = ResolveOpenAiCompatibleMaxTokens()
|
max_new_tokens = ResolveOpenAiCompatibleMaxTokens()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -827,12 +889,314 @@ public partial class LlmService
|
|||||||
tools = toolDefs,
|
tools = toolDefs,
|
||||||
parameters = new
|
parameters = new
|
||||||
{
|
{
|
||||||
temperature = ResolveTemperature(),
|
temperature = ResolveToolTemperature(),
|
||||||
max_new_tokens = ResolveOpenAiCompatibleMaxTokens()
|
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>
|
/// <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; }
|
||||||
|
}
|
||||||
@@ -407,6 +407,20 @@ public class SettingsViewModel : INotifyPropertyChanged
|
|||||||
set { _enableAuditLog = value; OnPropertyChanged(); }
|
set { _enableAuditLog = value; OnPropertyChanged(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool _enableDetailedLog;
|
||||||
|
public bool EnableDetailedLog
|
||||||
|
{
|
||||||
|
get => _enableDetailedLog;
|
||||||
|
set { _enableDetailedLog = value; OnPropertyChanged(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private int _detailedLogRetentionDays;
|
||||||
|
public int DetailedLogRetentionDays
|
||||||
|
{
|
||||||
|
get => _detailedLogRetentionDays;
|
||||||
|
set { _detailedLogRetentionDays = Math.Clamp(value, 1, 30); OnPropertyChanged(); }
|
||||||
|
}
|
||||||
|
|
||||||
private bool _enableAgentMemory;
|
private bool _enableAgentMemory;
|
||||||
public bool EnableAgentMemory
|
public bool EnableAgentMemory
|
||||||
{
|
{
|
||||||
@@ -1156,6 +1170,8 @@ public class SettingsViewModel : INotifyPropertyChanged
|
|||||||
_enableFilePathHighlight = llm.EnableFilePathHighlight;
|
_enableFilePathHighlight = llm.EnableFilePathHighlight;
|
||||||
_folderDataUsage = string.IsNullOrEmpty(llm.FolderDataUsage) ? "active" : llm.FolderDataUsage;
|
_folderDataUsage = string.IsNullOrEmpty(llm.FolderDataUsage) ? "active" : llm.FolderDataUsage;
|
||||||
_enableAuditLog = llm.EnableAuditLog;
|
_enableAuditLog = llm.EnableAuditLog;
|
||||||
|
_enableDetailedLog = llm.EnableDetailedLog;
|
||||||
|
_detailedLogRetentionDays = llm.DetailedLogRetentionDays > 0 ? llm.DetailedLogRetentionDays : 3;
|
||||||
_enableAgentMemory = llm.EnableAgentMemory;
|
_enableAgentMemory = llm.EnableAgentMemory;
|
||||||
_enableProjectRules = llm.EnableProjectRules;
|
_enableProjectRules = llm.EnableProjectRules;
|
||||||
_maxMemoryEntries = llm.MaxMemoryEntries;
|
_maxMemoryEntries = llm.MaxMemoryEntries;
|
||||||
@@ -1600,6 +1616,8 @@ public class SettingsViewModel : INotifyPropertyChanged
|
|||||||
s.Llm.EnableFilePathHighlight = _enableFilePathHighlight;
|
s.Llm.EnableFilePathHighlight = _enableFilePathHighlight;
|
||||||
s.Llm.FolderDataUsage = _folderDataUsage;
|
s.Llm.FolderDataUsage = _folderDataUsage;
|
||||||
s.Llm.EnableAuditLog = _enableAuditLog;
|
s.Llm.EnableAuditLog = _enableAuditLog;
|
||||||
|
s.Llm.EnableDetailedLog = _enableDetailedLog;
|
||||||
|
s.Llm.DetailedLogRetentionDays = _detailedLogRetentionDays;
|
||||||
s.Llm.EnableAgentMemory = _enableAgentMemory;
|
s.Llm.EnableAgentMemory = _enableAgentMemory;
|
||||||
s.Llm.EnableProjectRules = _enableProjectRules;
|
s.Llm.EnableProjectRules = _enableProjectRules;
|
||||||
s.Llm.MaxMemoryEntries = _maxMemoryEntries;
|
s.Llm.MaxMemoryEntries = _maxMemoryEntries;
|
||||||
@@ -1786,6 +1804,11 @@ public class SettingsViewModel : INotifyPropertyChanged
|
|||||||
sc.CommandAliases = cmdAliases;
|
sc.CommandAliases = cmdAliases;
|
||||||
|
|
||||||
_service.Save();
|
_service.Save();
|
||||||
|
|
||||||
|
// 워크플로우 상세 로그 설정 즉시 반영
|
||||||
|
WorkflowLogService.IsEnabled = _enableDetailedLog;
|
||||||
|
WorkflowLogService.RetentionDays = _detailedLogRetentionDays > 0 ? _detailedLogRetentionDays : 3;
|
||||||
|
|
||||||
SaveCompleted?.Invoke(this, EventArgs.Empty);
|
SaveCompleted?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ namespace AxCopilot.Views;
|
|||||||
|
|
||||||
public partial class ChatWindow
|
public partial class ChatWindow
|
||||||
{
|
{
|
||||||
|
// 레이아웃 재계산 억제용 캐시: 동일한 높이면 WPF measure/arrange 생략
|
||||||
|
private double _cachedInputBoxHeight = -1;
|
||||||
|
private int _cachedInputBoxMaxLines = -1;
|
||||||
|
|
||||||
private void UpdateInputBoxHeight()
|
private void UpdateInputBoxHeight()
|
||||||
{
|
{
|
||||||
if (InputBox == null)
|
if (InputBox == null)
|
||||||
@@ -33,11 +37,19 @@ public partial class ChatWindow
|
|||||||
const double lineStep = 22;
|
const double lineStep = 22;
|
||||||
var visibleLines = Math.Clamp(explicitLineCount, 1, maxLines);
|
var visibleLines = Math.Clamp(explicitLineCount, 1, maxLines);
|
||||||
var targetHeight = baseHeight + ((visibleLines - 1) * lineStep);
|
var targetHeight = baseHeight + ((visibleLines - 1) * lineStep);
|
||||||
|
var needsScroll = explicitLineCount > maxLines;
|
||||||
|
|
||||||
|
// 값이 바뀐 경우에만 WPF 속성 쓰기 (매 키입력마다 레이아웃 통과 방지)
|
||||||
|
if (Math.Abs(targetHeight - _cachedInputBoxHeight) < 0.5 && maxLines == _cachedInputBoxMaxLines)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_cachedInputBoxHeight = targetHeight;
|
||||||
|
_cachedInputBoxMaxLines = maxLines;
|
||||||
|
|
||||||
InputBox.MinLines = 1;
|
InputBox.MinLines = 1;
|
||||||
InputBox.MaxLines = maxLines;
|
InputBox.MaxLines = maxLines;
|
||||||
InputBox.Height = targetHeight;
|
InputBox.Height = targetHeight;
|
||||||
InputBox.VerticalScrollBarVisibility = explicitLineCount > maxLines
|
InputBox.VerticalScrollBarVisibility = needsScroll
|
||||||
? ScrollBarVisibility.Auto
|
? ScrollBarVisibility.Auto
|
||||||
: ScrollBarVisibility.Disabled;
|
: ScrollBarVisibility.Disabled;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ namespace AxCopilot.Views;
|
|||||||
|
|
||||||
public partial class ChatWindow
|
public partial class ChatWindow
|
||||||
{
|
{
|
||||||
|
// 토큰 추정 캐시: 메시지 수/대화 ID가 바뀔 때만 재계산
|
||||||
|
private int _cachedMessageTokens;
|
||||||
|
private int _cachedMessageCountForTokens = -1;
|
||||||
|
private string? _cachedConvIdForTokens;
|
||||||
|
|
||||||
private void RefreshContextUsageVisual()
|
private void RefreshContextUsageVisual()
|
||||||
{
|
{
|
||||||
if (TokenUsageCard == null || TokenUsageArc == null || TokenUsagePercentText == null
|
if (TokenUsageCard == null || TokenUsageArc == null || TokenUsagePercentText == null
|
||||||
@@ -27,11 +32,22 @@ public partial class ChatWindow
|
|||||||
var triggerPercent = Math.Clamp(llm.ContextCompactTriggerPercent, 10, 95);
|
var triggerPercent = Math.Clamp(llm.ContextCompactTriggerPercent, 10, 95);
|
||||||
var triggerRatio = triggerPercent / 100.0;
|
var triggerRatio = triggerPercent / 100.0;
|
||||||
|
|
||||||
|
// 메시지 토큰 추정: 메시지 수나 대화 ID가 바뀔 때만 재계산 (타이핑 중 반복 계산 방지)
|
||||||
int messageTokens;
|
int messageTokens;
|
||||||
lock (_convLock)
|
lock (_convLock)
|
||||||
messageTokens = _currentConversation?.Messages?.Count > 0
|
{
|
||||||
? Services.TokenEstimator.EstimateMessages(_currentConversation.Messages)
|
var convId = _currentConversation?.Id;
|
||||||
: 0;
|
var msgCount = _currentConversation?.Messages?.Count ?? 0;
|
||||||
|
if (convId != _cachedConvIdForTokens || msgCount != _cachedMessageCountForTokens)
|
||||||
|
{
|
||||||
|
_cachedMessageTokens = msgCount > 0
|
||||||
|
? Services.TokenEstimator.EstimateMessages(_currentConversation!.Messages)
|
||||||
|
: 0;
|
||||||
|
_cachedConvIdForTokens = convId;
|
||||||
|
_cachedMessageCountForTokens = msgCount;
|
||||||
|
}
|
||||||
|
messageTokens = _cachedMessageTokens;
|
||||||
|
}
|
||||||
|
|
||||||
var draftText = InputBox?.Text ?? "";
|
var draftText = InputBox?.Text ?? "";
|
||||||
var draftTokens = string.IsNullOrWhiteSpace(draftText) ? 0 : Services.TokenEstimator.Estimate(draftText) + 4;
|
var draftTokens = string.IsNullOrWhiteSpace(draftText) ? 0 : Services.TokenEstimator.Estimate(draftText) + 4;
|
||||||
|
|||||||
@@ -1750,41 +1750,45 @@
|
|||||||
<!-- 주 행: 다이아몬드 아이콘 + 상태 텍스트 -->
|
<!-- 주 행: 다이아몬드 아이콘 + 상태 텍스트 -->
|
||||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
<!-- 미니 다이아몬드 아이콘 (런처 아이콘 축소판, 코드비하인드에서 애니메이션 적용) -->
|
<!-- 미니 다이아몬드 아이콘 (런처 아이콘 축소판, 코드비하인드에서 애니메이션 적용) -->
|
||||||
<Canvas x:Name="StatusDiamondIcon" Width="16" Height="16"
|
<!-- 회전 후 실제 차지 크기 ≈ 22px이므로 외부 Border로 공간 확보 + 센터링 -->
|
||||||
VerticalAlignment="Center" ClipToBounds="False"
|
<Border Width="20" Height="16" VerticalAlignment="Center" Margin="0,0,6,0"
|
||||||
Margin="0,1,8,0">
|
ClipToBounds="False" Background="Transparent">
|
||||||
<Canvas.RenderTransformOrigin>0.5,0.5</Canvas.RenderTransformOrigin>
|
<Canvas x:Name="StatusDiamondIcon" Width="16" Height="16"
|
||||||
<Canvas.RenderTransform>
|
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
<TransformGroup>
|
ClipToBounds="False">
|
||||||
<RotateTransform x:Name="StatusIconRotate" Angle="45"/>
|
<Canvas.RenderTransformOrigin>0.5,0.5</Canvas.RenderTransformOrigin>
|
||||||
<ScaleTransform x:Name="StatusIconScale" ScaleX="1" ScaleY="1"/>
|
<Canvas.RenderTransform>
|
||||||
</TransformGroup>
|
<TransformGroup>
|
||||||
</Canvas.RenderTransform>
|
<RotateTransform x:Name="StatusIconRotate" Angle="45"/>
|
||||||
<!-- 파란 픽셀 (좌상) -->
|
<ScaleTransform x:Name="StatusIconScale" ScaleX="1" ScaleY="1"/>
|
||||||
<Rectangle x:Name="StatusPixelBlue"
|
</TransformGroup>
|
||||||
Canvas.Left="0.5" Canvas.Top="0.5"
|
</Canvas.RenderTransform>
|
||||||
Width="6.5" Height="6.5"
|
<!-- 파란 픽셀 (좌상) -->
|
||||||
RadiusX="1" RadiusY="1"
|
<Rectangle x:Name="StatusPixelBlue"
|
||||||
Fill="#4488FF"/>
|
Canvas.Left="1" Canvas.Top="1"
|
||||||
<!-- 초록 픽셀 (우상) -->
|
Width="6" Height="6"
|
||||||
<Rectangle x:Name="StatusPixelGreen1"
|
RadiusX="1" RadiusY="1"
|
||||||
Canvas.Left="8.5" Canvas.Top="0.5"
|
Fill="#4488FF"/>
|
||||||
Width="6.5" Height="6.5"
|
<!-- 초록 픽셀 (우상) -->
|
||||||
RadiusX="1" RadiusY="1"
|
<Rectangle x:Name="StatusPixelGreen1"
|
||||||
Fill="#44DD66"/>
|
Canvas.Left="9" Canvas.Top="1"
|
||||||
<!-- 초록 픽셀 (좌하) -->
|
Width="6" Height="6"
|
||||||
<Rectangle x:Name="StatusPixelGreen2"
|
RadiusX="1" RadiusY="1"
|
||||||
Canvas.Left="0.5" Canvas.Top="8.5"
|
Fill="#44DD66"/>
|
||||||
Width="6.5" Height="6.5"
|
<!-- 초록 픽셀 (좌하) -->
|
||||||
RadiusX="1" RadiusY="1"
|
<Rectangle x:Name="StatusPixelGreen2"
|
||||||
Fill="#44DD66"/>
|
Canvas.Left="1" Canvas.Top="9"
|
||||||
<!-- 빨간 픽셀 (우하) -->
|
Width="6" Height="6"
|
||||||
<Rectangle x:Name="StatusPixelRed"
|
RadiusX="1" RadiusY="1"
|
||||||
Canvas.Left="8.5" Canvas.Top="8.5"
|
Fill="#44DD66"/>
|
||||||
Width="6.5" Height="6.5"
|
<!-- 빨간 픽셀 (우하) -->
|
||||||
RadiusX="1" RadiusY="1"
|
<Rectangle x:Name="StatusPixelRed"
|
||||||
Fill="#FF4466"/>
|
Canvas.Left="9" Canvas.Top="9"
|
||||||
</Canvas>
|
Width="6" Height="6"
|
||||||
|
RadiusX="1" RadiusY="1"
|
||||||
|
Fill="#FF4466"/>
|
||||||
|
</Canvas>
|
||||||
|
</Border>
|
||||||
<!-- MDL2 아이콘 (호환성 유지, 숨김) -->
|
<!-- MDL2 아이콘 (호환성 유지, 숨김) -->
|
||||||
<TextBlock x:Name="PulseDotStatusIcon"
|
<TextBlock x:Name="PulseDotStatusIcon"
|
||||||
FontFamily="Segoe MDL2 Assets"
|
FontFamily="Segoe MDL2 Assets"
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ public partial class ChatWindow : Window
|
|||||||
_conversationSearchTimer.Stop();
|
_conversationSearchTimer.Stop();
|
||||||
RefreshConversationList();
|
RefreshConversationList();
|
||||||
};
|
};
|
||||||
_inputUiRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(120) };
|
_inputUiRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
|
||||||
_inputUiRefreshTimer.Tick += (_, _) =>
|
_inputUiRefreshTimer.Tick += (_, _) =>
|
||||||
{
|
{
|
||||||
_inputUiRefreshTimer.Stop();
|
_inputUiRefreshTimer.Stop();
|
||||||
@@ -6016,45 +6016,51 @@ public partial class ChatWindow : Window
|
|||||||
var workFolder = GetCurrentWorkFolder();
|
var workFolder = GetCurrentWorkFolder();
|
||||||
var llm = _settings.Settings.Llm;
|
var llm = _settings.Settings.Llm;
|
||||||
var sb = new System.Text.StringBuilder();
|
var sb = new System.Text.StringBuilder();
|
||||||
|
// ══════════════════════════════════════════════════════════════════
|
||||||
|
// 도구 호출 절대 규칙을 시스템 프롬프트 맨 첫 줄에 배치 — 최대 강제력
|
||||||
|
// ══════════════════════════════════════════════════════════════════
|
||||||
|
sb.AppendLine("## [절대 규칙] 도구 호출 필수 — TOOL CALLING IS MANDATORY (HIGHEST PRIORITY)");
|
||||||
|
sb.AppendLine("이 규칙들은 다른 모든 지시보다 우선합니다. 위반 시 작업이 즉시 실패합니다.");
|
||||||
|
sb.AppendLine("These rules override ALL other instructions. Violating them causes immediate task failure.");
|
||||||
|
sb.AppendLine("");
|
||||||
|
sb.AppendLine("### [규칙 1] 반드시 도구를 호출하라 — Tools First, Always");
|
||||||
|
sb.AppendLine("모든 응답에는 반드시 하나 이상의 도구 호출이 포함되어야 합니다.");
|
||||||
|
sb.AppendLine("텍스트만 있고 도구 호출이 없는 응답은 무효이며 즉시 거부됩니다.");
|
||||||
|
sb.AppendLine("Every single response MUST include at least one tool call.");
|
||||||
|
sb.AppendLine("A text-only response with NO tool call is INVALID — it will be rejected and you will be asked again.");
|
||||||
|
sb.AppendLine("출력의 첫 번째 항목은 반드시 도구 호출이어야 합니다. 텍스트로 시작하는 것은 금지입니다.");
|
||||||
|
sb.AppendLine("The FIRST item in your output MUST be a tool call. Starting with text is forbidden.");
|
||||||
|
sb.AppendLine("");
|
||||||
|
sb.AppendLine("### [규칙 2] 말 먼저 하지 마라 — No Verbal Preamble");
|
||||||
|
sb.AppendLine("절대 금지 문구 — 도구 호출 전에 절대 쓰지 말 것:");
|
||||||
|
sb.AppendLine(" ✗ '알겠습니다' / '네' / '확인했습니다' / '작업을 시작하겠습니다' / '먼저 ... 하겠습니다'");
|
||||||
|
sb.AppendLine(" ✗ '다음과 같이 진행하겠습니다' / '분석해보겠습니다' / '살펴보겠습니다'");
|
||||||
|
sb.AppendLine(" ✗ 'I will ...' / 'Let me ...' / 'Sure!' / 'Of course!' / 'I'll now ...' / 'First, I need to ...'");
|
||||||
|
sb.AppendLine("올바른 행동: 즉시 도구를 호출하세요. 간단한 설명은 도구 결과 이후에만 허용됩니다.");
|
||||||
|
sb.AppendLine("CORRECT: call the tool immediately. A brief explanation may follow AFTER the tool result, never before.");
|
||||||
|
sb.AppendLine("");
|
||||||
|
sb.AppendLine("### [규칙 3] 한 번에 여러 도구를 호출하라 — Batch Multiple Tools");
|
||||||
|
sb.AppendLine("독립적인 작업들은 반드시 같은 응답에서 동시에 호출해야 합니다. 순차적으로 하지 마세요.");
|
||||||
|
sb.AppendLine("You MUST call multiple tools in a single response when tasks are independent. Never do them one by one.");
|
||||||
|
sb.AppendLine(" 나쁜 예(BAD): 응답1: folder_map → 응답2: document_read → 응답3: html_create (순차 처리 — 금지)");
|
||||||
|
sb.AppendLine(" 좋은 예(GOOD): 응답1: folder_map + document_read 동시 호출 → 응답2: html_create (배치 처리)");
|
||||||
|
sb.AppendLine("");
|
||||||
|
sb.AppendLine("### [규칙 4] 불확실해도 즉시 도구를 호출하라 — Act on Uncertainty");
|
||||||
|
sb.AppendLine("어떤 파일을 읽어야 할지 모를 때도 사용자에게 묻지 말고 즉시 folder_map이나 document_read를 호출하세요.");
|
||||||
|
sb.AppendLine("If unsure which file to read: call folder_map or document_read NOW. Do NOT ask the user.");
|
||||||
|
sb.AppendLine("");
|
||||||
|
sb.AppendLine("### [규칙 5] 완료까지 계속 도구를 호출하라 — Continue Until Complete");
|
||||||
|
sb.AppendLine("사용자의 요청이 완전히 완료될 때까지 도구 호출을 계속하세요. '작업을 시작했습니다'는 완료가 아닙니다.");
|
||||||
|
sb.AppendLine("'완료'의 정의: 결과 파일이 실제로 존재하고, 검증되고, 사용자에게 보고된 상태.");
|
||||||
|
sb.AppendLine("Keep calling tools until fully done. 'Done' = output file exists + verified + reported to user.");
|
||||||
|
sb.AppendLine("");
|
||||||
|
|
||||||
|
// 소개 및 메타 정보
|
||||||
|
sb.AppendLine("---");
|
||||||
sb.AppendLine("You are AX Copilot Agent. You can read, write, and edit files using the provided tools.");
|
sb.AppendLine("You are AX Copilot Agent. You can read, write, and edit files using the provided tools.");
|
||||||
sb.AppendLine($"Today's date: {DateTime.Now:yyyy년 M월 d일} ({DateTime.Now:yyyy-MM-dd}, {DateTime.Now:dddd}).");
|
sb.AppendLine($"Today's date: {DateTime.Now:yyyy년 M월 d일} ({DateTime.Now:yyyy-MM-dd}, {DateTime.Now:dddd}).");
|
||||||
sb.AppendLine("Available skills: excel_create (.xlsx), docx_create (.docx), csv_create (.csv), markdown_create (.md), html_create (.html), script_create (.bat/.ps1), document_review (품질 검증), format_convert (포맷 변환).");
|
sb.AppendLine("Available skills: excel_create (.xlsx), docx_create (.docx), csv_create (.csv), markdown_create (.md), html_create (.html), script_create (.bat/.ps1), document_review (품질 검증), format_convert (포맷 변환).");
|
||||||
|
|
||||||
sb.AppendLine("\n## TOOL CALLING RULES — ABSOLUTE MANDATORY");
|
|
||||||
sb.AppendLine("These rules override everything else. Violating them causes task failure.");
|
|
||||||
sb.AppendLine("");
|
|
||||||
sb.AppendLine("### Rule 1 — Tools First, Always");
|
|
||||||
sb.AppendLine("Every single response MUST contain at least one tool call.");
|
|
||||||
sb.AppendLine("A response with text but NO tool call is INVALID and will be rejected.");
|
|
||||||
sb.AppendLine("The FIRST thing in your output must be a tool call, not words.");
|
|
||||||
sb.AppendLine("");
|
|
||||||
sb.AppendLine("### Rule 2 — No Verbal Preamble");
|
|
||||||
sb.AppendLine("FORBIDDEN opening phrases (never write these before calling a tool):");
|
|
||||||
sb.AppendLine(" ✗ '알겠습니다, ...' / '네, ...' / '확인했습니다.' / '작업을 시작하겠습니다.'");
|
|
||||||
sb.AppendLine(" ✗ 'I will ...' / 'Let me ...' / 'Sure!' / 'Of course!' / 'I'll now ...'");
|
|
||||||
sb.AppendLine(" ✗ '먼저 ... 하겠습니다.' / '다음과 같이 진행하겠습니다.'");
|
|
||||||
sb.AppendLine("CORRECT behavior: call the tool immediately. Brief explanation goes AFTER the tool result.");
|
|
||||||
sb.AppendLine("");
|
|
||||||
sb.AppendLine("### Rule 3 — Batch Multiple Tools in One Response");
|
|
||||||
sb.AppendLine("You are allowed to call MULTIPLE tools in a single response.");
|
|
||||||
sb.AppendLine("When tasks are independent, call them all at once — do NOT do them one by one across separate responses.");
|
|
||||||
sb.AppendLine("Example: creating a document requires reading context + creating file → call both in ONE response.");
|
|
||||||
sb.AppendLine("Example: reviewing a document requires reading it + running checks → call both in ONE response.");
|
|
||||||
sb.AppendLine("");
|
|
||||||
sb.AppendLine("### Rule 4 — Act on Uncertainty");
|
|
||||||
sb.AppendLine("If you are unsure which file to read or which tool to use, DO NOT ask. Call the most likely tool immediately.");
|
|
||||||
sb.AppendLine("Use folder_map or document_read to orient yourself if needed — but call them now, not 'in the next step'.");
|
|
||||||
sb.AppendLine("");
|
|
||||||
sb.AppendLine("### Rule 5 — Continue Until Complete");
|
|
||||||
sb.AppendLine("Keep calling tools until the user's request is fully and verifiably done.");
|
|
||||||
sb.AppendLine("'Done' means: the output file exists, has been verified, and the result has been reported. NOT just 'I started working'.");
|
|
||||||
sb.AppendLine("");
|
|
||||||
sb.AppendLine("### Bad vs. Good Response Examples");
|
|
||||||
sb.AppendLine("BAD (text only — REJECTED): '보고서를 작성하겠습니다. 먼저 요구사항을 분석하고...'");
|
|
||||||
sb.AppendLine("GOOD (tool call first): [call html_create with full content] → then one-line summary");
|
|
||||||
sb.AppendLine("BAD (sequential one-by-one): Response 1: folder_map / Response 2: document_read / Response 3: create...");
|
|
||||||
sb.AppendLine("GOOD (batched): Response 1: folder_map + document_read simultaneously / Response 2: create file");
|
|
||||||
|
|
||||||
sb.AppendLine("\nOnly present a step-by-step plan when the user explicitly asks for a plan or when the task is too ambiguous to execute safely.");
|
sb.AppendLine("\nOnly present a step-by-step plan when the user explicitly asks for a plan or when the task is too ambiguous to execute safely.");
|
||||||
sb.AppendLine("For ordinary Cowork requests, proceed directly with the work and focus on producing the real artifact.");
|
sb.AppendLine("For ordinary Cowork requests, proceed directly with the work and focus on producing the real artifact.");
|
||||||
sb.AppendLine("After creating files, summarize what was created and include the actual output path.");
|
sb.AppendLine("After creating files, summarize what was created and include the actual output path.");
|
||||||
@@ -6160,49 +6166,61 @@ public partial class ChatWindow : Window
|
|||||||
var code = llm.Code;
|
var code = llm.Code;
|
||||||
var sb = new System.Text.StringBuilder();
|
var sb = new System.Text.StringBuilder();
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════
|
||||||
|
// 도구 호출 절대 규칙을 시스템 프롬프트 맨 첫 줄에 배치 — 최대 강제력
|
||||||
|
// ══════════════════════════════════════════════════════════════════
|
||||||
|
sb.AppendLine("## [절대 규칙] 도구 호출 필수 — TOOL CALLING IS MANDATORY (HIGHEST PRIORITY)");
|
||||||
|
sb.AppendLine("이 규칙들은 다른 모든 지시보다 우선합니다. 위반 시 작업이 즉시 실패합니다.");
|
||||||
|
sb.AppendLine("These rules override ALL other instructions. Violating them causes immediate task failure.");
|
||||||
|
sb.AppendLine("");
|
||||||
|
sb.AppendLine("### [규칙 1] 반드시 도구를 호출하라 — Tools First, Always");
|
||||||
|
sb.AppendLine("모든 응답에는 반드시 하나 이상의 도구 호출이 포함되어야 합니다.");
|
||||||
|
sb.AppendLine("텍스트만 있고 도구 호출이 없는 응답은 무효이며 즉시 거부됩니다.");
|
||||||
|
sb.AppendLine("Every single response MUST include at least one tool call.");
|
||||||
|
sb.AppendLine("A text-only response with NO tool call is INVALID — it will be rejected and you will be asked again.");
|
||||||
|
sb.AppendLine("출력의 첫 번째 항목은 반드시 도구 호출이어야 합니다. 텍스트로 시작하는 것은 금지입니다.");
|
||||||
|
sb.AppendLine("The FIRST item in your output MUST be a tool call. Starting with text is forbidden.");
|
||||||
|
sb.AppendLine("");
|
||||||
|
sb.AppendLine("### [규칙 2] 말 먼저 하지 마라 — No Verbal Preamble");
|
||||||
|
sb.AppendLine("절대 금지 문구 — 도구 호출 전에 절대 쓰지 말 것:");
|
||||||
|
sb.AppendLine(" ✗ '알겠습니다' / '네' / '확인했습니다' / '분석해보겠습니다' / '살펴보겠습니다'");
|
||||||
|
sb.AppendLine(" ✗ '먼저 폴더 구조를 파악하겠습니다' — folder_map을 즉시 호출하세요, 예고하지 마세요");
|
||||||
|
sb.AppendLine(" ✗ 'I will ...' / 'Let me ...' / 'Sure!' / 'I'll start by ...' / 'First, I need to ...'");
|
||||||
|
sb.AppendLine("올바른 행동: 즉시 도구를 호출하세요. 한 문장 설명은 도구 결과 이후에만 허용됩니다.");
|
||||||
|
sb.AppendLine("CORRECT: call the tool immediately. One-sentence explanation may follow the tool result, never precede it.");
|
||||||
|
sb.AppendLine("");
|
||||||
|
sb.AppendLine("### [규칙 3] 모든 독립 작업은 병렬로 호출하라 — Parallelise Everything Possible");
|
||||||
|
sb.AppendLine("독립적인 읽기/검색 작업들은 반드시 같은 응답에서 동시에 호출해야 합니다. 절대 순차적으로 하지 마세요.");
|
||||||
|
sb.AppendLine("You MUST call multiple tools in a single response for independent operations. Do this aggressively.");
|
||||||
|
sb.AppendLine(" 나쁜 예(BAD): R1: folder_map → R2: grep → R3: file_read → R4: file_edit (순차 — 금지)");
|
||||||
|
sb.AppendLine(" 좋은 예(GOOD): R1: folder_map + grep + file_read 동시 호출 → R2: file_edit + build_run");
|
||||||
|
sb.AppendLine("모든 독립적인 읽기/검색 작업은 같은 응답에 배치해야 합니다.");
|
||||||
|
sb.AppendLine("Every independent read/search operation MUST be batched into the same response.");
|
||||||
|
sb.AppendLine("");
|
||||||
|
sb.AppendLine("### [규칙 4] 불확실해도 즉시 도구를 호출하라 — Act on Uncertainty, Do Not Ask");
|
||||||
|
sb.AppendLine("어떤 파일을 읽어야 할지 모를 때: 즉시 glob이나 grep을 호출하세요.");
|
||||||
|
sb.AppendLine("의존성이 불확실할 때: 즉시 dev_env_detect를 호출하세요.");
|
||||||
|
sb.AppendLine("If unsure which file: call glob or grep RIGHT NOW. If unsure about deps: call dev_env_detect RIGHT NOW.");
|
||||||
|
sb.AppendLine("'잘 모르겠습니다, 알려주시겠어요?' — 도구 호출로 답을 구할 수 있다면 절대 사용자에게 묻지 마세요.");
|
||||||
|
sb.AppendLine("Never say 'I'm not sure, could you clarify?' when a tool call would answer the question.");
|
||||||
|
sb.AppendLine("");
|
||||||
|
sb.AppendLine("### [규칙 5] 도구로 검증하라, 가정하지 마라 — Verify with Tools, Not Assumptions");
|
||||||
|
sb.AppendLine("편집 후에는 반드시 file_read로 최종 상태를 확인하세요. 편집이 성공했다고 가정하지 마세요.");
|
||||||
|
sb.AppendLine("빌드/테스트 후에는 build_run의 실제 출력을 인용하세요. '정상 작동할 것입니다'라고 말하지 마세요.");
|
||||||
|
sb.AppendLine("After editing: call file_read to confirm. Do not assume the edit succeeded.");
|
||||||
|
sb.AppendLine("After build/test: cite actual build_run output. Do not say 'it should work'.");
|
||||||
|
sb.AppendLine("'완료'의 정의: 빌드 통과 + 테스트 통과 + 편집 파일 재확인 + 결과 보고 — 모두 도구 출력으로 증명된 상태.");
|
||||||
|
sb.AppendLine("'Done' = build passes + tests pass + edited files re-read + result reported — all proven by tool output.");
|
||||||
|
sb.AppendLine("");
|
||||||
|
|
||||||
|
// 소개 및 메타 정보
|
||||||
|
sb.AppendLine("---");
|
||||||
sb.AppendLine("You are AX Copilot Code Agent — a senior software engineer for enterprise development.");
|
sb.AppendLine("You are AX Copilot Code Agent — a senior software engineer for enterprise development.");
|
||||||
sb.AppendLine($"Today's date: {DateTime.Now:yyyy년 M월 d일} ({DateTime.Now:yyyy-MM-dd}).");
|
sb.AppendLine($"Today's date: {DateTime.Now:yyyy년 M월 d일} ({DateTime.Now:yyyy-MM-dd}).");
|
||||||
sb.AppendLine("Available tools: file_read, file_write, file_edit (supports replace_all), glob, grep (supports context_lines, case_sensitive), folder_map, process, dev_env_detect, build_run, git_tool.");
|
sb.AppendLine("Available tools: file_read, file_write, file_edit (supports replace_all), glob, grep (supports context_lines, case_sensitive), folder_map, process, dev_env_detect, build_run, git_tool.");
|
||||||
sb.AppendLine("Do not pause after partial progress. Keep executing consecutive steps until completion or a concrete blocker is reached.");
|
sb.AppendLine("Do not pause after partial progress. Keep executing consecutive steps until completion or a concrete blocker is reached.");
|
||||||
sb.AppendLine("IMPORTANT: When creating documents with dates, always use today's actual date above.");
|
sb.AppendLine("IMPORTANT: When creating documents with dates, always use today's actual date above.");
|
||||||
|
|
||||||
sb.AppendLine("\n## TOOL CALLING RULES — ABSOLUTE MANDATORY");
|
|
||||||
sb.AppendLine("These rules override everything else. Violating them causes task failure.");
|
|
||||||
sb.AppendLine("");
|
|
||||||
sb.AppendLine("### Rule 1 — Tools First, Always");
|
|
||||||
sb.AppendLine("Every single response MUST contain at least one tool call.");
|
|
||||||
sb.AppendLine("A response with text but NO tool call is INVALID and will be rejected.");
|
|
||||||
sb.AppendLine("The FIRST thing in your output must be a tool call, not words.");
|
|
||||||
sb.AppendLine("");
|
|
||||||
sb.AppendLine("### Rule 2 — No Verbal Preamble");
|
|
||||||
sb.AppendLine("FORBIDDEN opening phrases (never write these before calling a tool):");
|
|
||||||
sb.AppendLine(" ✗ '알겠습니다' / '네' / '확인했습니다' / '분석해보겠습니다' / '살펴보겠습니다'");
|
|
||||||
sb.AppendLine(" ✗ 'I will ...' / 'Let me ...' / 'Sure!' / 'I'll start by ...' / 'First, I need to ...'");
|
|
||||||
sb.AppendLine(" ✗ '먼저 폴더 구조를 파악하겠습니다' — just call folder_map, don't announce it");
|
|
||||||
sb.AppendLine("CORRECT: call the tool immediately. One-sentence explanation may follow the tool result, not precede it.");
|
|
||||||
sb.AppendLine("");
|
|
||||||
sb.AppendLine("### Rule 3 — Parallelise Everything Possible");
|
|
||||||
sb.AppendLine("You are allowed to call MULTIPLE tools in a single response. Do this aggressively.");
|
|
||||||
sb.AppendLine("BAD (sequential): R1: folder_map → R2: grep → R3: file_read → R4: file_edit");
|
|
||||||
sb.AppendLine("GOOD (parallel): R1: folder_map + grep + file_read simultaneously → R2: file_edit + build_run");
|
|
||||||
sb.AppendLine("Every independent read/search operation must be batched into the same response.");
|
|
||||||
sb.AppendLine("");
|
|
||||||
sb.AppendLine("### Rule 4 — Act on Uncertainty, Do Not Ask");
|
|
||||||
sb.AppendLine("If you are unsure which file to read: call glob or grep to find it — right now.");
|
|
||||||
sb.AppendLine("If you are unsure about dependencies: call dev_env_detect — right now.");
|
|
||||||
sb.AppendLine("Never say 'I'm not sure, could you clarify?' when a tool call would answer the question.");
|
|
||||||
sb.AppendLine("");
|
|
||||||
sb.AppendLine("### Rule 5 — Verify with Tools, Not Assumptions");
|
|
||||||
sb.AppendLine("After editing, call file_read to confirm the final state. Do not assume the edit succeeded.");
|
|
||||||
sb.AppendLine("After build/test, cite the actual output from build_run. Do not say 'it should work'.");
|
|
||||||
sb.AppendLine("'Done' means: build passes, tests pass, edited files re-read, result reported — all proven by tool output.");
|
|
||||||
sb.AppendLine("");
|
|
||||||
sb.AppendLine("### Bad vs. Good Response Examples");
|
|
||||||
sb.AppendLine("BAD: '먼저 폴더 구조를 살펴보겠습니다. 그 다음 관련 파일을 찾아서...' (text only)");
|
|
||||||
sb.AppendLine("GOOD: [folder_map depth=2] + [grep pattern='TargetClass'] simultaneously → analyze results → next step");
|
|
||||||
sb.AppendLine("BAD: '수정이 완료되었습니다.' (no verification)");
|
|
||||||
sb.AppendLine("GOOD: [file_read path='...'] to confirm edit → [build_run action='build'] to verify → report pass/fail");
|
|
||||||
|
|
||||||
sb.AppendLine("\n## Core Workflow (MANDATORY — follow this order)");
|
sb.AppendLine("\n## Core Workflow (MANDATORY — follow this order)");
|
||||||
sb.AppendLine("1. ORIENT: Run folder_map (depth=2) to understand project structure. Check .gitignore, README, config files.");
|
sb.AppendLine("1. ORIENT: Run folder_map (depth=2) to understand project structure. Check .gitignore, README, config files.");
|
||||||
sb.AppendLine("2. BASELINE: If tests exist, run build_run action='test' FIRST to establish baseline. Record pass/fail count.");
|
sb.AppendLine("2. BASELINE: If tests exist, run build_run action='test' FIRST to establish baseline. Record pass/fail count.");
|
||||||
@@ -6939,16 +6957,15 @@ public partial class ChatWindow : Window
|
|||||||
|
|
||||||
var sb = new System.Windows.Media.Animation.Storyboard();
|
var sb = new System.Windows.Media.Animation.Storyboard();
|
||||||
|
|
||||||
// 심장 박동 스케일 펄스 (두 번 박동 후 휴지)
|
// 심장 박동 스케일 펄스 (부드러운 단일 박동 — 레이아웃 부하 경감)
|
||||||
var scaleAnimX = new System.Windows.Media.Animation.DoubleAnimationUsingKeyFrames
|
var scaleAnimX = new System.Windows.Media.Animation.DoubleAnimationUsingKeyFrames
|
||||||
{
|
{
|
||||||
RepeatBehavior = System.Windows.Media.Animation.RepeatBehavior.Forever,
|
RepeatBehavior = System.Windows.Media.Animation.RepeatBehavior.Forever,
|
||||||
Duration = new System.Windows.Duration(TimeSpan.FromSeconds(2.6)),
|
Duration = new System.Windows.Duration(TimeSpan.FromSeconds(3.0)),
|
||||||
};
|
};
|
||||||
var beatTimes = new (double t, double v)[]
|
var beatTimes = new (double t, double v)[]
|
||||||
{
|
{
|
||||||
(0.00, 1.00), (0.12, 1.28), (0.27, 0.96),
|
(0.00, 1.00), (0.18, 1.15), (0.40, 1.00), (3.00, 1.00),
|
||||||
(0.40, 1.14), (0.55, 1.00), (2.60, 1.00),
|
|
||||||
};
|
};
|
||||||
foreach (var (t, v) in beatTimes)
|
foreach (var (t, v) in beatTimes)
|
||||||
{
|
{
|
||||||
@@ -6968,19 +6985,19 @@ public partial class ChatWindow : Window
|
|||||||
System.Windows.Media.Animation.Storyboard.SetTargetProperty(scaleAnimY, new PropertyPath("ScaleY"));
|
System.Windows.Media.Animation.Storyboard.SetTargetProperty(scaleAnimY, new PropertyPath("ScaleY"));
|
||||||
sb.Children.Add(scaleAnimY);
|
sb.Children.Add(scaleAnimY);
|
||||||
|
|
||||||
// 픽셀 교차 페이드 (파란→초록→빨간 색상이 교대로 빛남)
|
// 픽셀 교차 페이드 (파란→초록→빨간 색상이 교대로 빛남, 부드럽게)
|
||||||
var pixels = new System.Windows.Shapes.Rectangle?[]
|
var pixels = new System.Windows.Shapes.Rectangle?[]
|
||||||
{ StatusPixelBlue, StatusPixelGreen1, StatusPixelGreen2, StatusPixelRed };
|
{ StatusPixelBlue, StatusPixelGreen1, StatusPixelGreen2, StatusPixelRed };
|
||||||
for (int i = 0; i < pixels.Length; i++)
|
for (int i = 0; i < pixels.Length; i++)
|
||||||
{
|
{
|
||||||
if (pixels[i] == null) continue;
|
if (pixels[i] == null) continue;
|
||||||
var fade = new System.Windows.Media.Animation.DoubleAnimation(
|
var fade = new System.Windows.Media.Animation.DoubleAnimation(
|
||||||
fromValue: 1.0, toValue: 0.45,
|
fromValue: 1.0, toValue: 0.55,
|
||||||
duration: new System.Windows.Duration(TimeSpan.FromSeconds(0.85)))
|
duration: new System.Windows.Duration(TimeSpan.FromSeconds(1.2)))
|
||||||
{
|
{
|
||||||
AutoReverse = true,
|
AutoReverse = true,
|
||||||
RepeatBehavior = System.Windows.Media.Animation.RepeatBehavior.Forever,
|
RepeatBehavior = System.Windows.Media.Animation.RepeatBehavior.Forever,
|
||||||
BeginTime = TimeSpan.FromSeconds(i * 0.22),
|
BeginTime = TimeSpan.FromSeconds(i * 0.30),
|
||||||
EasingFunction = new System.Windows.Media.Animation.SineEase
|
EasingFunction = new System.Windows.Media.Animation.SineEase
|
||||||
{ EasingMode = System.Windows.Media.Animation.EasingMode.EaseInOut },
|
{ EasingMode = System.Windows.Media.Animation.EasingMode.EaseInOut },
|
||||||
};
|
};
|
||||||
@@ -8925,6 +8942,12 @@ public partial class ChatWindow : Window
|
|||||||
activeCts.Cancel();
|
activeCts.Cancel();
|
||||||
else
|
else
|
||||||
foreach (var cts in _tabStreamCts.Values) cts.Cancel();
|
foreach (var cts in _tabStreamCts.Values) cts.Cancel();
|
||||||
|
|
||||||
|
// 즉시 UI 상태 정리 — 에이전트 루프의 finally가 비동기로 도달할 때까지 대기하지 않음
|
||||||
|
StopLiveAgentProgressHints();
|
||||||
|
RemoveAgentLiveCard();
|
||||||
|
HideStickyProgress();
|
||||||
|
StopRainbowGlow();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 대화 내보내기 ──────────────────────────────────────────────────
|
// ─── 대화 내보내기 ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -5575,6 +5575,67 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<Border Style="{StaticResource SettingsRow}">
|
||||||
|
<Grid>
|
||||||
|
<StackPanel HorizontalAlignment="Left" Margin="0,0,60,0">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<TextBlock Style="{StaticResource RowLabel}" Text="상세 워크플로우 로그"/>
|
||||||
|
<Border Width="16" Height="16" CornerRadius="8" Background="{DynamicResource ItemHoverBackground}" Margin="6,0,0,0" Cursor="Help" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="?" FontSize="10" FontWeight="Bold" Foreground="{DynamicResource AccentColor}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
<Border.ToolTip>
|
||||||
|
<ToolTip Style="{StaticResource HelpTooltipStyle}">
|
||||||
|
<TextBlock TextWrapping="Wrap" Foreground="White" FontSize="12" LineHeight="18">
|
||||||
|
LLM 요청/응답, 도구 호출/결과 등 에이전트 실행의 전체 이력을 상세하게 기록합니다.
|
||||||
|
<LineBreak/>워크플로우 분석기와 함께 사용하면 디버깅에 유용합니다.
|
||||||
|
<LineBreak/>보관 기간이 지난 로그는 자동으로 삭제됩니다.
|
||||||
|
</TextBlock>
|
||||||
|
</ToolTip>
|
||||||
|
</Border.ToolTip>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Style="{StaticResource RowHint}" Text="에이전트 워크플로우 상세 이력을 기록합니다. 디버깅 및 도구 미호출 문제 분석에 활용됩니다."/>
|
||||||
|
</StackPanel>
|
||||||
|
<CheckBox Style="{StaticResource ToggleSwitch}" HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||||
|
IsChecked="{Binding EnableDetailedLog, Mode=TwoWay}"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
<Border Style="{StaticResource SettingsRow}">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="80"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Grid.Column="0" VerticalAlignment="Center">
|
||||||
|
<TextBlock Style="{StaticResource RowLabel}" Text="상세 로그 보관 기간"/>
|
||||||
|
<TextBlock Style="{StaticResource RowHint}" Text="이 기간이 지난 상세 로그는 자동으로 삭제됩니다. (1~30일)"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
|
||||||
|
<TextBox Width="45" Height="28" TextAlignment="Center" FontSize="12"
|
||||||
|
VerticalContentAlignment="Center"
|
||||||
|
BorderBrush="{DynamicResource BorderColor}"
|
||||||
|
Foreground="{DynamicResource PrimaryText}"
|
||||||
|
Background="{DynamicResource ComposerBackground}"
|
||||||
|
Text="{Binding DetailedLogRetentionDays, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/>
|
||||||
|
<TextBlock Text="일" VerticalAlignment="Center" Margin="6,0,0,0"
|
||||||
|
Foreground="{DynamicResource SecondaryText}" FontSize="12"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
<Border Style="{StaticResource SettingsRow}">
|
||||||
|
<Grid>
|
||||||
|
<StackPanel HorizontalAlignment="Left" Margin="0,0,60,0">
|
||||||
|
<TextBlock Style="{StaticResource RowLabel}" Text="상세 로그 보기"/>
|
||||||
|
<TextBlock Style="{StaticResource RowHint}" Text="워크플로우 상세 로그가 저장된 폴더를 엽니다."/>
|
||||||
|
</StackPanel>
|
||||||
|
<Button HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||||
|
Click="BtnOpenWorkflowLog_Click" Padding="12,6" Cursor="Hand"
|
||||||
|
Background="Transparent" BorderBrush="{DynamicResource BorderColor}"
|
||||||
|
BorderThickness="1" Foreground="{DynamicResource PrimaryText}">
|
||||||
|
<TextBlock Text="폴더 열기" FontSize="12"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- ── 에이전트 고급 ── -->
|
<!-- ── 에이전트 고급 ── -->
|
||||||
<TextBlock Style="{StaticResource SectionHeader}" Text="에이전트 고급"/>
|
<TextBlock Style="{StaticResource SectionHeader}" Text="에이전트 고급"/>
|
||||||
<Border Style="{StaticResource SettingsRow}">
|
<Border Style="{StaticResource SettingsRow}">
|
||||||
|
|||||||
@@ -3694,6 +3694,12 @@ public partial class SettingsWindow : Window
|
|||||||
try { System.Diagnostics.Process.Start("explorer.exe", Services.AuditLogService.GetAuditFolder()); } catch { }
|
try { System.Diagnostics.Process.Start("explorer.exe", Services.AuditLogService.GetAuditFolder()); } catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 워크플로우 상세 로그 폴더 열기 ───────────────────────────────────
|
||||||
|
private void BtnOpenWorkflowLog_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
try { System.Diagnostics.Process.Start("explorer.exe", Services.WorkflowLogService.GetLogFolder()); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
// ─── 폴백/MCP 텍스트 박스 로드/저장 ───────────────────────────────────
|
// ─── 폴백/MCP 텍스트 박스 로드/저장 ───────────────────────────────────
|
||||||
private void BuildFallbackModelsPanel()
|
private void BuildFallbackModelsPanel()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user