Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 73 additions & 4 deletions docs/PluginDevelopment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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<IChatAgentService>().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 `<AppData>/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

Expand Down
3 changes: 3 additions & 0 deletions src/OneWare.Chat/ChatModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ public override void RegisterServices(IServiceCollection services)
services.AddSingleton<IChatManagerService>(provider => provider.Resolve<ChatViewModel>());

services.AddSingleton<AiFileEditService>();

services.AddSingleton<ChatAgentService>();
services.AddSingleton<IChatAgentService>(provider => provider.Resolve<ChatAgentService>());
}

public override void Initialize(IServiceProvider serviceProvider)
Expand Down
6 changes: 6 additions & 0 deletions src/OneWare.Chat/Services/AiBuiltInFunctions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -76,6 +77,7 @@ public static void Register(
functionProvider.RegisterFunction(new OneWareAiFunction
{
Name = "getActiveProject",
IsReadOnly = true,
FriendlyName = "Get Active Project",
RunOnUiThread = true,
Description =
Expand All @@ -90,6 +92,7 @@ public static void Register(
functionProvider.RegisterFunction(new OneWareAiFunction
{
Name = "getOpenFiles",
IsReadOnly = true,
FriendlyName = "Get Open Files",
RunOnUiThread = true,
Description = """
Expand All @@ -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 = """
Expand Down Expand Up @@ -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)",
Expand All @@ -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",
Expand Down
47 changes: 45 additions & 2 deletions src/OneWare.Chat/Services/AiFunctionProvider.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<AIFunctionArguments, string?>? GetConfirmationCheck(string functionName)
{
EnsureBuiltInsRegistered();
Expand Down Expand Up @@ -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<Type, PropertyInfo?> ToolCallIdProperties = new();

/// <summary>
/// Reads the tool call id the AI backend assigned to this invocation. Backends pass their
/// invocation context in <see cref="AIFunctionArguments.Context"/>; the shape of that context is
/// backend specific, so it is only probed for a <c>ToolCallId</c>.
/// </summary>
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(() =>
Expand Down Expand Up @@ -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)
{
Expand Down
Loading
Loading