framework-ify. proper agent runtime + adopt ag-ui
CI / Test + coverage (pull_request) Successful in 1m41s
CI / Test + coverage (pull_request) Successful in 1m41s
This commit is contained in:
@@ -48,7 +48,9 @@ docker run --rm -p 3000:3000 tbd
|
||||
| `POST` | `/oauth/register` | Dynamic client registration (RFC 7591) for MCP clients |
|
||||
| `GET`, `POST` | `/oauth/authorize` | Where an MCP client sends the user's browser: Gitea login if needed, then a consent page |
|
||||
| `POST` | `/oauth/token` | Trades an authorization code (with its PKCE verifier) or a refresh token for tokens |
|
||||
| `POST` | `/api/chat` | **Login required.** Body `{"messages": [{"role": "user", "content": "hi"}]}` (the conversation so far, 1 to 40 messages of up to 8000 characters, roles `user`, `assistant` or `tool`; and optionally `"page"`, the id of the UI page the user is on (`"tic-tac-toe"`, `"ai-setup"`: a lowercase slug of up to 64 characters), which the LLM is told in a system message, and `"tools"`, the page's tools as MCP describes them: see [the chatbot's tools](#the-chatbots-tools)) sends it to the [chatbot's](#chatbot) LLM and **streams the reply back** as server-sent events (`text/event-stream`): `data: {"thinking":"Let me"}` for each piece of the model's reasoning, `data: {"content":"Hel"}` for each piece of the reply, `data: {"tool-calls":[{"id":"call_1","name":"add","arguments":"{\"x\":1,\"y\":2}"}]}` if the model asks for tools to be called, and last `data: {"done":true}` or `data: {"error":"..."}`. Failures before the reply starts are ordinary errors: 400 for an invalid conversation, 502 if the LLM server can't be reached or refuses, 503 if `LMSTUDIO_URL` isn't set |
|
||||
| `POST` | `/api/chat` | **Login required.** An [AG-UI](https://docs.ag-ui.com) run: body a `RunAgentInput` (`{"messages": [{"id": "m1", "role": "user", "content": "hi"}], ...}`, the conversation so far, 1 to 40 messages of up to 8000 characters, roles `user`, `assistant` or `tool`; optionally `"tools"`, the page's tools as AG-UI describes them: see [the chatbot's tools](#the-chatbots-tools), and `"context"`, ambient information for the run — the id of the UI page the user is on travels as `{"description": "page", "value": "tic-tac-toe"}`) sends it to the [chatbot's](#chatbot) agent and **streams the reply back** as AG-UI server-sent events (`text/event-stream`): `RUN_STARTED`, then `TEXT_MESSAGE_START`/`TEXT_MESSAGE_CONTENT`/`TEXT_MESSAGE_END` for the reply, `REASONING_MESSAGE_START`/`_CONTENT`/`_END` for the model's reasoning, `TOOL_CALL_START`/`_ARGS`/`_END` if it asks for tools to be called, and last `RUN_FINISHED` or `RUN_ERROR`. Failures before the run starts are ordinary errors: 400 for an invalid input, 502 if the LLM server can't be reached or refuses, 503 if `LMSTUDIO_URL` isn't set |
|
||||
| `POST` | `/a2a` | The [A2A server](#a2a-server): JSON-RPC 2.0, `message/send` only (no streaming; see below). **Needs an OAuth access token**, the same one an MCP client uses (`401` without one) |
|
||||
| `GET` | `/.well-known/agent-card.json` | The A2A server's [AgentCard](https://a2a-protocol.org): its name, url, capabilities and skills |
|
||||
| `GET` | `/api-docs` | Swagger UI |
|
||||
| `GET` | `/swagger.json` | OpenAPI/Swagger spec |
|
||||
| `GET` | `/auth/login` | Starts the Gitea OAuth login (redirects to Gitea) |
|
||||
@@ -171,6 +173,32 @@ the token: it is looked up in `ADMIN_USERS` on every request, so removing someon
|
||||
effect at once. A Gitea account that is disabled keeps working until its refresh token expires (up to 30
|
||||
days without use) or its grant is deleted, since Gitea isn't asked again after the login.
|
||||
|
||||
## A2A server
|
||||
|
||||
The server also speaks [A2A](https://a2a-protocol.org) (Agent2Agent) at `/a2a`, for other agents
|
||||
(Claude Code, Codex, ...) to call directly, the way `/mcp` lets an MCP client call its tools. It's a
|
||||
second face of the same agent as the chatbot (`tbd.agent.core`), but not tied to a browser page: an
|
||||
A2A caller has no page open, so `tbd.agent.a2a` gives it the app's *server-side* tools instead —
|
||||
todo CRUD and math, the same operations `tbd.mcp.tools` exposes to MCP clients, defined again over
|
||||
`tbd.todo.service`/`tbd.math.core` directly (this ns doesn't depend on plumcp). Its system prompt
|
||||
(`tbd.agent.instructions/a2a`) is framed for a caller with no page and no visual feedback, not the
|
||||
chatbot's in-page persona.
|
||||
|
||||
```bash
|
||||
curl https://tbd.example.com/.well-known/agent-card.json # <BASE_URL>/a2a, its capabilities and skills
|
||||
curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"message/send","params":{"message":{"role":"user","kind":"message","messageId":"m1","parts":[{"kind":"text","text":"list my todos"}]}}}' \
|
||||
https://tbd.example.com/a2a
|
||||
```
|
||||
|
||||
`$TOKEN` is the same OAuth access token an MCP client gets ([above](#connecting-an-mcp-client)):
|
||||
`wrap-bearer-auth` verifies a token by looking it up, not by which `resource` it names, so a token
|
||||
minted for `/mcp` works here too. Only `message/send` is implemented — the AgentCard says so
|
||||
(`capabilities.streaming: false`) — so a call is synchronous: if the agent asks for a tool, this ns
|
||||
runs it itself immediately (server-side, no browser to hand it to) and asks the agent again, up to 8
|
||||
rounds, until it answers in text, which comes back as the JSON-RPC result (an A2A `Message`, role
|
||||
`"agent"`). Streaming for a browser-driven conversation is what [AG-UI](#chatbot) is for.
|
||||
|
||||
## Authentication and the admin API
|
||||
|
||||
Login is OAuth2 (authorization-code flow) against a [Gitea](https://about.gitea.com/) instance.
|
||||
@@ -186,7 +214,7 @@ login, **403** for a logged-in non-admin. With no allowlist, nobody is an admin.
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `GITEA_URL`, `GITEA_CLIENT_ID`, `GITEA_CLIENT_SECRET` | The Gitea OAuth2 application. Without all three (and `BASE_URL`), `/auth/login` answers 503 |
|
||||
| `BASE_URL` | Public URL of this app; an `https://` one also marks the session cookie `Secure`. It is also the address of the MCP server: the OAuth issuer, and what MCP clients are told to use (`<BASE_URL>/mcp`) |
|
||||
| `BASE_URL` | Public URL of this app; an `https://` one also marks the session cookie `Secure`. It is also the address of the MCP and A2A servers: the OAuth issuer, what MCP clients are told to use (`<BASE_URL>/mcp`), and the A2A AgentCard's `url` (`<BASE_URL>/a2a`) |
|
||||
| `ADMIN_USERS` | Comma-separated Gitea usernames (case-insensitive) allowed to use the admin API |
|
||||
| `SESSION_SECRET` | Signs and encrypts session cookies. Unset = a random key per start, so logins don't survive a restart |
|
||||
| `DB_PATH` | The app's SQLite file: todos, and the MCP OAuth server's registered clients, codes and tokens (default `data/tbd.db`). Mount a volume for it in Docker, or todos are lost and every MCP client has to be approved again after each restart |
|
||||
@@ -245,14 +273,17 @@ in another stage of the same Dockerfile (`--target ui-test`), which the server i
|
||||
|
||||
### Chatbot
|
||||
|
||||
The green square opens a small chat panel. What you type goes to `POST /api/chat`, and the backend
|
||||
forwards the conversation to an [LM Studio](https://lmstudio.ai) server (its OpenAI-compatible
|
||||
`/v1/chat/completions`) and returns the reply. The browser never talks to the LLM directly, so the
|
||||
server's address (and key) stay on the backend. The reply is streamed: the panel shows "Thinking…" until the
|
||||
model's first word (a reasoning model can take a while), then the reply appears as it is generated. What
|
||||
the model reasons is never shown, only that it is thinking; the tool calls (see below) show as small grey
|
||||
lines between the replies, in full, however long, and the conversation scrolls. The conversation lives in the page only (a reload or logging out clears it), and the UI sends
|
||||
the last 40 messages of it. Chat needs a login, like the todos: every message costs the LLM server's time.
|
||||
The green square opens a small chat panel. What you type goes to `POST /api/chat`, an
|
||||
[AG-UI](https://docs.ag-ui.com) run; the backend's agent (`tbd.agent.core`, a
|
||||
[LangChain4j](https://docs.langchain4j.dev/) streaming chat model) forwards the conversation to an
|
||||
[LM Studio](https://lmstudio.ai) server (its OpenAI-compatible `/v1/chat/completions`) and streams
|
||||
the reply back as AG-UI events. The browser never talks to the LLM directly, so the server's address
|
||||
(and key) stay on the backend. The panel shows "Thinking…" until the model's first word (a reasoning
|
||||
model can take a while), then the reply appears as it is generated. What the model reasons is never
|
||||
shown, only that it is thinking; the tool calls (see below) show as small grey lines between the
|
||||
replies, in full, however long, and the conversation scrolls. The conversation lives in the page only
|
||||
(a reload or logging out clears it), and the UI sends the last 40 messages of it. Chat needs a login,
|
||||
like the todos: every message costs the LLM server's time.
|
||||
|
||||
The conversation scrolls. It follows the reply while you are at its end; scroll up to read back and it
|
||||
stays put until you send a message or scroll down again. Drag the panel's top right corner to make it
|
||||
@@ -262,35 +293,36 @@ wider or taller (the size is kept while the page is open, through Clear and clos
|
||||
|
||||
```bash
|
||||
curl -N -H "Cookie: $COOKIE" -H 'Content-Type: application/json' \
|
||||
-d '{"messages":[{"role":"user","content":"hi"}]}' http://localhost:3000/api/chat
|
||||
-d '{"messages":[{"id":"m1","role":"user","content":"hi"}]}' http://localhost:3000/api/chat
|
||||
```
|
||||
|
||||
#### The chatbot's tools
|
||||
|
||||
The chatbot can use the page. The [browser tools](#browser-mcp-tools-webmcp) (the calculator, the todos,
|
||||
tic-tac-toe) are registered in the page's *model context*, `ui/src/tbd/ui/model_context.cljs`, which is
|
||||
what WebMCP's `navigator.modelContext` is to an agent outside the page. The chat is that agent, embedded:
|
||||
what WebMCP's `navigator.modelContext` is to an agent outside the page. The chat is that agent, embedded,
|
||||
and this is exactly the shape of AG-UI's **frontend tools**: the agent asks, the browser runs them.
|
||||
|
||||
1. Each `POST /api/chat` carries `"page"`, the page the user is on, and `"tools"`: the tools' metadata, exactly what an MCP server answers
|
||||
`tools/list` with (`{"name", "description", "inputSchema"}`, the schema being JSON Schema).
|
||||
The backend gives it to the LLM as OpenAI-style `tools` (LM Studio's own tool use), and stays stateless.
|
||||
Only the tools of the page being shown are listed: a tool registered with `:pages #{:calculator}` is
|
||||
offered on the Calculator page and nowhere else (tic-tac-toe's tools are the Tic-tac-toe page's, the
|
||||
todos' are the Todos page's), and one with no `:pages` (`ping`, `whoami`, `navigate`) on every page. The
|
||||
list follows the page the user is on each time the conversation is sent (so does `"page"`, which the
|
||||
backend puts in the system message ahead of the conversation for the LLM to know where the user is,
|
||||
with the names of the tools it has),
|
||||
and a call for a tool the page doesn't offer is refused with an error the LLM reads. The LLM
|
||||
can move between pages with `navigate`; its next request then carries the new page and that
|
||||
page's tools. An agent outside the page, through WebMCP, is still offered all of them.
|
||||
2. The LLM never runs anything. When it wants a tool, it says so: the reply ends with a
|
||||
`{"tool-calls": [...]}` event (the calls are put together from the pieces the model writes, the
|
||||
`arguments` being the JSON text it wrote). The backend only relays them.
|
||||
1. Each `POST /api/chat` carries `"tools"`, the ones the page currently offers, as AG-UI describes them
|
||||
(`{"name", "description", "parameters"}`, the schema being JSON Schema), and `"context"` naming the
|
||||
page (`{"description": "page", "value": "tic-tac-toe"}`). The backend converts them to LangChain4j's
|
||||
typed tool specifications for the LLM, and stays stateless. Only the tools of the page being shown are
|
||||
listed: a tool registered with `:pages #{:calculator}` is offered on the Calculator page and nowhere
|
||||
else (tic-tac-toe's tools are the Tic-tac-toe page's, the todos' are the Todos page's), and one with no
|
||||
`:pages` (`ping`, `whoami`, `navigate`) on every page. The list follows the page the user is on each
|
||||
time the conversation is sent (so does the page's name, which the backend's agent puts in the system
|
||||
message ahead of the conversation, with the names of the tools it has), and a call for a tool the page
|
||||
doesn't offer is refused with an error the LLM reads. The LLM can move between pages with `navigate`;
|
||||
its next request then carries the new page and that page's tools. An agent outside the page, through
|
||||
WebMCP, is still offered all of them.
|
||||
2. The LLM never runs anything. When it wants a tool, it says so: the reply ends with
|
||||
`TOOL_CALL_START`/`TOOL_CALL_ARGS`/`TOOL_CALL_END` events, one triple per call (`TOOL_CALL_ARGS`'s
|
||||
`delta` being the JSON text the model wrote). The backend only relays them.
|
||||
3. The page intercepts them and calls the tools right there (one after the other), through the same
|
||||
handlers WebMCP uses, so they act on the page as a user would and you see it happen. The chat panel
|
||||
shows each call and its result in small print between the messages.
|
||||
4. The results go back as messages with the role `tool` (`{"role": "tool", "tool-call-id": "call_1",
|
||||
"content": "..."}`, after the assistant message that asked, which carries `"tool-calls"`), in
|
||||
4. The results go back as messages with the role `tool` (`{"role": "tool", "toolCallId": "call_1",
|
||||
"content": "..."}`, after the assistant message that asked, which carries `"toolCalls"`), in
|
||||
another request with the conversation so far, for the LLM to go on from, until it answers with words.
|
||||
|
||||
A call that can't be made (a tool that isn't there, arguments that aren't a JSON object, a tool that
|
||||
@@ -298,21 +330,23 @@ fails) is a result with the reason, for the model to read and try again from. To
|
||||
stops calling tools from acting on your page for ever, one message of yours gets it at most 12 calls;
|
||||
then the chat stops, and says so. Results are cut to the 8000 characters the server accepts.
|
||||
|
||||
The chatbot is told to be an agent, not a conversationalist: the system message (`tbd.agent.prompt/chat`, in
|
||||
`shared/tbd/agent/prompt.cljc`, which `default-system-prompt` in `src/tbd/chat/core.clj` is) says that what it
|
||||
The chatbot is told to be an agent, not a conversationalist: its system prompt
|
||||
(`tbd.agent.instructions/chat`, in `src/tbd/agent/instructions.clj` — a JVM-only namespace, never on the
|
||||
UI's classpath, so the chatbot's own instructions are never bundled into the browser) says that what it
|
||||
does shows on your screen at once, so it should call a tool in the same turn rather than offer to, chain
|
||||
calls (navigate, list, then act), take obvious defaults instead of asking, and answer in one short
|
||||
sentence, since the call and its result are already shown. If a model still answers requests with offers,
|
||||
that prompt (and its four examples, which name tools) is what to tune for it; the tool descriptions in
|
||||
`webmcp.cljs` are the other lever. The same file gives an MCP client outside the page the same
|
||||
instructions, [see below](#browser-mcp-tools-webmcp).
|
||||
sentence, since the call and its result are already shown. It reuses the wording that genuinely is shared
|
||||
with an outside agent (`act`, `work-it-out`, `tic-tac-toe-rules`, `examples`) from `tbd.agent.prompt` (the
|
||||
shared `.cljc`, [see below](#browser-mcp-tools-webmcp)) rather than duplicating it. If a model still
|
||||
answers requests with offers, that's what to tune for it; the tool descriptions in `webmcp.cljs` are the
|
||||
other lever.
|
||||
|
||||
To try it, use a model that supports tool use in LM Studio, log in, and ask the chat something the page
|
||||
can do: *"add 2 and 3 with the calculator"*, *"add a todo to buy milk"*, *"play tic-tac-toe with me, you're O"*.
|
||||
|
||||
A reverse proxy in front of tbd must not buffer responses, or the reply arrives all at once at the end
|
||||
(the server sends `X-Accel-Buffering: no`, which nginx obeys; for others, turn buffering off for `/api/chat`).
|
||||
The `thinking` events (the reasoning) also keep the connection from looking idle to a proxy while the model reasons; the UI drops them.
|
||||
The `REASONING_MESSAGE_CONTENT` events also keep the connection from looking idle to a proxy while the model reasons; the UI drops their text, only using them to show "Thinking…".
|
||||
Anything that goes wrong is logged on the server's stdout, with a `[tbd] chat:` prefix.
|
||||
|
||||
Point the backend at your instance with environment variables (see [.env-template](.env-template)):
|
||||
@@ -483,11 +517,12 @@ src/tbd/
|
||||
<group>/schema.clj Malli schemas of the group's input, shared by its routes and the MCP tools
|
||||
auth/ Gitea OAuth (core), session + admin middleware (middleware), /auth routes
|
||||
mcp/ MCP server (PluMCP) mounted at `/mcp`: tools (tools.clj), Malli-to-tool glue (schema.clj) and the Ring handler (routes.clj)
|
||||
oauth/ tbd as the OAuth authorization server for MCP clients: rules (core), SQLite storage (db), `/oauth` + discovery routes, and the bearer-token middleware guarding `/mcp` (middleware)
|
||||
chat/ The chatbot: request to the LM Studio server, tool calls included (core), the request's Malli schema (schema), `/api/chat` route, per-turn logging and loop detection (trace)
|
||||
oauth/ tbd as the OAuth authorization server for MCP and A2A clients: rules (core), SQLite storage (db), `/oauth` + discovery routes, and the bearer-token middleware guarding `/mcp` and `/a2a` (middleware)
|
||||
agent/ The agent: a LangChain4j streaming chat model (core), JSON-Schema translation for tool specs (json_schema), its system prompts (instructions), and the A2A server mounted at `/a2a` (a2a)
|
||||
chat/ /api/chat: the AG-UI wire protocol (routes), the request's Malli schema (schema), per-turn logging and loop detection (trace) — the agent itself is agent/core.clj
|
||||
nrepl/ On-demand nREPL server (core) and the admin-only `/nrepl` routes
|
||||
todo/ Per-user todos: pure rules (core), Malli schemas of the input (schema), SQLite storage (db), `/api/todos` routes
|
||||
shared/ .cljc read by both the server and the UI (tbd.agent.prompt: the instructions an LLM is given)
|
||||
shared/ .cljc read by both the server and the UI (tbd.agent.prompt: the instructions an outside agent, through WebMCP, is given — the chatbot's own system prompt is server-only, in src/tbd/agent/instructions.clj)
|
||||
ui/ ClojureScript re-frame app (shadow-cljs project); ui/src/tbd/ui/{db,events,subs,views,webmcp,model_context,core}.cljs, tests in ui/test/
|
||||
.env-template Settings the server reads from the environment; copy to .env (git-ignored)
|
||||
resources/public/ index.html (the shell served at /) and js/ (compiled UI output, git-ignored)
|
||||
|
||||
Reference in New Issue
Block a user