docs: user documentation and headless sample host

This commit is contained in:
VolcanicAgenticCore
2026-09-25 12:36:24 +00:00
parent c220428cb7
commit 018c92af49
7 changed files with 879 additions and 1 deletions

238
docs/ui-integration.md Normal file
View File

@@ -0,0 +1,238 @@
# Building a UI on the core (Telegram guide)
The core has no user interface. A UI is a process that
1. creates one `AgentSession` per conversation,
2. turns incoming user actions into calls on the session (`PromptAsync`, `AbortAsync`, `SetModelAsync`, ...),
3. subscribes to `AgentSessionEvent`s and renders them,
4. implements `IUserInterface` so extensions can ask the user questions.
This guide uses Telegram as the worked example. Everything transfers to Slack, Discord, a web socket
front end or a desktop app; only the rendering code changes.
## 1. One session per chat
`AgentSession` is single-conversation and not thread-safe across conversations, so keep one per chat and
create it lazily. Give each chat its own working directory (or a shared one per user) and use the chat id
as the store scope so session files group by chat:
```csharp
public sealed class ChatSessions(IAgentSessionFactory factory, string workspaceRoot) : IAsyncDisposable
{
private readonly ConcurrentDictionary<long, Lazy<Task<AgentSession>>> _sessions = new();
public Task<AgentSession> GetAsync(long chatId, IUserInterface ui) =>
_sessions.GetOrAdd(chatId, id => new Lazy<Task<AgentSession>>(() => CreateAsync(id, ui))).Value;
private async Task<AgentSession> CreateAsync(long chatId, IUserInterface ui)
{
var cwd = Path.Combine(workspaceRoot, chatId.ToString());
Directory.CreateDirectory(cwd);
return await factory.CreateAsync(new AgentSessionOptions
{
Cwd = cwd,
Scope = $"telegram-{chatId}",
Ui = ui,
SessionId = await LastSessionIdAsync(chatId), // resume the most recent one, or null
});
}
private async Task<string?> LastSessionIdAsync(long chatId) =>
(await factory.ListSessionsAsync("unused", $"telegram-{chatId}")).FirstOrDefault()?.SessionId;
public async ValueTask DisposeAsync()
{
foreach (var s in _sessions.Values.Where(v => v.IsValueCreated)) await (await s.Value).DisposeAsync();
}
}
```
Persistence is automatic (`FileSessionStore` under `~/.volcanic/agent/sessions/telegram-<chatId>/`).
To store sessions in a database instead, implement `ISessionStore` (four methods: create, append, load,
list) and set `AgenticCoreOptions.SessionStoreFactory`.
## 2. Map chat input to session calls
| Telegram input | Call |
|---|---|
| Text message while idle | `session.PromptAsync(text, images)` |
| Text message while `session.IsStreaming` | `session.PromptAsync(text, images, StreamingBehavior.Steer)` (or `FollowUp`) |
| Photo | download it, wrap as `ImageContent(base64, "image/jpeg")`, pass in `images` |
| `/stop` | `session.AbortAsync()` |
| `/model` | list `session.Models.ListModels()` where `IsAvailable`, then `session.SetModelAsync(key)` |
| `/thinking <level>` | `session.SetThinkingLevelAsync(level)` (`session.AvailableThinkingLevels`) |
| `/new` | `session.NewSessionAsync()` |
| `/sessions`, `/resume <id>` | `session.ListSessionsAsync()`, `session.SwitchSessionAsync(id)` |
| `/compact` | `session.CompactAsync()` |
| `/tools` | `session.Tools`, `session.SetActiveTools(...)` |
| Any other `/command` | just pass it to `PromptAsync`: extension commands are dispatched there |
`PromptAsync` returns when the run has settled, which can take minutes. Do not await it inside the Telegram
update handler; start it with `Task.Run` (or a channel) and return so the bot keeps receiving updates
(`/stop` must arrive while the run is active).
Use `session.State` (`IsStreaming`, `Model`, `MessageCount`, `PendingSteering`, ...) for a `/status` command.
## 3. Render events
Subscribe once per session. Handlers are called synchronously on the agent loop: push into your own channel
and render from a separate task.
```csharp
var channel = Channel.CreateUnbounded<AgentSessionEvent>();
session.Subscribe(e => channel.Writer.TryWrite(e));
_ = Task.Run(() => RenderLoopAsync(chatId, channel.Reader));
```
What to render:
| Event | Rendering |
|---|---|
| `MessageStart` with an `AssistantMessage` | send a placeholder message ("…") and remember its id |
| `MessageUpdate` where `AssistantMessageEvent` is `TextDelta` | append to a buffer; edit the placeholder at most once per second (Telegram rate limits edits) |
| `MessageUpdate` with `ThinkingDelta` | optional: show a "thinking…" status; usually not displayed |
| `MessageEnd` with an `AssistantMessage` | final edit with `message.Text` (split at 4096 chars); if `StopReason` is `Error` show `ErrorMessage` |
| `ToolExecutionStart` | send or update a status line: `▶ bash: ls -la` (`Args` is the JSON arguments) |
| `ToolExecutionUpdate` | optional live output for long commands (`PartialResult.TextContent`) |
| `ToolExecutionEnd` | replace the status line with `✓ bash` or `✗ bash` plus a short excerpt of `Result.TextContent` |
| `MessageEnd` with a `ToolResultMessage` | nothing (already covered by ToolExecutionEnd) or a collapsed quote |
| `QueueUpdate` | show queued steering/follow-up texts, if any |
| `CompactionStart` / `CompactionEnd` | a status line ("compacting context…") |
| `AutoRetryStart` / `AutoRetryEnd` | a status line ("provider overloaded, retrying in 4 s") |
| `AgentSettled` | remove typing indicator, re-enable input |
| `ExtensionError` | log; optionally tell the user |
| `Notification` | send the text (rate limit warnings from Claude Code arrive here) |
| `ModelChanged`, `ThinkingLevelChanged`, `SessionInfoChanged` | confirmation message |
Send Telegram `sendChatAction: typing` on `AgentStart` and every few seconds until `AgentSettled`.
Markdown: the model writes Markdown; Telegram's `MarkdownV2` needs escaping. Simplest is to send plain
text, or convert to Telegram HTML (`<b>`, `<code>`, `<pre>`), which is more forgiving.
## 4. Implement `IUserInterface`
Extensions ask questions through `ctx.Ui`. With Telegram, dialogs become inline keyboards and pending
requests are correlated by callback data:
```csharp
public sealed class TelegramUi(ITelegramBotClient bot, long chatId) : IUserInterface
{
private readonly ConcurrentDictionary<string, TaskCompletionSource<string?>> _pending = new();
public async Task<string?> SelectAsync(string title, IReadOnlyList<string> options, UiRequestOptions? o, CancellationToken ct)
{
var id = Guid.NewGuid().ToString("N")[..8];
var tcs = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
_pending[id] = tcs;
var keyboard = new InlineKeyboardMarkup(options.Select((opt, i) => InlineKeyboardButton.WithCallbackData(opt, $"ui:{id}:{i}")));
await bot.SendMessage(chatId, title, replyMarkup: keyboard, cancellationToken: ct);
using var timeout = new CancellationTokenSource(o?.Timeout ?? TimeSpan.FromMinutes(10));
using var reg = timeout.Token.Register(() => tcs.TrySetResult(null));
using var reg2 = ct.Register(() => tcs.TrySetResult(null));
var index = await tcs.Task;
_pending.TryRemove(id, out _);
return index is null ? null : options[int.Parse(index)];
}
public async Task<bool> ConfirmAsync(string title, string message, UiRequestOptions? o, CancellationToken ct) =>
await SelectAsync($"{title}\n{message}", ["Yes", "No"], o, ct) == "Yes";
public async Task<string?> InputAsync(string title, string? placeholder, UiRequestOptions? o, CancellationToken ct)
{
// Ask with ForceReply and resolve from the next text message in this chat (see OnMessage below).
var tcs = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
_pending["input"] = tcs;
await bot.SendMessage(chatId, title, replyMarkup: new ForceReplyMarkup { InputFieldPlaceholder = placeholder }, cancellationToken: ct);
using var reg = ct.Register(() => tcs.TrySetResult(null));
return await tcs.Task;
}
public void Notify(string message, NotifyKind kind = NotifyKind.Info) => _ = bot.SendMessage(chatId, $"{Icon(kind)} {message}");
public void SetStatus(string key, string? text) { /* optional: keep a pinned status message per key */ }
// Called by the bot's callback-query handler with data "ui:<id>:<index>".
public bool TryResolve(string id, string value) => _pending.TryGetValue(id, out var tcs) && tcs.TrySetResult(value);
public bool TryResolveInput(string text) => _pending.TryRemove("input", out var tcs) && tcs.TrySetResult(text);
private static string Icon(NotifyKind kind) => kind switch { NotifyKind.Warning => "⚠️", NotifyKind.Error => "❌", _ => "ℹ️" };
}
```
When the user replies to a `ForceReply` prompt, route the text to `TryResolveInput` instead of `PromptAsync`.
Because `Ui` is not `NullUserInterface`, `ctx.HasUi` is true and extensions like a permission gate will ask
instead of blocking.
## 5. Minimal bot skeleton (Telegram.Bot)
```csharp
var services = new ServiceCollection();
services.AddLogging(b => b.AddConsole());
services.AddAgenticCore(o =>
{
o.DefaultModel = "claude-code/sonnet";
o.AddExtension<ClaudeCodeProviderExtension>();
o.AddExtension<AnthropicProviderExtension>();
});
var provider = services.BuildServiceProvider();
var sessions = new ChatSessions(provider.GetRequiredService<IAgentSessionFactory>(), "/srv/agent-workspaces");
var bot = new TelegramBotClient(Environment.GetEnvironmentVariable("TELEGRAM_BOT_TOKEN")!);
var uis = new ConcurrentDictionary<long, TelegramUi>();
bot.OnMessage += async (message, _) =>
{
if (message.Text is null && message.Photo is null) return;
var chatId = message.Chat.Id;
var ui = uis.GetOrAdd(chatId, id => new TelegramUi(bot, id));
if (message.ReplyToMessage is not null && ui.TryResolveInput(message.Text ?? "")) return;
var session = await sessions.GetAsync(chatId, ui);
AttachRenderer(session, chatId); // idempotent: subscribe once per session
switch (message.Text)
{
case "/stop": await session.AbortAsync(); return;
case "/new": await session.NewSessionAsync(); await bot.SendMessage(chatId, "New session."); return;
case "/status": await bot.SendMessage(chatId, Describe(session.State)); return;
case var t when t?.StartsWith("/model ") == true:
var ok = await session.SetModelAsync(t[7..].Trim());
await bot.SendMessage(chatId, ok ? $"Model: {session.Model!.Key}" : "Unknown or unavailable model.");
return;
}
var images = await DownloadPhotosAsync(bot, message);
var behavior = session.IsStreaming ? StreamingBehavior.Steer : (StreamingBehavior?)null;
_ = Task.Run(async () =>
{
try { await session.PromptAsync(message.Text ?? message.Caption ?? "", images, behavior); }
catch (Exception ex) { await bot.SendMessage(chatId, $"Error: {ex.Message}"); }
});
};
bot.OnUpdate += async (update, _) =>
{
if (update.CallbackQuery is { Data: var data } q && data?.StartsWith("ui:") == true)
{
var parts = data.Split(':');
if (uis.TryGetValue(q.Message!.Chat.Id, out var ui)) ui.TryResolve(parts[1], parts[2]);
await bot.AnswerCallbackQuery(q.Id);
}
};
```
`AttachRenderer` is the event loop from section 3. Keep a `HashSet<string>` of session ids you have
subscribed to; `NewSessionAsync`/`SwitchSessionAsync` keep the same `AgentSession` object, so one
subscription per `AgentSession` is enough.
## 6. Operational notes
- **Working directories**: the agent's tools (`bash`, `write`, ...) run with the bot process's permissions in
the session's `Cwd`. Run the bot in a container or with a dedicated user and one workspace per chat.
- **Authorisation**: check `message.From.Id` against an allow-list before creating a session.
- **Long runs**: Telegram delivers updates while your handler is running only if you do not block in it.
- **Restart**: sessions resume from JSONL; extension state rebuilds on `SessionStartEvent`.
- **Multiple bots or workers**: sessions are independent; the store must be safe for concurrent writers to
*different* sessions (the file store is).
- **Claude Code provider**: the `claude` CLI must be installed and logged in on the bot host; see
[claude-code-provider.md](claude-code-provider.md).
- **Headless test double**: `Volcanic.AgenticCore.Testing.FauxProvider` lets you develop the bot without any
model access (`o.AddExtension(faux.Extension)`, `faux.Enqueue(FauxTurn.Say("..."))`).