using AxCopilot.Models;
using AxCopilot.Services;
namespace AxCopilot.Services.Agent;
///
/// 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.
///
public sealed class AxAgentExecutionEngine
{
public sealed record PreparedTurn(List Messages);
public sealed record ExecutionMode(bool UseAgentLoop, bool UseStreamingTransport, string? TaskSystemPrompt);
public sealed record PreparedExecution(
ExecutionMode Mode,
IReadOnlyList PromptStack,
List Messages);
public sealed record FinalizedContent(string Content, bool Cancelled, string? FailureReason);
public sealed record SessionMutationResult(
ChatConversation CurrentConversation,
ChatConversation UpdatedConversation);
public IReadOnlyList BuildPromptStack(
string? conversationSystem,
string? slashSystem,
string? taskSystem = null)
{
var prompts = new List();
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, false, null);
}
public PreparedExecution PrepareExecution(
ChatConversation conversation,
string runTab,
bool streamingEnabled,
string resolvedService,
string? conversationSystem,
string? slashSystem,
string? coworkSystemPrompt,
string? codeSystemPrompt,
string? fileContext = null,
IReadOnlyList? 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 ExecutePreparedAsync(
PreparedExecution prepared,
Func, CancellationToken, Task> agentLoopRunner,
Func, CancellationToken, Task> llmRunner,
CancellationToken cancellationToken)
{
return prepared.Mode.UseAgentLoop
? agentLoopRunner(prepared.Messages, cancellationToken)
: llmRunner(prepared.Messages, cancellationToken);
}
public PreparedTurn PrepareTurn(
ChatConversation conversation,
IEnumerable systemPrompts,
string? fileContext = null,
IReadOnlyList? 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.AppendMessage(tab, assistant, storage);
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;
var latestEventSummary = conversation.ExecutionEvents?
.Where(evt => !string.IsNullOrWhiteSpace(evt.Summary))
.OrderByDescending(evt => evt.Timestamp)
.Select(evt => evt.Summary.Trim())
.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(latestEventSummary))
{
return runTab switch
{
"Cowork" => $"코워크 작업이 완료되었습니다.\n\n{latestEventSummary}",
"Code" => $"코드 작업이 완료되었습니다.\n\n{latestEventSummary}",
_ => latestEventSummary,
};
}
return "(빈 응답)";
}
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;
var latestEventSummary = conversation.ExecutionEvents?
.Where(evt => !string.IsNullOrWhiteSpace(evt.Summary))
.OrderByDescending(evt => evt.Timestamp)
.Select(evt => evt.Summary.Trim())
.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(latestEventSummary))
{
return runTab switch
{
"Cowork" => $"코워크 작업이 완료되었습니다.\n\n{latestEventSummary}",
"Code" => $"코드 작업이 완료되었습니다.\n\n{latestEventSummary}",
_ => latestEventSummary,
};
}
return "(빈 응답)";
}
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 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";
}
}