From ed5ddf0c0702ee02e383e80d7612f0f7528da1b3 Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Thu, 17 Sep 2026 16:23:55 +0200 Subject: [PATCH 1/5] progress --- .../Services/AiFunctionProvider.cs | 36 +- .../ChatMessageSubAgentViewModel.cs | 131 ++++++ .../ChatMessages/ChatMessageToolViewModel.cs | 6 + src/OneWare.Chat/ViewModels/ChatViewModel.cs | 345 ++++++++++++++-- .../ChatMessageSubAgentView.axaml | 50 +++ .../ChatMessageSubAgentView.axaml.cs | 11 + src/OneWare.Chat/Views/ChatView.axaml | 77 ++-- .../Services/CopilotChatService.cs | 284 ++++++++++++- src/OneWare.Core/Styles/Accents/Base.axaml | 34 +- src/OneWare.Core/Styles/Buttons.axaml | 10 +- src/OneWare.Core/Styles/Icons.axaml | 380 +++++++++--------- .../Models/AiFunctionEvent.cs | 14 + .../Models/ChatServiceEvents.cs | 84 +++- .../Views/SourceControlView.axaml | 61 +-- .../ChatMessageSubAgentViewModelTests.cs | 50 +++ 15 files changed, 1259 insertions(+), 314 deletions(-) create mode 100644 src/OneWare.Chat/ViewModels/ChatMessages/ChatMessageSubAgentViewModel.cs create mode 100644 src/OneWare.Chat/Views/ChatMessages/ChatMessageSubAgentView.axaml create mode 100644 src/OneWare.Chat/Views/ChatMessages/ChatMessageSubAgentView.axaml.cs create mode 100644 tests/OneWare.Chat.UnitTests/ChatMessageSubAgentViewModelTests.cs diff --git a/src/OneWare.Chat/Services/AiFunctionProvider.cs b/src/OneWare.Chat/Services/AiFunctionProvider.cs index 5ff96b8d7..9ae70cf37 100644 --- a/src/OneWare.Chat/Services/AiFunctionProvider.cs +++ b/src/OneWare.Chat/Services/AiFunctionProvider.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.IO; +using System.Reflection; using Avalonia.Threading; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; @@ -332,17 +333,47 @@ private void EnsureBuiltInsRegistered() aiFileEditService); } - private async Task NotifyFunctionStartedAsync(string id, string functionName, string? detail = null) + private async Task NotifyFunctionStartedAsync(string id, string functionName, string toolName, + string? toolCallId, string? detail = null) { await Dispatcher.UIThread.InvokeAsync(() => FunctionStarted?.Invoke(this, new AiFunctionStartedEvent { Id = id, FunctionName = functionName, + ToolName = toolName, + ToolCallId = toolCallId, Detail = detail })); } + private static readonly ConcurrentDictionary ToolCallIdProperties = new(); + + /// + /// Reads the tool call id the AI backend assigned to this invocation. Backends pass their + /// invocation context in ; the shape of that context is + /// backend specific, so it is only probed for a ToolCallId. + /// + private static string? TryGetBackendToolCallId(AIFunctionArguments arguments) + { + if (arguments.Context == null) return null; + + foreach (var value in arguments.Context.Values) + { + if (value == null) continue; + + var property = ToolCallIdProperties.GetOrAdd(value.GetType(), + type => type.GetProperty("ToolCallId", BindingFlags.Public | BindingFlags.Instance)); + + if (property?.PropertyType != typeof(string)) continue; + + if (property.GetValue(value) is string toolCallId && !string.IsNullOrWhiteSpace(toolCallId)) + return toolCallId; + } + + return null; + } + private async Task NotifyFunctionCompletedAsync(string id, Exception? exception = null) { await Dispatcher.UIThread.InvokeAsync(() => @@ -387,7 +418,8 @@ private sealed class RegisteredOneWareAiFunction( Exception? exception = null; try { - await provider.NotifyFunctionStartedAsync(id, friendlyName!, detail); + await provider.NotifyFunctionStartedAsync(id, friendlyName!, definition.Name, + TryGetBackendToolCallId(arguments), detail); if (definition.RunOnUiThread) { diff --git a/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessageSubAgentViewModel.cs b/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessageSubAgentViewModel.cs new file mode 100644 index 000000000..dcb13c78b --- /dev/null +++ b/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessageSubAgentViewModel.cs @@ -0,0 +1,131 @@ +using System.Collections.ObjectModel; +using System.Runtime.Serialization; +using CommunityToolkit.Mvvm.ComponentModel; +using OneWare.Essentials.Controls; +using OneWare.Essentials.Models; + +namespace OneWare.Chat.ViewModels.ChatMessages; + +/// +/// A block for work the AI delegated to a sub-agent. Everything the sub-agent does (its messages, +/// reasoning and tool calls) is nested inside instead of being mixed into the +/// main conversation, so a delegated task reads as one collapsible unit. +/// +public class ChatMessageSubAgentViewModel : ObservableObject, IChatMessage, IEstimatedHeightItem +{ + public ChatMessageSubAgentViewModel(string id, string displayName) + { + Id = id; + DisplayName = displayName; + Timestamp = DateTimeOffset.Now; + StatusText = "Starting…"; + } + + public string Id { get; } + + [DataMember] public string DisplayName { get; } + + [DataMember] + public string? Description + { + get; + set => SetProperty(ref field, value); + } + + [DataMember] + public string? Model + { + get; + set => SetProperty(ref field, value); + } + + [DataMember] + public bool IsBackground + { + get; + set => SetProperty(ref field, value); + } + + public DateTimeOffset Timestamp { get; } + + public ObservableCollection Items { get; } = []; + + public bool IsRunning + { + get; + set + { + if (SetProperty(ref field, value)) OnPropertyChanged(nameof(IsFinished)); + } + } = true; + + public bool IsFinished => !IsRunning; + + [DataMember] + public bool IsSuccessful + { + get; + set => SetProperty(ref field, value); + } = true; + + /// What the sub-agent is doing right now, or how it ended. + [DataMember] + public string StatusText + { + get; + set => SetProperty(ref field, value); + } + + /// Expanded while the sub-agent works, collapsed to its summary once it is done. + public bool IsExpanded + { + get; + set => SetProperty(ref field, value); + } = true; + + public void Complete(ChatSubAgentCompletedEvent completed) + { + IsRunning = false; + IsSuccessful = completed.Success && !completed.Cancelled; + IsExpanded = false; + StatusText = BuildSummary(completed); + } + + private static string BuildSummary(ChatSubAgentCompletedEvent completed) + { + if (!completed.Success) + return string.IsNullOrWhiteSpace(completed.Error) ? "Failed" : $"Failed: {completed.Error}"; + + var parts = new List { completed.Cancelled ? "Cancelled" : "Done" }; + + if (completed.Duration is { } duration && duration > TimeSpan.Zero) + parts.Add(duration.TotalSeconds < 60 + ? $"{duration.TotalSeconds:0.#}s" + : $"{(int)duration.TotalMinutes}m {duration.Seconds}s"); + + if (completed.TotalToolCalls is > 0) + parts.Add($"{completed.TotalToolCalls} tool calls"); + + if (completed.TotalTokens is > 0) + parts.Add($"{FormatTokens(completed.TotalTokens.Value)} tokens"); + + return string.Join(" · ", parts); + } + + private static string FormatTokens(long tokens) + { + return tokens >= 1000 ? $"{tokens / 1000d:0.#}k" : tokens.ToString(); + } + + public double EstimateHeight(double width) + { + const double header = 40; + if (!IsExpanded) return header; + + var height = header; + foreach (var item in Items) + height += item is IEstimatedHeightItem estimated ? estimated.EstimateHeight(width - 20) : 36; + + return height + 8; + } +} diff --git a/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessageToolViewModel.cs b/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessageToolViewModel.cs index ad6415045..d601cca9a 100644 --- a/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessageToolViewModel.cs +++ b/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessageToolViewModel.cs @@ -18,6 +18,12 @@ public ChatMessageToolViewModel(string id, string toolName) [DataMember] public string ToolName { get; } + + /// + /// Id the AI backend assigned to this tool call, used to match the entry against the tool + /// events of the chat service. + /// + public string? SourceToolCallId { get; init; } [DataMember] public string? ToolOutput diff --git a/src/OneWare.Chat/ViewModels/ChatViewModel.cs b/src/OneWare.Chat/ViewModels/ChatViewModel.cs index c01ad6f53..1923e6947 100644 --- a/src/OneWare.Chat/ViewModels/ChatViewModel.cs +++ b/src/OneWare.Chat/ViewModels/ChatViewModel.cs @@ -37,6 +37,18 @@ public partial class ChatViewModel : ExtendedTool, IChatManagerService private readonly Dictionary _assistantReasoningById = new(StringComparer.Ordinal); + + /// Sub-agent blocks by id, for as long as their events can still arrive. + private readonly Dictionary _subAgents = new(StringComparer.Ordinal); + + /// + /// Tool calls a sub-agent started that OneWare executes itself, keyed by the tool call id. They + /// are reported twice — once by the chat service (with the sub-agent it belongs to) and once by + /// the function provider (with the live output and a stop button) — and are matched up here so + /// the running tool is shown inside its sub-agent block instead of the main flow. + /// + private readonly Dictionary _pendingSubAgentTools = + new(StringComparer.Ordinal); private readonly Dictionary _selectedSessionByService = new(StringComparer.Ordinal); private readonly Dictionary> _historyByService = new(StringComparer.Ordinal); @@ -648,16 +660,32 @@ private void AddMessage(IChatMessage message) Messages.Add(message); } - private void AddErrorMessage(string? message) + /// + /// Adds a message to the conversation, or into the block of the sub-agent it belongs to. + /// + private void AddMessage(IChatMessage message, string? agentId) + { + if (agentId != null && _subAgents.TryGetValue(agentId, out var subAgent)) + { + subAgent.Items.Add(message); + RequestSaveState(); + return; + } + + AddMessage(message); + } + + private void AddErrorMessage(string? message, string? agentId = null) { var errorMessage = string.IsNullOrWhiteSpace(message) ? "An unexpected error occurred." : message; - AddMessage(new ChatMessageErrorViewModel(errorMessage)); + AddMessage(new ChatMessageErrorViewModel(errorMessage), agentId); } - private ChatMessageReasoningViewModel GetOrCreateAssistantReasoningMessage(string? reasoningId) + private ChatMessageReasoningViewModel GetOrCreateAssistantReasoningMessage(string? reasoningId, + string? agentId = null) { if (!string.IsNullOrWhiteSpace(reasoningId)) { @@ -665,18 +693,18 @@ private ChatMessageReasoningViewModel GetOrCreateAssistantReasoningMessage(strin return existing; var created = new ChatMessageReasoningViewModel(reasoningId); - AddMessage(created); + AddMessage(created, agentId); _assistantReasoningById[reasoningId] = created; return created; } var activeReasoning = new ChatMessageReasoningViewModel(reasoningId); - AddMessage(activeReasoning); + AddMessage(activeReasoning, agentId); return activeReasoning; } - private ChatMessageAssistantViewModel GetOrCreateAssistantMessage(string? messageId) + private ChatMessageAssistantViewModel GetOrCreateAssistantMessage(string? messageId, string? agentId = null) { if (Messages.LastOrDefault() is ChatMessageAssistantViewModel { MessageId: "init" } initMessage) { @@ -689,13 +717,13 @@ private ChatMessageAssistantViewModel GetOrCreateAssistantMessage(string? messag return existing; var created = new ChatMessageAssistantViewModel(messageId); - AddMessage(created); + AddMessage(created, agentId); _assistantMessagesById[messageId] = created; return created; } var activeAssistantMessage = new ChatMessageAssistantViewModel(messageId); - AddMessage(activeAssistantMessage); + AddMessage(activeAssistantMessage, agentId); return activeAssistantMessage; } @@ -704,11 +732,33 @@ private void FinishTurn() { Dispatcher.UIThread.Post(() => { - foreach (var message in Messages.OfType()) - message.IsStreaming = false; + foreach (var message in EnumerateAllMessages()) + { + switch (message) + { + case ChatMessageAssistantViewModel assistant: + assistant.IsStreaming = false; + break; + case ChatMessageReasoningViewModel reasoning: + reasoning.IsStreaming = false; + break; + // A background sub-agent outlives the turn that started it, so it keeps + // running (and receiving events) until its own completion event arrives. + case ChatMessageSubAgentViewModel { IsRunning: true, IsBackground: false } subAgent: + // The turn ended without a completion event (e.g. after an abort). + FinishRunningItems(subAgent); + subAgent.IsRunning = false; + subAgent.IsExpanded = false; + subAgent.StatusText = "Stopped"; + break; + } + } - foreach (var message in Messages.OfType()) - message.IsStreaming = false; + foreach (var id in _subAgents.Where(x => !x.Value.IsRunning).Select(x => x.Key).ToArray()) + _subAgents.Remove(id); + + foreach (var claim in _pendingSubAgentTools.Where(x => !x.Value.IsRunning).Select(x => x.Key).ToArray()) + _pendingSubAgentTools.Remove(claim); IsBusy = false; // Safety: never let the steering indicator stick past the end of a turn. @@ -720,6 +770,25 @@ private void FinishTurn() }); } + /// All messages of the conversation, including those nested in sub-agent blocks. + private IEnumerable EnumerateAllMessages() + { + return EnumerateMessages(Messages); + } + + private static IEnumerable EnumerateMessages(IEnumerable messages) + { + foreach (var message in messages.ToArray()) + { + yield return message; + + if (message is not ChatMessageSubAgentViewModel subAgent) continue; + + foreach (var nested in EnumerateMessages(subAgent.Items)) + yield return nested; + } + } + private void OnEventReceived(object? sender, ChatEvent e) { switch (e) @@ -729,9 +798,10 @@ private void OnEventReceived(object? sender, ChatEvent e) if (string.IsNullOrWhiteSpace(x.Content)) break; Dispatcher.UIThread.Post(() => { - var message = GetOrCreateAssistantMessage(x.MessageId); + var message = GetOrCreateAssistantMessage(x.MessageId, x.AgentId); message.IsStreaming = true; message.Content += x.Content; + SetSubAgentStatus(x.AgentId, "Responding…"); NotifyContentAdded(); }); break; @@ -741,7 +811,7 @@ private void OnEventReceived(object? sender, ChatEvent e) if (string.IsNullOrWhiteSpace(x.Content)) break; Dispatcher.UIThread.Post(() => { - var message = GetOrCreateAssistantMessage(x.MessageId); + var message = GetOrCreateAssistantMessage(x.MessageId, x.AgentId); message.Content = x.Content; message.IsStreaming = false; NotifyContentAdded(); @@ -752,9 +822,10 @@ private void OnEventReceived(object? sender, ChatEvent e) { Dispatcher.UIThread.Post(() => { - var message = GetOrCreateAssistantReasoningMessage(x.ReasoningId); + var message = GetOrCreateAssistantReasoningMessage(x.ReasoningId, x.AgentId); message.IsStreaming = true; message.Content += x.Content; + SetSubAgentStatus(x.AgentId, "Thinking…"); NotifyContentAdded(); }); break; @@ -763,22 +834,54 @@ private void OnEventReceived(object? sender, ChatEvent e) { Dispatcher.UIThread.Post(() => { - var message = GetOrCreateAssistantReasoningMessage(x.ReasoningId); + var message = GetOrCreateAssistantReasoningMessage(x.ReasoningId, x.AgentId); message.Content = x.Content; message.IsStreaming = false; NotifyContentAdded(); }); break; } - case ChatToolExecutionStartEvent: + case ChatSubAgentStartedEvent x: + { + Dispatcher.UIThread.Post(() => + { + AddSubAgent(x); + NotifyContentAdded(); + }); + break; + } + case ChatSubAgentCompletedEvent x: + { + Dispatcher.UIThread.Post(() => + { + CompleteSubAgent(x); + NotifyContentAdded(); + }); + break; + } + case ChatToolExecutionStartEvent x: { + // Tool calls of the main agent are rendered from the function provider events, + // which also carry the live output and a stop button. + if (x.AgentId == null) break; + + Dispatcher.UIThread.Post(() => + { + StartSubAgentTool(x); + NotifyContentAdded(); + }); + break; + } + case ChatToolExecutionCompleteEvent x: + { + Dispatcher.UIThread.Post(() => CompleteSubAgentTool(x)); break; } case ChatSkillLoadedEvent x: { Dispatcher.UIThread.Post(() => { - AddMessage(new ChatMessageSkillViewModel(x.SkillName, x.Content)); + AddMessage(new ChatMessageSkillViewModel(x.SkillName, x.Content), x.AgentId); NotifyContentAdded(); }); break; @@ -850,7 +953,7 @@ private void OnEventReceived(object? sender, ChatEvent e) { Dispatcher.UIThread.Post(() => { - AddErrorMessage(x.Message); + AddErrorMessage(x.Message, x.AgentId); NotifyContentAdded(); }); break; @@ -870,6 +973,8 @@ private void OnEventReceived(object? sender, ChatEvent e) _cancelledQueuedMessages.Clear(); _assistantMessagesById.Clear(); _assistantReasoningById.Clear(); + _subAgents.Clear(); + _pendingSubAgentTools.Clear(); _notConnectedMessage = null; if (SelectedChatService != null) UpdateSelectedSessionFromService(SelectedChatService); @@ -968,18 +1073,30 @@ private void OnFunctionStarted(object? sender, AiFunctionStartedEvent function) var newMessage = new ChatMessageToolViewModel(function.Id, function.FunctionName) { IsToolRunning = true, - ToolOutput = $"{function.Detail}" + ToolOutput = $"{function.Detail}", + SourceToolCallId = function.ToolCallId }; newMessage.StopCommand = new RelayCommand( () => _aiFunctionProvider.CancelFunction(newMessage.Id), () => newMessage.IsToolRunning); - AddMessage(newMessage); + + var subAgent = DequeueSubAgentToolClaim(function.ToolCallId); + if (subAgent != null) + { + subAgent.Items.Add(newMessage); + subAgent.StatusText = function.FunctionName; + } + else + { + AddMessage(newMessage); + } + NotifyContentAdded(); } private void OnFunctionCompleted(object? sender, AiFunctionCompletedEvent function) { - var toolFinished = Messages.OfType().LastOrDefault(x => x.Id == function.Id); + var toolFinished = FindToolMessage(function.Id); if (toolFinished == null) return; toolFinished.IsToolRunning = false; @@ -997,13 +1114,148 @@ private void OnFunctionCompleted(object? sender, AiFunctionCompletedEvent functi private void OnFunctionProgress(object? sender, AiFunctionProgressEvent progress) { - var tool = Messages.OfType().LastOrDefault(x => x.Id == progress.Id); + var tool = FindToolMessage(progress.Id); if (tool == null || !tool.IsToolRunning) return; tool.ToolOutput = progress.Output; RequestSaveState(); } + // ── Sub-agents ──────────────────────────────────────────────────────────── + + private void AddSubAgent(ChatSubAgentStartedEvent started) + { + var subAgent = new ChatMessageSubAgentViewModel(started.Id, started.DisplayName) + { + Description = started.Description, + Model = started.Model, + IsBackground = started.IsBackground, + StatusText = started.IsBackground ? "Running in background…" : "Working…" + }; + + // A nested sub-agent belongs into the block of the agent that spawned it. + AddMessage(subAgent, started.ParentSubAgentId); + _subAgents[started.Id] = subAgent; + } + + private void CompleteSubAgent(ChatSubAgentCompletedEvent completed) + { + if (!_subAgents.Remove(completed.Id, out var subAgent)) return; + + FinishRunningItems(subAgent); + subAgent.Complete(completed); + } + + /// Stops spinners of nested entries that never reported a result of their own. + private static void FinishRunningItems(ChatMessageSubAgentViewModel subAgent) + { + foreach (var item in subAgent.Items.ToArray()) + { + switch (item) + { + case ChatMessageToolViewModel { IsToolRunning: true } tool: + tool.IsToolRunning = false; + tool.StopCommand = null; + break; + case ChatMessageAssistantViewModel assistant: + assistant.IsStreaming = false; + break; + case ChatMessageReasoningViewModel reasoning: + reasoning.IsStreaming = false; + break; + } + } + } + + private void SetSubAgentStatus(string? agentId, string status) + { + if (agentId == null) return; + if (!_subAgents.TryGetValue(agentId, out var subAgent)) return; + + subAgent.StatusText = status; + } + + /// + /// A tool call a sub-agent started. Tools OneWare executes itself are only announced here and + /// rendered once the function provider reports them, so they keep their live output. + /// + private void StartSubAgentTool(ChatToolExecutionStartEvent start) + { + if (start.AgentId == null || !_subAgents.TryGetValue(start.AgentId, out var subAgent)) return; + + subAgent.StatusText = start.Tool; + + if (string.IsNullOrWhiteSpace(start.ToolCallId)) return; + + if (start.IsClientTool) + { + // The tool call can already be shown in the main flow when the function provider + // reported it before this event arrived. + if (!TryAdoptRunningTool(subAgent, start.ToolCallId)) + _pendingSubAgentTools[start.ToolCallId] = subAgent; + + return; + } + + subAgent.Items.Add(new ChatMessageToolViewModel(start.ToolCallId, start.Tool) + { + IsToolRunning = true, + ToolOutput = start.Detail, + SourceToolCallId = start.ToolCallId + }); + } + + private void CompleteSubAgentTool(ChatToolExecutionCompleteEvent complete) + { + var tool = FindToolMessage(complete.ToolCallId); + if (tool == null || !tool.IsToolRunning) return; + + tool.IsToolRunning = false; + tool.StopCommand = null; + tool.IsSuccessful = complete.Success; + + if (!string.IsNullOrWhiteSpace(complete.Output)) + { + tool.ToolOutput = string.IsNullOrWhiteSpace(tool.ToolOutput) + ? complete.Output + : tool.ToolOutput + "\n" + complete.Output; + } + + RequestSaveState(); + } + + /// + /// Moves a tool that was already shown in the main flow into a sub-agent block. Needed because + /// the function provider can report a tool call before the chat service tells which sub-agent + /// it belongs to. + /// + private bool TryAdoptRunningTool(ChatMessageSubAgentViewModel subAgent, string toolCallId) + { + var running = Messages.OfType().LastOrDefault(x => + string.Equals(x.SourceToolCallId, toolCallId, StringComparison.Ordinal)); + + if (running == null) return false; + + Messages.Remove(running); + subAgent.Items.Add(running); + subAgent.StatusText = running.ToolName; + return true; + } + + private ChatMessageSubAgentViewModel? DequeueSubAgentToolClaim(string? toolCallId) + { + if (string.IsNullOrWhiteSpace(toolCallId)) return null; + if (!_pendingSubAgentTools.Remove(toolCallId, out var subAgent)) return null; + + return subAgent; + } + + private ChatMessageToolViewModel? FindToolMessage(string id) + { + return EnumerateAllMessages().OfType() + .LastOrDefault(x => string.Equals(x.Id, id, StringComparison.Ordinal)); + } + private void ShowEdit(AiEditViewModel? editViewModel) { if (editViewModel == null) return; @@ -1258,6 +1510,17 @@ private ChatState BuildChatState() SkillName = skill.SkillName, Content = skill.Content }; + case ChatMessageSubAgentViewModel subAgent: + return new ChatMessageState(ChatMessageKind.SubAgent) + { + Id = subAgent.Id, + Message = subAgent.DisplayName, + Content = subAgent.Description, + ToolName = subAgent.Model, + ToolOutput = subAgent.StatusText, + IsSuccessful = subAgent.IsSuccessful, + Children = subAgent.Items.Select(BuildMessageState).OfType().ToList() + }; default: return null; } @@ -1307,6 +1570,32 @@ private static bool TryCreateMessage(ChatMessageState state, out IChatMessage me message = new ChatMessageSkillViewModel(state.SkillName, state.Content ?? string.Empty); return true; + case ChatMessageKind.SubAgent: + if (string.IsNullOrWhiteSpace(state.Message)) + { + message = null!; + return false; + } + + var restored = new ChatMessageSubAgentViewModel(state.Id ?? Guid.NewGuid().ToString("N"), + state.Message) + { + Description = state.Content, + Model = state.ToolName, + IsRunning = false, + IsExpanded = false, + IsSuccessful = state.IsSuccessful, + StatusText = state.ToolOutput ?? string.Empty + }; + + foreach (var childState in state.Children) + { + if (TryCreateMessage(childState, out var child)) + restored.Items.Add(child); + } + + message = restored; + return true; default: message = null!; return false; @@ -1378,6 +1667,8 @@ private void LoadMessagesForService(string serviceName) Messages.Clear(); _assistantMessagesById.Clear(); _assistantReasoningById.Clear(); + _subAgents.Clear(); + _pendingSubAgentTools.Clear(); } private void LoadMessagesFromStates(IReadOnlyCollection states) @@ -1385,6 +1676,8 @@ private void LoadMessagesFromStates(IReadOnlyCollection states Messages.Clear(); _assistantMessagesById.Clear(); _assistantReasoningById.Clear(); + _subAgents.Clear(); + _pendingSubAgentTools.Clear(); foreach (var messageState in states) { @@ -1703,6 +1996,9 @@ public ChatMessageState(ChatMessageKind kind) public string? ToolOutput { get; set; } public string? SkillName { get; set; } public bool IsSuccessful { get; set; } + + /// Messages nested inside a sub-agent block. + public List Children { get; set; } = []; } private enum ChatMessageKind @@ -1711,6 +2007,7 @@ private enum ChatMessageKind Assistant, Reasoning, Tool, - Skill + Skill, + SubAgent } } diff --git a/src/OneWare.Chat/Views/ChatMessages/ChatMessageSubAgentView.axaml b/src/OneWare.Chat/Views/ChatMessages/ChatMessageSubAgentView.axaml new file mode 100644 index 000000000..f7b2134c6 --- /dev/null +++ b/src/OneWare.Chat/Views/ChatMessages/ChatMessageSubAgentView.axaml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OneWare.Chat/Views/ChatMessages/ChatMessageSubAgentView.axaml.cs b/src/OneWare.Chat/Views/ChatMessages/ChatMessageSubAgentView.axaml.cs new file mode 100644 index 000000000..697af3cd1 --- /dev/null +++ b/src/OneWare.Chat/Views/ChatMessages/ChatMessageSubAgentView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace OneWare.Chat.Views.ChatMessages; + +public partial class ChatMessageSubAgentView : UserControl +{ + public ChatMessageSubAgentView() + { + InitializeComponent(); + } +} diff --git a/src/OneWare.Chat/Views/ChatView.axaml b/src/OneWare.Chat/Views/ChatView.axaml index 98cac5f1e..3377a5e01 100644 --- a/src/OneWare.Chat/Views/ChatView.axaml +++ b/src/OneWare.Chat/Views/ChatView.axaml @@ -234,7 +234,7 @@ - @@ -255,49 +255,52 @@ - - - - - - - + + + + + + + - + - + + diff --git a/src/OneWare.Copilot/Services/CopilotChatService.cs b/src/OneWare.Copilot/Services/CopilotChatService.cs index 33e44f4d4..659e98472 100644 --- a/src/OneWare.Copilot/Services/CopilotChatService.cs +++ b/src/OneWare.Copilot/Services/CopilotChatService.cs @@ -1197,6 +1197,9 @@ private async Task InitializeSessionAsync() var sessionId = _requestedSessionId; _requestedSessionId = null; + // Plugins can register tools at any time, so the cache is rebuilt per session. + _clientToolNames = null; + if (string.IsNullOrWhiteSpace(sessionId)) { var tools = toolProvider.GetTools().Cast().ToList(); @@ -1206,8 +1209,9 @@ private async Task InitializeSessionAsync() ReasoningEffort = ShowReasoningEffort ? SelectedReasoningEffort : null, ContextTier = ResolveContextTier(), Streaming = true, - // Only stream root-agent deltas; the chat UI does not differentiate sub-agents. - IncludeSubAgentStreamingEvents = false, + // The chat UI renders sub-agents in their own block, so their deltas are needed + // to show progress while they work. + IncludeSubAgentStreamingEvents = true, SystemMessage = BuildSystemMessageConfig(), Tools = tools, // Restrict the session to OneWare's own tools plus the session-isolated built-ins @@ -1235,7 +1239,7 @@ private async Task InitializeSessionAsync() { Streaming = true, ContextTier = ResolveContextTier(), - IncludeSubAgentStreamingEvents = false, + IncludeSubAgentStreamingEvents = true, Tools = toolProvider.GetTools().Cast().ToList(), AvailableTools = BuildAvailableTools(), ExcludedTools = ExcludedBuiltInTools.ToList(), @@ -1452,6 +1456,7 @@ public async Task AbortAsync() { ReleasePendingInputRequests(); toolProvider.CancelActiveFunctions(); + DropForegroundSubAgents(); if (_session == null) return; await _session.AbortAsync(); } @@ -1558,6 +1563,9 @@ private async Task DisposeSessionAsync() _session = null; } + lock (_subAgents) + _subAgents.Clear(); + CurrentSessionId = null; ResetUsageStats(); } @@ -1580,33 +1588,72 @@ private void ResetUsageStats() private void HandleSessionEvent(SessionEvent evt) { + // Tool events name both the sub-agent instance and the task call that spawned it, which is + // the only authoritative pairing of the two the runtime provides. + BindAgentInstance(evt.AgentId, evt switch + { + ToolExecutionStartEvent start => start.Data.ParentToolCallId, + ToolExecutionCompleteEvent complete => complete.Data.ParentToolCallId, + _ => null + }); + + var agentId = ResolveSubAgentId(evt); + switch (evt) { + case SubagentStartedEvent x: + { + HandleSubAgentStarted(x); + break; + } + case SubagentCompletedEvent x: + { + CompleteSubAgent(x.Data.ToolCallId, true, null, x.Data.Cancelled == true, + x.Data.Duration, x.Data.TotalTokens, x.Data.TotalToolCalls); + break; + } + case SubagentFailedEvent x: + { + CompleteSubAgent(x.Data.ToolCallId, false, x.Data.Error, false, + x.Data.Duration, x.Data.TotalTokens, x.Data.TotalToolCalls); + break; + } case AssistantMessageDeltaEvent x: { EventReceived?.Invoke(this, - new ChatMessageDeltaEvent(x.Data.DeltaContent, x.Data.MessageId)); + new ChatMessageDeltaEvent(x.Data.DeltaContent, x.Data.MessageId) { AgentId = agentId }); break; } case AssistantMessageEvent x: { EventReceived?.Invoke(this, - new ChatMessageEvent(x.Data.Content, x.Data.MessageId)); + new ChatMessageEvent(x.Data.Content, x.Data.MessageId) { AgentId = agentId }); break; } case AssistantReasoningDeltaEvent x: { EventReceived?.Invoke(this, - new ChatReasoningDeltaEvent(x.Data.DeltaContent, x.Data.ReasoningId)); + new ChatReasoningDeltaEvent(x.Data.DeltaContent, x.Data.ReasoningId) { AgentId = agentId }); break; } case AssistantReasoningEvent x: { EventReceived?.Invoke(this, - new ChatReasoningEvent(x.Data.Content, x.Data.ReasoningId)); + new ChatReasoningEvent(x.Data.Content, x.Data.ReasoningId) { AgentId = agentId }); + break; + } + case ToolExecutionCompleteEvent x: + { + var toolAgentId = ResolveToolAgentId(x.Data.ParentToolCallId, agentId); + if (toolAgentId == null) break; + + EventReceived?.Invoke(this, new ChatToolExecutionCompleteEvent( + x.Data.ToolCallId, + x.Data.Success, + x.Data.Error?.Message ?? Truncate(x.Data.Result?.Content)) { AgentId = toolAgentId }); break; } - case UserMessageEvent x: + case UserMessageEvent x when agentId == null: { // The backend injects the content of loaded skills into the user message. That // content is meant for the model, so report it as a skill indicator and only show @@ -1622,15 +1669,22 @@ private void HandleSessionEvent(SessionEvent evt) } case ToolExecutionStartEvent x: { + var toolAgentId = ResolveToolAgentId(x.Data.ParentToolCallId, agentId); + EventReceived?.Invoke(this, - new ChatToolExecutionStartEvent(x.Data.ToolName)); + new ChatToolExecutionStartEvent(x.Data.ToolName, x.Data.ToolCallId, IsClientTool(x.Data.ToolName)) + { + AgentId = toolAgentId, + Detail = DescribeToolArguments(x.Data) + }); break; } case SessionErrorEvent error: EventReceived?.Invoke(this, - new ChatErrorEvent(error.Data.Message)); + new ChatErrorEvent(error.Data.Message) { AgentId = agentId }); break; - case SessionIdleEvent: + case SessionIdleEvent when agentId == null: + DropForegroundSubAgents(); EventReceived?.Invoke(this, new ChatIdleEvent()); break; case AssistantUsageEvent usage: @@ -1658,6 +1712,214 @@ private void HandleSessionEvent(SessionEvent evt) } } + // ── Sub-agents ──────────────────────────────────────────────────────────── + + /// + /// Running sub-agents by block id. The block id is the tool call id of the task call that + /// spawned the sub-agent, because that is the only id present on every subagent.* event + /// and on the tool events of the sub-agent (as parentToolCallId). + /// + private readonly Dictionary _subAgents = new(StringComparer.Ordinal); + + private sealed class SubAgentRun(string id, bool isBackground) + { + public string Id { get; } = id; + + /// Background sub-agents keep running after the turn that spawned them ended. + public bool IsBackground { get; } = isBackground; + + /// Runtime agent instance id, used to attribute streaming events to this run. + public string? AgentInstanceId { get; set; } + } + + private void HandleSubAgentStarted(SubagentStartedEvent evt) + { + var id = evt.Data.ToolCallId; + if (string.IsNullOrWhiteSpace(id)) return; + + var isBackground = + string.Equals(evt.Data.ExecutionMode, "background", StringComparison.OrdinalIgnoreCase); + var run = new SubAgentRun(id, isBackground); + string? parentId; + + lock (_subAgents) + { + // The started event is emitted by the spawning agent, so its agentId identifies the + // parent (absent for the main agent) and never the new sub-agent itself. + parentId = FindSubAgentByInstanceId(evt.AgentId)?.Id; + _subAgents[id] = run; + } + + var displayName = FirstNonEmpty(evt.Data.AgentDisplayName, evt.Data.AgentName, evt.Data.AgentType, "Agent")!; + + EventReceived?.Invoke(this, new ChatSubAgentStartedEvent(id, displayName) + { + Description = evt.Data.AgentDescription, + Model = evt.Data.Model, + IsBackground = isBackground, + ParentSubAgentId = parentId, + AgentId = parentId + }); + } + + private void CompleteSubAgent(string? toolCallId, bool success, string? error, bool cancelled, + TimeSpan? duration, long? totalTokens, long? totalToolCalls) + { + if (string.IsNullOrWhiteSpace(toolCallId)) return; + + lock (_subAgents) + _subAgents.Remove(toolCallId); + + EventReceived?.Invoke(this, new ChatSubAgentCompletedEvent(toolCallId, success) + { + Error = error, + Cancelled = cancelled, + Duration = duration, + TotalTokens = totalTokens, + TotalToolCalls = totalToolCalls + }); + } + + /// + /// Learns which runtime agent instance a sub-agent run is executed by. The lifecycle events only + /// carry the tool call id, so the pairing has to come from an event that carries both. + /// + private void BindAgentInstance(string? agentInstanceId, string? toolCallId) + { + if (string.IsNullOrWhiteSpace(agentInstanceId) || string.IsNullOrWhiteSpace(toolCallId)) return; + + lock (_subAgents) + { + if (!_subAgents.TryGetValue(toolCallId, out var run)) return; + if (string.Equals(run.AgentInstanceId, agentInstanceId, StringComparison.Ordinal)) return; + + // Take the id away from a run it was only guessed for. + var previous = FindSubAgentByInstanceId(agentInstanceId); + if (previous != null) previous.AgentInstanceId = null; + + run.AgentInstanceId = agentInstanceId; + } + } + + /// + /// Maps the runtime agent instance id of an event to the sub-agent block it belongs to. + /// Returns null for events of the main agent. + /// + private string? ResolveSubAgentId(SessionEvent evt) + { + if (string.IsNullOrWhiteSpace(evt.AgentId)) return null; + + lock (_subAgents) + { + var known = FindSubAgentByInstanceId(evt.AgentId); + if (known != null) return known.Id; + + // Only one run can be meant when exactly one is still waiting for its instance id. + // With several unidentified runs the event is dropped instead of risking a wrong block. + var unbound = _subAgents.Values.Where(x => x.AgentInstanceId == null).ToList(); + if (unbound.Count != 1) return null; + + unbound[0].AgentInstanceId = evt.AgentId; + return unbound[0].Id; + } + } + + /// + /// Forgets sub-agents that cannot outlive the finished turn. Without this a run that never + /// reported completion (e.g. after an abort) would keep taking events from later sub-agents. + /// + private void DropForegroundSubAgents() + { + lock (_subAgents) + { + foreach (var id in _subAgents.Where(x => !x.Value.IsBackground).Select(x => x.Key).ToArray()) + _subAgents.Remove(id); + } + } + + /// + /// Tool events name their spawning task call, which is exactly the sub-agent block id. + /// + private string? ResolveToolAgentId(string? parentToolCallId, string? agentId) + { + if (!string.IsNullOrWhiteSpace(parentToolCallId)) + { + lock (_subAgents) + { + if (_subAgents.ContainsKey(parentToolCallId)) return parentToolCallId; + } + } + + return agentId; + } + + private SubAgentRun? FindSubAgentByInstanceId(string? agentInstanceId) + { + if (string.IsNullOrWhiteSpace(agentInstanceId)) return null; + + return _subAgents.Values.FirstOrDefault(x => + string.Equals(x.AgentInstanceId, agentInstanceId, StringComparison.Ordinal)); + } + + /// + /// Names of the tools OneWare executes itself, cached because it is checked for every tool event + /// and building the tool list is not free. Refreshed whenever a session is created. + /// + private HashSet? _clientToolNames; + + private bool IsClientTool(string? toolName) + { + if (string.IsNullOrWhiteSpace(toolName)) return false; + + var names = _clientToolNames ??= toolProvider.GetTools() + .Select(x => x.Name) + .ToHashSet(StringComparer.Ordinal); + + return names.Contains(toolName); + } + + /// + /// Compact, human readable summary of what a tool was called with, used as the first line of a + /// sub-agent tool entry. + /// + private static string? DescribeToolArguments(ToolExecutionStartData data) + { + if (data.ShellToolInfo?.DisplayCommand is { Length: > 0 } command) + return Truncate(command, 400); + + if (data.Arguments is not { ValueKind: JsonValueKind.Object } arguments) return null; + + var parts = new List(); + foreach (var property in arguments.EnumerateObject()) + { + var value = property.Value.ValueKind switch + { + JsonValueKind.String => property.Value.GetString(), + JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => property.Value.ToString(), + _ => null + }; + + if (string.IsNullOrWhiteSpace(value)) continue; + + parts.Add($"{property.Name}: {Truncate(value, 200)}"); + if (parts.Count == 3) break; + } + + return parts.Count == 0 ? null : string.Join('\n', parts); + } + + private static string? Truncate(string? text, int maxLength = 4000) + { + if (string.IsNullOrEmpty(text) || text.Length <= maxLength) return text; + + return text[..maxLength] + "…"; + } + + private static string? FirstNonEmpty(params string?[] values) + { + return values.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)); + } + /// /// Mirrors the tier the runtime actually committed back into the picker, without triggering /// another switch request. diff --git a/src/OneWare.Core/Styles/Accents/Base.axaml b/src/OneWare.Core/Styles/Accents/Base.axaml index 3f83a5481..72938bb5a 100644 --- a/src/OneWare.Core/Styles/Accents/Base.axaml +++ b/src/OneWare.Core/Styles/Accents/Base.axaml @@ -110,6 +110,8 @@ Color="{StaticResource ThemeControlHighlightHighColor}" /> + + @@ -158,6 +160,8 @@ Color="{StaticResource ThemeControlHighlightHighColor}" /> + + @@ -187,21 +191,21 @@ - #FFF5F5F5 - #FFAAAAAA - #FF888888 - #FF333333 + #FFF7F7F8 + #FFD1D5DB + #FF9CA3AF + #FF4B5563 #FFFFFFFF - #FFDDDDDD - #FFC2C3C9 - #FF686868 - #FF5B5B5B - #FFF0F0F0 - #FFD0D0D0 - #FF808080 - #FF000000 - #FF222222 - #FF000000 + #FFE5E7EB + #FFD1D5DB + #FF9CA3AF + #FF6B7280 + #FFF3F4F6 + #FFE5E7EB + #FFCBD5E1 + #FF1F2937 + #FF4B5563 + #FF111827 @@ -221,6 +225,8 @@ Color="{StaticResource ThemeControlHighlightHighColor}" /> + + diff --git a/src/OneWare.Core/Styles/Buttons.axaml b/src/OneWare.Core/Styles/Buttons.axaml index 9a70a3381..f6f8e6e56 100644 --- a/src/OneWare.Core/Styles/Buttons.axaml +++ b/src/OneWare.Core/Styles/Buttons.axaml @@ -71,18 +71,18 @@ diff --git a/src/OneWare.Core/Styles/Icons.axaml b/src/OneWare.Core/Styles/Icons.axaml index 1a9a31091..643b4e300 100644 --- a/src/OneWare.Core/Styles/Icons.axaml +++ b/src/OneWare.Core/Styles/Icons.axaml @@ -4,7 +4,7 @@ M12.000008,2.00139734 C16.0040628,2.00139734 19.249992,5.24733208 19.249992,9.25139734 C19.249992,11.3474567 18.3493333,13.2711054 16.5869535,14.9933011 C16.5100054,15.0685004 16.450441,15.1590617 16.4118457,15.2586031 L16.380373,15.3609016 L15.2492957,20.256497 C15.0265913,21.2204195 14.2034707,21.9187611 13.2302558,21.9933796 L13.0570456,22 L10.9432689,22 C9.95374997,22 9.08791486,21.3549492 8.79629808,20.4232534 L8.75088874,20.2559339 L7.62132399,15.3611533 C7.58904306,15.2212595 7.51728321,15.0935695 7.41456824,14.9932517 C5.73516207,13.3530399 4.83779011,11.5301519 4.75613429,9.54965138 L4.750008,9.25139734 L4.75388322,9.01208292 C4.88014853,5.11879041 8.07602134,2.00139734 12.000008,2.00139734 Z M14.115008,18.4993973 L9.884008,18.4993973 L10.2124755,19.9186446 C10.2831529,20.2249131 10.5356676,20.4507962 10.8400612,20.4929101 L10.9432689,20.5 L13.0570456,20.5 C13.3712985,20.5 13.6481364,20.3048226 13.757662,20.0177756 L13.7877956,19.9188323 L14.115008,18.4993973 Z M12.000008,3.50139734 C8.89821922,3.50139734 6.37007083,5.95741111 6.25416008,9.03084139 L6.250008,9.25139734 L6.25672343,9.52840679 C6.33286953,11.0917695 7.05722352,12.5475342 8.46263227,13.9201432 C8.72676013,14.1781068 8.92266257,14.4964075 9.03423135,14.8463815 L9.08291336,15.0238752 L9.538008,16.9993973 L14.461008,16.9993973 L14.9188729,15.023237 C15.0019011,14.6638694 15.1717664,14.3313677 15.4124215,14.0542893 L15.5385739,13.9205009 C16.9431998,12.547902 17.6671738,11.0920338 17.7432801,9.5284475 L17.749992,9.25139734 L17.7458399,9.03084154 C17.6299293,5.9574132 15.1017833,3.50139734 12.000008,3.50139734 Z - @@ -25,13 +25,13 @@ - - @@ -44,25 +44,25 @@ - - - - @@ -74,7 +74,7 @@ Geometry="F1M2,2L9,2 9,4 2,4z M2,6L9,6 9,8 2,8z M2,10L9,10 9,12 2,12z" /> - @@ -244,19 +244,19 @@ - - - @@ -265,24 +265,24 @@ - - - - @@ -297,7 +297,7 @@ - @@ -305,26 +305,26 @@ - - - - - @@ -334,7 +334,7 @@ - @@ -344,48 +344,48 @@ - - - - - - - - - @@ -435,11 +435,11 @@ - - - @@ -452,7 +452,7 @@ - @@ -462,52 +462,52 @@ - - - - - - - - - - @@ -524,7 +524,7 @@ - @@ -541,7 +541,7 @@ - @@ -555,7 +555,7 @@ - @@ -579,9 +579,9 @@ - - @@ -596,19 +596,19 @@ - - - @@ -688,7 +688,7 @@ - @@ -719,25 +719,25 @@ Geometry="F1M16.012,16.042L0.0120000000000005,16.042 0.0120000000000005,0.0419999999999998 16.012,0.0419999999999998z" /> - - - - @@ -763,7 +763,7 @@ - @@ -785,7 +785,7 @@ - @@ -798,7 +798,7 @@ - @@ -809,7 +809,7 @@ Geometry="F1M6.0003,-0.000199999999999534L6.0003,5.9998 0.000300000000000189,5.9998 0.000300000000000189,14.4138 1.5853,15.9998 10.0003,15.9998 10.0003,9.9998 16.0003,9.9998 16.0003,-0.000199999999999534z" /> - @@ -829,7 +829,7 @@ - @@ -840,7 +840,7 @@ - @@ -850,7 +850,7 @@ - @@ -858,7 +858,7 @@ - @@ -867,7 +867,7 @@ - @@ -877,7 +877,7 @@ - @@ -888,7 +888,7 @@ - @@ -898,14 +898,14 @@ - - @@ -914,12 +914,12 @@ - - @@ -937,7 +937,7 @@ - @@ -957,7 +957,7 @@ - @@ -968,7 +968,7 @@ - @@ -980,7 +980,7 @@ - @@ -990,7 +990,7 @@ - @@ -998,7 +998,7 @@ - @@ -1007,11 +1007,11 @@ - - - @@ -1021,7 +1021,7 @@ - @@ -1048,7 +1048,7 @@ - @@ -1058,15 +1058,15 @@ - - - - - @@ -1077,14 +1077,14 @@ - - @@ -1100,7 +1100,7 @@ - @@ -1110,7 +1110,7 @@ - @@ -1120,7 +1120,7 @@ - @@ -1130,7 +1130,7 @@ - @@ -1179,44 +1179,44 @@ - - - - - - - - - - @@ -1233,136 +1233,136 @@ - - - + - + - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - @@ -1371,7 +1371,7 @@ - + @@ -1382,7 +1382,7 @@ - + @@ -1393,7 +1393,7 @@ - + @@ -1404,45 +1404,45 @@ - + - - - - - - @@ -1453,14 +1453,14 @@ - - @@ -1472,7 +1472,7 @@ - @@ -1481,14 +1481,14 @@ - - @@ -1501,32 +1501,32 @@ - - - - - - - @@ -1572,7 +1572,7 @@ - @@ -1581,14 +1581,14 @@ - - @@ -1612,12 +1612,12 @@ - - @@ -1626,16 +1626,16 @@ - - - + + + - - @@ -1648,7 +1648,7 @@ - @@ -1661,20 +1661,20 @@ - - - - @@ -1685,37 +1685,37 @@ Geometry="M3,13H5.79L10.1,4.79L11.28,13.75L14.5,9.66L17.83,13H21V15H17L14.67,12.67L9.92,18.73L8.94,11.31L7,15H3V13Z" /> - - - - - - - @@ -1738,7 +1738,7 @@ - @@ -1746,29 +1746,29 @@ - - - - - - \ No newline at end of file diff --git a/src/OneWare.Essentials/Models/AiFunctionEvent.cs b/src/OneWare.Essentials/Models/AiFunctionEvent.cs index 62b7c069b..1ab598e29 100644 --- a/src/OneWare.Essentials/Models/AiFunctionEvent.cs +++ b/src/OneWare.Essentials/Models/AiFunctionEvent.cs @@ -8,6 +8,20 @@ public class AiFunctionEvent public class AiFunctionStartedEvent : AiFunctionEvent { public required string FunctionName { get; init; } + + /// + /// Name the function is registered with at the AI backend. is the + /// display name, which can differ, so this is what tool events of a chat service refer to. + /// + public string? ToolName { get; init; } + + /// + /// Id the AI backend assigned to this tool call, when it provides one. Used to correlate the + /// call with the tool events of the chat service, e.g. to show it inside the sub-agent that + /// invoked it. + /// + public string? ToolCallId { get; init; } + public string? Detail { get; init; } } diff --git a/src/OneWare.Essentials/Models/ChatServiceEvents.cs b/src/OneWare.Essentials/Models/ChatServiceEvents.cs index f4016193e..60d159ca9 100644 --- a/src/OneWare.Essentials/Models/ChatServiceEvents.cs +++ b/src/OneWare.Essentials/Models/ChatServiceEvents.cs @@ -6,7 +6,12 @@ namespace OneWare.Essentials.Models; public abstract class ChatEvent() { - + /// + /// Id of the sub-agent this event belongs to, or null when it comes from the main agent. + /// The chat UI shows events of a sub-agent inside the corresponding + /// block instead of the main conversation flow. + /// + public string? AgentId { get; init; } } public sealed class ChatMessageDeltaEvent(string content, string? messageId = null) @@ -47,10 +52,85 @@ public sealed class ChatUserMessageEvent(string content) public string Content { get; } = content; } -public sealed class ChatToolExecutionStartEvent(string tool) +public sealed class ChatToolExecutionStartEvent(string tool, string? toolCallId = null, bool isClientTool = false) : ChatEvent() { public string Tool { get; } = tool; + + /// Id of the tool call, used to correlate with . + public string? ToolCallId { get; } = toolCallId; + + /// + /// True when the tool is executed by OneWare itself. Those tool calls are already reported + /// through the AI function provider, so the chat UI only uses this event to attribute them to a + /// sub-agent instead of rendering a second entry. + /// + public bool IsClientTool { get; } = isClientTool; + + /// Short description of what the tool was called with, when known. + public string? Detail { get; init; } +} + +/// +/// Completion of a tool call previously announced by . +/// +public sealed class ChatToolExecutionCompleteEvent(string toolCallId, bool success, string? output = null) + : ChatEvent() +{ + public string ToolCallId { get; } = toolCallId; + + public bool Success { get; } = success; + + public string? Output { get; } = output; +} + +/// +/// Raised when the agent delegated work to a sub-agent. The chat UI opens a collapsible block for +/// it; all following events carrying this as their +/// belong inside that block. +/// +public sealed class ChatSubAgentStartedEvent(string id, string displayName) + : ChatEvent() +{ + public string Id { get; } = id; + + public string DisplayName { get; } = displayName; + + /// What the sub-agent was created for, when the agent definition provides it. + public string? Description { get; init; } + + /// Model the sub-agent runs with, when known. + public string? Model { get; init; } + + /// True when the sub-agent runs in the background instead of blocking its parent. + public bool IsBackground { get; init; } + + /// Id of the spawning sub-agent, for nested delegation. Null when the main agent spawned it. + public string? ParentSubAgentId { get; init; } +} + +/// +/// Raised when a sub-agent announced by finished, failed or +/// was cancelled. +/// +public sealed class ChatSubAgentCompletedEvent(string id, bool success) + : ChatEvent() +{ + public string Id { get; } = id; + + public bool Success { get; } = success; + + /// Error message when the sub-agent failed. + public string? Error { get; init; } + + /// True when the sub-agent was torn down instead of finishing its work. + public bool Cancelled { get; init; } + + public TimeSpan? Duration { get; init; } + + public long? TotalTokens { get; init; } + + public long? TotalToolCalls { get; init; } } /// diff --git a/src/OneWare.SourceControl/Views/SourceControlView.axaml b/src/OneWare.SourceControl/Views/SourceControlView.axaml index 24a6f284d..9924ce48e 100644 --- a/src/OneWare.SourceControl/Views/SourceControlView.axaml +++ b/src/OneWare.SourceControl/Views/SourceControlView.axaml @@ -503,46 +503,49 @@ - - + + + - + - - + + + diff --git a/tests/OneWare.Chat.UnitTests/ChatMessageSubAgentViewModelTests.cs b/tests/OneWare.Chat.UnitTests/ChatMessageSubAgentViewModelTests.cs new file mode 100644 index 000000000..486846fc2 --- /dev/null +++ b/tests/OneWare.Chat.UnitTests/ChatMessageSubAgentViewModelTests.cs @@ -0,0 +1,50 @@ +using System; +using OneWare.Chat.ViewModels.ChatMessages; +using OneWare.Essentials.Models; +using Xunit; + +namespace OneWare.Chat.UnitTests; + +public class ChatMessageSubAgentViewModelTests +{ + [Fact] + public void Complete_CollapsesAndSummarizesSuccessfulRun() + { + var subAgent = new ChatMessageSubAgentViewModel("call_1", "explore"); + + subAgent.Complete(new ChatSubAgentCompletedEvent("call_1", true) + { + Duration = TimeSpan.FromSeconds(12.5), + TotalToolCalls = 8, + TotalTokens = 12400 + }); + + Assert.False(subAgent.IsRunning); + Assert.True(subAgent.IsFinished); + Assert.True(subAgent.IsSuccessful); + Assert.False(subAgent.IsExpanded); + Assert.Equal("Done · 12.5s · 8 tool calls · 12.4k tokens", subAgent.StatusText); + } + + [Fact] + public void Complete_ReportsFailureReason() + { + var subAgent = new ChatMessageSubAgentViewModel("call_1", "explore"); + + subAgent.Complete(new ChatSubAgentCompletedEvent("call_1", false) { Error = "boom" }); + + Assert.False(subAgent.IsSuccessful); + Assert.Equal("Failed: boom", subAgent.StatusText); + } + + [Fact] + public void Complete_MarksCancelledRunAsUnsuccessful() + { + var subAgent = new ChatMessageSubAgentViewModel("call_1", "explore"); + + subAgent.Complete(new ChatSubAgentCompletedEvent("call_1", true) { Cancelled = true }); + + Assert.False(subAgent.IsSuccessful); + Assert.Equal("Cancelled", subAgent.StatusText); + } +} From 9a9b69825501384f9c57ee57e8d521606e056e1f Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Thu, 17 Sep 2026 16:46:59 +0200 Subject: [PATCH 2/5] planning mode --- docs/PluginDevelopment.md | 68 +++- src/OneWare.Chat/ChatModule.cs | 3 + .../Services/AiBuiltInFunctions.cs | 6 + .../Services/AiFunctionProvider.cs | 11 + src/OneWare.Chat/Services/ChatAgentService.cs | 339 ++++++++++++++++++ src/OneWare.Chat/ViewModels/ChatViewModel.cs | 11 +- src/OneWare.Chat/Views/ChatView.axaml | 44 +++ .../Services/CopilotChatService.cs | 147 +++++++- .../Models/ChatAgentDefinition.cs | 70 ++++ .../Models/OneWareAiFunction.cs | 10 + .../Services/IAiFunctionProvider.cs | 7 + .../Services/IChatAgentService.cs | 45 +++ .../ChatAgentServiceTests.cs | 127 +++++++ 13 files changed, 881 insertions(+), 7 deletions(-) create mode 100644 src/OneWare.Chat/Services/ChatAgentService.cs create mode 100644 src/OneWare.Essentials/Models/ChatAgentDefinition.cs create mode 100644 src/OneWare.Essentials/Services/IChatAgentService.cs create mode 100644 tests/OneWare.Chat.UnitTests/ChatAgentServiceTests.cs diff --git a/docs/PluginDevelopment.md b/docs/PluginDevelopment.md index 18a50fc9d..cfe600457 100644 --- a/docs/PluginDevelopment.md +++ b/docs/PluginDevelopment.md @@ -329,6 +329,12 @@ Provides all app path locations: `AppDataDirectory`, `ProjectsDirectory`, `Packa - `SelectedChatService`: currently selected provider. - `SaveState()`: persist selection. +#### `IChatAgentService` (src/OneWare.Essentials/Services/IChatAgentService.cs) + +- `Agents` / `SelectedAgent`: the agents offered in the chat agent picker and the selected one. +- `RegisterAgent(ChatAgentDefinition)`: add a selectable agent (see "Chat agents"). +- `Refresh()`: re-read the markdown agents of the active project. + #### `IAiFunctionProvider` (src/OneWare.Essentials/Services/IAiFunctionProvider.cs) - `RegisterFunction(IOneWareAiFunction)`: register an AI tool. @@ -338,6 +344,8 @@ Provides all app path locations: `AppDataDirectory`, `ProjectsDirectory`, `Packa - `GetTools()`: return the registered tools for chat or automation. - `FunctionStarted`, `FunctionProgress`, `FunctionCompleted` events identify each concurrent invocation by its unique ID. +- Set `OneWareAiFunction.IsReadOnly` on tools that only read state, so read-only chat agents + (`Plan`, `Ask`) may use them. Everything else is blocked for those agents. - Set `OneWareAiFunction.InvocationHandler` when a tool needs an `AiFunctionInvocationContext` for invocation-scoped progress reporting. Keep `Handler` as the typed delegate used to generate the tool schema. @@ -662,10 +670,62 @@ Optional members: `Tools` (restrict the agent to specific tool names), `Model` a `ReasoningEffort` (overrides for this agent), and `Infer = false` if the main agent must not delegate to it on its own. -There is no agent picker in the chat UI. An agent is used either automatically — the main agent -delegates when the request matches the `Description` — or because the user names it (*"use the -OneAI dataset agent to …"*). Write the `Description` for the first case: state *when* to use the -agent, not what it is. +Agents registered this way are *delegation targets*: they are used either automatically — the main +agent delegates when the request matches the `Description` — or because the user names it (*"use +the OneAI dataset agent to …"*). Write the `Description` for the first case: state *when* to use +the agent, not what it is. Their work is shown in the chat as a collapsible sub-agent block. + +To add an agent the **user selects** for the whole conversation, use `IChatAgentService` instead +(see "Chat agents"). + +### Chat agents + +`IChatAgentService` (src/OneWare.Essentials/Services/IChatAgentService.cs) holds the agents offered +in the picker below the chat input. OneWare ships three built-ins: + +| Agent | Behaviour | +| --- | --- | +| `agent` | Full access: researches, edits files and runs tools | +| `plan` | Runs the turn in plan mode and works out a plan; workspace changes are blocked | +| `ask` | Answers questions; workspace changes are blocked | + +The selected agent applies to every message sent while it is active: its `Instructions` are added +to the turn, `TurnMode` selects interactive or plan mode, and `IsReadOnly`/`Tools` are enforced +before a tool runs — a blocked tool call is denied, not just hidden from the model. + +Register an agent from a module: + +```csharp +serviceProvider.Resolve().RegisterAgent(new ChatAgentDefinition +{ + Id = "fpga-bringup", + DisplayName = "FPGA Bring-up", + Description = "Walks through pin planning, constraints and the first bitstream.", + Instructions = "You guide the user through bringing up a new FPGA board. ...", + Tools = ["readFile", "getActiveProject", "getAllErrors"] +}); +``` + +Users can add agents without writing code by dropping a markdown file into `.github/agents/` of the +project (or into `/Agents/` to have it available everywhere). The front matter is optional; +the body is used as the instructions: + +```markdown +--- +name: release-notes +displayName: Release Notes +description: Summarizes what changed since the last tag. +mode: plan # "plan" or omitted for interactive +readOnly: true +tools: [readFile, getActiveProject] +model: claude-haiku-4.5 +reasoningEffort: low +--- + +Summarize the changes since the last release tag, grouped by area. +``` + +File agents are re-read whenever the active project changes or a new chat is started. ### Skills diff --git a/src/OneWare.Chat/ChatModule.cs b/src/OneWare.Chat/ChatModule.cs index bace72555..9fc25c2f8 100644 --- a/src/OneWare.Chat/ChatModule.cs +++ b/src/OneWare.Chat/ChatModule.cs @@ -21,6 +21,9 @@ public override void RegisterServices(IServiceCollection services) services.AddSingleton(provider => provider.Resolve()); services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(provider => provider.Resolve()); } public override void Initialize(IServiceProvider serviceProvider) diff --git a/src/OneWare.Chat/Services/AiBuiltInFunctions.cs b/src/OneWare.Chat/Services/AiBuiltInFunctions.cs index a2b4b04ec..c5b7ccbbb 100644 --- a/src/OneWare.Chat/Services/AiBuiltInFunctions.cs +++ b/src/OneWare.Chat/Services/AiBuiltInFunctions.cs @@ -31,6 +31,7 @@ public static void Register( functionProvider.RegisterFunction(new OneWareAiFunction { Name = "readFile", + IsReadOnly = true, FriendlyName = "Read File", RunOnUiThread = true, Description = "Read the specified file (optionally by line range). Always pass an absolute path.", @@ -76,6 +77,7 @@ public static void Register( functionProvider.RegisterFunction(new OneWareAiFunction { Name = "getActiveProject", + IsReadOnly = true, FriendlyName = "Get Active Project", RunOnUiThread = true, Description = @@ -90,6 +92,7 @@ public static void Register( functionProvider.RegisterFunction(new OneWareAiFunction { Name = "getOpenFiles", + IsReadOnly = true, FriendlyName = "Get Open Files", RunOnUiThread = true, Description = """ @@ -110,6 +113,7 @@ Do not assume or invent open files. functionProvider.RegisterFunction(new OneWareAiFunction { Name = "getFocusedFile", + IsReadOnly = true, FriendlyName = "Get Focused File", RunOnUiThread = true, Description = """ @@ -151,6 +155,7 @@ Optionally jumps to a specific line. Always pass an absolute path. functionProvider.RegisterFunction(new OneWareAiFunction { Name = "getErrorsForFile", + IsReadOnly = true, FriendlyName = "Get Errors for File", RunOnUiThread = true, Description = "Returns the LSP Errors for the specified path (if any)", @@ -162,6 +167,7 @@ Optionally jumps to a specific line. Always pass an absolute path. functionProvider.RegisterFunction(new OneWareAiFunction { Name = "getAllErrors", + IsReadOnly = true, FriendlyName = "Get Errors", RunOnUiThread = true, Description = "Returns all the errors found by LSP", diff --git a/src/OneWare.Chat/Services/AiFunctionProvider.cs b/src/OneWare.Chat/Services/AiFunctionProvider.cs index 9ae70cf37..6e4621e07 100644 --- a/src/OneWare.Chat/Services/AiFunctionProvider.cs +++ b/src/OneWare.Chat/Services/AiFunctionProvider.cs @@ -260,6 +260,17 @@ private static string ToYamlString(string value) return $"\"{escaped}\""; } + public bool? IsFunctionReadOnly(string functionName) + { + EnsureBuiltInsRegistered(); + lock (_registrationLock) + { + return _registeredFunctions + .FirstOrDefault(f => string.Equals(f.Name, functionName, StringComparison.Ordinal)) + ?.IsReadOnly; + } + } + public Func? GetConfirmationCheck(string functionName) { EnsureBuiltInsRegistered(); diff --git a/src/OneWare.Chat/Services/ChatAgentService.cs b/src/OneWare.Chat/Services/ChatAgentService.cs new file mode 100644 index 000000000..00db4f1de --- /dev/null +++ b/src/OneWare.Chat/Services/ChatAgentService.cs @@ -0,0 +1,339 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.Extensions.Logging; +using OneWare.Essentials.Models; +using OneWare.Essentials.Services; + +namespace OneWare.Chat.Services; + +/// +/// Provides the selectable chat agents: the built-in Agent, Plan and Ask modes, +/// agents registered by modules, and markdown agents discovered in .github/agents of the +/// active project and in the user agent directory. +/// +public class ChatAgentService : ObservableObject, IChatAgentService +{ + /// Directory name searched for markdown agents inside a project. + public const string ProjectAgentDirectory = ".github/agents"; + + private const string SelectedAgentSettingKey = "AiChat_SelectedAgent"; + + private readonly IPaths _paths; + private readonly IProjectExplorerService _projectExplorerService; + private readonly ISettingsService _settingsService; + private readonly ILogger _logger; + + private readonly List _builtInAgents; + private readonly List _registeredAgents = []; + private readonly List _fileAgents = []; + + private ChatAgentDefinition? _selectedAgent; + + /// + /// Id the user last picked (or the persisted one). Kept even while no matching agent exists, so + /// a project agent that is only discovered after startup still becomes the selection. + /// + private string? _desiredAgentId; + + public ChatAgentService(IPaths paths, IProjectExplorerService projectExplorerService, + ISettingsService settingsService, ILogger logger) + { + _paths = paths; + _projectExplorerService = projectExplorerService; + _settingsService = settingsService; + _logger = logger; + + _builtInAgents = CreateBuiltInAgents(); + + if (!settingsService.HasSetting(SelectedAgentSettingKey)) + settingsService.Register(SelectedAgentSettingKey, _builtInAgents[0].Id); + + _desiredAgentId = settingsService.GetSettingValue(SelectedAgentSettingKey); + + if (projectExplorerService is INotifyPropertyChanged notify) + notify.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(IProjectExplorerService.ActiveProject)) Refresh(); + }; + + Refresh(); + } + + public ObservableCollection Agents { get; } = []; + + public ChatAgentDefinition? SelectedAgent + { + get => _selectedAgent; + set + { + // The selector rebuilds its items on every refresh, so ignore the transient null the + // bound ListBox pushes back while its item source is being replaced. + if (value == null && Agents.Count > 0) return; + if (!SetProperty(ref _selectedAgent, value)) return; + if (value == null) return; + + _desiredAgentId = value.Id; + _settingsService.SetSettingValue(SelectedAgentSettingKey, value.Id); + } + } + + public void RegisterAgent(ChatAgentDefinition agent) + { + if (string.IsNullOrWhiteSpace(agent.Id)) throw new ArgumentException("Agent id must not be empty."); + + _registeredAgents.RemoveAll(x => IdEquals(x.Id, agent.Id)); + _registeredAgents.Add(agent); + RebuildAgents(); + } + + public bool RemoveAgent(string id) + { + if (_registeredAgents.RemoveAll(x => IdEquals(x.Id, id)) == 0) return false; + + RebuildAgents(); + return true; + } + + public bool SelectAgent(string? id) + { + if (string.IsNullOrWhiteSpace(id)) return false; + + var match = Agents.FirstOrDefault(x => IdEquals(x.Id, id)); + if (match == null) return false; + + SelectedAgent = match; + return true; + } + + public void Refresh() + { + _fileAgents.Clear(); + + foreach (var directory in GetAgentDirectories()) + LoadAgentsFrom(directory); + + RebuildAgents(); + } + + private IEnumerable GetAgentDirectories() + { + yield return Path.Combine(_paths.AppDataDirectory, "Agents"); + + var projectRoot = _projectExplorerService.ActiveProject?.FullPath; + if (!string.IsNullOrEmpty(projectRoot)) + yield return Path.Combine(projectRoot, ".github", "agents"); + } + + private void LoadAgentsFrom(string directory) + { + try + { + if (!Directory.Exists(directory)) return; + + foreach (var file in Directory.EnumerateFiles(directory, "*.md", SearchOption.TopDirectoryOnly)) + { + try + { + var agent = ParseAgentFile(file); + if (agent == null) continue; + + _fileAgents.RemoveAll(x => IdEquals(x.Id, agent.Id)); + _fileAgents.Add(agent); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load chat agent from {File}.", file); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to scan chat agent directory {Directory}.", directory); + } + } + + /// + /// Reads a markdown agent file: an optional YAML front matter block with the agent metadata, + /// followed by the markdown body used as the agent instructions. + /// + internal static ChatAgentDefinition? ParseAgentFile(string file) + { + var text = File.ReadAllText(file); + var (frontMatter, body) = SplitFrontMatter(text); + + var id = FirstValue(frontMatter, "name", "id") ?? Path.GetFileNameWithoutExtension(file); + if (string.IsNullOrWhiteSpace(id)) return null; + + var instructions = body.Trim(); + if (instructions.Length == 0) return null; + + var displayName = FirstValue(frontMatter, "displayName", "display_name", "title") + ?? ToDisplayName(id); + + return new ChatAgentDefinition + { + Id = id.Trim(), + DisplayName = displayName, + Description = FirstValue(frontMatter, "description"), + Instructions = instructions, + TurnMode = string.Equals(FirstValue(frontMatter, "mode"), "plan", StringComparison.OrdinalIgnoreCase) + ? ChatAgentTurnMode.Plan + : ChatAgentTurnMode.Interactive, + IsReadOnly = ParseBool(FirstValue(frontMatter, "readOnly", "read_only")) ?? false, + Tools = ParseList(FirstValue(frontMatter, "tools")), + Model = FirstValue(frontMatter, "model"), + ReasoningEffort = FirstValue(frontMatter, "reasoningEffort", "reasoning_effort"), + SourcePath = file + }; + } + + private static (Dictionary FrontMatter, string Body) SplitFrontMatter(string text) + { + var frontMatter = new Dictionary(StringComparer.OrdinalIgnoreCase); + var normalized = text.Replace("\r\n", "\n"); + + if (!normalized.StartsWith("---\n", StringComparison.Ordinal)) return (frontMatter, normalized); + + var end = normalized.IndexOf("\n---", 3, StringComparison.Ordinal); + if (end < 0) return (frontMatter, normalized); + + var block = normalized[4..end]; + + // Skip the rest of the closing fence line; everything after it is body, including a leading + // markdown list or horizontal rule. + var bodyStart = normalized.IndexOf('\n', end + 1); + var body = bodyStart < 0 ? string.Empty : normalized[(bodyStart + 1)..]; + + string? listKey = null; + var listValues = new List(); + + foreach (var rawLine in block.Split('\n')) + { + var line = rawLine.TrimEnd(); + if (line.Length == 0 || line.TrimStart().StartsWith('#')) continue; + + // Continuation of a "key:" block that lists its values as "- value" lines. + if (listKey != null && line.TrimStart().StartsWith("- ", StringComparison.Ordinal)) + { + listValues.Add(Unquote(line.TrimStart()[2..].Trim())); + frontMatter[listKey] = string.Join(", ", listValues); + continue; + } + + var separator = line.IndexOf(':'); + if (separator <= 0) continue; + + var key = line[..separator].Trim(); + var value = Unquote(line[(separator + 1)..].Trim()); + + listKey = value.Length == 0 ? key : null; + listValues.Clear(); + frontMatter[key] = value; + } + + return (frontMatter, body); + } + + private static string Unquote(string value) + { + if (value.Length >= 2 && ((value[0] == '"' && value[^1] == '"') || (value[0] == '\'' && value[^1] == '\''))) + return value[1..^1]; + return value; + } + + private static string? FirstValue(Dictionary frontMatter, params string[] keys) + { + foreach (var key in keys) + if (frontMatter.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)) + return value.Trim(); + return null; + } + + private static bool? ParseBool(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + return value.Trim().ToLowerInvariant() is "true" or "yes" or "1"; + } + + private static IReadOnlyList? ParseList(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + + var items = value.Trim().Trim('[', ']') + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(Unquote) + .Where(x => x.Length > 0) + .ToArray(); + + return items.Length == 0 ? null : items; + } + + private static string ToDisplayName(string id) + { + var parts = id.Split(['-', '_', ' '], StringSplitOptions.RemoveEmptyEntries); + return string.Join(' ', parts.Select(p => char.ToUpperInvariant(p[0]) + p[1..])); + } + + private void RebuildAgents() + { + var all = _builtInAgents + .Concat(_registeredAgents.OrderBy(x => x.DisplayName, StringComparer.OrdinalIgnoreCase)) + .Concat(_fileAgents.OrderBy(x => x.DisplayName, StringComparer.OrdinalIgnoreCase)) + .ToList(); + + Agents.Clear(); + foreach (var agent in all) Agents.Add(agent); + + _selectedAgent = Agents.FirstOrDefault(x => IdEquals(x.Id, _desiredAgentId ?? _selectedAgent?.Id)) + ?? Agents.FirstOrDefault(); + OnPropertyChanged(nameof(SelectedAgent)); + } + + private static bool IdEquals(string? a, string? b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase); + + private static List CreateBuiltInAgents() => + [ + new() + { + Id = "agent", + DisplayName = "Agent", + Description = "Full access: researches, edits files and runs tools to complete the task.", + IsBuiltIn = true + }, + new() + { + Id = "plan", + DisplayName = "Plan", + Description = "Researches the codebase and works out a plan before anything is changed.", + TurnMode = ChatAgentTurnMode.Plan, + IsReadOnly = true, + IsBuiltIn = true, + Instructions = """ + You are in plan mode. Investigate the codebase and produce an implementation + plan for the user's request instead of carrying it out. + + - Do not modify files, run commands with side effects, or change IDE state. + - Read the code you need first; never plan against assumptions. + - Deliver a concise, ordered plan of concrete steps with the files involved, + and call out open questions, risks and decisions the user has to make. + - Wait for the user to accept the plan before doing any work. + """ + }, + new() + { + Id = "ask", + DisplayName = "Ask", + Description = "Answers questions about the code base without changing anything.", + IsReadOnly = true, + IsBuiltIn = true, + Instructions = """ + You are in ask mode: answer the user's question, do not carry out work. + + - Do not modify files, run commands with side effects, or change IDE state. + - Read the relevant code before answering; never answer from assumptions. + - Answer directly and concisely, with short code examples where they help. + """ + } + ]; +} diff --git a/src/OneWare.Chat/ViewModels/ChatViewModel.cs b/src/OneWare.Chat/ViewModels/ChatViewModel.cs index 1923e6947..9935e6e27 100644 --- a/src/OneWare.Chat/ViewModels/ChatViewModel.cs +++ b/src/OneWare.Chat/ViewModels/ChatViewModel.cs @@ -93,8 +93,9 @@ private sealed record CancelledQueuedMessage(string Content, DateTimeOffset Expi public ChatViewModel(IAiFunctionProvider aiFunctionProvider, IMainDockService mainDockService, AiFileEditService aiFileEditService, IPaths paths, ISettingsService settingsService, - IApplicationStateService applicationStateService) : base(IconKey) + IApplicationStateService applicationStateService, IChatAgentService chatAgentService) : base(IconKey) { + AgentService = chatAgentService; Id = "AI_Chat"; Title = "AI Chat"; @@ -240,6 +241,11 @@ public string WorkingStatusText public ObservableCollection ChatServices { get; } = []; + /// + /// Selectable chat agents ("Agent", "Plan", "Ask" and custom ones), bound by the agent selector. + /// + public IChatAgentService AgentService { get; } + public ObservableCollection SessionHistory { get; } = []; public ChatSessionHistoryItem? SelectedSessionHistory @@ -411,6 +417,9 @@ private async Task InitializeAndRestoreCurrentServiceAsync(IChatService chatServ private async Task NewChatAsync() { + // Agent files may have been added or edited since the last chat started. + AgentService.Refresh(); + if (SelectedChatService != null) { StoreCurrentMessages(SelectedChatService.Name, SelectedChatService); diff --git a/src/OneWare.Chat/Views/ChatView.axaml b/src/OneWare.Chat/Views/ChatView.axaml index 3377a5e01..ab80ad04a 100644 --- a/src/OneWare.Chat/Views/ChatView.axaml +++ b/src/OneWare.Chat/Views/ChatView.axaml @@ -7,6 +7,7 @@ xmlns:controls="clr-namespace:OneWare.Essentials.Controls;assembly=OneWare.Essentials" xmlns:behaviors="clr-namespace:OneWare.Chat.Behaviors" xmlns:behaviors1="clr-namespace:OneWare.Essentials.Behaviors;assembly=OneWare.Essentials" + xmlns:models="clr-namespace:OneWare.Essentials.Models;assembly=OneWare.Essentials" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="OneWare.Chat.Views.ChatView" Name="ChatViewView" Background="{DynamicResource ThemeControlLowBrush}" x:DataType="viewModels:ChatViewModel"> @@ -345,6 +346,49 @@ + + + diff --git a/src/OneWare.Copilot/Services/CopilotChatService.cs b/src/OneWare.Copilot/Services/CopilotChatService.cs index 659e98472..5db01dabf 100644 --- a/src/OneWare.Copilot/Services/CopilotChatService.cs +++ b/src/OneWare.Copilot/Services/CopilotChatService.cs @@ -42,7 +42,8 @@ public sealed class CopilotChatService( IPackageWindowService packageWindowService, IWindowService windowService, IMainDockService mainDockService, - IPaths paths) + IPaths paths, + IChatAgentService agentService) : ObservableObject, IChatServiceWithSessions { private CopilotClient? _client; @@ -552,6 +553,9 @@ private void ApplyModelToSession() var model = SelectedModel; if (session == null || model == null) return; + // The user's choice wins over a model an agent pinned earlier. + _appliedAgentModelId = null; + var options = new SetModelOptions { ReasoningEffort = ShowReasoningEffort ? SelectedReasoningEffort : null @@ -1225,6 +1229,7 @@ private async Task InitializeSessionAsync() ClientName = "OneWare Studio", OnPermissionRequest = OnPermissionRequestAsync, OnUserInputRequest = OnUserInputRequestAsync, + OnExitPlanModeRequest = OnExitPlanModeRequestAsync, Hooks = new SessionHooks { OnPreToolUse = OnPreToolUseAsync @@ -1248,6 +1253,7 @@ private async Task InitializeSessionAsync() EnableSkills = true, OnPermissionRequest = OnPermissionRequestAsync, OnUserInputRequest = OnUserInputRequestAsync, + OnExitPlanModeRequest = OnExitPlanModeRequestAsync, Hooks = new SessionHooks { OnPreToolUse = OnPreToolUseAsync @@ -1435,7 +1441,24 @@ public async Task SendAsync(string prompt, ChatSendMode mode) if (_session == null) return; - var options = new MessageOptions { Prompt = prompt }; + var agent = agentService.SelectedAgent; + + var options = new MessageOptions + { + Prompt = ApplyAgentInstructions(prompt, agent), + AgentMode = agent?.TurnMode switch + { + ChatAgentTurnMode.Plan => AgentMode.Plan, + ChatAgentTurnMode.Interactive => AgentMode.Interactive, + _ => null + } + }; + + // The instructions are only for the model; the timeline keeps showing what the user wrote. + if (!string.Equals(options.Prompt, prompt, StringComparison.Ordinal)) options.DisplayPrompt = prompt; + + await ApplyAgentModelAsync(agent).ConfigureAwait(false); + var attachments = CollectAttachments(); if (attachments != null) options.Attachments = attachments; @@ -1563,6 +1586,8 @@ private async Task DisposeSessionAsync() _session = null; } + _appliedAgentModelId = null; + lock (_subAgents) _subAgents.Clear(); @@ -2013,10 +2038,128 @@ public async Task DisableRemoteSessionAsync() } } + // ── Chat agents ("Agent", "Plan", "Ask" and custom agents) ──────────── + + /// + /// Model the session currently runs on, which is the model an agent pinned while such an agent is + /// selected, and the user's model otherwise. + /// + private string? _appliedAgentModelId; + + /// + /// Applies the model an agent pinned for the upcoming turn. The user's model selection stays + /// untouched, so deselecting the agent restores it with the next message. + /// + private async Task ApplyAgentModelAsync(ChatAgentDefinition? agent) + { + var session = _session; + var userModel = SelectedModel; + if (session == null || userModel == null) return; + + var pinned = string.IsNullOrWhiteSpace(agent?.Model) + ? null + : Models.FirstOrDefault(x => string.Equals(x.Id, agent!.Model, StringComparison.OrdinalIgnoreCase)) + ?? Models.FirstOrDefault(x => string.Equals(x.Name, agent!.Model, StringComparison.OrdinalIgnoreCase)); + + var target = pinned ?? userModel; + + // Nothing to do while the session already runs the right model: it is set up with the user's + // model, and only this method ever changes it for an agent. + if (_appliedAgentModelId == null && pinned == null) return; + if (string.Equals(_appliedAgentModelId, target.Id, StringComparison.Ordinal)) return; + + var effort = pinned != null && !string.IsNullOrWhiteSpace(agent!.ReasoningEffort) + ? agent.ReasoningEffort + : ShowReasoningEffort + ? SelectedReasoningEffort + : null; + + try + { + await session.SetModelAsync(target.Id, new SetModelOptions { ReasoningEffort = effort }); + _appliedAgentModelId = pinned == null ? null : target.Id; + } + catch (Exception ex) + { + ContainerLocator.Container.Resolve() + .LogError(ex, "Failed to apply the model {Model} of the selected chat agent.", target.Id); + } + } + + /// + /// Prefixes the prompt with the instructions of the selected agent, so they apply to this turn + /// regardless of how long ago the agent was selected. + /// + private static string ApplyAgentInstructions(string prompt, ChatAgentDefinition? agent) + { + if (agent?.Instructions is not { Length: > 0 } instructions) return prompt; + + return $""" + + {instructions.Trim()} + + + {prompt} + """; + } + + /// + /// Returns why the selected agent must not call the given tool, or if it + /// may. Only OneWare tools can reach the workspace or the IDE — backend built-ins that touch the + /// host are already kept out of the session by — so a read-only + /// agent is enforced by blocking every OneWare tool that is not marked read-only. + /// + private string? GetAgentToolDenyReason(string toolName) + { + var agent = agentService.SelectedAgent; + if (agent == null) return null; + + // Only tools OneWare provides are restricted: the session built-ins (task/skill delegation, + // planning bookkeeping, …) are what the agent works with and are never named in a tool list. + var isOneWareTool = toolProvider.IsFunctionReadOnly(toolName); + if (isOneWareTool == null) return null; + + if (agent.Tools != null && !agent.Tools.Contains(toolName, StringComparer.OrdinalIgnoreCase)) + return $"The {agent.DisplayName} agent is not allowed to use '{toolName}'."; + + if (agent.IsReadOnly && isOneWareTool == false) + return $"'{toolName}' changes the workspace, which is not allowed in {agent.DisplayName} mode. " + + "Report what you would change instead, or ask the user to switch to Agent mode."; + + return null; + } + + /// + /// The model asks to leave plan mode and start implementing. The selected agent — not the model + /// — decides what the chat may do, so this is refused while a read-only agent is active. + /// + private Task OnExitPlanModeRequestAsync(ExitPlanModeRequest request, + ExitPlanModeInvocation invocation) + { + var agent = agentService.SelectedAgent; + if (agent is not { IsReadOnly: true }) return Task.FromResult(new ExitPlanModeResult { Approved = true }); + + return Task.FromResult(new ExitPlanModeResult + { + Approved = false, + Feedback = $"The chat is in {agent.DisplayName} mode. Present the plan and let the user " + + "switch to Agent mode to implement it." + }); + } + // ── OnPreToolUse — returns "ask" to escalate to OnPermissionRequest ──────── private Task OnPreToolUseAsync(PreToolUseHookInput input, HookInvocation invocation) { + // The selected agent restricts which tools may run, no matter how permissions are configured. + var denyReason = GetAgentToolDenyReason(input.ToolName); + if (denyReason != null) + return Task.FromResult(new PreToolUseHookOutput + { + PermissionDecision = "deny", + PermissionDecisionReason = denyReason + }); + // Bypass Approval / Autopilot: auto-approve all permission requests without prompting if (IsApprovalBypassed) return Task.FromResult(new PreToolUseHookOutput { PermissionDecision = "allow" }); diff --git a/src/OneWare.Essentials/Models/ChatAgentDefinition.cs b/src/OneWare.Essentials/Models/ChatAgentDefinition.cs new file mode 100644 index 000000000..2a34f8a06 --- /dev/null +++ b/src/OneWare.Essentials/Models/ChatAgentDefinition.cs @@ -0,0 +1,70 @@ +namespace OneWare.Essentials.Models; + +/// +/// The conversational mode a chat agent runs its turns in. +/// +public enum ChatAgentTurnMode +{ + /// The agent answers and acts interactively. + Interactive, + + /// The agent researches and proposes a plan before changing anything. + Plan +} + +/// +/// A user-selectable chat agent ("chat mode"): the personality, tool budget and turn mode the chat +/// uses for the messages that are sent while it is selected. +/// +/// +/// Built-in agents (Agent, Plan, Ask) ship with OneWare. Additional agents come +/// from modules via or from markdown files in +/// .github/agents of the active project. +/// +public sealed class ChatAgentDefinition +{ + /// + /// Unique identifier. Use a lowercase, hyphenated name (e.g. code-review); registering the + /// same id twice replaces the previous agent. + /// + public required string Id { get; init; } + + /// Name shown in the agent selector. + public required string DisplayName { get; init; } + + /// Short explanation shown as tooltip in the agent selector. + public string? Description { get; init; } + + /// + /// Additional instructions applied to every message sent while this agent is selected. + /// + public string? Instructions { get; init; } + + /// The turn mode the chat backend should use. + public ChatAgentTurnMode TurnMode { get; init; } = ChatAgentTurnMode.Interactive; + + /// + /// When , tools that change the workspace or the IDE are blocked, so the + /// agent can only research and answer. + /// + public bool IsReadOnly { get; init; } + + /// + /// Names of the tools the agent may use. grants the full tool set. + /// + public IReadOnlyList? Tools { get; init; } + + /// Optional model override. Uses the chat's selected model when . + public string? Model { get; init; } + + /// Optional reasoning effort override ("low", "medium", "high", "xhigh"). + public string? ReasoningEffort { get; init; } + + /// Whether the agent ships with OneWare and cannot be removed. + public bool IsBuiltIn { get; init; } + + /// Markdown file the agent was loaded from, if it is file-based. + public string? SourcePath { get; init; } + + public override string ToString() => DisplayName; +} diff --git a/src/OneWare.Essentials/Models/OneWareAiFunction.cs b/src/OneWare.Essentials/Models/OneWareAiFunction.cs index 6a1521a1f..ed34dbc36 100644 --- a/src/OneWare.Essentials/Models/OneWareAiFunction.cs +++ b/src/OneWare.Essentials/Models/OneWareAiFunction.cs @@ -31,6 +31,13 @@ public interface IOneWareAiFunction /// Func>? InvocationHandler => null; + + /// + /// Whether the function only reads state. Read-only agents (e.g. the built-in Ask and + /// Plan agents) may call these functions; everything else is blocked for them, so a + /// function that changes files, the IDE or the system must leave this . + /// + bool IsReadOnly => false; } public sealed class AiFunctionInvocationContext(string id, Action reportProgress) @@ -54,4 +61,7 @@ public sealed class OneWareAiFunction : IOneWareAiFunction /// public Func>? InvocationHandler { get; init; } + + /// + public bool IsReadOnly { get; init; } } diff --git a/src/OneWare.Essentials/Services/IAiFunctionProvider.cs b/src/OneWare.Essentials/Services/IAiFunctionProvider.cs index a9f1dee11..46ddaeee9 100644 --- a/src/OneWare.Essentials/Services/IAiFunctionProvider.cs +++ b/src/OneWare.Essentials/Services/IAiFunctionProvider.cs @@ -78,6 +78,13 @@ void RegisterSkillDirectory(string directory) /// IReadOnlyCollection GetSkillDirectories() => []; + /// + /// Returns whether the named function is registered and marked + /// . Returns for tools that are + /// not provided by OneWare (e.g. built-in tools of the AI backend). + /// + bool? IsFunctionReadOnly(string functionName) => null; + /// /// Returns the delegate for the named function, /// or if the function has no check or is not registered. diff --git a/src/OneWare.Essentials/Services/IChatAgentService.cs b/src/OneWare.Essentials/Services/IChatAgentService.cs new file mode 100644 index 000000000..d28ef6f13 --- /dev/null +++ b/src/OneWare.Essentials/Services/IChatAgentService.cs @@ -0,0 +1,45 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using OneWare.Essentials.Models; + +namespace OneWare.Essentials.Services; + +/// +/// Keeps the list of selectable chat agents and the agent the user currently chats with. +/// +/// +/// Chat services read when they send a message and apply its turn mode, +/// instructions and tool restrictions to that turn. +/// +public interface IChatAgentService : INotifyPropertyChanged +{ + /// + /// Built-in, module-registered and file-based agents, in display order. + /// + ObservableCollection Agents { get; } + + /// + /// The agent used for new messages. Never while agents exist. + /// + ChatAgentDefinition? SelectedAgent { get; set; } + + /// + /// Adds an agent or replaces the one with the same . + /// + void RegisterAgent(ChatAgentDefinition agent); + + /// + /// Removes a registered agent. Built-in agents cannot be removed. + /// + bool RemoveAgent(string id); + + /// + /// Re-reads the file-based agents of the active project. + /// + void Refresh(); + + /// + /// Selects the agent with the given id, if it exists. + /// + bool SelectAgent(string? id); +} diff --git a/tests/OneWare.Chat.UnitTests/ChatAgentServiceTests.cs b/tests/OneWare.Chat.UnitTests/ChatAgentServiceTests.cs new file mode 100644 index 000000000..252ceecb8 --- /dev/null +++ b/tests/OneWare.Chat.UnitTests/ChatAgentServiceTests.cs @@ -0,0 +1,127 @@ +using System; +using System.IO; +using OneWare.Chat.Services; +using OneWare.Essentials.Models; +using Xunit; + +namespace OneWare.Chat.UnitTests; + +public class ChatAgentServiceTests : IDisposable +{ + private readonly string _directory = + Path.Combine(Path.GetTempPath(), "OneWareAgentTests", Guid.NewGuid().ToString("N")); + + public ChatAgentServiceTests() => Directory.CreateDirectory(_directory); + + public void Dispose() + { + if (Directory.Exists(_directory)) Directory.Delete(_directory, true); + } + + private string WriteAgent(string fileName, string content) + { + var path = Path.Combine(_directory, fileName); + File.WriteAllText(path, content); + return path; + } + + [Fact] + public void ParseAgentFile_ReadsFrontMatterAndBody() + { + var path = WriteAgent("review.md", """ + --- + name: code-review + displayName: Code Review + description: Reviews a diff. + mode: plan + readOnly: true + model: claude-opus-5 + reasoningEffort: high + tools: [readFile, getAllErrors] + --- + + Review the change and report bugs. + """); + + var agent = ChatAgentService.ParseAgentFile(path); + + Assert.NotNull(agent); + Assert.Equal("code-review", agent!.Id); + Assert.Equal("Code Review", agent.DisplayName); + Assert.Equal("Reviews a diff.", agent.Description); + Assert.Equal(ChatAgentTurnMode.Plan, agent.TurnMode); + Assert.True(agent.IsReadOnly); + Assert.Equal("claude-opus-5", agent.Model); + Assert.Equal("high", agent.ReasoningEffort); + Assert.Equal(["readFile", "getAllErrors"], agent.Tools); + Assert.Equal("Review the change and report bugs.", agent.Instructions); + Assert.Equal(path, agent.SourcePath); + Assert.False(agent.IsBuiltIn); + } + + [Fact] + public void ParseAgentFile_ReadsToolsFromYamlList() + { + var path = WriteAgent("tools.md", """ + --- + name: docs + tools: + - readFile + - "getOpenFiles" + --- + + Write documentation. + """); + + var agent = ChatAgentService.ParseAgentFile(path); + + Assert.NotNull(agent); + Assert.Equal(["readFile", "getOpenFiles"], agent!.Tools); + } + + [Fact] + public void ParseAgentFile_FallsBackToFileNameAndDefaults() + { + var path = WriteAgent("release-notes.md", "Summarize the release."); + + var agent = ChatAgentService.ParseAgentFile(path); + + Assert.NotNull(agent); + Assert.Equal("release-notes", agent!.Id); + Assert.Equal("Release Notes", agent.DisplayName); + Assert.Equal(ChatAgentTurnMode.Interactive, agent.TurnMode); + Assert.False(agent.IsReadOnly); + Assert.Null(agent.Tools); + Assert.Equal("Summarize the release.", agent.Instructions); + } + + [Fact] + public void ParseAgentFile_KeepsBodyStartingWithAList() + { + var path = WriteAgent("reviewer.md", """ + --- + name: reviewer + --- + + - Review the diff + - Report bugs + """); + + var agent = ChatAgentService.ParseAgentFile(path); + + Assert.NotNull(agent); + Assert.Equal("- Review the diff\n- Report bugs", agent!.Instructions); + } + + [Fact] + public void ParseAgentFile_IgnoresFilesWithoutInstructions() + { + var path = WriteAgent("empty.md", """ + --- + name: empty + --- + """); + + Assert.Null(ChatAgentService.ParseAgentFile(path)); + } +} From dd0098e80aa7a40a9917f793b347b7f2edec0d23 Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Thu, 17 Sep 2026 17:04:12 +0200 Subject: [PATCH 3/5] fix plan mode --- docs/PluginDevelopment.md | 5 ++ src/OneWare.Chat/Services/ChatAgentService.cs | 10 ++- .../ChatMessagePlanReadyViewModel.cs | 68 +++++++++++++++ src/OneWare.Chat/ViewModels/ChatViewModel.cs | 49 +++++++++++ .../ChatMessagePlanReadyView.axaml | 51 ++++++++++++ .../ChatMessagePlanReadyView.axaml.cs | 11 +++ .../Services/CopilotChatService.cs | 83 +++++++++++++++++-- .../Models/ChatAgentDefinition.cs | 15 ++++ .../Models/ChatServiceEvents.cs | 43 ++++++++++ .../ChatMessagePlanReadyViewModelTests.cs | 67 +++++++++++++++ 10 files changed, 392 insertions(+), 10 deletions(-) create mode 100644 src/OneWare.Chat/ViewModels/ChatMessages/ChatMessagePlanReadyViewModel.cs create mode 100644 src/OneWare.Chat/Views/ChatMessages/ChatMessagePlanReadyView.axaml create mode 100644 src/OneWare.Chat/Views/ChatMessages/ChatMessagePlanReadyView.axaml.cs create mode 100644 tests/OneWare.Chat.UnitTests/ChatMessagePlanReadyViewModelTests.cs diff --git a/docs/PluginDevelopment.md b/docs/PluginDevelopment.md index cfe600457..7ac7a3a93 100644 --- a/docs/PluginDevelopment.md +++ b/docs/PluginDevelopment.md @@ -693,6 +693,11 @@ The selected agent applies to every message sent while it is active: its `Instru to the turn, `TurnMode` selects interactive or plan mode, and `IsReadOnly`/`Tools` are enforced before a tool runs — a blocked tool call is denied, not just hidden from the model. +When a planning turn ends, the chat shows a **Plan ready** block with two choices: *Start +implementation* switches to the `agent` mode and lets the plan be carried out, *Update plan* keeps +planning. Chat services can raise that block themselves with `ChatPlanReadyEvent`; otherwise the +chat adds it at the end of a turn of a `ChatAgentTurnMode.Plan` agent. + Register an agent from a module: ```csharp diff --git a/src/OneWare.Chat/Services/ChatAgentService.cs b/src/OneWare.Chat/Services/ChatAgentService.cs index 00db4f1de..e339c4861 100644 --- a/src/OneWare.Chat/Services/ChatAgentService.cs +++ b/src/OneWare.Chat/Services/ChatAgentService.cs @@ -296,14 +296,14 @@ private static List CreateBuiltInAgents() => [ new() { - Id = "agent", + Id = BuiltInChatAgents.Agent, DisplayName = "Agent", Description = "Full access: researches, edits files and runs tools to complete the task.", IsBuiltIn = true }, new() { - Id = "plan", + Id = BuiltInChatAgents.Plan, DisplayName = "Plan", Description = "Researches the codebase and works out a plan before anything is changed.", TurnMode = ChatAgentTurnMode.Plan, @@ -317,12 +317,14 @@ You are in plan mode. Investigate the codebase and produce an implementation - Read the code you need first; never plan against assumptions. - Deliver a concise, ordered plan of concrete steps with the files involved, and call out open questions, risks and decisions the user has to make. - - Wait for the user to accept the plan before doing any work. + - Finish by presenting the plan with the exit_plan_mode tool, so the user can + start the implementation or have the plan changed. Never implement before + the user accepted it. """ }, new() { - Id = "ask", + Id = BuiltInChatAgents.Ask, DisplayName = "Ask", Description = "Answers questions about the code base without changing anything.", IsReadOnly = true, diff --git a/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessagePlanReadyViewModel.cs b/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessagePlanReadyViewModel.cs new file mode 100644 index 000000000..179eb58aa --- /dev/null +++ b/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessagePlanReadyViewModel.cs @@ -0,0 +1,68 @@ +using Avalonia.Controls; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using OneWare.Essentials.Models; + +namespace OneWare.Chat.ViewModels.ChatMessages; + +/// +/// Shown when the agent finished planning: lets the user start the implementation or keep refining +/// the plan. Stays in the transcript after the choice, so it is visible what was decided. +/// +public class ChatMessagePlanReadyViewModel : ObservableObject, IChatMessage +{ + public ChatMessagePlanReadyViewModel(ChatPlanReadyEvent planEvent) + { + Event = planEvent; + + if (planEvent.IsExpired) Expire(); + // Never block here: the withdrawal can come from the thread that is tearing the turn down. + else planEvent.Expired += (_, _) => + { + if (Dispatcher.UIThread.CheckAccess()) Expire(); + else Dispatcher.UIThread.Post(Expire); + }; + } + + public ChatPlanReadyEvent Event { get; } + + public string Summary => string.IsNullOrWhiteSpace(Event.Summary) ? "The plan is ready." : Event.Summary; + + public bool HasPlanContent => !string.IsNullOrWhiteSpace(Event.PlanContent); + + /// Whether the choice was made or has been withdrawn. + public bool IsAnswered + { + get; + private set => SetProperty(ref field, value); + } + + /// What happened, shown in place of the buttons once the choice is gone. + public string? AnswerText + { + get; + private set => SetProperty(ref field, value); + } + + public void StartImplementation() => Answer(Event.StartImplementationCommand, "Implementation started"); + + public void UpdatePlan() => Answer(Event.UpdatePlanCommand, "Still planning — describe what to change"); + + private void Answer(IRelayCommand command, string answer) + { + if (IsAnswered || Event.IsExpired) return; + + IsAnswered = true; + AnswerText = answer; + command.Execute(null); + } + + private void Expire() + { + if (IsAnswered) return; + + IsAnswered = true; + AnswerText = "The turn ended before a decision was made."; + } +} diff --git a/src/OneWare.Chat/ViewModels/ChatViewModel.cs b/src/OneWare.Chat/ViewModels/ChatViewModel.cs index 9935e6e27..59c84a4e3 100644 --- a/src/OneWare.Chat/ViewModels/ChatViewModel.cs +++ b/src/OneWare.Chat/ViewModels/ChatViewModel.cs @@ -239,6 +239,9 @@ public string WorkingStatusText set => SetProperty(ref field, value); } = DefaultWorkingStatus; + /// Whether the current planning turn already offered how to continue. + private bool _planOptionsOffered; + public ObservableCollection ChatServices { get; } = []; /// @@ -450,6 +453,10 @@ private async Task SendInternalAsync(ChatSendMode mode) var prompt = CurrentMessage.Trim(); if (string.IsNullOrWhiteSpace(prompt)) return; + // The next planning turn gets its own decision block. Steering or queueing happens inside a + // running turn and must not bring the offer of the current one back. + if (mode == ChatSendMode.Send) _planOptionsOffered = false; + var chatService = SelectedChatService; if (chatService == null) { @@ -769,6 +776,8 @@ private void FinishTurn() foreach (var claim in _pendingSubAgentTools.Where(x => !x.Value.IsRunning).Select(x => x.Key).ToArray()) _pendingSubAgentTools.Remove(claim); + OfferPlanOptionsIfMissing(); + IsBusy = false; // Safety: never let the steering indicator stick past the end of a turn. WorkingStatusText = DefaultWorkingStatus; @@ -779,6 +788,36 @@ private void FinishTurn() }); } + /// + /// Ends a planning turn with the same choice the agent would offer: start the implementation or + /// keep planning. Chat backends that report a finished plan themselves already added the block; + /// this is the fallback for a planning turn that simply ended with the plan written out. + /// + private void OfferPlanOptionsIfMissing() + { + if (_planOptionsOffered) return; + if (AgentService.SelectedAgent is not { TurnMode: ChatAgentTurnMode.Plan }) return; + + // Only after the agent actually said something — an aborted or empty turn has no plan. + if (Messages.LastOrDefault() is not ChatMessageAssistantViewModel { Content.Length: > 0 }) return; + + _planOptionsOffered = true; + + var start = new RelayCommand(sender => + { + AgentService.SelectAgent(BuiltInChatAgents.Agent); + CurrentMessage = "Implement the plan."; + _ = SendInternalAsync(ChatSendMode.Send); + }); + + var update = new RelayCommand(_ => { }); + + AddMessage(new ChatMessagePlanReadyViewModel( + new ChatPlanReadyEvent("The plan is ready. Start the implementation or have it changed.", + null, start, update))); + NotifyContentAdded(); + } + /// All messages of the conversation, including those nested in sub-agent blocks. private IEnumerable EnumerateAllMessages() { @@ -949,6 +988,16 @@ private void OnEventReceived(object? sender, ChatEvent e) }); break; } + case ChatPlanReadyEvent x: + { + Dispatcher.UIThread.Post(() => + { + _planOptionsOffered = true; + AddMessage(new ChatMessagePlanReadyViewModel(x)); + NotifyContentAdded(); + }); + break; + } case ChatUserInputRequestEvent x: { Dispatcher.UIThread.Post(() => diff --git a/src/OneWare.Chat/Views/ChatMessages/ChatMessagePlanReadyView.axaml b/src/OneWare.Chat/Views/ChatMessages/ChatMessagePlanReadyView.axaml new file mode 100644 index 000000000..f339df2d9 --- /dev/null +++ b/src/OneWare.Chat/Views/ChatMessages/ChatMessagePlanReadyView.axaml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + +