+10
-14
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
+19
-39
@@ -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}})
|
||||
|
||||
+102
-184
@@ -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 <message>}`.
|
||||
(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]]
|
||||
|
||||
@@ -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 []))
|
||||
|
||||
@@ -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; }
|
||||
|
||||
+11
-31
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user