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
453 lines
17 KiB
C#
453 lines
17 KiB
C#
using AxCopilot.Models;
|
|
using AxCopilot.Services;
|
|
|
|
namespace AxCopilot.Services.Agent;
|
|
|
|
/// <summary>
|
|
/// AX Agent execution-prep engine.
|
|
/// Inspired by the `claw-code` split between input preparation and session execution,
|
|
/// so the UI layer stops owning message assembly and final assistant commit logic.
|
|
/// </summary>
|
|
public sealed class AxAgentExecutionEngine
|
|
{
|
|
public sealed record PreparedTurn(List<ChatMessage> Messages);
|
|
public sealed record ExecutionMode(bool UseAgentLoop, bool UseStreamingTransport, string? TaskSystemPrompt);
|
|
public sealed record PreparedExecution(
|
|
ExecutionMode Mode,
|
|
IReadOnlyList<string> PromptStack,
|
|
List<ChatMessage> Messages);
|
|
public sealed record FinalizedContent(string Content, bool Cancelled, string? FailureReason);
|
|
public sealed record SessionMutationResult(
|
|
ChatConversation CurrentConversation,
|
|
ChatConversation UpdatedConversation);
|
|
|
|
public IReadOnlyList<string> BuildPromptStack(
|
|
string? conversationSystem,
|
|
string? slashSystem,
|
|
string? taskSystem = null)
|
|
{
|
|
var prompts = new List<string>();
|
|
if (!string.IsNullOrWhiteSpace(conversationSystem))
|
|
prompts.Add(conversationSystem.Trim());
|
|
if (!string.IsNullOrWhiteSpace(slashSystem))
|
|
prompts.Add(slashSystem.Trim());
|
|
if (!string.IsNullOrWhiteSpace(taskSystem))
|
|
prompts.Add(taskSystem.Trim());
|
|
return prompts;
|
|
}
|
|
|
|
public ExecutionMode ResolveExecutionMode(
|
|
string runTab,
|
|
bool streamingEnabled,
|
|
string resolvedService,
|
|
string? coworkSystemPrompt,
|
|
string? codeSystemPrompt)
|
|
{
|
|
if (string.Equals(runTab, "Cowork", StringComparison.OrdinalIgnoreCase))
|
|
return new ExecutionMode(true, false, coworkSystemPrompt);
|
|
|
|
if (string.Equals(runTab, "Code", StringComparison.OrdinalIgnoreCase))
|
|
return new ExecutionMode(true, false, codeSystemPrompt);
|
|
|
|
return new ExecutionMode(false, streamingEnabled, null);
|
|
}
|
|
|
|
public PreparedExecution PrepareExecution(
|
|
ChatConversation conversation,
|
|
string runTab,
|
|
bool streamingEnabled,
|
|
string resolvedService,
|
|
string? conversationSystem,
|
|
string? slashSystem,
|
|
string? coworkSystemPrompt,
|
|
string? codeSystemPrompt,
|
|
string? fileContext = null,
|
|
IReadOnlyList<ImageAttachment>? images = null)
|
|
{
|
|
var mode = ResolveExecutionMode(
|
|
runTab,
|
|
streamingEnabled,
|
|
resolvedService,
|
|
coworkSystemPrompt,
|
|
codeSystemPrompt);
|
|
var promptStack = BuildPromptStack(
|
|
conversationSystem,
|
|
slashSystem,
|
|
mode.TaskSystemPrompt);
|
|
var preparedTurn = PrepareTurn(conversation, promptStack, fileContext, images);
|
|
return new PreparedExecution(mode, promptStack, preparedTurn.Messages);
|
|
}
|
|
|
|
public Task<string> ExecutePreparedAsync(
|
|
PreparedExecution prepared,
|
|
Func<IReadOnlyList<ChatMessage>, CancellationToken, Task<string>> agentLoopRunner,
|
|
Func<IReadOnlyList<ChatMessage>, CancellationToken, Task<string>> llmRunner,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return prepared.Mode.UseAgentLoop
|
|
? agentLoopRunner(prepared.Messages, cancellationToken)
|
|
: llmRunner(prepared.Messages, cancellationToken);
|
|
}
|
|
|
|
public PreparedTurn PrepareTurn(
|
|
ChatConversation conversation,
|
|
IEnumerable<string?> systemPrompts,
|
|
string? fileContext = null,
|
|
IReadOnlyList<ImageAttachment>? images = null)
|
|
{
|
|
var outbound = conversation.Messages
|
|
.Select(CloneMessage)
|
|
.ToList();
|
|
|
|
if (!string.IsNullOrWhiteSpace(fileContext))
|
|
{
|
|
var lastUserIndex = outbound.FindLastIndex(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase));
|
|
if (lastUserIndex >= 0)
|
|
outbound[lastUserIndex].Content = (outbound[lastUserIndex].Content ?? string.Empty) + fileContext;
|
|
}
|
|
|
|
if (images is { Count: > 0 })
|
|
{
|
|
var lastUserIndex = outbound.FindLastIndex(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase));
|
|
if (lastUserIndex >= 0)
|
|
outbound[lastUserIndex].Images = images.Select(CloneImage).ToList();
|
|
}
|
|
|
|
var promptList = systemPrompts
|
|
.Where(prompt => !string.IsNullOrWhiteSpace(prompt))
|
|
.Select(prompt => prompt!.Trim())
|
|
.ToList();
|
|
|
|
for (var i = promptList.Count - 1; i >= 0; i--)
|
|
outbound.Insert(0, new ChatMessage { Role = "system", Content = promptList[i] });
|
|
|
|
return new PreparedTurn(outbound);
|
|
}
|
|
|
|
public ChatMessage CommitAssistantMessage(
|
|
ChatSessionStateService? session,
|
|
ChatConversation conversation,
|
|
string tab,
|
|
string content,
|
|
int promptTokens = 0,
|
|
int completionTokens = 0,
|
|
long? responseElapsedMs = null,
|
|
string? metaRunId = null,
|
|
ChatStorageService? storage = null)
|
|
{
|
|
var assistant = new ChatMessage
|
|
{
|
|
Role = "assistant",
|
|
Content = content,
|
|
PromptTokens = Math.Max(0, promptTokens),
|
|
CompletionTokens = Math.Max(0, completionTokens),
|
|
ResponseElapsedMs = responseElapsedMs is > 0 ? responseElapsedMs : null,
|
|
MetaRunId = string.IsNullOrWhiteSpace(metaRunId) ? null : metaRunId,
|
|
};
|
|
|
|
if (session != null)
|
|
{
|
|
// session.CurrentConversation이 전달된 conversation과 다른 경우 (새 대화 시작 등),
|
|
// session을 통하지 않고 conversation에 직접 추가하여 새 대화가 오염되지 않도록 함.
|
|
if (session.CurrentConversation == null ||
|
|
string.Equals(session.CurrentConversation.Id, conversation.Id, StringComparison.Ordinal))
|
|
{
|
|
session.AppendMessage(tab, assistant, storage);
|
|
}
|
|
else
|
|
{
|
|
conversation.Messages.Add(assistant);
|
|
conversation.UpdatedAt = DateTime.Now;
|
|
try { storage?.Save(conversation); } catch { }
|
|
}
|
|
return assistant;
|
|
}
|
|
|
|
conversation.Messages.Add(assistant);
|
|
conversation.UpdatedAt = DateTime.Now;
|
|
return assistant;
|
|
}
|
|
|
|
public string FinalizeAssistantTurn(
|
|
ChatSessionStateService? session,
|
|
ChatConversation conversation,
|
|
string tab,
|
|
string? content,
|
|
int promptTokens = 0,
|
|
int completionTokens = 0,
|
|
long? responseElapsedMs = null,
|
|
string? metaRunId = null,
|
|
ChatStorageService? storage = null)
|
|
{
|
|
var normalized = NormalizeAssistantContentForUi(conversation, tab, content);
|
|
if (tab is "Cowork" or "Code")
|
|
conversation.ShowExecutionHistory = false;
|
|
|
|
CommitAssistantMessage(session, conversation, tab, normalized, promptTokens, completionTokens, responseElapsedMs, metaRunId, storage);
|
|
return normalized;
|
|
}
|
|
|
|
public FinalizedContent FinalizeExecutionContentForUi(string? currentContent, Exception? error = null, bool cancelled = false)
|
|
{
|
|
if (cancelled)
|
|
{
|
|
var content = string.IsNullOrWhiteSpace(currentContent) ? "(취소됨)" : currentContent!;
|
|
return new FinalizedContent(content, true, "사용자가 작업을 중단했습니다.");
|
|
}
|
|
|
|
if (error != null)
|
|
return new FinalizedContent($"오류: {error.Message}", false, error.Message);
|
|
|
|
return new FinalizedContent(currentContent ?? string.Empty, false, null);
|
|
}
|
|
|
|
public string NormalizeAssistantContentForUi(
|
|
ChatConversation conversation,
|
|
string runTab,
|
|
string? content)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(content))
|
|
return content;
|
|
|
|
return BuildFallbackCompletionMessage(conversation, runTab);
|
|
}
|
|
|
|
public FinalizedContent FinalizeExecutionContent(string? currentContent, Exception? error = null, bool cancelled = false)
|
|
{
|
|
if (cancelled)
|
|
{
|
|
var content = string.IsNullOrWhiteSpace(currentContent) ? "(취소됨)" : currentContent!;
|
|
return new FinalizedContent(content, true, "사용자가 작업을 중단했습니다.");
|
|
}
|
|
|
|
if (error != null)
|
|
{
|
|
return new FinalizedContent($"⚠ 오류: {error.Message}", false, error.Message);
|
|
}
|
|
|
|
return new FinalizedContent(currentContent ?? string.Empty, false, null);
|
|
}
|
|
|
|
public SessionMutationResult AppendExecutionEvent(
|
|
ChatSessionStateService session,
|
|
ChatStorageService storage,
|
|
ChatConversation? activeConversation,
|
|
string activeTab,
|
|
string targetTab,
|
|
AgentEvent evt)
|
|
{
|
|
return ApplyConversationMutation(
|
|
session,
|
|
storage,
|
|
activeConversation,
|
|
activeTab,
|
|
targetTab,
|
|
normalizedTarget => session.AppendExecutionEvent(normalizedTarget, evt, null));
|
|
}
|
|
|
|
public SessionMutationResult AppendAgentRun(
|
|
ChatSessionStateService session,
|
|
ChatStorageService storage,
|
|
ChatConversation? activeConversation,
|
|
string activeTab,
|
|
string targetTab,
|
|
AgentEvent evt,
|
|
string status,
|
|
string summary)
|
|
{
|
|
return ApplyConversationMutation(
|
|
session,
|
|
storage,
|
|
activeConversation,
|
|
activeTab,
|
|
targetTab,
|
|
normalizedTarget => session.AppendAgentRun(normalizedTarget, evt, status, summary, null));
|
|
}
|
|
|
|
public string NormalizeAssistantContent(
|
|
ChatConversation conversation,
|
|
string runTab,
|
|
string? content)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(content))
|
|
return content;
|
|
|
|
return BuildFallbackCompletionMessage(conversation, runTab);
|
|
}
|
|
|
|
/// <summary>
|
|
/// LLM 응답이 비어있을 때 실행 이벤트에서 의미 있는 완료 메시지를 구성합니다.
|
|
/// UserPromptSubmit/Paused/Resumed 같은 내부 운영 이벤트는 제외합니다.
|
|
/// </summary>
|
|
private static string BuildFallbackCompletionMessage(ChatConversation conversation, string runTab)
|
|
{
|
|
static bool IsSignificantEventType(string t)
|
|
=> !string.Equals(t, "UserPromptSubmit", StringComparison.OrdinalIgnoreCase)
|
|
&& !string.Equals(t, "Paused", StringComparison.OrdinalIgnoreCase)
|
|
&& !string.Equals(t, "Resumed", StringComparison.OrdinalIgnoreCase)
|
|
&& !string.Equals(t, "SessionStart", StringComparison.OrdinalIgnoreCase);
|
|
|
|
// 완료 메시지로 표시하면 안 되는 Thinking 이벤트 요약 패턴 (내부 진행 상태 문자열)
|
|
static bool IsInternalStatusSummary(string? summary)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(summary)) return false;
|
|
return summary.StartsWith("LLM에 요청 중", StringComparison.OrdinalIgnoreCase)
|
|
|| summary.StartsWith("도구 미호출 루프", StringComparison.OrdinalIgnoreCase)
|
|
|| summary.StartsWith("강제 실행 유도", StringComparison.OrdinalIgnoreCase)
|
|
|| summary.StartsWith("읽기 전용 도구", StringComparison.OrdinalIgnoreCase)
|
|
|| summary.StartsWith("병렬 실행", StringComparison.OrdinalIgnoreCase)
|
|
|| summary.StartsWith("Self-Reflection", StringComparison.OrdinalIgnoreCase)
|
|
|| summary.StartsWith("일시적 LLM 오류", StringComparison.OrdinalIgnoreCase)
|
|
|| summary.StartsWith("컨텍스트 한도 초과", StringComparison.OrdinalIgnoreCase)
|
|
|| summary.Contains("반복 ") && summary.Contains('/')
|
|
|| summary.Contains("[System:");
|
|
}
|
|
|
|
var completionLine = runTab switch
|
|
{
|
|
"Cowork" => "코워크 작업이 완료되었습니다.",
|
|
"Code" => "코드 작업이 완료되었습니다.",
|
|
_ => null,
|
|
};
|
|
|
|
// 파일 경로가 있는 이벤트를 최우선으로 — 산출물 파일을 명시적으로 표시
|
|
var artifactEvent = conversation.ExecutionEvents?
|
|
.Where(evt => !string.IsNullOrWhiteSpace(evt.FilePath) && IsSignificantEventType(evt.Type))
|
|
.OrderByDescending(evt => evt.Timestamp)
|
|
.FirstOrDefault();
|
|
|
|
if (artifactEvent != null)
|
|
{
|
|
var fileLine = string.IsNullOrWhiteSpace(artifactEvent.Summary)
|
|
? $"생성된 파일: {artifactEvent.FilePath}"
|
|
: $"{artifactEvent.Summary}\n경로: {artifactEvent.FilePath}";
|
|
return completionLine != null
|
|
? $"{completionLine}\n\n{fileLine}"
|
|
: fileLine;
|
|
}
|
|
|
|
// 파일 없으면 가장 최근 의미 있는 이벤트 요약 사용 (내부 상태 문자열 제외)
|
|
var latestSummary = conversation.ExecutionEvents?
|
|
.Where(evt => !string.IsNullOrWhiteSpace(evt.Summary)
|
|
&& IsSignificantEventType(evt.Type)
|
|
&& !IsInternalStatusSummary(evt.Summary))
|
|
.OrderByDescending(evt => evt.Timestamp)
|
|
.Select(evt => evt.Summary.Trim())
|
|
.FirstOrDefault();
|
|
|
|
if (!string.IsNullOrWhiteSpace(latestSummary))
|
|
{
|
|
return completionLine != null
|
|
? $"{completionLine}\n\n{latestSummary}"
|
|
: latestSummary;
|
|
}
|
|
|
|
return completionLine ?? "(빈 응답)";
|
|
}
|
|
|
|
private static ChatMessage CloneMessage(ChatMessage source)
|
|
{
|
|
return new ChatMessage
|
|
{
|
|
Role = source.Role,
|
|
Content = source.Content,
|
|
Timestamp = source.Timestamp,
|
|
MetaRunId = source.MetaRunId,
|
|
AttachedFiles = source.AttachedFiles?.ToList(),
|
|
Images = source.Images?.Select(CloneImage).ToList(),
|
|
};
|
|
}
|
|
|
|
private static ImageAttachment CloneImage(ImageAttachment source)
|
|
{
|
|
return new ImageAttachment
|
|
{
|
|
Base64 = source.Base64,
|
|
MimeType = source.MimeType,
|
|
FileName = source.FileName,
|
|
};
|
|
}
|
|
|
|
private static SessionMutationResult ApplyConversationMutation(
|
|
ChatSessionStateService session,
|
|
ChatStorageService storage,
|
|
ChatConversation? activeConversation,
|
|
string activeTab,
|
|
string targetTab,
|
|
Func<string, ChatConversation> mutate)
|
|
{
|
|
var normalizedTarget = NormalizeTabName(targetTab);
|
|
var normalizedActive = NormalizeTabName(activeTab);
|
|
ChatConversation updatedConversation;
|
|
|
|
if (string.Equals(normalizedTarget, normalizedActive, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
session.CurrentConversation = updatedConversation = mutate(normalizedTarget);
|
|
return new SessionMutationResult(updatedConversation, updatedConversation);
|
|
}
|
|
|
|
var activeSnapshot = activeConversation;
|
|
var previousSessionConversation = session.CurrentConversation;
|
|
updatedConversation = mutate(normalizedTarget);
|
|
|
|
if (activeSnapshot != null
|
|
&& string.Equals(NormalizeTabName(activeSnapshot.Tab), normalizedActive, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
session.CurrentConversation = activeSnapshot;
|
|
return new SessionMutationResult(activeSnapshot, updatedConversation);
|
|
}
|
|
|
|
if (previousSessionConversation != null
|
|
&& string.Equals(NormalizeTabName(previousSessionConversation.Tab), normalizedActive, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
session.CurrentConversation = previousSessionConversation;
|
|
return new SessionMutationResult(previousSessionConversation, updatedConversation);
|
|
}
|
|
|
|
var activeId = session.GetConversationId(normalizedActive);
|
|
var restoredConversation = string.IsNullOrWhiteSpace(activeId)
|
|
? null
|
|
: storage.Load(activeId);
|
|
|
|
if (restoredConversation != null)
|
|
{
|
|
session.CurrentConversation = restoredConversation;
|
|
return new SessionMutationResult(restoredConversation, updatedConversation);
|
|
}
|
|
|
|
var fallbackConversation = session.LoadOrCreateConversation(normalizedActive, storage, GetFallbackSettings());
|
|
session.CurrentConversation = fallbackConversation;
|
|
return new SessionMutationResult(fallbackConversation, updatedConversation);
|
|
}
|
|
|
|
private static SettingsService GetFallbackSettings()
|
|
{
|
|
return (System.Windows.Application.Current as App)?.SettingsService
|
|
?? new SettingsService();
|
|
}
|
|
|
|
private static string NormalizeTabName(string? tab)
|
|
{
|
|
var normalized = (tab ?? "").Trim();
|
|
if (string.IsNullOrEmpty(normalized))
|
|
return "Chat";
|
|
|
|
if (normalized.Contains("코워크", StringComparison.OrdinalIgnoreCase))
|
|
return "Cowork";
|
|
|
|
var canonical = new string(normalized
|
|
.Where(char.IsLetterOrDigit)
|
|
.ToArray())
|
|
.ToLowerInvariant();
|
|
|
|
if (canonical is "cowork" or "coworkcode" or "coworkcodetab")
|
|
return "Cowork";
|
|
|
|
if (normalized.Contains("코드", StringComparison.OrdinalIgnoreCase)
|
|
|| canonical is "code" or "codetab")
|
|
return "Code";
|
|
|
|
return "Chat";
|
|
}
|
|
}
|