diff --git a/docs/PluginDevelopment.md b/docs/PluginDevelopment.md index 18a50fc9d..c184d6ebf 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. @@ -546,6 +554,10 @@ Key points: - Use `EventReceived` to stream chat content and `StatusChanged` to report activity or errors. - Set `BottomUiExtension` to a custom `Avalonia.Controls.Control` if you need extra UI (model selector, provider settings, etc.). +- Report which AI answered: set `ChatMessageEvent.Model` to the display name of the model that + wrote the message, and `ChatIdleEvent.Model` for turns whose messages carry no model. The chat + shows it beneath the message that ended the turn. `ChatSubAgentStartedEvent.Model` does the same + for a delegated task, shown in the header of its block. - Call `IChatManagerService.RegisterChatService` during module initialization. Skeleton example: @@ -662,10 +674,67 @@ 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. + +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 +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 5ff96b8d7..6e4621e07 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; @@ -259,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(); @@ -332,17 +344,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 +429,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/Services/ChatAgentService.cs b/src/OneWare.Chat/Services/ChatAgentService.cs new file mode 100644 index 000000000..e339c4861 --- /dev/null +++ b/src/OneWare.Chat/Services/ChatAgentService.cs @@ -0,0 +1,341 @@ +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 = BuiltInChatAgents.Agent, + DisplayName = "Agent", + Description = "Full access: researches, edits files and runs tools to complete the task.", + IsBuiltIn = true + }, + new() + { + Id = BuiltInChatAgents.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. + - 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 = BuiltInChatAgents.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/ChatMessages/ChatMessageAssistantViewModel.cs b/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessageAssistantViewModel.cs index e67e8839a..688240b3a 100644 --- a/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessageAssistantViewModel.cs +++ b/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessageAssistantViewModel.cs @@ -29,5 +29,33 @@ public bool IsStreaming set => SetProperty(ref field, value); } + /// Name of the model that wrote this message, when the chat service reports it. + [DataMember] + public string? Model + { + get; + set + { + if (SetProperty(ref field, value)) OnPropertyChanged(nameof(HasModel)); + } + } + + /// + /// Whether the model is shown beneath the message. Only set on the message that ended a turn, + /// so the attribution reads as "this answer was completed by X" instead of repeating for every + /// intermediate message. + /// + [DataMember] + public bool ShowModel + { + get; + set + { + if (SetProperty(ref field, value)) OnPropertyChanged(nameof(HasModel)); + } + } + + public bool HasModel => ShowModel && !string.IsNullOrWhiteSpace(Model); + public double EstimateHeight(double width) => ChatHeightEstimation.EstimateMarkdown(Content, width); } diff --git a/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessagePlanReadyViewModel.cs b/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessagePlanReadyViewModel.cs new file mode 100644 index 000000000..9fa7d70a3 --- /dev/null +++ b/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessagePlanReadyViewModel.cs @@ -0,0 +1,82 @@ +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; } + + /// + /// The plan as markdown. Backends do not agree on where the plan text is: some fill + /// , others write the whole plan into the summary, + /// so whichever carries it is rendered. + /// + public string PlanMarkdown => FirstNonEmpty(Event.PlanContent, Event.Summary) ?? "The plan is ready."; + + /// Summary line above the plan, shown only when it is not the plan text itself. + public string? Summary => string.IsNullOrWhiteSpace(Event.PlanContent) ? null : Trim(Event.Summary); + + public bool HasSummary => Summary != null; + + private static string? FirstNonEmpty(params string?[] values) => + values.Select(Trim).FirstOrDefault(x => x != null); + + private static string? Trim(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + /// 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/ChatMessages/ChatMessageSubAgentViewModel.cs b/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessageSubAgentViewModel.cs new file mode 100644 index 000000000..af6eefe86 --- /dev/null +++ b/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessageSubAgentViewModel.cs @@ -0,0 +1,136 @@ +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 + { + if (SetProperty(ref field, value)) OnPropertyChanged(nameof(HasModel)); + } + } + + public bool HasModel => !string.IsNullOrWhiteSpace(Model); + + [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..03979cee2 100644 --- a/src/OneWare.Chat/ViewModels/ChatViewModel.cs +++ b/src/OneWare.Chat/ViewModels/ChatViewModel.cs @@ -32,11 +32,29 @@ public partial class ChatViewModel : ExtendedTool, IChatManagerService private readonly string _statePath; private readonly string _historyRootPath; + /// + /// Assistant messages of the running turn, in order, outside of sub-agent blocks. Only these may + /// be labelled with the model when the turn ends. + /// + private List _turnAssistantMessages = []; + private readonly Dictionary _assistantMessagesById = new(StringComparer.Ordinal); 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); @@ -81,8 +99,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"; @@ -226,8 +245,16 @@ 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; } = []; + /// + /// 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 @@ -399,6 +426,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); @@ -420,6 +450,7 @@ private async Task NewChatAsync() _cancelledQueuedMessages.Clear(); WorkingStatusText = DefaultWorkingStatus; _assistantMessagesById.Clear(); + _turnAssistantMessages.Clear(); _assistantReasoningById.Clear(); _notConnectedMessage = null; } @@ -429,6 +460,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) { @@ -648,16 +683,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,22 +716,23 @@ 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) { Messages.Remove(initMessage); + _turnAssistantMessages.Remove(initMessage); } if (!string.IsNullOrWhiteSpace(messageId)) @@ -689,26 +741,58 @@ private ChatMessageAssistantViewModel GetOrCreateAssistantMessage(string? messag return existing; var created = new ChatMessageAssistantViewModel(messageId); - AddMessage(created); + AddMessage(created, agentId); _assistantMessagesById[messageId] = created; + if (agentId == null) _turnAssistantMessages.Add(created); return created; } var activeAssistantMessage = new ChatMessageAssistantViewModel(messageId); - AddMessage(activeAssistantMessage); + AddMessage(activeAssistantMessage, agentId); + if (agentId == null) _turnAssistantMessages.Add(activeAssistantMessage); return activeAssistantMessage; } - private void FinishTurn() + /// + /// Model that answered the turn, when the chat service reports it. Used for messages that did + /// not carry the information themselves. + /// + private void FinishTurn(string? model = null) { Dispatcher.UIThread.Post(() => { - foreach (var message in Messages.OfType()) - message.IsStreaming = false; + ShowModelOfLastAnswer(model); - 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 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); + + OfferPlanOptionsIfMissing(); IsBusy = false; // Safety: never let the steering indicator stick past the end of a turn. @@ -720,6 +804,77 @@ private void FinishTurn() }); } + /// + /// Attributes the answer to the model that produced it, on the last message of the finished turn + /// only. Earlier turns keep their own attribution, and messages inside a sub-agent block are left + /// alone because that block names its own model. + /// + private void ShowModelOfLastAnswer(string? model) + { + var turnMessages = _turnAssistantMessages; + _turnAssistantMessages = []; + + var last = turnMessages.LastOrDefault(); + if (last == null) return; + + if (string.IsNullOrWhiteSpace(last.Model)) last.Model = model; + if (string.IsNullOrWhiteSpace(last.Model)) return; + + // Intermediate messages of the same turn stay unlabelled, so the turn ends with a single + // "answered by" line. + foreach (var message in turnMessages) + message.ShowModel = ReferenceEquals(message, last); + } + + /// + /// 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() + { + 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 +884,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,9 +897,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.Content = x.Content; message.IsStreaming = false; + if (!string.IsNullOrWhiteSpace(x.Model)) message.Model = x.Model; NotifyContentAdded(); }); break; @@ -752,9 +909,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 +921,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; @@ -837,6 +1027,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(() => @@ -850,14 +1050,14 @@ private void OnEventReceived(object? sender, ChatEvent e) { Dispatcher.UIThread.Post(() => { - AddErrorMessage(x.Message); + AddErrorMessage(x.Message, x.AgentId); NotifyContentAdded(); }); break; } - case ChatIdleEvent: + case ChatIdleEvent x: { - FinishTurn(); + FinishTurn(x.Model); break; } case ChatClearMessagesEvent: @@ -869,7 +1069,10 @@ private void OnEventReceived(object? sender, ChatEvent e) _pendingLocalMessages.Clear(); _cancelledQueuedMessages.Clear(); _assistantMessagesById.Clear(); + _turnAssistantMessages.Clear(); _assistantReasoningById.Clear(); + _subAgents.Clear(); + _pendingSubAgentTools.Clear(); _notConnectedMessage = null; if (SelectedChatService != null) UpdateSelectedSessionFromService(SelectedChatService); @@ -915,6 +1118,7 @@ private void OnSessionReset(object? sender, EventArgs e) _pendingLocalMessages.Clear(); _cancelledQueuedMessages.Clear(); _assistantMessagesById.Clear(); + _turnAssistantMessages.Clear(); _assistantReasoningById.Clear(); IsBusy = false; WorkingStatusText = DefaultWorkingStatus; @@ -968,18 +1172,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 +1213,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; @@ -1234,7 +1585,9 @@ private ChatState BuildChatState() case ChatMessageAssistantViewModel assistant: return new ChatMessageState(ChatMessageKind.Assistant) { - Content = assistant.Content + Content = assistant.Content, + Model = assistant.Model, + ShowModel = assistant.ShowModel }; case ChatMessageReasoningViewModel reasoning: return new ChatMessageState(ChatMessageKind.Reasoning) @@ -1258,6 +1611,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, + Model = subAgent.Model, + ToolOutput = subAgent.StatusText, + IsSuccessful = subAgent.IsSuccessful, + Children = subAgent.Items.Select(BuildMessageState).OfType().ToList() + }; default: return null; } @@ -1274,7 +1638,9 @@ private static bool TryCreateMessage(ChatMessageState state, out IChatMessage me message = new ChatMessageAssistantViewModel { Content = state.Content ?? string.Empty, - IsStreaming = false + IsStreaming = false, + Model = state.Model, + ShowModel = state.ShowModel }; return true; case ChatMessageKind.Reasoning: @@ -1307,6 +1673,33 @@ 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, + // Sessions written before the dedicated field kept the model in ToolName. + Model = state.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; @@ -1377,14 +1770,20 @@ private void LoadMessagesForService(string serviceName) Messages.Clear(); _assistantMessagesById.Clear(); + _turnAssistantMessages.Clear(); _assistantReasoningById.Clear(); + _subAgents.Clear(); + _pendingSubAgentTools.Clear(); } private void LoadMessagesFromStates(IReadOnlyCollection states) { Messages.Clear(); _assistantMessagesById.Clear(); + _turnAssistantMessages.Clear(); _assistantReasoningById.Clear(); + _subAgents.Clear(); + _pendingSubAgentTools.Clear(); foreach (var messageState in states) { @@ -1702,7 +2101,16 @@ public ChatMessageState(ChatMessageKind kind) public string? ToolName { get; set; } public string? ToolOutput { get; set; } public string? SkillName { get; set; } + + /// Model that produced the message, or that a sub-agent ran with. + public string? Model { get; set; } + + /// Whether the message is the one that ended its turn and therefore names its model. + public bool ShowModel { get; set; } public bool IsSuccessful { get; set; } + + /// Messages nested inside a sub-agent block. + public List Children { get; set; } = []; } private enum ChatMessageKind @@ -1711,6 +2119,7 @@ private enum ChatMessageKind Assistant, Reasoning, Tool, - Skill + Skill, + SubAgent } } diff --git a/src/OneWare.Chat/Views/ChatMessages/ChatMessageAssistantView.axaml b/src/OneWare.Chat/Views/ChatMessages/ChatMessageAssistantView.axaml index 44fc5455e..1301b706e 100644 --- a/src/OneWare.Chat/Views/ChatMessages/ChatMessageAssistantView.axaml +++ b/src/OneWare.Chat/Views/ChatMessages/ChatMessageAssistantView.axaml @@ -10,8 +10,15 @@ x:DataType="chatMessages:ChatMessageAssistantViewModel"> - + + + + + diff --git a/src/OneWare.Chat/Views/ChatMessages/ChatMessagePlanReadyView.axaml b/src/OneWare.Chat/Views/ChatMessages/ChatMessagePlanReadyView.axaml new file mode 100644 index 000000000..f027e74c5 --- /dev/null +++ b/src/OneWare.Chat/Views/ChatMessages/ChatMessagePlanReadyView.axaml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + - - - + + + + + + + - + - + + @@ -342,6 +346,49 @@ + + + diff --git a/src/OneWare.Copilot/Services/CopilotChatService.cs b/src/OneWare.Copilot/Services/CopilotChatService.cs index 33e44f4d4..65c2ea85b 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; @@ -51,6 +52,8 @@ public sealed class CopilotChatService( private IDisposable? _subscription; private string? _requestedSessionId; private readonly List> _pendingInputRequests = new(); + private readonly List _pendingPlanRequests = new(); + private readonly HashSet _sessionApprovedTools = new(); // Usage tracking @@ -197,6 +200,17 @@ private void RefreshFilteredModels() return Models.FirstOrDefault(x => NormalizeModelId(x.Id) == normalized); } + /// + /// Turns a model id from the runtime into the name the user knows from the model picker, and + /// keeps the raw id when the model is not in the list (e.g. models only sub-agents may use). + /// + private string? DescribeModel(string? modelId) + { + if (string.IsNullOrWhiteSpace(modelId)) return null; + + return ResolveModel(modelId)?.Name ?? modelId; + } + private static string NormalizeModelId(string modelId) { return new string(modelId.Where(char.IsLetterOrDigit).ToArray()).ToLowerInvariant(); @@ -552,6 +566,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 @@ -1197,6 +1214,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 +1226,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 @@ -1221,6 +1242,7 @@ private async Task InitializeSessionAsync() ClientName = "OneWare Studio", OnPermissionRequest = OnPermissionRequestAsync, OnUserInputRequest = OnUserInputRequestAsync, + OnExitPlanModeRequest = OnExitPlanModeRequestAsync, Hooks = new SessionHooks { OnPreToolUse = OnPreToolUseAsync @@ -1235,7 +1257,7 @@ private async Task InitializeSessionAsync() { Streaming = true, ContextTier = ResolveContextTier(), - IncludeSubAgentStreamingEvents = false, + IncludeSubAgentStreamingEvents = true, Tools = toolProvider.GetTools().Cast().ToList(), AvailableTools = BuildAvailableTools(), ExcludedTools = ExcludedBuiltInTools.ToList(), @@ -1244,6 +1266,7 @@ private async Task InitializeSessionAsync() EnableSkills = true, OnPermissionRequest = OnPermissionRequestAsync, OnUserInputRequest = OnUserInputRequestAsync, + OnExitPlanModeRequest = OnExitPlanModeRequestAsync, Hooks = new SessionHooks { OnPreToolUse = OnPreToolUseAsync @@ -1431,7 +1454,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; @@ -1443,6 +1483,10 @@ public async Task SendAsync(string prompt, ChatSendMode mode) }; if (sdkMode != null) options.Mode = sdkMode; + // A new turn has nothing to do with the model of the previous one; without this a turn that + // ends before any request was billed (e.g. an abort) would report a stale model. + if (mode == ChatSendMode.Send) _lastTurnModel = null; + await _session.SendAsync(options).ConfigureAwait(false); Dispatcher.UIThread.Post(ClearAttachmentsAfterSend); @@ -1451,7 +1495,9 @@ public async Task SendAsync(string prompt, ChatSendMode mode) public async Task AbortAsync() { ReleasePendingInputRequests(); + ReleasePendingPlanRequests(); toolProvider.CancelActiveFunctions(); + DropForegroundSubAgents(); if (_session == null) return; await _session.AbortAsync(); } @@ -1473,6 +1519,7 @@ public async Task ClearQueuedMessagesAsync() public async Task NewChatAsync() { ReleasePendingInputRequests(); + ReleasePendingPlanRequests(); lock (_sessionApprovedTools) _sessionApprovedTools.Clear(); _requestedSessionId = null; @@ -1505,6 +1552,7 @@ public async Task LoadSessionAsync(string sessionId) public async ValueTask DisposeAsync() { ReleasePendingInputRequests(); + ReleasePendingPlanRequests(); if (_attachmentTrackingInitialized) { @@ -1558,12 +1606,18 @@ private async Task DisposeSessionAsync() _session = null; } + _appliedAgentModelId = null; + + lock (_subAgents) + _subAgents.Clear(); + CurrentSessionId = null; ResetUsageStats(); } private void ResetUsageStats() { + _lastTurnModel = null; LastInputTokens = 0; LastOutputTokens = 0; LastReasoningTokens = null; @@ -1580,33 +1634,85 @@ 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, + AssistantMessageEvent message => message.Data.ParentToolCallId, + AssistantMessageDeltaEvent delta => delta.Data.ParentToolCallId, + AssistantUsageEvent usage => usage.Data.ParentToolCallId, + _ => null + }); + + // Sub-agent lifecycle events name their run through the tool call id and must never take part + // in instance guessing: their AgentId belongs to the starting/ending run itself, not to the + // block the event should be shown in. + var agentId = evt is SubagentStartedEvent or SubagentCompletedEvent or SubagentFailedEvent + ? null + : 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)); + EventReceived?.Invoke(this, new ChatMessageDeltaEvent(x.Data.DeltaContent, x.Data.MessageId) + { + AgentId = ResolveToolAgentId(x.Data.ParentToolCallId, agentId) + }); break; } case AssistantMessageEvent x: { - EventReceived?.Invoke(this, - new ChatMessageEvent(x.Data.Content, x.Data.MessageId)); + EventReceived?.Invoke(this, new ChatMessageEvent(x.Data.Content, x.Data.MessageId) + { + AgentId = ResolveToolAgentId(x.Data.ParentToolCallId, agentId), + Model = DescribeModel(x.Data.Model) + }); 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,16 +1728,23 @@ 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: - EventReceived?.Invoke(this, new ChatIdleEvent()); + case SessionIdleEvent when agentId == null: + DropForegroundSubAgents(); + EventReceived?.Invoke(this, new ChatIdleEvent { Model = _lastTurnModel }); break; case AssistantUsageEvent usage: UpdateUsageFromAssistantEvent(usage.Data); @@ -1658,6 +1771,229 @@ 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) + { + // Only the spawner nests this run into another block; it is absent when the main agent + // started it. Everything else would nest concurrently started siblings into each other. + // The spawner is named by the id it answers to, or by the task call that created it. + parentId = FindSubAgentByInstanceId(evt.Data.ParentId)?.Id + ?? (evt.Data.ParentId != null && _subAgents.ContainsKey(evt.Data.ParentId) + ? evt.Data.ParentId + : null); + + // The remaining id identifies the new run, which makes its streaming events attributable + // right away instead of only from its first tool call on. + if (!string.IsNullOrWhiteSpace(evt.AgentId) && + !string.Equals(evt.AgentId, evt.Data.ParentId, StringComparison.Ordinal) && + // An id another run already answers to belongs to that run, not to this one. + FindSubAgentByInstanceId(evt.AgentId) == null) + { + run.AgentInstanceId = evt.AgentId; + } + + _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 = DescribeModel(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. @@ -1705,8 +2041,19 @@ private static string ExtractSkillContext(string content, out IReadOnlyList<(str return stripped.Trim(); } + /// + /// Model of the most recent request of the main agent. With auto routing the answering model is + /// only known from the usage report, so it is remembered for the end of the turn. + /// + private string? _lastTurnModel; + private void UpdateUsageFromAssistantEvent(AssistantUsageData data) { + // Requests of sub-agents name the task call they belong to and must not be mistaken for the + // model of the main conversation. + if (string.IsNullOrWhiteSpace(data.ParentToolCallId)) + _lastTurnModel = DescribeModel(data.Model); + LastInputTokens = data.InputTokens ?? 0; LastOutputTokens = data.OutputTokens ?? 0; LastReasoningTokens = data.ReasoningTokens is > 0 ? data.ReasoningTokens : null; @@ -1751,10 +2098,194 @@ 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 finished planning and asks to start implementing. The user decides: either the plan + /// is carried out — which also leaves the read-only agent, because that agent, not the model, + /// controls what the chat may do — or planning continues. + /// + private Task OnExitPlanModeRequestAsync(ExitPlanModeRequest request, + ExitPlanModeInvocation invocation) + { + var agent = agentService.SelectedAgent; + if (agent is not { IsReadOnly: true }) return Task.FromResult(new ExitPlanModeResult { Approved = true }); + + var pending = new PendingPlanRequest(); + + lock (_pendingPlanRequests) + _pendingPlanRequests.Add(pending); + + var startCommand = new RelayCommand(_ => + { + // The choice only counts if it is still the one the runtime is waiting for; an aborted + // turn must not pull the user out of the planning agent. + if (!CompletePlanRequest(pending, new ExitPlanModeResult + { + Approved = true, + // The action decides the approval posture of what follows, so it must match what + // OneWare is configured for — never what the model recommended. + SelectedAction = IsAutopilot ? "autopilot" : "interactive" + })) return; + + // Without leaving the read-only agent every edit of the implementation would be denied. + agentService.SelectAgent(BuiltInChatAgents.Agent); + }); + + var updateCommand = new RelayCommand(_ => CompletePlanRequest(pending, + new ExitPlanModeResult + { + Approved = false, + Feedback = "The user wants to refine the plan first. Stay in planning, ask what should " + + "change, and do not implement anything yet." + })); + + pending.Event = new ChatPlanReadyEvent(request.Summary, request.PlanContent, startCommand, updateCommand); + EventReceived?.Invoke(this, pending.Event); + + return pending.Source.Task; + } + + private bool CompletePlanRequest(PendingPlanRequest pending, ExitPlanModeResult result) + { + lock (_pendingPlanRequests) + _pendingPlanRequests.Remove(pending); + + return pending.Source.TrySetResult(result); + } + + /// + /// Answers plan decisions nobody can make any more (the turn was aborted or the session is gone) + /// by keeping the chat in planning, so the runtime is never left waiting, and withdraws the offer + /// from the chat so a late click cannot change the chat agent for nothing. + /// + private void ReleasePendingPlanRequests() + { + List pending; + lock (_pendingPlanRequests) + { + pending = new List(_pendingPlanRequests); + _pendingPlanRequests.Clear(); + } + + foreach (var request in pending) + { + request.Source.TrySetResult(new ExitPlanModeResult { Approved = false }); + request.Event?.Expire(); + } + } + + /// A plan decision the runtime is waiting for, together with the block that offers it. + private sealed class PendingPlanRequest + { + public TaskCompletionSource Source { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public ChatPlanReadyEvent? Event { get; set; } + } + // ── 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.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..ae8713d78 100644 --- a/src/OneWare.Core/Styles/Icons.axaml +++ b/src/OneWare.Core/Styles/Icons.axaml @@ -4,19 +4,26 @@ 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 +32,13 @@ - - @@ -44,25 +51,25 @@ - - - - @@ -74,7 +81,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 +251,19 @@ - - - @@ -265,24 +272,24 @@ - - - - @@ -297,7 +304,7 @@ - @@ -305,26 +312,26 @@ - - - - - @@ -334,7 +341,7 @@ - @@ -344,48 +351,48 @@ - - - - - - - - - @@ -435,11 +442,11 @@ - - - @@ -452,7 +459,7 @@ - @@ -462,52 +469,52 @@ - - - - - - - - - - @@ -524,7 +531,7 @@ - @@ -541,7 +548,7 @@ - @@ -555,7 +562,7 @@ - @@ -579,9 +586,9 @@ - - @@ -596,19 +603,19 @@ - - - @@ -688,7 +695,7 @@ - @@ -719,25 +726,25 @@ Geometry="F1M16.012,16.042L0.0120000000000005,16.042 0.0120000000000005,0.0419999999999998 16.012,0.0419999999999998z" /> - - - - @@ -763,7 +770,7 @@ - @@ -785,7 +792,7 @@ - @@ -798,7 +805,7 @@ - @@ -809,7 +816,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 +836,7 @@ - @@ -840,7 +847,7 @@ - @@ -850,7 +857,7 @@ - @@ -858,7 +865,7 @@ - @@ -867,7 +874,7 @@ - @@ -877,7 +884,7 @@ - @@ -888,7 +895,7 @@ - @@ -898,14 +905,14 @@ - - @@ -914,12 +921,12 @@ - - @@ -937,7 +944,7 @@ - @@ -957,7 +964,7 @@ - @@ -968,7 +975,7 @@ - @@ -980,7 +987,7 @@ - @@ -990,7 +997,7 @@ - @@ -998,7 +1005,7 @@ - @@ -1007,11 +1014,11 @@ - - - @@ -1021,7 +1028,7 @@ - @@ -1048,7 +1055,7 @@ - @@ -1058,15 +1065,15 @@ - - - - - @@ -1077,14 +1084,14 @@ - - @@ -1100,7 +1107,7 @@ - @@ -1110,7 +1117,7 @@ - @@ -1120,7 +1127,7 @@ - @@ -1130,7 +1137,7 @@ - @@ -1179,44 +1186,44 @@ - - - - - - - - - - @@ -1233,136 +1240,136 @@ - - - + - + - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - @@ -1371,7 +1378,7 @@ - + @@ -1382,7 +1389,7 @@ - + @@ -1393,7 +1400,7 @@ - + @@ -1404,45 +1411,45 @@ - + - - - - - - @@ -1453,14 +1460,14 @@ - - @@ -1472,7 +1479,7 @@ - @@ -1481,14 +1488,14 @@ - - @@ -1501,32 +1508,32 @@ - - - - - - - @@ -1572,7 +1579,7 @@ - @@ -1581,14 +1588,14 @@ - - @@ -1612,12 +1619,12 @@ - - @@ -1626,16 +1633,16 @@ - - - + + + - - @@ -1648,7 +1655,7 @@ - @@ -1661,20 +1668,20 @@ - - - - @@ -1685,37 +1692,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 +1745,7 @@ - @@ -1746,29 +1753,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/ChatAgentDefinition.cs b/src/OneWare.Essentials/Models/ChatAgentDefinition.cs new file mode 100644 index 000000000..04fda6077 --- /dev/null +++ b/src/OneWare.Essentials/Models/ChatAgentDefinition.cs @@ -0,0 +1,85 @@ +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 +} + +/// +/// Ids of the chat agents that ship with OneWare. +/// +public static class BuiltInChatAgents +{ + /// Full access agent that carries work out. + public const string Agent = "agent"; + + /// Read-only agent that works out a plan first. + public const string Plan = "plan"; + + /// Read-only agent that only answers questions. + public const string Ask = "ask"; +} + +/// +/// 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/ChatServiceEvents.cs b/src/OneWare.Essentials/Models/ChatServiceEvents.cs index f4016193e..6be347a01 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) @@ -23,6 +28,9 @@ public sealed class ChatMessageEvent(string content, string? messageId = null) public string Content { get; } = content; public string? MessageId { get; } = messageId; + + /// Display name of the model that produced this message, when the service reports it. + public string? Model { get; init; } } public sealed class ChatReasoningDeltaEvent(string content, string? reasoningId = null) @@ -47,10 +55,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; } } /// @@ -106,9 +189,58 @@ public sealed class ChatPermissionRequestEvent( public IRelayCommand? AllowForSessionCommand { get; } = allowForSessionCommand; } +/// +/// The agent finished planning and asks how to continue: start the implementation, or keep +/// refining the plan. +/// +public sealed class ChatPlanReadyEvent( + string summary, + string? planContent, + IRelayCommand startImplementationCommand, + IRelayCommand updatePlanCommand) + : ChatEvent() +{ + /// Short summary of the plan or of the proposed next step. + public string Summary { get; } = summary; + + /// Full plan text, when the agent provided one. + public string? PlanContent { get; } = planContent; + + /// Accepts the plan and lets the agent carry it out. + public IRelayCommand StartImplementationCommand { get; } = startImplementationCommand; + + /// Keeps planning so the user can have the plan changed. + public IRelayCommand UpdatePlanCommand { get; } = updatePlanCommand; + + public string StartImplementationButtonText { get; init; } = "Start implementation"; + + public string UpdatePlanButtonText { get; init; } = "Update plan"; + + /// + /// Raised when the decision can no longer be made, e.g. because the turn was aborted. The UI + /// stops offering the choice. + /// + public event EventHandler? Expired; + + /// Withdraws the offer; the commands must not be executed afterwards. + public void Expire() + { + IsExpired = true; + Expired?.Invoke(this, EventArgs.Empty); + } + + public bool IsExpired { get; private set; } +} + public sealed class ChatIdleEvent() : ChatEvent() { + /// + /// Display name of the model that produced the last response of the turn, when the service + /// reports it. Lets the chat show which AI finished the turn even when the final message + /// carried no model information. + /// + public string? Model { get; init; } } /// 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/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/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)); + } +} diff --git a/tests/OneWare.Chat.UnitTests/ChatMessagePlanReadyViewModelTests.cs b/tests/OneWare.Chat.UnitTests/ChatMessagePlanReadyViewModelTests.cs new file mode 100644 index 000000000..ab2146582 --- /dev/null +++ b/tests/OneWare.Chat.UnitTests/ChatMessagePlanReadyViewModelTests.cs @@ -0,0 +1,81 @@ +using Avalonia.Controls; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.Input; +using OneWare.Chat.ViewModels.ChatMessages; +using OneWare.Essentials.Models; +using Xunit; + +namespace OneWare.Chat.UnitTests; + +public class ChatMessagePlanReadyViewModelTests +{ + private static (ChatPlanReadyEvent Event, int[] Counts) CreateEvent() + { + var counts = new int[2]; + var planEvent = new ChatPlanReadyEvent("Plan is ready.", "1. Do the thing", + new RelayCommand(_ => counts[0]++), + new RelayCommand(_ => counts[1]++)); + + return (planEvent, counts); + } + + [Fact] + public void StartImplementation_RunsOnceAndRecordsTheDecision() + { + var (planEvent, counts) = CreateEvent(); + var message = new ChatMessagePlanReadyViewModel(planEvent); + + message.StartImplementation(); + message.StartImplementation(); + message.UpdatePlan(); + + Assert.Equal(1, counts[0]); + Assert.Equal(0, counts[1]); + Assert.True(message.IsAnswered); + Assert.Equal("Implementation started", message.AnswerText); + Assert.Equal("1. Do the thing", message.PlanMarkdown); + Assert.Equal("Plan is ready.", message.Summary); + } + + [Fact] + public void PlanMarkdown_FallsBackToTheSummaryWhenNoPlanContentIsReported() + { + var planEvent = new ChatPlanReadyEvent("## Plan\n\n1. Do the thing", null, + new RelayCommand(_ => { }), new RelayCommand(_ => { })); + + var message = new ChatMessagePlanReadyViewModel(planEvent); + + Assert.Equal("## Plan\n\n1. Do the thing", message.PlanMarkdown); + Assert.Null(message.Summary); + Assert.False(message.HasSummary); + } + + [Fact] + public void Expire_WithdrawsTheChoice() + { + var (planEvent, counts) = CreateEvent(); + var message = new ChatMessagePlanReadyViewModel(planEvent); + + planEvent.Expire(); + Dispatcher.UIThread.RunJobs(); + message.StartImplementation(); + + Assert.Equal(0, counts[0]); + Assert.True(message.IsAnswered); + Assert.Equal("The turn ended before a decision was made.", message.AnswerText); + } + + [Fact] + public void Expire_DoesNotOverwriteAnAlreadyMadeDecision() + { + var (planEvent, counts) = CreateEvent(); + var message = new ChatMessagePlanReadyViewModel(planEvent); + + message.UpdatePlan(); + planEvent.Expire(); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(1, counts[1]); + Assert.Equal("Still planning — describe what to change", message.AnswerText); + } +} 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); + } +}