forked from admin/VolcanicAgenticCore
docs: user documentation and headless sample host
This commit is contained in:
238
docs/ui-integration.md
Normal file
238
docs/ui-integration.md
Normal 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("..."))`).
|
||||
Reference in New Issue
Block a user