14 KiB
Embedded Agent — In-Browser LLM with First-Class MCP Access
Concurrency model — long-running tasks
The agent loop runs on a background GThread, not the GTK main thread. This is critical for long-running tasks like "follow each link, download images, write a summary for each" which may involve dozens of LLM calls and hundreds of tool calls.
sequenceDiagram
participant UI as Chat UI main thread
participant Chat as agent_chat.c
participant Thread as Agent Loop background thread
participant LLM as LLM API
participant Main as GTK main loop
participant Tools as agent_tools_dispatch
UI->>Chat: POST sovereign://agents/send?text=...
Chat->>Thread: g_thread_new agent_loop_run
Chat-->>UI: 200 OK session started
loop ReAct iterations
Thread->>LLM: POST chat/completions blocking
LLM-->>Thread: response + tool_calls
Thread->>Thread: persist assistant message
alt has tool_calls
loop each tool call
Thread->>Main: g_idle_add dispatch_tool
Main->>Tools: agent_tools_dispatch sync JS eval
Tools-->>Main: result
Main-->>Thread: result via GAsyncQueue
Thread->>Thread: persist tool result
end
else no tool_calls
Thread->>Thread: mark session complete
end
end
Thread->>Main: g_idle_add update_ui final
Threading rules
-
LLM HTTP calls happen on the background thread —
soup_session_sendblocks in-thread, the GTK UI stays responsive. -
Browser tool dispatch (snapshot, click, eval, etc.) MUST run on the GTK main thread because WebKitGTK is not thread-safe. The background thread schedules each tool call via
g_idle_add()and waits for the result on aGAsyncQueueor a per-callGMainLoop(same pattern the MCP HTTP handler uses for sync JS eval). -
Filesystem + shell tools run directly on the background thread — they don't touch GTK or WebKit, so no main-thread hop needed. This keeps long shell commands from blocking the UI.
-
SQLite writes use the existing
SQLITE_OPEN_FULLMUTEXconnection (thread-safe). The background thread can calldb_kv_set/agent_chat_store_*directly. -
UI updates happen via
g_idle_add()— the background thread pushes status updates (current tool, iteration count, partial output) to a shared struct, and an idle callback renders them in the chat page. -
Cancellation — a
g_atomic_intcancel flag is checked at the top of each loop iteration. The chat UI's "Stop" button sets it. The background thread exits cleanly at the next check point.
Iteration cap
The default max_iterations is 100 (configurable on
sovereign://agents), not 20. Long tasks like "follow 30 links" need
multiple tool calls per link (open, snapshot, extract, download, write) —
easily 100+ calls. The cap is a safety valve, not a tight limit. The user
can raise it in settings for very long batch jobs.
Status polling
The chat UI polls sovereign://agents/status?session=... every 500ms while
a session is active. The response includes:
state:idle|thinking|tool_call|complete|error|cancellediteration: current iteration numbercurrent_tool: name of the tool being executed (if any)last_message: most recent assistant text (for progressive display)error: error message if state iserror
This gives the user live visibility into long-running tasks without streaming complexity.
Goal
Embed an LLM-powered agent directly inside sovereign_browser. The user types
; <message> in the URL bar to talk to the agent. The agent has first-class
access to the browser's own MCP tool set (snapshot, click, eval, tabs, etc.)
plus full filesystem and shell access (the browser runs in a dedicated Qubes
qube, so arbitrary command execution is acceptable and intended).
Provider config (base URL, API key, model name) is managed on a new
sovereign://agents internal page. Chat history is persisted per-session in
SQLite so it can be fed back as context on follow-up messages.
User-facing flow
flowchart LR
User[User types in URL bar] --> Check{Starts with semicolon?}
Check -- yes --> Agent[Embedded Agent Chat UI]
Check -- no --> Nav[Normal navigation]
Agent --> LLM[OpenAI-compatible API call]
LLM --> ToolLoop{Tool calls in response?}
ToolLoop -- yes --> Dispatch[agent_tools_dispatch via internal MCP loopback]
Dispatch --> ToolLoop
ToolLoop -- no --> Render[Render assistant message in chat UI]
Render --> User
Architecture
flowchart TB
subgraph URLBar
Entry[GtkEntry on_url_activate]
end
subgraph AgentModule
Router[agent_chat_route_input]
Client[agent_llm.c — HTTP client]
Loop[agent_loop.c — tool-call loop]
Store[agent_chat_store.c — SQLite persistence]
end
subgraph BrowserCore
Tools[agent_tools_dispatch]
FsTools[agent_fs_tools.c — fs + shell tools]
Bridge[nostr_bridge.c — sovereign:// scheme]
end
subgraph External
API[OpenAI-compatible API]
end
subgraph UI
ChatPage[sovereign://agents/chat — HTML/JS chat UI]
ConfigPage[sovereign://agents — provider config]
end
Entry --> Router
Router -->|chat message| Client
Client -->|HTTPS POST| API
API -->|JSON response| Client
Client --> Loop
Loop -->|tool call| Tools
Loop -->|fs/shell call| FsTools
Tools --> BrowserCore
Loop --> Store
Store --> ChatPage
Bridge --> ChatPage
Bridge --> ConfigPage
Key design decisions
-
Reuse
agent_tools_dispatch()— The embedded agent calls the exact same C dispatch function the external MCP server uses. No parallel tool implementation. The agent passesconn = NULLso async JS tools use the syncagent_js_eval_sync()path (same as the MCP HTTP handler). -
New system tools (
agent_fs_tools.c) —fs_read,fs_write,fs_list,fs_mkdir,fs_delete,shell_exec. These are registered alongside the existing browser tools so both the embedded agent and external MCP clients can use them. Full shell access — the Qubes qube provides the sandbox. -
OpenAI-compatible HTTP client (
agent_llm.c) — Uses libsoup-3.0 (already linked) to POST to{base_url}/chat/completionswith thetoolsarray built from the sametool_defs[]catalog inagent_mcp.c. Streams or polls; parses tool calls from the response. One client covers OpenAI, OpenRouter, Ollama, LM Studio, Groq, etc. via base URL + key. -
Tool-call loop (
agent_loop.c) — Standard ReAct loop, runs on a backgroundGThread(see Concurrency model above):- Build messages array (system prompt + persisted history + new user msg).
- Call LLM (blocking HTTP on the background thread).
- If response contains
tool_calls, dispatch each:- Browser tools → hop to GTK main thread via
g_idle_add()+ wait onGAsyncQueue(WebKitGTK is not thread-safe). - Filesystem/shell tools → run directly on the background thread.
- Browser tools → hop to GTK main thread via
- Append tool results to messages, call LLM again.
- Repeat until no more tool calls → render final assistant text.
- Cap at N iterations (default 100, configurable) to prevent infinite loops. Check cancel flag at the top of each iteration.
-
Chat persistence (
agent_chat_store.c) — New SQLite tables (agent_sessions,agent_messages) in the per-userbrowser.db. Each session has an id, title, created_at. Messages store role (user/assistant/tool), content, and tool-call JSON. The URL-bar;command reuses the most recent session (or starts a new one if none exists). -
sovereign://agentsconfig page — Rendered bynostr_bridge.c(same pattern assovereign://settings). Fields: provider name, base URL, API key, model name, system prompt (optional). Saved viasovereign://agents/set?key=...&value=...to thekey_valuetable (existingdb_kv_set). API key stored in the key_value table; since this is a dedicated qube, that's acceptable. -
sovereign://agents/chatchat UI — A lightweight HTML/JS page (rendered bynostr_bridge.c) that:- Shows the message history (loaded from SQLite via a
sovereign://agents/messages?session=...endpoint). - Has an input box for follow-up messages (POSTed to
sovereign://agents/send?session=...&text=...). - Polls
sovereign://agents/status?session=...for in-progress tool calls and streams the assistant's final response. - The URL-bar
;shortcut navigates here with the message pre-filled and auto-submits.
- Shows the message history (loaded from SQLite via a
-
URL-bar
;routing — Inon_url_activate, check iftext[0] == ';'. If so, extract the message (text + 1, trimmed) and callagent_chat_route_input(message)instead ofnormalize_url(). The function openssovereign://agents/chatin the active tab (or a new tab if the active tab isn't already the chat page) and kicks off the agent loop. If the message is empty (;alone), just open the chat page without sending. -
System prompt — A default system prompt explains the agent's capabilities: it controls a web browser via MCP tools, has filesystem and shell access, and should use the snapshot+ref pattern for page interaction. The user can override this on
sovereign://agents.
New files
| File | Purpose |
|---|---|
src/agent_llm.h / src/agent_llm.c |
OpenAI-compatible HTTP client (libsoup). Sends chat-completions request with tools, parses response + tool_calls. |
src/agent_loop.h / src/agent_loop.c |
ReAct tool-call loop. Orchestrates LLM calls ↔ tool dispatch. |
src/agent_chat_store.h / src/agent_chat_store.c |
SQLite persistence for chat sessions and messages. |
src/agent_fs_tools.h / src/agent_fs_tools.c |
Filesystem + shell tools (fs_read, fs_write, fs_list, fs_mkdir, fs_delete, shell_exec). |
src/agent_chat.h / src/agent_chat.c |
High-level entry point: agent_chat_route_input(), session management, bridges URL-bar → loop → UI. |
Modified files
| File | Change |
|---|---|
src/tab_manager.c |
on_url_activate: detect ; prefix, route to agent_chat_route_input(). |
src/nostr_bridge.c |
Add sovereign://agents, sovereign://agents/chat, sovereign://agents/set, sovereign://agents/messages, sovereign://agents/send, sovereign://agents/status route handlers. |
src/agent_mcp.c |
Export tool_defs[] / build_tools_list() (or move to a shared header) so agent_llm.c can build the OpenAI tools array from the same catalog. Add fs/shell tool defs. |
src/agent_tools.c |
Dispatch fs/shell tools (or route them via a new agent_fs_tools_dispatch() called from the same dispatcher). |
src/db.c / src/db.h |
Add agent_sessions + agent_messages tables and CRUD functions. |
src/settings.h / src/settings.c |
Add agent provider settings fields (base_url, api_key, model, system_prompt). |
Makefile |
Add new .c files to SRC. |
SQLite schema
CREATE TABLE IF NOT EXISTS agent_sessions (
id TEXT PRIMARY KEY, -- UUID
title TEXT,
created_at INTEGER,
updated_at INTEGER
);
CREATE TABLE IF NOT EXISTS agent_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT,
role TEXT, -- 'user' | 'assistant' | 'tool' | 'system'
content TEXT, -- message text (or tool result JSON)
tool_calls TEXT, -- JSON array of tool calls (assistant msgs)
tool_call_id TEXT, -- for role='tool': which call this answers
created_at INTEGER,
FOREIGN KEY (session_id) REFERENCES agent_sessions(id)
);
CREATE INDEX IF NOT EXISTS idx_agent_messages_session
ON agent_messages(session_id, created_at);
Tool catalog additions (fs + shell)
| Tool | Description |
|---|---|
fs_read |
Read a file's contents (text). Params: path. |
fs_write |
Write text to a file (overwrite). Params: path, content. |
fs_list |
List directory entries. Params: path. |
fs_mkdir |
Create a directory (recursive). Params: path. |
fs_delete |
Delete a file or empty directory. Params: path. |
shell_exec |
Run a shell command, return stdout+stderr+exit code. Params: command, timeout_ms (default 30000). |
OpenAI tools array format
The agent_llm.c client builds the tools field from the shared
tool_defs[] array, converting each entry to the OpenAI format:
{
"type": "function",
"function": {
"name": "snapshot",
"description": "Get the accessibility tree...",
"parameters": { ...schema... }
}
}
Concurrency note
See the Concurrency model section at the top of this document for the
full design. Summary: the agent loop runs on a background GThread. LLM
HTTP calls block in-thread. Browser tool calls hop to the GTK main thread
via g_idle_add() + GAsyncQueue (WebKitGTK is not thread-safe).
Filesystem and shell tools run directly on the background thread. The UI
polls sovereign://agents/status for live progress. A cancel flag allows
the user to stop long-running tasks.
Phasing
The work breaks into two phases that can be implemented sequentially:
Phase 1 — Foundation: fs/shell tools, LLM client, tool-call loop, chat
persistence, sovereign://agents config page, URL-bar ; routing, basic
chat UI. After Phase 1 the example task ("save links to ~/temp/links.txt")
works end-to-end.
Phase 2 — Polish: streaming responses, tool-call progress display in the chat UI, session list / history sidebar, editable system prompt per session, multi-session support from the URL bar, error recovery UI.