From d884ca4179b574a53ae1c750c35f9df7fd883e70 Mon Sep 17 00:00:00 2001 From: ajet Date: Wed, 23 Sep 2026 03:21:23 -0900 Subject: [PATCH] simplify comments --- src/tbd/agent/a2a.clj | 24 ++- src/tbd/agent/core.clj | 98 +++++------ src/tbd/chat/routes.clj | 18 +- src/tbd/chat/trace.clj | 71 +++----- src/tbd/core.clj | 10 +- ui/src/tbd/ui/db.cljs | 58 ++----- ui/src/tbd/ui/events.cljs | 286 +++++++++++-------------------- ui/src/tbd/ui/model_context.cljs | 34 ++-- ui/src/tbd/ui/views.cljs | 9 +- ui/src/tbd/ui/webmcp.cljs | 42 ++--- 10 files changed, 233 insertions(+), 417 deletions(-) diff --git a/src/tbd/agent/a2a.clj b/src/tbd/agent/a2a.clj index 1582dc3..9ca7074 100644 --- a/src/tbd/agent/a2a.clj +++ b/src/tbd/agent/a2a.clj @@ -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] diff --git a/src/tbd/agent/core.clj b/src/tbd/agent/core.clj index 2033d82..461896e 100644 --- a/src/tbd/agent/core.clj +++ b/src/tbd/agent/core.clj @@ -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 `/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 + `/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} diff --git a/src/tbd/chat/routes.clj b/src/tbd/chat/routes.clj index 4f81c9a..2bf1ee8 100644 --- a/src/tbd/chat/routes.clj +++ b/src/tbd/chat/routes.clj @@ -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] diff --git a/src/tbd/chat/trace.clj b/src/tbd/chat/trace.clj index 3bb3e91..a39fc4a 100644 --- a/src/tbd/chat/trace.clj +++ b/src/tbd/chat/trace.clj @@ -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 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 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) diff --git a/src/tbd/core.clj b/src/tbd/core.clj index 87a2eaa..c9eb76a 100644 --- a/src/tbd/core.clj +++ b/src/tbd/core.clj @@ -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) diff --git a/ui/src/tbd/ui/db.cljs b/ui/src/tbd/ui/db.cljs index 884fedb..b44d7ed 100644 --- a/ui/src/tbd/ui/db.cljs +++ b/ui/src/tbd/ui/db.cljs @@ -2,50 +2,30 @@ (:require [tbd.ui.tictactoe :as tictactoe])) (def default-db - {;; The page being shown (see tbd.ui.pages); it follows the URL's fragment. + {;; Which page is showing (tbd.ui.pages); follows the URL's fragment. :page :tic-tac-toe :calc {:x "" :y "" - ;; {:status :idle} - ;; {:status :loading} - ;; {:status :ok :x "2" :y "3" :sum 5} - ;; {:status :error :message "..."} + ;; :result is {:status :idle|:loading}, {:status :ok :x :y :sum}, or + ;; {:status :error :message}. :result {:status :idle}} - ;; {:status :unknown} (asking /auth/me) - ;; {:status :anonymous} (not logged in) - ;; {:status :logged-in :login "alice"} - ;; {:status :error :message "..."} + ;; :user is {:status :unknown}, {:status :anonymous}, {:status :logged-in :login "alice"}, or + ;; {:status :error :message}. :user {:status :unknown} - ;; :status is the list load: :idle, :loading, :ok or :error. :error is the message of the - ;; last failed request (a load or a change); it is cleared when the next request starts. - :todos {:status :idle + :todos {;; :status is :idle, :loading, :ok or :error; :error is the last failed request's + ;; message, cleared when the next request starts. + :status :idle :items [] :new-title "" :error nil} - ;; The "Add this MCP server to Claude Code" page: :copied? is true right after the - ;; command was copied to the clipboard. + ;; The "Add this MCP server to Claude Code" page. :copied? is true right after the command was + ;; copied to the clipboard. :mcp {:copied? false} - ;; The chat widget (the green square in the corner): :open? is whether its panel is showing, - ;; :messages the conversation so far, :input what is typed, :status :idle or :loading (waiting - ;; for the reply, or for the tools it asked for), and :error the message of the last failed send. - ;; A message is one of - ;; {:role "user" :content "..."} - ;; {:role "assistant" :content "..."}, with :tool-calls [{:id :name :arguments "{...}"}] when - ;; the LLM asked for tools of the page to be called (:content may then be empty), and - ;; :thinking "..." with the reasoning behind it, when the model did any (see - ;; ::events/chat-event); shown only with "Show reasoning" on. - ;; {:role "tool" :tool-call-id "..." :name "..." :content "..." :error? bool}: the result of - ;; one such call. - ;; :size is what the panel was dragged to, in px: its :width and the :height of the conversation - ;; in it (the rest of the panel is the same at any size). - ;; :tools-expanded? is whether the tool calls and results show whole (wrapped) or, as they do by - ;; default, each cut to one line ending in "…" when it doesn't fit. - ;; :reasoning-expanded? is whether each reply's reasoning shows in full, or not at all (the - ;; default) — unlike tool use, there is no cut-down middle ground for it. - ;; The other three are for a reply that is still coming or being answered: :pending-calls are the - ;; calls it has asked for so far, :tool-calls-used how many calls this turn has made, and - ;; :tools-used-this-turn the names of the once-per-turn tools (tbd.ui.model-context/once-per-turn-names) - ;; it has already run, so the turn can neither offer nor run one of them again. - ;; :thinking-at is when (ms since the epoch) the last piece of the model's reasoning came, or nil. + ;; The chat widget (the green square). A message is {:role "user"|"assistant"|"tool" :content} + ;; with :tool-calls [{:id :name :arguments}] and :thinking on an assistant message that used + ;; tools or reasoned, and :tool-call-id/:name on a tool message. :size is the panel's dragged + ;; :width/:height. :tools-expanded?/:reasoning-expanded? control whether tool use/reasoning show + ;; in full or collapsed. :pending-calls/:tool-calls-used/:tools-used-this-turn/:thinking-at + ;; track a reply that is still coming or being answered (see tbd.ui.events). :chat {:open? false :size {:width 320 :height 256} :tools-expanded? false @@ -55,12 +35,12 @@ :status :idle :error nil :pending-calls nil - ;; Tool calls the server is still streaming (AG-UI's TOOL_CALL_START/ARGS/END), keyed by - ;; toolCallId, until each is complete and moves into :pending-calls. + ;; Tool calls still streaming in (AG-UI's TOOL_CALL_START/ARGS/END), keyed by + ;; toolCallId, until complete and moved into :pending-calls. :tool-call-buffer {} :thinking-at nil :tool-calls-used 0 :tools-used-this-turn #{}} - ;; Tic-tac-toe, entirely client side: the board (see tbd.ui.tictactoe) and whose turn it is. + ;; Tic-tac-toe, client side only. :ttt {:board tictactoe/new-board :turn :x}}) diff --git a/ui/src/tbd/ui/events.cljs b/ui/src/tbd/ui/events.cljs index 087ab5a..07fc9a5 100644 --- a/ui/src/tbd/ui/events.cljs +++ b/ui/src/tbd/ui/events.cljs @@ -8,9 +8,9 @@ [tbd.ui.sse :as sse] [tbd.ui.tictactoe :as tictactoe])) -;; A `js/fetch` of `uri` (GET unless `method` is given). A `body` is sent as EDN. -;; A `signal` (an AbortSignal) lets the request be aborted. -(defn- fetch-request [{:keys [uri method body signal]}] +(defn- fetch-request + "GETs (or `method`s) `uri`. `body`, if given, is sent as EDN. `signal` (an AbortSignal) aborts it." + [{:keys [uri method body signal]}] (js/fetch uri (cond-> (clj->js (cond-> {:method (or method "GET")} body (assoc :headers {"Content-Type" "application/edn"} :body (pr-str body)))) @@ -19,22 +19,19 @@ (defn- response-map [resp text] {:ok? (.-ok resp) :status (.-status resp) :body text}) -;; Request `uri` (GET unless `method` is given), then dispatch -;; `(conj on-response {:ok? bool :status int-or-nil :body string})`. A `body` is sent as EDN. -;; Network failures come back as `{:ok? false :status nil :body }`. -(defn- do-fetch! [{:keys [on-response] :as request}] +(defn- do-fetch! + "Fetches `request` (see fetch-request), dispatching `(conj on-response {:ok? :status :body})`. + A network failure dispatches `{:ok? false :status nil :body message}`." + [{:keys [on-response] :as request}] (-> (fetch-request request) (.then (fn [resp] (.then (.text resp) #(rf/dispatch (conj on-response (response-map resp %)))))) (.catch #(rf/dispatch (conj on-response {:ok? false :status nil :body (.-message %)}))))) -;; Effect: see do-fetch!. (rf/reg-fx ::fetch do-fetch!) -;; Effect: like ::fetch, for the chat history's own save/clear requests (see save-chat-fx and -;; ::chat-clear) — its own effect id so it never lands in the same bucket as the LLM conversation's -;; requests (tbd.ui.events-test's `requests`/`last-request` are built on ::fetch/::fetch-stream -;; alone, and would otherwise sometimes see this request instead of the one a test is waiting for). +;; Same as ::fetch, with its own id so chat-history saves don't collide with the chat stream's +;; requests in tests. (rf/reg-fx ::persist-chat do-fetch!) (def ^:private connection-lost {:type "RUN_ERROR" :message "the connection was lost"}) @@ -44,11 +41,9 @@ #{"RUN_FINISHED" "RUN_ERROR"}) (defn- read-events! - "Dispatches `(conj on-event event)` for each AG-UI event (see tbd.ui.sse, tbd.chat.routes) in the - body read by `reader`, as they arrive. A body that ends without a RUN_FINISHED or RUN_ERROR event - (the connection dropped) is a RUN_ERROR event of its own, so whoever waits for the end always - gets one — unless `signal` was aborted (see ::abort-stream): then nothing more is dispatched at - all, since whoever aborted it has stopped waiting, and may have started another stream since." + "Dispatches `(conj on-event event)` for each AG-UI event decoded from `reader`, as it arrives. A + stream that ends without a RUN_FINISHED/RUN_ERROR event dispatches a RUN_ERROR of its own — unless + `signal` was aborted, in which case nothing more is dispatched." [reader on-event ^js signal] (let [decoder (js/TextDecoder. "utf-8") ended? (atom false) @@ -72,15 +67,12 @@ (.catch (fn [_] (lost!)))))] (step ""))) -;; The AbortController of the stream ::fetch-stream last started, for ::abort-stream. There is only -;; ever one in flight (the chat sends one message at a time). +;; The in-flight stream's AbortController, for ::abort-stream. Only one stream runs at a time. (defonce ^:private stream-controller (atom nil)) -;; Effect: like ::fetch, for a response that is a stream of server-sent events. While it comes in, -;; `(conj on-event event)` is dispatched for each event, as a map (see read-events!). When the -;; request fails instead (a network error or a status that isn't 2xx: no stream came), -;; `(conj on-response {:ok? false :status int-or-nil :body string})` is dispatched, as ::fetch does. -;; After ::abort-stream, nothing more is dispatched for it. +;; Like ::fetch, for a streamed response: dispatches `(conj on-event event)` for each event as it +;; arrives (see read-events!), or `(conj on-response {:ok? false ...})` if the request itself fails. +;; Nothing more is dispatched once ::abort-stream has run. (rf/reg-fx ::fetch-stream (fn [{:keys [on-event on-response] :as request}] @@ -95,8 +87,8 @@ (.then (.text resp) #(respond! (response-map resp %)))))) (.catch #(respond! {:ok? false :status nil :body (.-message %)})))))) -;; Effect: abort the stream ::fetch-stream last started, if it is still going. The connection -;; closing is what tells the server to stop the LLM (see tbd.chat.routes/event-stream). +;; Aborts the in-flight stream, if any. Closing the connection is what tells the server to stop +;; the LLM (see tbd.chat.routes/event-stream). (rf/reg-fx ::abort-stream (fn [_] @@ -104,24 +96,20 @@ (reset! stream-controller nil) (.abort controller)))) -;; Coeffect: the URL's fragment, which says which page to start on. +;; The URL's fragment, which says which page to start on. (rf/reg-cofx ::hash (fn [cofx _] (assoc cofx :hash js/location.hash))) -;; Coeffect: the time now, in ms since the epoch. (rf/reg-cofx ::now (fn [cofx _] (assoc cofx :now (js/Date.now)))) -;; localStorage key the chat widget's :open? and :size are kept under (see ::persist-chat-ui and -;; ::initialize), so a reload leaves the panel showing (or hidden) and sized as it was left. (def ^:private chat-ui-storage-key "tbd.chat-ui") -;; Coeffect: the chat widget's :open? and :size as they were last left, or nil if nothing was ever -;; saved (or what's there can't be read, e.g. it was hand-edited into garbage). +;; The chat widget's saved :open?/:size, or nil if there is none (or it's unreadable). (rf/reg-cofx ::chat-ui-storage (fn [cofx _] @@ -132,18 +120,14 @@ (select-keys [:open? :size])) (catch :default _ nil))))) -;; Effect: remembers the chat widget's :open? and :size in localStorage, across reloads. (rf/reg-fx ::persist-chat-ui (fn [chat-ui] (.setItem js/localStorage chat-ui-storage-key (pr-str chat-ui)))) -;; localStorage key the tic-tac-toe board and whose turn it is are kept under (see ::persist-ttt -;; and ::initialize), so a reload finds the game as it was left instead of starting over. (def ^:private ttt-storage-key "tbd.ttt") -;; Coeffect: the tic-tac-toe :board and :turn as they were last left, or nil if nothing was ever -;; saved (or what's there can't be read, e.g. it was hand-edited into garbage). +;; The tic-tac-toe :board/:turn as last saved, or nil. (rf/reg-cofx ::ttt-storage (fn [cofx _] @@ -154,14 +138,13 @@ (select-keys [:board :turn])) (catch :default _ nil))))) -;; Effect: remembers the tic-tac-toe :board and :turn in localStorage, across reloads. (rf/reg-fx ::persist-ttt (fn [ttt] (.setItem js/localStorage ttt-storage-key (pr-str ttt)))) -;; Effect: show the page the URL names whenever it changes (a link was followed, or Back and -;; Forward were pressed). Only one listener is ever installed, however often this runs. +;; Shows the page named by the URL whenever it changes (a link, or Back/Forward). Installs at +;; most one listener, however often this runs. (defonce ^:private hash-listener (atom nil)) (rf/reg-fx @@ -172,7 +155,6 @@ (reset! hash-listener #(rf/dispatch [::hash-changed js/location.hash])) (.addEventListener js/window "hashchange" @hash-listener))) -;; Effect: put `hash` in the URL, which is how a page gets its own address (and a Back button). (rf/reg-fx ::set-hash (fn [hash] @@ -190,14 +172,12 @@ ;; --- Pages --- -;; The URL changed (a link was followed, or Back was pressed): show the page it names. (rf/reg-event-db ::hash-changed (fn [db [_ hash]] (assoc db :page (pages/hash->page hash)))) -;; Go to a page without a link, as the browser tools do. The page changes right away; the URL -;; follows, and its change event finds the page already shown. +;; Navigates without a link: the page changes right away, and the URL follows. (rf/reg-event-fx ::navigate (fn [{:keys [db]} [_ page]] @@ -259,7 +239,7 @@ {::fetch {:uri "/auth/logout" :method "POST" :on-response [::logout-response]}})) (defn- reset-chat - "An empty conversation, in a panel that is as open (or closed), and as big, as it was." + "An empty conversation, keeping the panel's :open?/:size/:tools-expanded?/:reasoning-expanded?." [db] (assoc db :chat (merge (:chat db/default-db) (select-keys (:chat db) [:open? :size :tools-expanded? :reasoning-expanded?])))) @@ -291,8 +271,8 @@ (update db :todos assoc :status :ok :items (:todos (reader/read-string body))) (update db :todos assoc :status :error :error (error-message resp))))) -;; A failed change keeps the list as it is and shows the message. A 401 means the session -;; expired, so fall back to the logged-out view (which hides the todos). +;; A 401 means the session expired: fall back to the logged-out view. Otherwise the list stays, +;; with the error shown. (rf/reg-event-db ::todo-failed (fn [db [_ {:keys [status] :as resp}]] @@ -356,39 +336,34 @@ ;; --- Chat --- -;; The chat is a loop when the LLM uses the page's tools (see tbd.ui.model-context): the reply to a -;; message can be a request for tool calls instead of (or after) text; the page makes the calls, -;; and sends the conversation again, now with their results, for the LLM to go on from. It does -;; that until the LLM answers without asking for tools. +;; The chat loops when the LLM calls tools of the page (tbd.ui.model-context): the page runs them +;; and resends the conversation, until the LLM answers without asking for more. (def ^:private max-sent-messages - "How much of the conversation goes to the server with each message (its limit is - tbd.chat.schema/max-messages, which this stays within), the most recent part." + "How much of the conversation goes to the server per request (bounded by + tbd.chat.schema/max-messages), the most recent part." 40) (def ^:private max-tool-calls-per-turn - "How many tool calls the LLM may make in answer to one message of the user's, before the page - stops it. A model that keeps calling tools never answers, and every call is something done on the - user's page. (A turn is at most 1 + this many assistant messages and this many results, so it is - always inside `max-sent-messages`.)" + "Max tool calls the LLM may make answering one message, before the turn is stopped as a runaway + loop. (A turn is at most 1 + this many assistant messages and this many results, so it always + stays within max-sent-messages.)" 12) (def ^:private max-tool-result-length - "The longest result of a tool that goes to the LLM (tbd.chat.schema/max-message-length)." + "The longest tool result sent to the LLM (tbd.chat.schema/max-message-length)." 8000) (def ^:private truncated " … (truncated)") (defn- display-text - "A tool's result text as kept in memory and shown in the chat log: never empty. Not cut down — - the browser keeps the whole thing for as long as the tab is open, so \"Expand tool use\" has - something to expand to; only what leaves the tab (to the LLM, or to /api/chat/history) is fit to - the server's limit, by `fit-result`." + "`text` for the chat log, never empty. Kept in full here; only what leaves the tab is cut down, + by fit-result." [text] (if (str/blank? text) "(no output)" text)) (defn- fit-result - "`text`, no longer than the server takes, for a tool result about to leave the tab." + "`text`, cut to max-tool-result-length, for a tool result about to leave the tab." [text] (if (> (count text) max-tool-result-length) (str (subs text 0 (- max-tool-result-length (count truncated))) truncated) @@ -397,15 +372,13 @@ (defonce ^:private id-counter (atom 0)) (defn- next-id - "An id unique within this page's session, for an AG-UI message: they only have to identify a - message within one run's conversation, never across page loads." + "A fresh id for an AG-UI message, unique within this page's session." [] (str "m" (swap! id-counter inc))) (defn- wire-message - "A message of the conversation as AG-UI's Message has it (see tbd.chat.schema/Message: user, - assistant with `toolCalls`, or tool). A tool result is fit to the server's limit here, on the - way out — the browser's own copy, in `db`, stays whole." + "A conversation message as AG-UI's Message (tbd.chat.schema/Message). A tool result is fit to + the server's limit here; the browser's own copy stays whole." [{:keys [role content tool-calls tool-call-id]}] (cond-> {:id (next-id) :role role} (= "tool" role) (assoc :toolCallId tool-call-id :content (fit-result content)) @@ -416,51 +389,41 @@ tool-calls)))) (defn- windowed - "The most recent `n` of `messages`, without the results of calls whose asking is cut off: a - result stranded at the front, its call dropped, answers nothing." + "The most recent `n` of `messages`, dropping a leading tool result whose call fell out of the + window." [n messages] (drop-while #(= "tool" (:role %)) (take-last n messages))) -(defn- recent - "The most recent `max-sent-messages` of `messages`, without the results of calls whose asking - is cut off: the LLM server refuses a result that answers nothing." - [messages] +(defn- recent [messages] (windowed max-sent-messages messages)) (def ^:private max-stored-messages - "How much of the conversation is saved for the \"restore my last session\" feature - (tbd.chat.schema/max-history-messages, which this stays within): more than max-sent-messages, - since this is scrollback for the user to read, not context sent to the LLM." + "How much of the conversation is saved for \"restore my last session\" (bounded by + tbd.chat.schema/max-history-messages): more than max-sent-messages, since this is scrollback." 100) (def ^:private max-stored-content-length - "The longest tool result, or reply's reasoning, that is saved - (tbd.chat.schema/max-history-content-length): the saved conversation is the whole log, for - \"Expand tool use\"/\"Show reasoning\" to show after a reload, so it is not cut down to what the - LLM is sent." + "The longest tool result or reasoning kept in a saved conversation + (tbd.chat.schema/max-history-content-length): the full log, for \"Expand\"/\"Show reasoning\" + after a reload, not cut to what the LLM sees." 100000) (def ^:private max-stored-length - "How long the saved conversation may be, printed, all told (tbd.chat.schema/max-history-length)." + "Total length of a saved conversation, printed (tbd.chat.schema/max-history-length)." 1000000) (declare only-reasoning?) -(defn- fit-stored - "`text`, no longer than /api/chat/history takes for a tool result or reasoning." - [text] +(defn- fit-stored [text] (if (> (count text) max-stored-content-length) (str (subs text 0 (- max-stored-content-length (count truncated))) truncated) text)) (defn- storable - "The most recent `max-stored-messages` of `messages`, as /api/chat/history's PUT wants them: the - browser's own internal shape (see tbd.ui.db), not AG-UI's wire Message — that endpoint is this - app's own addition, not part of an AG-UI run, so nothing is lost translating to and from it. - Tool results and reasoning are saved whole (up to `max-stored-content-length`), not fit to what - the LLM is sent, so a reload restores the full log; when the whole is over `max-stored-length`, - the oldest turns go instead. A reply halted before it said anything (see ::chat-halt) isn't - saved: the server keeps no message that says nothing." + "The most recent `max-stored-messages` of `messages`, as /api/chat/history's PUT wants them. + Drops replies that came to nothing but reasoning, and fits tool results/reasoning to + max-stored-content-length. If the total is still over max-stored-length, drops whole oldest + turns (never a result without its call, or a reply without its question)." [messages] (let [fitted (into [] (comp (remove only-reasoning?) @@ -474,15 +437,12 @@ size (reduce + 2 sizes) sizes sizes] (if (and (> size max-stored-length) (seq messages)) - ;; The oldest turn goes whole, up to the next message of the user's: nothing is left - ;; stranded, a result without its call or a reply without its question. (let [n (inc (count (take-while #(not= "user" (:role %)) (rest messages))))] (recur (subvec messages n) (- size (reduce + (take n sizes))) (subvec sizes n))) messages)))) -;; Effect: best-effort save of the conversation so far, for /api/chat/history to hand back on the -;; next login. Never blocks anything: on failure the next natural save point (the next message -;; sent, or the turn after) tries again with the conversation as it is by then. +;; Best-effort save of the conversation for /api/chat/history to hand back on next login. Never +;; blocks anything: a failure is retried at the next natural save point. (defn- save-chat-fx [db] {:uri "/api/chat/history" :method "PUT" :body {:messages (storable (get-in db [:chat :messages]))} @@ -491,35 +451,26 @@ (rf/reg-event-db ::save-chat-history-response (fn [db [_ {:keys [status]}]] - ;; A 401 means the session expired since the save was sent; anything else is a transient - ;; failure the next save point papers over, so there is nothing to do for it. (if (= 401 status) (-> db (assoc :user {:status :anonymous}) reset-chat) db))) (defn- wire-tool - "A tool as AG-UI's Tool has it (see tbd.chat.schema/Tool): `parameters`, not MCP's `inputSchema`." + "A tool as AG-UI's Tool has it (tbd.chat.schema/Tool): `parameters`, not MCP's `inputSchema`." [{:keys [name description inputSchema]}] (cond-> {:name name :parameters inputSchema} description (assoc :description description))) -;; The request that sends the conversation: an AG-UI RunAgentInput. It goes with the page being -;; shown (as a `context` entry, for the LLM to know where the user is), the tools that page offers -;; the LLM, and its current state (`state`, see tbd.ui.model-context/current-state and -;; tbd.chat.schema/RunAgentInput) — what a read-only tool would have told it, so it never has to -;; spend a call finding out. All three are read from the db every time, so a conversation that -;; moves between pages (the LLM's own `navigate`, say) always tells the LLM where it is now. Old -;; tool results are sent as they are (nothing here marks them stale): the state is always -;; refreshed and current, and the system prompt tells the model to go by that over anything said -;; earlier, so there is nothing an old result could mislead it into believing. +;; An AG-UI RunAgentInput: the page's name (as `context`, so the LLM knows where the user is), the +;; tools that page offers, and its current state (model-context/current-state), so the LLM never +;; has to spend a call finding out. Read from the db each time, so a conversation that moves +;; between pages always reflects where it is now. (defn- chat-request [db messages] (let [page (:page db) once (model-context/once-per-turn-names page) used (get-in db [:chat :tools-used-this-turn]) tools (remove (comp #(and (once %) (used %)) :name) (model-context/definitions page)) state (model-context/current-state page) - ;; A reply halted (see ::chat-halt) before it said anything stays on screen, with whatever - ;; reasoning it had, but there is nothing in it for the LLM (which refuses it). messages (remove only-reasoning? messages)] {:uri "/api/chat" :method "POST" :body (cond-> {:threadId (next-id) :runId (next-id) @@ -530,9 +481,8 @@ :on-event [::chat-event] :on-response [::chat-response]})) -;; Restoring the last session: fetched once, right after login (see ::user-response), and merged -;; in only if nothing has been said in this page load yet — a slow response landing after the user -;; already started a fresh conversation would otherwise stomp on it. +;; Restores the last session, right after login. Merged in only if nothing has been said in this +;; page load yet, so a slow response can't stomp on a conversation already started. (rf/reg-event-fx ::load-chat-history (fn [_ _] @@ -545,8 +495,6 @@ (assoc-in db [:chat :messages] (:messages (reader/read-string body))) db))) -;; Whether the chat widget is open persists (see ::persist-chat-ui) so a reload leaves it as the -;; user left it. (rf/reg-event-fx ::chat-toggle (fn [{:keys [db]} _] @@ -554,8 +502,6 @@ {:db db ::persist-chat-ui (select-keys (:chat db) [:open? :size])}))) -;; The panel is dragged to `width` px wide and its conversation `height` px high (the view keeps -;; them within the window). The size persists (see ::persist-chat-ui) so a reload keeps it. (rf/reg-event-fx ::chat-resize (fn [{:keys [db]} [_ width height]] @@ -563,13 +509,11 @@ {:db db ::persist-chat-ui (select-keys (:chat db) [:open? :size])}))) -;; Whether the tool calls and results show whole, or each cut to one line. (rf/reg-event-db ::chat-toggle-tools (fn [db _] (update-in db [:chat :tools-expanded?] not))) -;; Whether each reply's reasoning shows in full, or not at all. (rf/reg-event-db ::chat-toggle-reasoning (fn [db _] @@ -580,7 +524,6 @@ (fn [db [_ input]] (assoc-in db [:chat :input] input))) -;; Only one message is in flight at a time, and a blank one isn't sent. (rf/reg-event-fx ::chat-send (fn [{:keys [db]} _] @@ -595,18 +538,15 @@ ::persist-chat (save-chat-fx db)}))))) (defn- only-reasoning? - "Whether `message` is a reply that has come to nothing but the reasoning for it: there is nothing - in it for the LLM to be sent (the server refuses an assistant message that says and asks for - nothing)." + "Whether `message` is a reply that has come to nothing but reasoning: no content, no tool calls + (the server refuses a message that says and asks for nothing)." [{:keys [role content tool-calls]}] (and (= "assistant" role) (str/blank? content) (empty? tool-calls))) (defn- chat-failed - "The conversation after a send failed with `message`. If the last message is the user's (nothing - of the reply had come, but perhaps some reasoning, which goes), it leaves the conversation again - and goes back in the input, so trying once more is pressing Send (and the next message doesn't - follow one nobody answered). If part of the reply had come, or the tools' results had been sent, - that stays, and the next message follows it." + "The conversation after a send failed with `message`. If nothing of the reply had come, the + unanswered user message goes back into the input (so retrying is just pressing Send); otherwise + whatever came stays, and the next message follows it." [db message] (let [messages (get-in db [:chat :messages]) messages (cond-> messages (only-reasoning? (peek messages)) pop) @@ -616,9 +556,8 @@ (when unanswered? {:messages (pop messages) :input (:content (peek messages))})))) -;; The server answered without a stream: the request failed. A 401 means the session expired: fall -;; back to the logged-out view. A response nobody is waiting for (the conversation was reset since, -;; by logging out) is dropped. +;; The server answered without a stream: the request failed. A 401 means the session expired. +;; A response nobody is waiting for (the conversation was reset since) is dropped. (rf/reg-event-fx ::chat-response (fn [{:keys [db]} [_ {:keys [status] :as resp}]] @@ -629,10 +568,9 @@ {:db db ::persist-chat (save-chat-fx db)})))) (defn- append-to-reply - "The conversation's assistant message being written, with `text` added to its content. The first - piece starts the message, the rest are added to it. Leading whitespace is dropped while nothing - has been said yet (the model often opens its answer with a stray blank line right after - reasoning), but preserved once the message has content, since it may be meaningful there." + "The conversation with `text` added to the assistant message being written (starting one if + needed). Leading whitespace is dropped until the message has content (a reasoning model often + opens with a stray blank line), then preserved." [db text] (update-in db [:chat :messages] (fn [messages] @@ -643,9 +581,8 @@ (conj messages {:role "assistant" :content (str/triml text)}))))) (defn- append-reasoning - "The conversation's assistant message being written, with `text` added to its reasoning — kept - apart from `:content` (see append-to-reply) so \"Show reasoning\" can reveal it, in full, for - whichever reply it belongs to; hidden otherwise." + "Like append-to-reply, but onto :thinking, kept apart from :content so \"Show reasoning\" can + reveal it for the right reply." [db text] (update-in db [:chat :messages] (fn [messages] @@ -654,8 +591,8 @@ (conj messages {:role "assistant" :content "" :thinking text}))))) (defn- with-tool-calls - "The conversation with the calls the LLM asked for: on the reply's text (the message it - started), or on a message of their own when it said nothing." + "The conversation with `calls` attached to the reply (on its text, or a fresh message if it said + nothing)." [messages calls] (if (= "assistant" (:role (peek messages))) (update messages (dec (count messages)) assoc :tool-calls calls) @@ -665,10 +602,8 @@ {:role "tool" :tool-call-id id :name name :content content :error? (boolean error?)}) (defn- blocked-calls - "`calls`, each marked `:blocked?` when it names a tool in `once` (see - tbd.ui.model-context/once-per-turn-names) that `used` (the names already spent earlier this - turn) already has, or an earlier call of this same batch does — the model asking twice in one - reply is blocked exactly like asking again after the results of the first came back." + "`calls`, each marked `:blocked?` when it names a once-per-turn tool (model-context/once-per-turn-names) + already used this turn, or by an earlier call in this same batch." [once used calls] (first (reduce (fn [[marked seen] {:keys [name] :as call}] (let [counts? (once name)] @@ -677,10 +612,9 @@ [[] #{}] calls))) (defn- finish-reply - "The reply is complete. Without calls that is all; with them, they are made (see ::call-tools) — - except a call to a once-per-turn tool already spent this turn (`blocked-calls`), which is - refused without running — and the conversation goes on when they are done, unless this turn has - used all the calls it may." + "The reply is complete. With no calls, that's it. With calls, runs them (::call-tools) — except + blocked ones (blocked-calls), refused without running — unless this turn has already used all + its tool calls (max-tool-calls-per-turn), in which case the turn is stopped instead." [db] (let [{:keys [pending-calls tool-calls-used]} (:chat db) used (+ (or tool-calls-used 0) (count pending-calls)) @@ -695,7 +629,6 @@ {:db db ::persist-chat (save-chat-fx db)}) (> used max-tool-calls-per-turn) - ;; The calls still get their answers, for the conversation to make sense to the LLM later. (let [db (-> db (update-in [:chat :messages] with-tool-calls pending-calls) (update-in [:chat :messages] into @@ -728,15 +661,10 @@ call (-> (update-in [:chat :pending-calls] (fnil conj []) call) (update-in [:chat :tool-call-buffer] dissoc toolCallId))))) -;; One AG-UI event of the reply (see tbd.chat.routes, https://docs.ag-ui.com): TEXT_MESSAGE_CONTENT -;; ({:delta "..."}) for the next piece of the reply (the first starts the assistant's message, the -;; rest are added to it), REASONING_MESSAGE_CONTENT for the next piece of the model's reasoning -;; (added to that same message's own :thinking, apart from :content — see append-reasoning; the -;; user only sees "Thinking…" while it comes in, and, if "Show reasoning" is on, the whole of it -;; once it's there; while it's still coming, :thinking-at, the time the last piece came, is for -;; that), TOOL_CALL_START/TOOL_CALL_ARGS/TOOL_CALL_END as it asks for tools to be called -;; (accumulated in :tool-call-buffer until each call is complete), and last RUN_FINISHED or -;; RUN_ERROR. Events nobody is waiting for (the conversation was reset since) are dropped. +;; One AG-UI event of the reply (tbd.chat.routes, https://docs.ag-ui.com): TEXT_MESSAGE_CONTENT +;; and REASONING_MESSAGE_CONTENT append to the reply's :content/:thinking, TOOL_CALL_START/ARGS/END +;; build up :tool-call-buffer, and RUN_FINISHED/RUN_ERROR end the turn. Events nobody is waiting +;; for (the conversation was reset since) are dropped. (rf/reg-event-fx ::chat-event [(rf/inject-cofx ::now)] @@ -759,14 +687,12 @@ :else {:db db})))) (def ^:private blocked-message - "The result of a call `blocked-calls` marked: a once-per-turn tool, already spent this turn." "not run: this only runs once per message, and already has this turn — answer instead of calling it again") (defn- call-all - "A promise of the results of `calls`, in order, as {:id :name :content :error?}. One after the - other: the tools work on the same page (one may need the page the last one opened). Only the - tools `page` offers can be called; a call `finish-reply` marked `:blocked?` (see `blocked-calls`) - never reaches the page at all." + "A promise of the results of `calls`, in order, as {:id :name :content :error?}. Run one after + the other (a tool may need the page the last one left). A call marked :blocked? by blocked-calls + never reaches the page." [page calls] (reduce (fn [results {:keys [id name arguments blocked?]}] (.then results @@ -779,25 +705,23 @@ (js/Promise.resolve []) calls)) -;; Effect: make the tool calls the LLM asked for, on the tools `page` (the one being shown) offers, -;; and dispatch `(conj on-results results)` when all are done: one {:id :name :content :error?} -;; for each, in order. A call that can't be made (an unknown tool, one the page doesn't offer, -;; arguments that aren't JSON, a tool that fails) is a result with :error?, for the LLM to read. +;; Runs the LLM's tool calls on `page`'s tools, and dispatches `(conj on-results results)` when +;; done: one {:id :name :content :error?} per call. A call that can't be made (unknown tool, bad +;; arguments, a tool that throws) comes back as an :error? result, for the LLM to read. (rf/reg-fx ::call-tools (fn [{:keys [page calls on-results]}] (.then (call-all page calls) #(rf/dispatch (conj on-results %))))) (defn- answers? - "Whether `results` are for the calls the conversation is waiting on. Results nobody waits for - (the conversation was reset while the tools ran) are not." + "Whether `results` answer the calls the conversation is waiting on (not a stale response after + the conversation was reset while the tools ran)." [db results] (let [calls (:tool-calls (peek (get-in db [:chat :messages])))] (and (= :loading (get-in db [:chat :status])) (seq calls) (= (map :id calls) (map :id results))))) -;; The tools have run: their results join the conversation, and it goes to the LLM again. (rf/reg-event-fx ::chat-tool-results (fn [{:keys [db]} [_ results]] @@ -807,14 +731,11 @@ {:db db ::fetch-stream (chat-request db (get-in db [:chat :messages]))})))) -;; The user halts the reply mid-way, to steer it with their next message: the stream stops (and the -;; LLM with it, see ::abort-stream), and what has come of the reply so far stays, marked :halted? — -;; its reasoning and words, but not tool calls it was still asking for, which are never made. If -;; the tools it asked for are running, their results no longer go to the LLM (see answers?); each -;; is answered with `halted-result` instead, since the LLM refuses a call left unanswered. (def ^:private halted-result "(halted by the user before the result came back: this call may or may not have taken effect)") +;; Marks the reply :halted? and answers any in-flight tool calls with halted-result, since the LLM +;; refuses a call left unanswered. (defn- halt-reply [messages] (let [{:keys [role tool-calls]} (peek messages)] (cond @@ -828,6 +749,8 @@ :else (update messages (dec (count messages)) assoc :halted? true)))) +;; The user halts a reply mid-way, to steer it with the next message: stops the stream +;; (::abort-stream), and marks what came so far :halted? (see halt-reply) instead of discarding it. (rf/reg-event-fx ::chat-halt (fn [{:keys [db]} _] @@ -841,8 +764,7 @@ ::abort-stream true ::persist-chat (save-chat-fx db)})))) -;; Starts a new session: not while a reply is on its way (it would land in the new one), and the -;; stored history goes with it, so a reload doesn't bring the old conversation back. +;; Starts a new session (not while a reply is in flight), clearing the stored history too. (rf/reg-event-fx ::chat-clear (fn [{:keys [db]} _] @@ -853,14 +775,12 @@ (rf/reg-event-db ::clear-chat-history-response - ;; Best-effort: a failure here just means the old session may come back on the next reload, - ;; which the next successful save (or clear) fixes. (fn [db _] db)) ;; --- MCP setup page --- -;; Effect: put `text` on the clipboard. The Clipboard API only exists on secure origins (https -;; or localhost), so the view doesn't offer the button without it; failures are ignored. +;; Puts `text` on the clipboard. Requires a secure origin (https or localhost); failures are +;; ignored. (rf/reg-fx ::copy-to-clipboard (fn [text] @@ -880,8 +800,6 @@ ;; --- Tic-tac-toe --- -;; An illegal move (taken cell, game over) changes nothing. The board persists (see ::persist-ttt) -;; so a reload finds the game where it was left. (rf/reg-event-fx ::ttt-move (fn [{:keys [db]} [_ i]] diff --git a/ui/src/tbd/ui/model_context.cljs b/ui/src/tbd/ui/model_context.cljs index 8015353..390ba76 100644 --- a/ui/src/tbd/ui/model_context.cljs +++ b/ui/src/tbd/ui/model_context.cljs @@ -1,30 +1,16 @@ (ns tbd.ui.model-context - "The page's `modelContext`: the tools the page offers to an agent, as WebMCP names it (a page - registers tools with `navigator.modelContext` in the browsers that have one). Here it is the - registry the page's own chatbot reads. tbd.ui.core registers the tools of tbd.ui.webmcp, which - are also offered to an outside agent through the WebMCP widget; the chatbot is the same thing - embedded in the page. + "The registry of tools the page's own chatbot offers its LLM, mirroring WebMCP's + `navigator.modelContext` (tbd.ui.core registers tbd.ui.webmcp's tools here too). The chatbot + sends `definitions` (MCP's `tools/list` shape) with the conversation; tbd.ui.events intercepts + the LLM's requested calls and runs them via `call!`, right here in the page. - The chatbot passes the tools' metadata (`definitions`, the shape of MCP's `tools/list`) to the - LLM along with the conversation. The LLM never runs anything: it answers with the calls it wants - made, tbd.ui.events intercepts them, `call!` invokes the tool right here in the page (the same - handlers as WebMCP's, so it shows on screen like a user's own doing) and the result goes back to - the LLM with the next request, as MCP's `tools/call` result would. + A tool is {:name :description :input-schema :handler :pages}. `handler` takes a map of + arguments and returns a value or promise of one (a string as-is, other data as JSON; throwing is + an error result). `pages` (tbd.ui.pages) is where the tool is offered; omitted means every page. - A tool is {:name :description :input-schema :handler :pages}: `handler` takes its arguments as a - map and returns a value or a promise of one. A string is the result as it is, any other data - becomes JSON, and throwing (or rejecting) is an error result the agent can read. `pages` is the - set of pages (see tbd.ui.pages) the tool is offered on, so the LLM on the tic-tac-toe page is not - handed the todos' tools; a tool without it is offered on every page. What is offered follows the - page being shown: `definitions` and `call!` both take it, and a tool that is not offered on it - can neither be listed nor called. - - Besides tools, the page registers state providers: {:pages :provide}, `provide` a plain function - of no arguments returning the page's current state as a map (`pages` means the same as a tool's). - tbd.ui.events attaches `(current-state page)` to every /api/chat request (see - tbd.chat.schema/RunAgentInput's `state`), so the model has what the page shows already, without - spending a tool call to read it — tbd.ui.webmcp's `state-providers` is where these live, since - they need `db`, and it already requires this ns for the tools." + State providers ({:pages :provide}) are the same idea for ambient state: tbd.ui.events attaches + `(current-state page)` to every /api/chat request (tbd.chat.schema/RunAgentInput's `state`), so + the model sees what the page shows without spending a call to read it." (:require [clojure.string :as str])) (defonce ^:private registry (atom [])) diff --git a/ui/src/tbd/ui/views.cljs b/ui/src/tbd/ui/views.cljs index 08489e0..5c33449 100644 --- a/ui/src/tbd/ui/views.cljs +++ b/ui/src/tbd/ui/views.cljs @@ -179,12 +179,9 @@ (def ^:private chat-green "#28a745") -;; What Markdown blocks (tbd.ui.markdown) look like inside a bubble: :white-space "pre-wrap" (the -;; bubble's old way of keeping line breaks without any markup) is gone, since the hiccup now -;; carries its own paragraphs, lists and code blocks; this fills in the spacing and borders -;; browsers don't give those elements by default, and a low-alpha black overlay for code and -;; rules, which reads on both the green and the grey bubble. Global, not scoped to one bubble, -;; but "chat-markdown" is specific enough not to collide with anything else on the page. +;; Spacing/border defaults for Markdown blocks (tbd.ui.markdown) inside a bubble, plus a low-alpha +;; overlay for code/rules that reads on both the green and grey bubble colors. Global (not scoped +;; to one bubble), but "chat-markdown" is specific enough not to collide with the rest of the page. (defn- chat-markdown-style [] [:style " .chat-markdown > :first-child { margin-top: 0; } diff --git a/ui/src/tbd/ui/webmcp.cljs b/ui/src/tbd/ui/webmcp.cljs index 4ecc4a8..a681d27 100644 --- a/ui/src/tbd/ui/webmcp.cljs +++ b/ui/src/tbd/ui/webmcp.cljs @@ -1,33 +1,18 @@ (ns tbd.ui.webmcp "The page's own MCP tools, for an agent running in the browser: the client-side counterpart of - tbd.mcp.tools. They are offered through WebMCP (https://webmcp.dev, the `@jason.today/webmcp` - library): it adds a small widget to the page, the user pastes a token from their MCP client - (Claude) into it, and the client then sees the tools registered here. Where tbd.mcp.tools works - on the server's data, these work the UI: each one does what a user could do on the page (type in - the form, press a button, tick a box) by dispatching the same re-frame events the views do, so - everything shows on screen, and reads what the page shows from the app-db. + tbd.mcp.tools, offered through WebMCP (https://webmcp.dev). Each tool does what a user could do + on the page (type in a form, press a button) by dispatching the same re-frame events the views + do, and reads results from the app-db. A tool takes a map of arguments and returns a value or a + promise of one (a string, or data that becomes JSON); throwing becomes an error result. - Besides the tools, the page registers prompts and resources (`prompts`, `resources`), which is - what an MCP client lists next to the tools (Claude Code shows the prompts as slash commands and - the resources as @-mentions). They are the same instructions the page's own chatbot is given - (tbd.agent.prompt, shared with the server), so Claude or Codex is told to act rather than offer, - to chain calls, and can read what page is open. + `tools` also feeds tbd.ui.model-context (via tbd.ui.core), which offers the page's chatbot the + same tools. `:read-only? true` tools are left out there — the chatbot gets that information as + ambient state instead (`state-providers`) — but stay registered here for WebMCP's outside + agents. `:once-per-turn? true` tools (e.g. move_tic_tac_toe) are offered to the chatbot only + until used once per turn (tbd.ui.events); that cap doesn't apply to WebMCP's outside agents. - The same tools are registered with tbd.ui.model-context (by tbd.ui.core), which is how the page's - own chatbot offers them to its LLM and calls them, the ones of the page being shown (see - `tools`): this ns says what the tools are, and has the WebMCP way of offering them all to an - agent outside the page. A `:read-only? true` tool (one that only reads what `state-providers` - already covers, like list_todos) stays registered here for WebMCP's outside agents, but is left - out of what the page's own chatbot is offered (see tbd.ui.model-context/definitions): that - chatbot is handed the state instead, through `state-providers`. A `:once-per-turn? true` tool - (like move_tic_tac_toe: a game move is the person's to ask for again, not the model's to repeat - on its own) is offered to the chatbot as usual, but only until it has been called once in the - current turn — tbd.ui.events stops offering it, and refuses a second call, for the rest of the - turn (this app's own loop only: it doesn't apply to WebMCP's outside agents, who run their own). - - A tool takes its arguments as a map and returns a value or a promise of one: a string, or data - that becomes JSON. Throwing (or rejecting) becomes an error result the agent can read. Input - schemas are JSON Schema, written here: the UI has no Malli to share with the server's." + `prompts` and `resources` carry the same instructions as the chatbot's system prompt + (tbd.agent.prompt), so an outside MCP client is told to act instead of offer, chain calls, etc." (:require ["@jason.today/webmcp/src/webmcp.js" :as WebMCP] [clojure.string :as str] [re-frame.core :as rf] @@ -281,11 +266,6 @@ (in-order delete-one ids))) (.then (constantly nil))))) -;; Tic-tac-toe is moves taken in turns: X, then O, on the same board. A person plays on the same -;; board, so a move names the side it is for, and is refused when it is not that side's turn: an -;; agent that lost track of whose turn it is cannot play the person's move (or both sides). -;; Every result says whose turn it is. - (defn- tic-tac-toe-state "The game as the page shows it: the 9 cells (row by row, cell 0 is the top left; null when empty, else \"x\" or \"o\"), `turn` (whose move it is, while the game is on), and `status`