This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
genuinely shared (`act`, `work-it-out`, `tic-tac-toe-rules`, `examples`) from tbd.agent.prompt
|
||||
rather than duplicating it."
|
||||
(:require [clojure.string :as str]
|
||||
[jsonista.core :as json]
|
||||
[tbd.agent.prompt :as prompt]))
|
||||
|
||||
(defn chat
|
||||
@@ -19,8 +20,8 @@
|
||||
"\n"
|
||||
prompt/act
|
||||
(prompt/work-it-out (str "- Chain calls without stopping. The tool you need belongs to another page: navigate there, then\n"
|
||||
" use it. You need an id: list first, then act. Never invent an id, a title or the state of the\n"
|
||||
" page: read it with a tool.\n"))
|
||||
" use it. Never invent an id, a title or the state of the page: it's in \"Current state of the\n"
|
||||
" page\" below, if there is one, or read it with a tool.\n"))
|
||||
"Then say one short plain sentence: \"Added it.\" \"That's 23.\" The call and its result are already\n"
|
||||
"on screen under your message, so never repeat them, never paste JSON, never use markdown,\n"
|
||||
"headings or bullet lists. Say something was done only if a tool result says it was.\n"
|
||||
@@ -30,20 +31,23 @@
|
||||
"the tools you have. Think briefly before you act.\n"
|
||||
"\n"
|
||||
prompt/tic-tac-toe-rules
|
||||
prompt/examples))
|
||||
(prompt/examples false)))
|
||||
|
||||
(defn chat-system-message
|
||||
"`chat`'s prompt, with, last, where the user is (`page`) and the names of the `tools` on offer
|
||||
there. The tools' names are in the request's `tools` too, but a model picks them more readily
|
||||
when the instructions say them as well, and it can say what it can do without a call. One
|
||||
message, and the first: several chat templates (Gemma's, Mistral's) refuse more than one system
|
||||
message, or one that isn't first."
|
||||
[page tools]
|
||||
"`chat`'s prompt, with, last, where the user is (`page`), the names of the `tools` on offer
|
||||
there, and `state` (see tbd.chat.schema/RunAgentInput) — what the page is showing right now, so
|
||||
the model is never told to call a tool to read what it already has. The tools' names are in the
|
||||
request's `tools` too, but a model picks them more readily when the instructions say them as
|
||||
well, and it can say what it can do without a call. One message, and the first: several chat
|
||||
templates (Gemma's, Mistral's) refuse more than one system message, or one that isn't first."
|
||||
[page tools state]
|
||||
(cond-> (chat)
|
||||
page (str "\n\nThe user is on the \"" page "\" page of the app: what it shows is what they can see.")
|
||||
(seq tools) (str (if page " Its" "\n\nYour")
|
||||
" tools: " (str/join ", " (map :name tools))
|
||||
". The tools of other pages are offered once you navigate there.")))
|
||||
". The tools of other pages are offered once you navigate there.")
|
||||
(seq state) (str "\n\nCurrent state of the page (JSON), already up to date as of this message — "
|
||||
"never call a tool just to read it, only to act:\n" (json/write-value-as-string state))))
|
||||
|
||||
(defn a2a
|
||||
"The A2A server's system prompt: an outside agent calling this app directly, not through a
|
||||
@@ -61,4 +65,4 @@
|
||||
"Then report in one short plain sentence, from the tool results: \"Added it.\" \"That's 23.\" Don't\n"
|
||||
"paste JSON, don't describe the tool call itself, just its outcome.\n"
|
||||
"\n"
|
||||
prompt/examples))
|
||||
(prompt/examples true)))
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
(ns tbd.chat.db
|
||||
"SQLite persistence for the chat widget's history, in the app's database (see tbd.sqlite). Not
|
||||
the AG-UI run itself (tbd.chat.routes' /api/chat stays stateless) — this is the browser's own
|
||||
conversation (tbd.ui.db's :chat :messages), saved so a reload or a new browser can pick it back
|
||||
up. One row per user: the whole conversation is kept as one EDN blob and replaced wholesale on
|
||||
each save, the same way the browser already treats it as a single vector, never a diff."
|
||||
(:require [clojure.edn :as edn]
|
||||
[next.jdbc :as jdbc]
|
||||
[next.jdbc.result-set :as rs]
|
||||
[tbd.sqlite :as sqlite]))
|
||||
|
||||
(def ^:private create-chat-history
|
||||
;; `owner_id` is the user's id in Gitea, like tbd.todo.db's todos.
|
||||
"CREATE TABLE IF NOT EXISTS chat_history (
|
||||
owner_id INTEGER PRIMARY KEY,
|
||||
messages TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL)")
|
||||
|
||||
(defn migrate!
|
||||
"Creates the chat_history table in the database `ds`, or brings it up to date."
|
||||
[ds]
|
||||
(jdbc/execute! ds [create-chat-history]))
|
||||
|
||||
(defn open
|
||||
"A database with just the chat_history table, at `path`; the app opens one with every group's
|
||||
tables (see tbd.core), which this is for tests of the group on its own."
|
||||
[path]
|
||||
(sqlite/open path migrate!))
|
||||
|
||||
(def ^:private opts {:builder-fn rs/as-unqualified-kebab-maps})
|
||||
|
||||
(defn get-history
|
||||
"The owner's stored conversation (tbd.chat.schema/HistoryMessage maps), oldest first, or [] if
|
||||
nothing is stored for them yet."
|
||||
[ds owner-id]
|
||||
(if-let [{:keys [messages]} (jdbc/execute-one!
|
||||
ds ["SELECT messages FROM chat_history WHERE owner_id = ?" owner-id] opts)]
|
||||
(edn/read-string messages)
|
||||
[]))
|
||||
|
||||
(defn save-history!
|
||||
"Replaces the owner's stored conversation with `messages`."
|
||||
[ds owner-id messages now]
|
||||
(jdbc/execute! ds ["INSERT INTO chat_history (owner_id, messages, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT (owner_id) DO UPDATE SET messages = excluded.messages, updated_at = excluded.updated_at"
|
||||
owner-id (pr-str messages) now]))
|
||||
|
||||
(defn clear-history!
|
||||
"Deletes the owner's stored conversation, if any."
|
||||
[ds owner-id]
|
||||
(jdbc/execute! ds ["DELETE FROM chat_history WHERE owner_id = ?" owner-id]))
|
||||
+35
-8
@@ -13,6 +13,7 @@
|
||||
(:require [muuntaja.core :as m]
|
||||
[ring.core.protocols :as ring-protocols]
|
||||
[tbd.agent.core :as agent]
|
||||
[tbd.chat.db :as chat-db]
|
||||
[tbd.chat.schema :as schema]
|
||||
[tbd.http :refer [respond]])
|
||||
(:import (java.io IOException OutputStream)
|
||||
@@ -126,13 +127,39 @@
|
||||
"X-Accel-Buffering" "no"}
|
||||
:body (event-stream ok thread-id run-id)}))))
|
||||
|
||||
(defn- history-handler [ds]
|
||||
(fn [{:keys [user]}]
|
||||
(respond 200 {:messages (chat-db/get-history @ds (:id user))})))
|
||||
|
||||
(defn- save-history-handler [ds]
|
||||
(fn [{{{:keys [messages]} :body} :parameters :keys [user]}]
|
||||
(chat-db/save-history! @ds (:id user) messages (System/currentTimeMillis))
|
||||
(respond 200 {:saved true})))
|
||||
|
||||
(defn- clear-history-handler [ds]
|
||||
(fn [{:keys [user]}]
|
||||
(chat-db/clear-history! @ds (:id user))
|
||||
(respond 200 {:cleared true})))
|
||||
|
||||
(defn routes
|
||||
"`config` is the agent config (see tbd.agent.core/env-config). Only logged-in users may chat:
|
||||
every message costs the LLM server's time."
|
||||
[config]
|
||||
"`config` is the agent config (see tbd.agent.core/env-config). `ds` is a delay of the app's
|
||||
database (see tbd.sqlite/open), for /api/chat/history. Only logged-in users may chat: every
|
||||
message costs the LLM server's time."
|
||||
[config ds]
|
||||
["/api/chat"
|
||||
{:post {:tags ["chat"]
|
||||
:login? true
|
||||
:summary "An AG-UI (https://docs.ag-ui.com) run: POST a RunAgentInput (the conversation so far, tools on offer, and `context` — the page the user is on travels as {\"description\":\"page\",\"value\":\"...\"}); the reply streams back as AG-UI server-sent events (RUN_STARTED, TEXT_MESSAGE_*/REASONING_MESSAGE_*/TOOL_CALL_* as the model generates, then RUN_FINISHED or RUN_ERROR). Failures before the run starts are ordinary errors: 400 for an invalid input, 502 if the LLM server can't be reached or refuses, 503 if the agent isn't configured"
|
||||
:parameters {:body schema/RunAgentInput}
|
||||
:handler (chat-handler config)}}])
|
||||
{:tags ["chat"]}
|
||||
[""
|
||||
{:post {:login? true
|
||||
:summary "An AG-UI (https://docs.ag-ui.com) run: POST a RunAgentInput (the conversation so far, tools on offer, and `context` — the page the user is on travels as {\"description\":\"page\",\"value\":\"...\"}); the reply streams back as AG-UI server-sent events (RUN_STARTED, TEXT_MESSAGE_*/REASONING_MESSAGE_*/TOOL_CALL_* as the model generates, then RUN_FINISHED or RUN_ERROR). Failures before the run starts are ordinary errors: 400 for an invalid input, 502 if the LLM server can't be reached or refuses, 503 if the agent isn't configured"
|
||||
:parameters {:body schema/RunAgentInput}
|
||||
:handler (chat-handler config)}}]
|
||||
["/history"
|
||||
{:login? true}
|
||||
[""
|
||||
{:get {:summary "The logged-in user's stored chat history (tbd.chat.schema/HistoryMessage, oldest first); {:messages []} if there is none"
|
||||
:handler (history-handler ds)}
|
||||
:put {:summary "Replaces the logged-in user's stored chat history with the given messages — the browser's own full conversation, saved so a reload or another browser can restore it"
|
||||
:parameters {:body schema/History}
|
||||
:handler (save-history-handler ds)}
|
||||
:delete {:summary "Clears the logged-in user's stored chat history (the \"Clear\" button: the next reload starts a new session)"
|
||||
:handler (clear-history-handler ds)}}]]])
|
||||
|
||||
+54
-5
@@ -24,6 +24,14 @@
|
||||
(defn- small? [tool]
|
||||
(<= (count (pr-str tool)) max-tool-schema-length))
|
||||
|
||||
(def ^:private max-state-length
|
||||
"How long RunAgentInput's `state` may be, printed: room for a todo list or a game board, not a
|
||||
free way to put an unbounded payload in every system prompt."
|
||||
8000)
|
||||
|
||||
(defn- small-state? [state]
|
||||
(<= (count (pr-str state)) max-state-length))
|
||||
|
||||
(def Tool
|
||||
"AG-UI's Tool: {name, description, parameters}. `parameters` is JSON Schema for the arguments,
|
||||
which is the LLM's to read, so it is any (small) object."
|
||||
@@ -83,7 +91,10 @@
|
||||
|
||||
(def RunAgentInput
|
||||
"AG-UI's RunAgentInput: the conversation so far, oldest first (`messages`), the tools on offer
|
||||
for this run (`tools`), and ambient information (`context`, which is where `page` travels).
|
||||
for this run (`tools`), ambient information (`context`, which is where `page` travels), and
|
||||
`state` — what the page is showing right now (its todo list, its tic-tac-toe board, ...), so the
|
||||
model has it up front instead of spending a tool call to read it (see tbd.ui.model-context's
|
||||
state registry, which is what fills it on the browser's side).
|
||||
`threadId`/`runId` identify the run to the client; this server is stateless between turns (the
|
||||
browser resends the whole conversation each time, see tbd.ui.events), so it only echoes them
|
||||
back, never reads them."
|
||||
@@ -92,7 +103,45 @@
|
||||
[:runId {:optional true} [:string {:max 200}]]
|
||||
[:messages [:vector {:min 1 :max max-messages} Message]]
|
||||
[:tools {:optional true} [:vector {:max max-tools} Tool]]
|
||||
[:context {:optional true} [:vector {:max 8} Context]]])
|
||||
[:context {:optional true} [:vector {:max 8} Context]]
|
||||
[:state {:optional true} [:and [:map-of :keyword :any] [:fn small-state?]]]])
|
||||
|
||||
(def max-history-messages
|
||||
"How many messages of a browser's own conversation (see tbd.ui.db's :chat :messages) the
|
||||
/api/chat/history endpoint stores for it, the most recent (see tbd.ui.events/storable) — more
|
||||
than max-messages, since this is scrollback for the user, not context for the LLM."
|
||||
100)
|
||||
|
||||
(def HistoryMessage
|
||||
"A message as the browser's own :chat :messages keeps it (see tbd.ui.db), not AG-UI's wire
|
||||
Message: /api/chat/history is this app's own addition, not part of the AG-UI run protocol, so it
|
||||
round-trips the client's fields as-is (kebab-case, and a tool result's :name/:error? for
|
||||
display, which AG-UI's Message has no place for) instead of forcing them through Message's shape."
|
||||
[:multi {:dispatch :role}
|
||||
["user" [:map
|
||||
[:role [:= "user"]]
|
||||
[:content [:string {:min 1 :max max-message-length}]]]]
|
||||
["assistant" [:and
|
||||
[:map
|
||||
[:role [:= "assistant"]]
|
||||
[:content {:optional true} [:string {:max max-message-length}]]
|
||||
[:tool-calls {:optional true}
|
||||
[:vector {:max max-tool-calls}
|
||||
[:map
|
||||
[:id Id]
|
||||
[:name [:string {:min 1 :max 200}]]
|
||||
[:arguments [:string {:max 100000}]]]]]]
|
||||
[:fn (fn [{:keys [content tool-calls]}] (or (seq content) (seq tool-calls)))]]]
|
||||
["tool" [:map
|
||||
[:role [:= "tool"]]
|
||||
[:tool-call-id Id]
|
||||
[:name {:optional true} [:string {:max 200}]]
|
||||
[:content [:string {:min 1 :max max-message-length}]]
|
||||
[:error? {:optional true} :boolean]]]])
|
||||
|
||||
(def History
|
||||
"The body of a PUT to /api/chat/history: the browser's whole conversation, oldest first."
|
||||
[:map [:messages [:vector {:max max-history-messages} HistoryMessage]]])
|
||||
|
||||
(defn page-of
|
||||
"The `page` context entry's value, or nil."
|
||||
@@ -119,6 +168,6 @@
|
||||
|
||||
(defn system-text
|
||||
"The agent's own system prompt for this run (see tbd.agent.instructions), never the caller's to
|
||||
supply: only `page` (from `context`) and `tools` shape it."
|
||||
[{:keys [tools context]}]
|
||||
(instructions/chat-system-message (page-of context) tools))
|
||||
supply: only `page` (from `context`), `tools` and `state` shape it."
|
||||
[{:keys [tools context state]}]
|
||||
(instructions/chat-system-message (page-of context) tools state))
|
||||
|
||||
+3
-2
@@ -19,6 +19,7 @@
|
||||
[tbd.auth.core :as auth]
|
||||
[tbd.auth.middleware :as auth-mw]
|
||||
[tbd.auth.routes :as auth-routes]
|
||||
[tbd.chat.db :as chat-db]
|
||||
[tbd.chat.routes :as chat-routes]
|
||||
[tbd.health.routes :as health]
|
||||
[tbd.http :as http]
|
||||
@@ -74,7 +75,7 @@
|
||||
;; namespace that returns its own route subtree; they're concatenated here.
|
||||
(defn make-router [{:keys [auth nrepl db webmcp chat]}]
|
||||
;; One database for every group's tables, opened (and migrated) on first use, not when the app loads.
|
||||
(let [ds (delay (sqlite/open (:db-path db) todo-db/migrate! oauth-db/migrate!))]
|
||||
(let [ds (delay (sqlite/open (:db-path db) todo-db/migrate! oauth-db/migrate! chat-db/migrate!))]
|
||||
(ring/router
|
||||
[["/swagger.json"
|
||||
;; The spec is JSON, whatever the API default is (Swagger UI can't read EDN).
|
||||
@@ -90,7 +91,7 @@
|
||||
(a2a/routes auth ds chat)
|
||||
(oauth-routes/routes auth ds)
|
||||
(auth-routes/routes auth)
|
||||
(chat-routes/routes chat)
|
||||
(chat-routes/routes chat ds)
|
||||
(nrepl-routes/routes nrepl)
|
||||
(todo-routes/routes ds)]
|
||||
{:data {:muuntaja http/muuntaja
|
||||
|
||||
Reference in New Issue
Block a user