simplify comments
CI / Test + coverage (push) Successful in 1m1s

This commit is contained in:
2026-09-23 03:21:23 -09:00
parent 2637b69c2c
commit d884ca4179
10 changed files with 233 additions and 417 deletions
+10 -14
View File
@@ -1,21 +1,17 @@
(ns tbd.agent.a2a
"The A2A (Agent2Agent, https://a2a-protocol.org) server, mounted at `/a2a`, with its discovery
document at `/.well-known/agent-card.json`. Unlike /api/chat (the in-page chatbot, driven by the
browser's own tools) an A2A caller has no page open, so this agent's tools are the app's
server-side ones — the same operations tbd.mcp.tools exposes to MCP clients, defined again here
directly over tbd.todo.service/tbd.math.core (not through plumcp's tool-item wrapper, which
tbd.mcp.tools builds for a different transport) so this ns has no plumcp dependency.
document at `/.well-known/agent-card.json`. Unlike /api/chat (driven by the browser's own tools),
an A2A caller has no page open, so this agent's tools are the app's server-side ones — the same
operations tbd.mcp.tools exposes to MCP, defined again here directly over
tbd.todo.service/tbd.math.core so this ns has no plumcp dependency.
A2A calls are synchronous (`message/send` only: no `message/stream`, and the AgentCard says so
truthfully in `capabilities.streaming`) — /api/chat and the AG-UI protocol are where this app's
streaming surface is. Since there is no browser to run a tool and report back, this ns runs the
tool loop itself: ask the agent, and if it asks for tools, run them immediately and ask again,
until it answers in text or `max-turns` is reached.
A2A calls are synchronous (`message/send` only; the AgentCard's `capabilities.streaming` is
false). With no browser to run a tool and report back, this ns runs the tool loop itself: ask
the agent, run any tools it asks for, ask again, until it answers in text or `max-turns` is hit.
Protected the same way /mcp is (tbd.oauth.middleware/wrap-bearer-auth): any access token this
server issued works here too, since verifying one doesn't check which `resource` it was minted
for (see tbd.oauth.middleware) — a client that has authorized for MCP can use A2A with the same
token."
Protected the same way /mcp is (tbd.oauth.middleware/wrap-bearer-auth): a client authorized for
MCP can use A2A with the same token, since verifying one doesn't check which `resource` it was
minted for."
(:require [clojure.string :as str]
[malli.json-schema :as json-schema]
[muuntaja.core :as m]
+41 -57
View File
@@ -3,27 +3,21 @@
Studio server named by the environment. No Ring here; tbd.chat.routes owns that, tbd.agent.a2a
drives the same model for the A2A server.
`start` sends the request and gives {:ok pump} or {:error text :kind :unavailable|:upstream},
like tbd.todo.service's. `pump` forwards the reply, as it is generated, to a function that takes
events: {:thinking \"text\"} (a piece of the model's reasoning), {:content \"text\"} (a piece of
the reply), {:tool-calls [{:id :name :arguments}]} (the model wants these tools run, once its
reply is complete: `arguments` is the JSON text the model wrote), and finally {:done true} or
{:error \"text\"}. This event shape is internal (tbd.chat.routes and tbd.agent.a2a each translate
it to their own wire protocol — AG-UI events, A2A artifacts) and is what tbd.chat.trace watches.
`start` sends the request and gives {:ok pump} or {:error text :kind :unavailable|:upstream}.
`pump` forwards the reply, as it's generated, to a function of events: {:thinking \"text\"},
{:content \"text\"}, {:tool-calls [{:id :name :arguments}]} (arguments is the JSON text the
model wrote), and finally {:done true} or {:error \"text\"}. This event shape is internal —
tbd.chat.routes and tbd.agent.a2a each translate it to their own wire protocol — and is what
tbd.chat.trace watches.
The system prompt is the agent's own (tbd.agent.instructions), not something the caller supplies:
`start` takes `system-text` already composed (tbd.agent.instructions/chat-system-message for the
in-page chatbot, tbd.agent.instructions/a2a for the A2A server), and always sends it as the first
message. `tools` are MCP-style {:name :description :inputSchema}, converted to LangChain4j's
typed tool specifications (tbd.agent.json-schema) — the model only sees their metadata; running
them and reporting the result back (as a \"tool\" role message, see tbd.chat.schema/Message) is
the caller's job.
`start` takes `system-text` already composed by the caller (tbd.agent.instructions) and sends it
as the first message. `tools` are MCP-style {:name :description :inputSchema}, converted to
LangChain4j's typed tool specifications (tbd.agent.json-schema); running them and reporting the
result back is the caller's job.
Every turn is traced (see tbd.chat.trace): what was asked, how the reply went, and whether the
model looks stuck in a loop. The config's `:trace` says where that goes. A turn that does loop is
not just left running (a model that loops keeps generating, so it is never silent long enough for
a timeout to catch it) nor simply failed: `pump` cancels it and retries once, nudged to answer, an
error only if that loops too — see `pump`."
Every turn is traced (tbd.chat.trace: what was asked, how the reply went, whether the model
looks stuck in a loop). A turn that loops isn't left running or simply failed: `pump` cancels it
and retries once, nudged to answer, erroring only if the retry loops too."
(:require [clojure.string :as str]
[tbd.agent.json-schema :as json-schema]
[tbd.chat.trace :as trace])
@@ -47,19 +41,16 @@
(let [v (get env k)]
(when-not (str/blank? v) (str/trim v))))
;; HTTP/1.1 on purpose, same reason as the old tbd.chat.core: the JDK client's default is to ask a
;; plain-http server to upgrade to HTTP/2 (`Upgrade: h2c`), and LM Studio's server never answers
;; that request, so the backend would wait out the whole timeout while the LLM sat idle.
;; HTTP/1.1 on purpose: the JDK client's default asks a plain-http server to upgrade to HTTP/2
;; (`Upgrade: h2c`), and LM Studio never answers that, so requests would hang for the full timeout.
(defn- jdk-http-client-builder []
(-> (JdkHttpClientBuilder.)
(.httpClientBuilder (-> (HttpClient/newBuilder) (.version HttpClient$Version/HTTP_1_1)))))
(defn- api-base-url
"`url` (the server's address, no path, see env-config) with the `/v1` LM Studio's OpenAI-compatible
endpoints live under: LangChain4j's OpenAI client posts straight to `<baseUrl>/chat/completions`,
it does not add this itself (its own default base url is `https://api.openai.com/v1`). Without
it, LM Studio answers 200 with a JSON error body langchain4j can't tell from an empty reply, so
every turn silently becomes \"the LLM server sent no reply\" instead of a clear error."
"`url` (see env-config) with `/v1` appended if missing: LangChain4j posts straight to
`<baseUrl>/chat/completions` without adding it, and LM Studio without it answers 200 with a JSON
error body that reads as an empty reply."
[url]
(if (str/ends-with? url "/v1") url (str url "/v1")))
@@ -163,16 +154,14 @@
:long-reasoning "the model's reasoning went on without end, and the reply was stopped"))
(def ^:private loop-nudge
"Appended to the system prompt for the one retry `pump` gives a turn that loops (see its
docstring): blunt, since reasoning that has not converged in thousands of characters is not
going to be talked into converging gently."
"Appended to the system prompt for pump's one retry of a looping turn."
"\n\nYou have been reasoning for a long time without answering. Stop reasoning now: give your final answer immediately, or call the tool you need.")
(defn- streaming-handler
"A StreamingChatResponseHandler that pushes every callback onto `queue` as a map: {:content s},
{:thinking s}, {:tool-call {:id :name :arguments}} (once a call is fully assembled — LangChain4j
does the delta accumulation itself), {:complete response} or {:error throwable}. `handle!` is
called with the first StreamingHandle seen, so the caller can cancel a stuck stream."
{:thinking s}, {:tool-call {:id :name :arguments}} (once a call is fully assembled), {:complete
response} or {:error throwable}. `handle!` is called with the first StreamingHandle seen, so the
caller can cancel a stuck stream."
^StreamingChatResponseHandler [^LinkedBlockingQueue queue handle!]
(let [capture! (fn [^StreamingHandle h] (when h (handle! h)))]
;; No type hints on the reify methods' params below: hinting an overloaded interface method's
@@ -212,11 +201,10 @@
(some-> (.finishReason response) str str/lower-case))
(defn- send-request!
"Sends `system-text`/`messages`/`tools`, and waits (up to `timeout-seconds`) for the first sign of
life: {:ok {:queue :handle-atom :first-item}}, or {:error text :detail text} for an LLM that is
down, refuses, or stays silent too long — so that is a clean error instead of one inside a stream
that has already begun. Used for the turn's first attempt (see `start`) and, unlike it, for the
retry `pump` sends after a turn that loops."
"Sends `system-text`/`messages`/`tools`, and waits (up to `timeout-seconds`) for the first sign
of life: {:ok {:queue :handle-atom :first-item}}, or {:error text :detail text} for an LLM that
is down, refuses, or stays silent too long. Used for the turn's first attempt (`start`) and for
the retry `pump` sends after a turn that loops."
[config system-text messages tools timeout-seconds]
(try
(let [queue (LinkedBlockingQueue.)
@@ -232,17 +220,15 @@
{:error "the LLM server can't be reached" :detail (exception-detail e)})))
(defn- pump
"Drains `queue`, starting from the already-polled `first-item` (see `start`/`send-request!`, which
peek one item before deciding {:ok}/{:error}), calling `emit` with each event (see the ns
docstring). `timeout-seconds` of silence between items is an error, closing the stream via
`handle-atom` (a StreamingHandle, once one has arrived).
"Drains `queue`, starting from the already-polled `first-item` (see send-request!), calling
`emit` with each event (see the ns docstring). `timeout-seconds` of silence between items is an
error, closing the stream via `handle-atom`.
A model stuck in a loop is never silent, so that timeout never catches it; `tracer` (see
tbd.chat.trace/observe!) watches for it instead. The first time a turn loops (`retried?` false),
the stream is cancelled and sent again — same `system-text`, with `loop-nudge` appended, same
`messages`/`tools` — as a fresh, separately traced attempt; a caller only sees this as a pause; a
turn that loops again on the retry, or that fails to even start it, is an error. An exception from
`emit` (the client went away, say) ends the attempt too, closing the stream, and is rethrown."
A looping model is never silent, so that timeout can't catch it; `tracer` (tbd.chat.trace/observe!)
watches for that instead. The first time a turn loops, the stream is cancelled and resent with
`loop-nudge` appended to `system-text`, as a fresh attempt (the caller only sees a pause); looping
again on the retry, or failing to start it, is an error. An exception from `emit` ends the attempt
too, closing the stream, and is rethrown."
[config system-text messages tools timeout-seconds emit tracer
^LinkedBlockingQueue queue handle-atom first-item retried?]
(let [timer (atom nil)
@@ -332,15 +318,13 @@
(cancel-stream!)))))
(defn start
"Sends the LLM the conversation `messages` (see tbd.chat.schema/Message), and the `tools` it may
ask the caller to run (MCP's {:name :description :inputSchema}; none if nil), after `system-text`
(the agent's own system prompt, see tbd.agent.instructions — composed by the caller, not the
browser). Waits for the first sign of life from the model (a piece of its reply, or an error) so
that an LLM that is down or refuses is still a clean {:error text :kind :unavailable|:upstream},
not an error inside a stream that has already begun. Otherwise {:ok pump}: `(pump emit)` blocks
until the reply is complete (retrying once, on its own, if the model loops — see `pump`), calling
`emit` with its events (see the ns docstring). If `emit` throws, the request to the LLM is closed
(which stops it generating) and the exception is rethrown."
"Sends the LLM `messages` (tbd.chat.schema/Message) and the `tools` it may ask to be run (MCP's
{:name :description :inputSchema}; none if nil), after `system-text` (composed by the caller,
see tbd.agent.instructions). Waits for the first sign of life so an LLM that is down or refuses
comes back as a clean {:error text :kind :unavailable|:upstream} rather than an error mid-stream.
Otherwise {:ok pump}: `(pump emit)` blocks until the reply is complete (retrying once on its own
if the model loops, see `pump`), calling `emit` with its events. If `emit` throws, the request to
the LLM is closed and the exception is rethrown."
[{:keys [timeout-seconds trace] :as config} system-text messages tools]
(if-not (configured? config)
{:error "chat is not configured" :kind :unavailable}
+7 -11
View File
@@ -1,15 +1,11 @@
(ns tbd.chat.routes
"/api/chat: an AG-UI (Agent-User Interaction Protocol, https://docs.ag-ui.com) run. A caller
POSTs a RunAgentInput (tbd.chat.schema/RunAgentInput) and gets the agent's reply streamed back
as AG-UI's server-sent events: RUN_STARTED, then TEXT_MESSAGE_*/REASONING_MESSAGE_*/TOOL_CALL_*
as the model generates, then RUN_FINISHED or RUN_ERROR. The agent itself (its system prompt,
the LLM call, tool-call assembly) is tbd.agent.core; this ns only translates its internal event
shape ({:thinking}/{:content}/{:tool-calls}/{:done}/{:error}, see tbd.agent.core's ns docstring)
to and from AG-UI's wire format.
AG-UI's frontend-tool model — the agent asks for a call, the browser (tbd.ui.webmcp) runs it and
sends the result back as a `tool` message on the next run — is exactly the loop tbd.ui.events
already drove against the old bespoke SSE shape; nothing about that loop changes here."
"/api/chat: an AG-UI (https://docs.ag-ui.com) run. A caller POSTs a RunAgentInput
(tbd.chat.schema/RunAgentInput) and gets the agent's reply streamed back as AG-UI server-sent
events: RUN_STARTED, then TEXT_MESSAGE_*/REASONING_MESSAGE_*/TOOL_CALL_* as the model generates,
then RUN_FINISHED or RUN_ERROR. The agent itself is tbd.agent.core; this ns only translates its
internal event shape ({:thinking}/{:content}/{:tool-calls}/{:done}/{:error}) to AG-UI's wire
format. AG-UI's frontend-tool model (the agent asks for a call, the browser runs it and sends
the result back as a `tool` message on the next run) is exactly the loop tbd.ui.events drives."
(:require [muuntaja.core :as m]
[ring.core.protocols :as ring-protocols]
[tbd.agent.core :as agent]
+26 -45
View File
@@ -1,35 +1,22 @@
(ns tbd.chat.trace
"Observability for the chatbot's LLM turns: what the model was asked, what it did with it, how long
it took, and whether it looks stuck. No Ring and no LLM here; tbd.agent.core (the chatbot's agent)
and tbd.agent.a2a (the A2A server) both call it.
"Observability for the chatbot's LLM turns: what the model was asked, how it went, and whether it
looks stuck. Called by tbd.agent.core and tbd.agent.a2a; no Ring or LLM code here.
One request to /api/chat, or one message to the A2A server, is one turn of the model. The loop that
runs the chatbot's tools lives in the browser
(tbd.ui.events): a tool's result comes back as a new request holding the conversation so far, so the
server keeps nothing between turns. A stuck agent shows up in two ways, and both are watched:
One request to /api/chat, or one message to the A2A server, is one turn. A stuck agent is watched
for two ways: within a turn, reasoning that never ends (`:long-reasoning`) or repeats a sentence
(`:repeated-thinking`); across turns, the same tool call made over and over (`:repeated-call`).
- within a turn, as reasoning that never ends (`:long-reasoning`) or that says the same sentence
again and again (`:repeated-thinking`);
- across turns, as the same tool call made over and over (`:repeated-call`), read off the
conversation each request carries.
A turn is one `tracer`: `begin!` logs `:turn-start`, `observe!` runs per reply event (logging
`:loop-suspect` and telling the caller to stop the turn once a loop is confirmed), `finish!` logs
`:turn-end` with the outcome, timings and flags. A tracer is for one thread.
Every turn is one `tracer`: `begin!` when the request goes out (logs `:turn-start`), `observe!` for
each event of the reply (logs a `:loop-suspect` when one of the above trips, and, past
`repeat-abort-count`/`long-reasoning-abort-chars`, tells the caller to stop the turn — a model
that loops keeps generating, so it never goes silent long enough for a timeout to catch it),
`finish!` when it ends (logs `:turn-end`, with the outcome, timings, sizes and flags). A tracer is
for one thread, the one that reads the reply.
Turns are correlated by `req` (per turn) and `run` (a hash of the user's last message, shared by
every turn of the tool loop it started), with `step` counting turns of the run from 0.
Turns are correlated by `req` (one per turn) and `run` (a hash of the user's last message, the same
for every turn of the tool loop it started), with `step` counting the turns of the run from 0.
Entries are maps that go to a `sink`. `sink` prints each as one `[tbd] chat <event> k=v ...` line and,
if given a file, appends it there as JSON. Only the file has the text of the reasoning and the reply
(`:reasoning`, `:reply`), the run's messages (`:input`), and the turn's system prompt (`:system-text`,
the \"Current state of the page\" it was told included — see tbd.agent.instructions/chat-system-message):
they are what you read to see why a model is looping, or confused about the state it was acting on,
and they are the user's words (or the app's data), so they are off unless a file is asked for
(CHAT_TRACE_FILE, see tbd.agent.core/env-config)."
Entries go to a `sink`, which prints one `[tbd] chat <event> k=v ...` line and, if given a file,
appends it as JSON. Only the file gets the reasoning/reply text, the run's messages and the
system prompt — the user's words or the app's data — so that's off unless CHAT_TRACE_FILE is set
(tbd.agent.core/env-config)."
(:require [clojure.java.io :as io]
[clojure.string :as str]
[muuntaja.core :as m])
@@ -234,12 +221,10 @@
(catch Throwable _ nil)))
(defn begin!
"Starts tracing a turn, and logs `:turn-start`; `messages`, `tools` and `page` are the request's, and
`model` the LLM's, if the config names one. `system-text` is the turn's system prompt (see
tbd.agent.instructions/chat-system-message) — with `full-text?` on, it goes to the trace file too,
since it is the only place the page's state (whose turn it is, a board, a list) as the model was
actually told it, rather than as the model later recalls it in its reasoning, is ever recorded.
`trace` is a config of `config-for`, or nil for `default-config`. Returns the tracer."
"Starts tracing a turn, logging `:turn-start`. `messages`/`tools`/`page`/`model` are the
request's. `system-text` is the turn's system prompt; with `full-text?` on, it's also written to
the trace file. `trace` is a config from `config-for`, or nil for `default-config`. Returns the
tracer."
[trace {:keys [messages tools page model system-text]}]
(let [{:keys [sink full-text?]} (or trace default-config)
{:keys [run step run-calls top-call input]} (run-summary messages)
@@ -265,12 +250,10 @@
nil))
(defn observe!
"Takes one event of the LLM's reply (see tbd.agent.core, whose pump calls this: :content, :thinking,
or, at the end of a turn, :finish-reason and :usage), logging a `:loop-suspect` if the reasoning has
gone on for long, or is repeating itself. Returns the alert to stop the turn over (see
`repeat-abort-count`/`long-reasoning-abort-chars`), or nil to keep going: a model that repeats
itself, or reasons on forever, does so at every following event too, so the caller does not have
to wait for the silence-timeout that a model still actively (if uselessly) generating never trips."
"Takes one event of the LLM's reply (tbd.agent.core/pump: :content, :thinking, or at the end
:finish-reason/:usage), logging `:loop-suspect` if the reasoning is running long or repeating.
Returns the alert to stop the turn over once it passes repeat-abort-count/long-reasoning-abort-chars,
else nil."
[{:keys [state full-text?] :as tracer} event]
(let [[state' alerts] (advance @state event (now-ms) full-text?)]
(reset! state (update state' :flags into (map :kind) alerts))
@@ -279,12 +262,10 @@
(some abortable alerts)))
(defn finish!
"Ends the turn, logging `:turn-end`; only the first call does anything, so it is safe to call
again in a `finally`. `outcome` is a map: {:outcome :done | :tool-calls | :error | :upstream-error
| :looped | :aborted, :calls [{:name :arguments}] for :tool-calls, :error text and :detail text
for the failures}. :aborted is a turn that just stopped (the client went away, or something
threw); :looped is one tbd.agent.core/pump cut short and retried because it was stuck (see
`observe!`) — the retry is a turn of its own, with its own `:turn-start`/`:turn-end`."
"Ends the turn, logging `:turn-end`. Only the first call does anything, so it's safe to call
again in a `finally`. `outcome` is {:outcome :done|:tool-calls|:error|:upstream-error|:looped|:aborted
:calls [...] :error :detail}. :aborted is a turn that just stopped; :looped is one pump cut short
and retried (the retry is a turn of its own)."
[{:keys [state full-text?] :as tracer} {:keys [outcome calls error detail]}]
(let [s @state]
(when-not (:finished? s)
+4 -6
View File
@@ -96,12 +96,10 @@
(todo-routes/routes ds)]
{:data {:muuntaja http/muuntaja
:coercion malli/coercion
;; Handlers (and the auth/error middleware) return plain data; the muuntaja
;; middleware encodes it as the Accept header asks, so it wraps everything else.
;; Auth comes next, so unauthenticated callers to `:admin? true` / `:login? true`
;; routes are turned away before any body parsing or coercion runs. The request
;; half of muuntaja is split off below the exception middleware (rather than using the
;; combined format-middleware) so a malformed request body is caught and answered with a 400.
;; Order matters: response-formatting outermost (handlers return plain data), then
;; auth (so unauthenticated callers are turned away before body parsing/coercion),
;; then request-formatting split below the exception middleware so a malformed body
;; is caught and answered with a 400 rather than thrown.
:middleware [muuntaja-middleware/format-negotiate-middleware
muuntaja-middleware/format-response-middleware
(auth-mw/admin-middleware auth)