+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)
|
||||
|
||||
Reference in New Issue
Block a user