makepad/libs/asset/chat/AI_BACKENDS.md
Admin 88048aa784 assets: extend authoring pipelines and typed asset search
Add sandbox authoring effects and character/composite creation, improve creator submission handling, and extend publishing/search metadata. Update importer conversions and the asset UI integration.

Validation: 31 store search tests, ten composite tests and two author-policy tests passed in release mode.
2026-09-05 01:42:39 +02:00

144 lines
8.7 KiB
Markdown

# AI backends: one broker, six providers, one lock
The asset server's chat broker (`libs/asset/chat` + `libs/asset/store/src/host/chat.rs`)
is the ONLY place a model is reached from. Every app — the sandbox game, the
VJ, the Asset UI — is a thin client of `/v1/chat/*`: it opens a session on a
named provider, sends text, and renders the event stream. No app holds a
key, a fleet address, or a CLI.
## Providers (`GET /v1/chat/providers`)
| slug | what runs | locality | tools contract |
|--------------|----------------------------------------------|----------|----------------|
| `fleet-qwen` | Qwen on our asset-ai fleet (LAN beacons) | `local` | `<<tool>>` text markers |
| `openai` | OpenAI Responses API (`OPENAI_API_KEY` on the server) | `cloud` | native functions |
| `grok` | xAI Responses API (`XAI_API_KEY` on the server) | `cloud` | native functions |
| `claude-cli` | the `claude` CLI logged in on the server host | `cloud` | `<<tool>>` text markers |
| `codex-cli` | the `codex` CLI on the server host | `cloud` | `<<tool>>` text markers |
| `grok-cli` | the `grok` CLI on the server host | `cloud` | `<<tool>>` text markers |
Each row is `{kind, locality, state: available{model} | unavailable{reason}}`.
`locality` is the server's word: `local` means our own fleet, `cloud` means a
vendor — by key or by a logged-in CLI, the client does not care which. A
frontend that promises "Local AI only" filters on that field and nothing
else, so adding a provider never needs a client release to stay honest.
### The CLI providers (`cli.rs`, `claude.rs`, `codex_cli.rs`, `grok_cli.rs`)
- One process per turn, in its own process group, cwd = an empty scratch
dir under the temp dir; cancel kills the group. Conversation continuity
is the CLI's own session id (`--resume` / `codex exec resume`), so only
the new tail of the history is sent on later turns.
- Chat-only by construction: claude `--tools ""` + empty strict MCP set;
grok `--disallowed-tools <all it advertises>` + `--permission-mode dontAsk`
+ `--max-turns 1`; codex `--sandbox read-only`, user config and rules
ignored, `shell_environment_policy.inherit=none`. The only tools any of
them can express are the broker's content tools, executed by the
dispatcher against the asset server (same as the fleet lane).
- Reasoning is forwarded inside `<think>…</think>` text like the fleet lane,
so every client renders it one way and none of it enters the history.
- Availability = "is the executable there" (`CLAUDE_CODE_PATH` /
`CODEX_CLI_PATH` / `GROK_CLI_PATH` override, then `$PATH`, then the usual
install dirs). The reason string never carries a path to the wire.
- Verified live (2026-08-26, macOS): claude 2.1.246 needs the prompt on
stdin (a trailing positional after `--tools ""` is swallowed as a tool
name); grok 1.0.5 ignores `--tools ""`/`none` but honours
`--disallowed-tools`; codex 0.149.1 `exec --json` emits whole
`agent_message` items, no deltas.
### Fleet discovery (`fleet_discovery.rs`)
The fleet lane listens for asset-ai beacons on UDP 41830 and keeps only the
fleet named by the server config, else `MAKEPAD_AI_FLEET`, else `default`.
The embedded server inside the Asset UI pins this to the fleet the UI's own
panel shows (`gen`); an empty name used to force `default` and silently
drop every beacon ("no fleet nodes configured" while the panel showed 2/2
up). The listener starts with the broker and a probe in its first seconds
waits for the first beacon instead of reporting an empty fleet.
## Durable per-game conversations (keyed sessions)
The server keeps the chat STATE for its clients. A session created with
both `client_key` and `context_key` is one durable conversation per
`(principal, client_key, context_key)`:
- `client_key` = who is talking — an opaque, display-safe id the app
chooses (the sandbox sends `ip:<lan-ip>` today; a multiplayer player id
later). `context_key` = what it is about — the GAME asset id. One
conversation per (client, game), never shared across games: a game's
context is context we cannot pollute.
- Key shape (enforced identically by the client crate, `wire::chat_key_ok`,
and the route): 1..=64 bytes of `[A-Za-z0-9._:@-]` with at least one
alnum. Never a secret — the bearer token is the authorization.
### Wire
- `POST /v1/chat/sessions` body gains the optional `client_key` and
`context_key` (both or neither; one alone is a 400). With both it is
CREATE-OR-RESUME: a LIVE session for that key answers `200` with itself
(same `session` id, state as it is); a PERSISTED one is rebuilt from
its transcript under the SAME id on a fresh provider and answers `200`;
nothing known answers `201` with a fresh session. Without keys:
today's behaviour (`201`, ephemeral). A live keyed session asked for on
a different provider/namespace/profile is rebuilt on the requested one
while idle (its transcript kept), and is `409 busy` mid-turn.
- The session document (`POST`/`GET`/list/cancel) carries `client_key`
and `context_key` on keyed sessions, omitted otherwise.
- `GET /v1/chat/sessions/{id}/transcript`
`{"session":…, "provider":"<slug>", "turn":N, "truncated":bool,
"messages":[{"role":"user|assistant|system|tool","text":"…"}]}` — the
conversation as the client should render it: what the session feeds
its provider, minus the prompt plumbing (thinking stripped, the tool
reminder and trained call text folded away); each executed tool is one
`tool` row whose `text` is a short chip title (`world.set_source · ok`)
with `tool` (dotted name) and `outcome`
(`ok|unavailable|denied|refused|failed`) alongside. Bounded: the LAST
128 rows within 192 KiB of text, each row's text clipped to 8 KiB;
`truncated` says older rows were dropped. Works for unkeyed sessions
too (memory only). `404` for a session that is not live — a client
always create-or-resumes first, then reads.
- `DELETE /v1/chat/sessions/{id}` on a keyed session is the client's
Clear: the worker AND the persisted transcript go, synchronously; the
next create-or-resume is a fresh conversation (new id).
- Retiring an asset (`DELETE /v1/assets/{id}`) drops every keyed
conversation whose `context_key` is that asset — live or on disk,
whoever's — and logs the count.
### Persistence (`libs/asset/store/src/host/chat_store.rs`)
- `<root>/chat/<principal>/<client_key>/<context_key>.jsonl`, keys
path-encoded (`[A-Za-z0-9._-]` verbatim, else `%XX`: `ip:10.0.0.7` is
`ip%3A10.0.0.7`). Header line `{"k":"h","v":1,"session","provider",
"namespace","profile","client_key","context_key","created_ms"}`, then
one `{"k":"m","role","text","turn"}` per history row exactly as the
provider sees it (the transcript is rendered on read), and an optional
`{"k":"p","resume":…}` slot for a provider-native resume id (unused
today: the threaded provider wrapper does not expose one; a resumed
session simply replays its history to a fresh provider, which every
lane — CLIs included — accepts).
- The session worker appends the new tail after every publish
(`write_all` + `sync_data`); a crash costs at most one torn last line,
which `load` drops. A resume rewrites the file (temp + rename) with the
current binding and the history trimmed to the newest 64 rows, so a
resumed conversation always has room to continue.
- Workers are bounded: a keyed session idle 30 min is evicted to disk;
when an owner's cap (or the server's) is full of idle keyed sessions,
the longest-idle one is evicted to make room. Unkeyed sessions are
never evicted (nowhere to go) and refuse as before.
- One writer per file (the worker) and Clear wipes under the same lock,
so a late append can never resurrect a cleared transcript.
### `world.new_level` (game profile)
Advertised next to `world.set_source`, client-executed: `{"title",
"source", "note"?}` — "create a NEW game from this source and switch the
player to it". The game publishes, switches, and answers the tool result
`{asset_id, alias, title}`; the broker records the round and ends the
turn with `done` (no further model round — the player is in another game,
which has its own conversation). `world.set_source` stays the in-place
edit of the CURRENT game.
## The sandbox (`apps/sandbox`)
- `fleet_chat.rs` holds `ChatPrefs { provider, local_only }`, persisted in
`local/sandbox/ai-prefs`, default `{fleet-qwen, true}`. The lock is
enforced in the worker, not just hidden in the UI: selecting a `cloud`
provider while locked is refused with a chat line; turning the lock on
while on a cloud provider drops back to the fleet.
- Settings = one dropdown of what the server listed (cloud rows removed
while locked; unavailable rows shown as such) + the "Local AI only"
checkbox. No keys, no pairing, no device-local backends.