feat: zotcore SDK + zot rpc subprocess protocol
two new ways to embed the zot agent runtime in third-party apps:
1. pkg/zotcore - public Go SDK
- Runtime type: New(Config), Prompt(ctx,text,imgs)->chan Event,
Cancel, Compact, SetModel, State, Messages, Cost, ListModels,
Close. Concurrent-safe; one prompt at a time per Runtime,
ErrBusy if you try to overlap. Spawn multiple Runtimes for
multiple projects.
- Public types mirror the JSON-RPC wire schema 1:1 so consumers
can share parsing code with the out-of-process clients.
- Internal core/agent/provider stay internal; SDK is a thin
facade that exposes only what's stable.
2. zot rpc subcommand - newline-delimited JSON on stdin/stdout
- 'zot rpc' (or 'zot --rpc') turns the agent runtime into a
subprocess that any language can drive via pipes.
- Commands: hello, prompt, abort, compact, get_state,
get_messages, clear, set_model, get_models, ping. Each
optionally carries an id; the matching response echoes it.
- Stream notifications: turn_start, user_message,
assistant_start, text_delta, tool_call, tool_progress,
tool_result, assistant_message, usage, turn_end, done,
error, compact_done. Same shape as the existing --json mode
events (modes.EventToJSON / ContentToJSON were exported
for reuse).
- Auth: optional ZOTCORE_RPC_TOKEN env var; first command
must be hello {token: ...} when set. Without the env var
the spawning process is implicitly trusted.
- Concurrency: one prompt or compact at a time per process,
enforced by a turnMu mutex. abort fires immediately
regardless. Stdin close exits the process.
3. docs/rpc.md - full schema reference
4. examples/rpc/{python,node,shell,go} - reference clients
5. examples/sdk - in-process Go embedding example
6. README updated with a new modes entry and an embedding section- commit
- a670442c9ef04c46b094276013eabe61f27c64c2
- author
- patriceckhart · · 2026-04-19 10:26 UTC
- parent
- d6ac856
- signature
- unsigned
- refs
- tag: v0.0.13
16 files · +1744 -54
M README.md +11 -1
| @@ -151,6 +151,16 @@ when the sandbox is on (see `/lock`), all four tools refuse paths outside the se | |||
| 151 | 151 | - **interactive** (default): chat tui with streaming output, spinner, cost meter, slash commands. | |
| 152 | 152 | - **print**: `zot -p "prompt"` runs the agent to completion and writes only the final assistant text to stdout. | |
| 153 | 153 | - **json**: `zot --json "prompt"` emits one json object per agent event to stdout, newline-delimited. the schema is documented in `instructions.md` §8. | |
| 154 | + | - **rpc**: `zot rpc` runs as a long-lived child process; commands in on stdin, events + responses out on stdout, both as ndjson. designed for embedding zot in third-party apps written in any language. see `docs/rpc.md` for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients. | |
| 155 | + | ||
| 156 | + | ## embedding | |
| 157 | + | ||
| 158 | + | two ways to drive zot from another program: | |
| 159 | + | ||
| 160 | + | - **go in-process**: import `github.com/patriceckhart/zot/pkg/zotcore`. one `Runtime` per project; `Prompt(ctx, text, images)` returns a channel of `Event`. small example in `examples/sdk/`. | |
| 161 | + | - **any language out-of-process**: spawn `zot rpc` as a subprocess and exchange newline-delimited json over its stdin/stdout. wire format and event schema in [docs/rpc.md](docs/rpc.md). reference clients live under `examples/rpc/`. | |
| 162 | + | ||
| 163 | + | both interfaces share the same event schema, so transcripts captured by one can be replayed through the other. | |
| 154 | 164 | ||
| 155 | 165 | ## slash commands | |
| 156 | 166 | ||
| @@ -235,7 +245,7 @@ slash commands also work while the agent is busy. read-only ones (`/help`, `/jum | |||
| 235 | 245 | | `alt+enter` | newline | | |
| 236 | 246 | | `tab` | complete the selected slash command | | |
| 237 | 247 | | `esc` | cancel the current turn (while busy); clear input (while idle) | | |
| 238 | - | | `ctrl+c` | exit when idle; cancel the current turn while busy | | |
| 248 | + | | `ctrl+c` | clear the input + queue (or cancel the current turn). press again within 2s to exit. | | |
| 239 | 249 | | `ctrl+d` | exit on empty input | | |
| 240 | 250 | | `ctrl+l` | redraw the screen | | |
| 241 | 251 | | `ctrl+o` | expand / collapse long tool results | | |
| @@ -151,6 +151,16 @@ when the sandbox is on (see `/lock`), all four tools refuse paths outside the se | |||||
| 151 | - **interactive** (default): chat tui with streaming output, spinner, cost meter, slash commands. | 151 | - **interactive** (default): chat tui with streaming output, spinner, cost meter, slash commands. | ||
| 152 | - **print**: `zot -p "prompt"` runs the agent to completion and writes only the final assistant text to stdout. | 152 | - **print**: `zot -p "prompt"` runs the agent to completion and writes only the final assistant text to stdout. | ||
| 153 | - **json**: `zot --json "prompt"` emits one json object per agent event to stdout, newline-delimited. the schema is documented in `instructions.md` §8. | 153 | - **json**: `zot --json "prompt"` emits one json object per agent event to stdout, newline-delimited. the schema is documented in `instructions.md` §8. | ||
| 154 | + | - **rpc**: `zot rpc` runs as a long-lived child process; commands in on stdin, events + responses out on stdout, both as ndjson. designed for embedding zot in third-party apps written in any language. see `docs/rpc.md` for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients. | |||
| 155 | + | ||||
| 156 | + | ## embedding | |||
| 157 | + | ||||
| 158 | + | two ways to drive zot from another program: | |||
| 159 | + | ||||
| 160 | + | - **go in-process**: import `github.com/patriceckhart/zot/pkg/zotcore`. one `Runtime` per project; `Prompt(ctx, text, images)` returns a channel of `Event`. small example in `examples/sdk/`. | |||
| 161 | + | - **any language out-of-process**: spawn `zot rpc` as a subprocess and exchange newline-delimited json over its stdin/stdout. wire format and event schema in [docs/rpc.md](docs/rpc.md). reference clients live under `examples/rpc/`. | |||
| 162 | + | ||||
| 163 | + | both interfaces share the same event schema, so transcripts captured by one can be replayed through the other. | |||
| 154 | 164 | ||||
| 155 | ## slash commands | 165 | ## slash commands | ||
| 156 | 166 | ||||
| @@ -235,7 +245,7 @@ slash commands also work while the agent is busy. read-only ones (`/help`, `/jum | |||||
| 235 | | `alt+enter` | newline | | 245 | | `alt+enter` | newline | | ||
| 236 | | `tab` | complete the selected slash command | | 246 | | `tab` | complete the selected slash command | | ||
| 237 | | `esc` | cancel the current turn (while busy); clear input (while idle) | | 247 | | `esc` | cancel the current turn (while busy); clear input (while idle) | | ||
| 238 | - | | `ctrl+c` | exit when idle; cancel the current turn while busy | | 248 | + | | `ctrl+c` | clear the input + queue (or cancel the current turn). press again within 2s to exit. | |
| 239 | | `ctrl+d` | exit on empty input | | 249 | | `ctrl+d` | exit on empty input | | ||
| 240 | | `ctrl+l` | redraw the screen | | 250 | | `ctrl+l` | redraw the screen | | ||
| 241 | | `ctrl+o` | expand / collapse long tool results | | 251 | | `ctrl+o` | expand / collapse long tool results | | ||
A docs/rpc.md +230 -0
| @@ -0,0 +1,230 @@ | |||
| 1 | + | # zot RPC | |
| 2 | + | ||
| 3 | + | `zot rpc` runs the agent runtime as a subprocess that speaks newline-delimited JSON on stdin and stdout. Use it from any language that can spawn a process and read/write its pipes — Go, TypeScript, Python, Rust, shell, anything. | |
| 4 | + | ||
| 5 | + | For a Go program embedding the runtime in-process, use the `pkg/zotcore` SDK instead. The wire format below mirrors the SDK's types one-for-one so consumers can share parsing code. | |
| 6 | + | ||
| 7 | + | ## Quick start | |
| 8 | + | ||
| 9 | + | ```bash | |
| 10 | + | # spawn zot rpc; talk to it from a shell | |
| 11 | + | ( echo '{"id":"1","type":"prompt","message":"hello"}'; sleep 5 ) \ | |
| 12 | + | | zot rpc --provider anthropic | |
| 13 | + | ``` | |
| 14 | + | ||
| 15 | + | You'll see one JSON object per line on stdout: a response acknowledging the prompt, a stream of events (`text_delta`, `tool_call`, `tool_result`, `usage`), then `done`. | |
| 16 | + | ||
| 17 | + | ## Process model | |
| 18 | + | ||
| 19 | + | - One `zot rpc` process serves **one cwd, one model, one session**. | |
| 20 | + | - For multiple projects, spawn multiple processes. | |
| 21 | + | - Concurrency: at most one prompt or compact in flight at a time. A second one queues until the first finishes; aborting fires immediately. | |
| 22 | + | - The process exits when stdin closes. | |
| 23 | + | ||
| 24 | + | ## Flags | |
| 25 | + | ||
| 26 | + | `zot rpc` accepts the same flags as the other modes: `--provider`, `--model`, `--cwd`, `--api-key`, `--base-url`, `--system-prompt`, `--append-system-prompt`, `--reasoning`, `--max-steps`, `--no-tools`, `--tools`. Sessions are disabled by default in RPC mode — the embedding application owns persistence. | |
| 27 | + | ||
| 28 | + | ## Auth | |
| 29 | + | ||
| 30 | + | If the environment variable `ZOTCORE_RPC_TOKEN` is set on the spawned process, the first line on stdin **must** be a `hello` command containing the matching token: | |
| 31 | + | ||
| 32 | + | ```json | |
| 33 | + | {"id":"0","type":"hello","token":"shared-secret"} | |
| 34 | + | ``` | |
| 35 | + | ||
| 36 | + | If absent or wrong, the response carries `success:false` and the process exits. Without `ZOTCORE_RPC_TOKEN` set, no auth is required (the spawning process is implicitly trusted; if it can spawn `zot` it can also read your `auth.json` directly). | |
| 37 | + | ||
| 38 | + | ## Wire format | |
| 39 | + | ||
| 40 | + | Every line in either direction is one JSON object terminated by `\n`. Object boundaries follow newline boundaries — no multi-line JSON. | |
| 41 | + | ||
| 42 | + | ### Frame types | |
| 43 | + | ||
| 44 | + | | `type` | Direction | Description | | |
| 45 | + | |---|---|---| | |
| 46 | + | | any command (`prompt`, `abort`, …) | client → server | Request | | |
| 47 | + | | `response` | server → client | Reply to one command, correlated by `id` | | |
| 48 | + | | any event (`text_delta`, `tool_call`, …) | server → client | Stream notification (no `id`) | | |
| 49 | + | ||
| 50 | + | ## Commands | |
| 51 | + | ||
| 52 | + | All commands share an optional `id` field; if present, the matching `response` echoes it. Use `id` to correlate replies with requests, especially when several requests are in flight. | |
| 53 | + | ||
| 54 | + | ### `hello` | |
| 55 | + | ||
| 56 | + | ```json | |
| 57 | + | {"id":"0","type":"hello","token":"shared-secret"} | |
| 58 | + | ``` | |
| 59 | + | ||
| 60 | + | Response: | |
| 61 | + | ||
| 62 | + | ```json | |
| 63 | + | {"type":"response","id":"0","command":"hello","success":true, | |
| 64 | + | "data":{"protocol_version":1,"version":"0.0.4","provider":"anthropic","model":"claude-opus-4-5"}} | |
| 65 | + | ``` | |
| 66 | + | ||
| 67 | + | Required as the first message when `ZOTCORE_RPC_TOKEN` is set; optional otherwise. | |
| 68 | + | ||
| 69 | + | ### `prompt` | |
| 70 | + | ||
| 71 | + | ```json | |
| 72 | + | {"id":"1","type":"prompt","message":"fix the failing test","images":[]} | |
| 73 | + | ``` | |
| 74 | + | ||
| 75 | + | Optional `images` is `[{"mime_type":"image/png","data":"<base64>"}]`. | |
| 76 | + | ||
| 77 | + | Response is immediate (the turn is starting): | |
| 78 | + | ||
| 79 | + | ```json | |
| 80 | + | {"type":"response","id":"1","command":"prompt","success":true,"data":{"started":true}} | |
| 81 | + | ``` | |
| 82 | + | ||
| 83 | + | Then a stream of event objects (see below) until the turn ends with `{"type":"done"}`. | |
| 84 | + | ||
| 85 | + | ### `abort` | |
| 86 | + | ||
| 87 | + | Cancel the active prompt or compact. | |
| 88 | + | ||
| 89 | + | ```json | |
| 90 | + | {"id":"2","type":"abort"} | |
| 91 | + | ``` | |
| 92 | + | ||
| 93 | + | Response: `{"type":"response","id":"2","command":"abort","success":true}`. | |
| 94 | + | ||
| 95 | + | If the turn was streaming, the next events you see will be a `turn_end` with `stop:"aborted"` then `done`. | |
| 96 | + | ||
| 97 | + | ### `compact` | |
| 98 | + | ||
| 99 | + | Summarise the current transcript into one synthetic user message. Same lifecycle as `prompt` (immediate response, then events). | |
| 100 | + | ||
| 101 | + | ```json | |
| 102 | + | {"id":"3","type":"compact"} | |
| 103 | + | ``` | |
| 104 | + | ||
| 105 | + | Final event: `{"type":"compact_done","summary":"<text>"}`. | |
| 106 | + | ||
| 107 | + | ### `get_state` | |
| 108 | + | ||
| 109 | + | Snapshot of the runtime. | |
| 110 | + | ||
| 111 | + | ```json | |
| 112 | + | {"id":"4","type":"get_state"} | |
| 113 | + | ``` | |
| 114 | + | ||
| 115 | + | Response data: | |
| 116 | + | ||
| 117 | + | ```json | |
| 118 | + | { | |
| 119 | + | "provider": "anthropic", | |
| 120 | + | "model": "claude-opus-4-5", | |
| 121 | + | "cwd": "/Users/pat/Developer/zot", | |
| 122 | + | "message_count": 12, | |
| 123 | + | "busy": false, | |
| 124 | + | "usage": {"input": 1234, "output": 567, "cache_read": 890, "cache_write": 0, "cost_usd": 0.0123} | |
| 125 | + | } | |
| 126 | + | ``` | |
| 127 | + | ||
| 128 | + | ### `get_messages` | |
| 129 | + | ||
| 130 | + | Full transcript. | |
| 131 | + | ||
| 132 | + | ```json | |
| 133 | + | {"id":"5","type":"get_messages"} | |
| 134 | + | ``` | |
| 135 | + | ||
| 136 | + | Response data: `{"messages": [<message>, ...]}`. See **message shape** below. | |
| 137 | + | ||
| 138 | + | ### `clear` | |
| 139 | + | ||
| 140 | + | Drop the entire transcript. Equivalent to the `/clear` slash command. | |
| 141 | + | ||
| 142 | + | ```json | |
| 143 | + | {"id":"6","type":"clear"} | |
| 144 | + | ``` | |
| 145 | + | ||
| 146 | + | ### `set_model` | |
| 147 | + | ||
| 148 | + | Switch model within the same provider. | |
| 149 | + | ||
| 150 | + | ```json | |
| 151 | + | {"id":"7","type":"set_model","model":"claude-sonnet-4-5"} | |
| 152 | + | ``` | |
| 153 | + | ||
| 154 | + | Cross-provider swaps require relaunching `zot rpc` with the new `--provider`. | |
| 155 | + | ||
| 156 | + | ### `get_models` | |
| 157 | + | ||
| 158 | + | List models known for the current provider. | |
| 159 | + | ||
| 160 | + | ```json | |
| 161 | + | {"id":"8","type":"get_models"} | |
| 162 | + | ``` | |
| 163 | + | ||
| 164 | + | Response data: `{"models":[{"id":"...","provider":"...","context_window":200000,"max_output":8192,"reasoning":true}, ...]}`. | |
| 165 | + | ||
| 166 | + | ### `ping` | |
| 167 | + | ||
| 168 | + | Health check. | |
| 169 | + | ||
| 170 | + | ```json | |
| 171 | + | {"id":"9","type":"ping"} | |
| 172 | + | ``` | |
| 173 | + | ||
| 174 | + | Response: `{"type":"response","id":"9","command":"ping","success":true,"data":{"pong":true}}`. | |
| 175 | + | ||
| 176 | + | ## Events | |
| 177 | + | ||
| 178 | + | Stream notifications during a `prompt` or `compact`. None carry an `id`. | |
| 179 | + | ||
| 180 | + | | `type` | Fields | Meaning | | |
| 181 | + | |---|---|---| | |
| 182 | + | | `turn_start` | `step` | Beginning of one model call (max-steps loop iteration) | | |
| 183 | + | | `user_message` | `content`, `time` | The submitted prompt as it was added to the transcript | | |
| 184 | + | | `assistant_start` | (none) | About to receive assistant streaming | | |
| 185 | + | | `text_delta` | `delta` | Partial assistant text. Concatenate to build the full reply | | |
| 186 | + | | `tool_call` | `id`, `name`, `args` | The model wants to call a tool | | |
| 187 | + | | `tool_progress` | `id`, `text` | Optional progress line from the tool while it runs | | |
| 188 | + | | `tool_result` | `id`, `is_error`, `content` | Tool finished | | |
| 189 | + | | `assistant_message` | `content`, `time` | Final assistant message after the model turn ends | | |
| 190 | + | | `usage` | `input`, `output`, `cache_read`, `cache_write`, `cost_usd`, `cumulative` | Per-turn + cumulative tokens / cost | | |
| 191 | + | | `turn_end` | `stop`, optional `error` | One model call finished. `stop` is `end_turn`, `tool_use`, `length`, `error`, or `aborted` | | |
| 192 | + | | `done` | (none) | The whole prompt/compact completed (success or error) | | |
| 193 | + | | `error` | `message` | Non-fatal error message | | |
| 194 | + | | `compact_done` | `summary` | Compaction finished, summary text included | | |
| 195 | + | ||
| 196 | + | ## Message shape | |
| 197 | + | ||
| 198 | + | Used by `get_messages` and inside `user_message` / `assistant_message` events. | |
| 199 | + | ||
| 200 | + | ```json | |
| 201 | + | { | |
| 202 | + | "role": "user", | |
| 203 | + | "content": [<content_block>...], | |
| 204 | + | "time": "2026-04-19T11:30:00Z" | |
| 205 | + | } | |
| 206 | + | ``` | |
| 207 | + | ||
| 208 | + | ### Content block types | |
| 209 | + | ||
| 210 | + | ```json | |
| 211 | + | {"type": "text", "text": "..."} | |
| 212 | + | {"type": "image", "mime_type": "image/png", "bytes": 12345} | |
| 213 | + | {"type": "tool_call", "id": "toolu_xyz", "name": "read", "args": {"path": "..."}} | |
| 214 | + | {"type": "tool_result", "call_id": "toolu_xyz", "is_error": false, "content": [<content_block>...]} | |
| 215 | + | ``` | |
| 216 | + | ||
| 217 | + | Image bytes are reported as a count rather than embedded base64 to keep transcript dumps small. Tool results may nest text and image blocks. | |
| 218 | + | ||
| 219 | + | ## Reference clients | |
| 220 | + | ||
| 221 | + | See `examples/rpc/` for working implementations in: | |
| 222 | + | ||
| 223 | + | - `shell` — `bash` + `jq` one-liner | |
| 224 | + | - `python` — `subprocess.Popen` + `json.loads` per line | |
| 225 | + | - `node` — `child_process.spawn` + `readline` | |
| 226 | + | - `go` — direct subprocess wrapper (the `pkg/zotcore` SDK is the in-process Go API) | |
| 227 | + | ||
| 228 | + | ## Versioning | |
| 229 | + | ||
| 230 | + | The `protocol_version` field in the `hello` response is the major version of this schema. Backwards-incompatible changes bump it. The set of supported events and commands within a major version only grows. | |
| @@ -0,0 +1,230 @@ | |||||
| 1 | + | # zot RPC | |||
| 2 | + | ||||
| 3 | + | `zot rpc` runs the agent runtime as a subprocess that speaks newline-delimited JSON on stdin and stdout. Use it from any language that can spawn a process and read/write its pipes — Go, TypeScript, Python, Rust, shell, anything. | |||
| 4 | + | ||||
| 5 | + | For a Go program embedding the runtime in-process, use the `pkg/zotcore` SDK instead. The wire format below mirrors the SDK's types one-for-one so consumers can share parsing code. | |||
| 6 | + | ||||
| 7 | + | ## Quick start | |||
| 8 | + | ||||
| 9 | + | ```bash | |||
| 10 | + | # spawn zot rpc; talk to it from a shell | |||
| 11 | + | ( echo '{"id":"1","type":"prompt","message":"hello"}'; sleep 5 ) \ | |||
| 12 | + | | zot rpc --provider anthropic | |||
| 13 | + | ``` | |||
| 14 | + | ||||
| 15 | + | You'll see one JSON object per line on stdout: a response acknowledging the prompt, a stream of events (`text_delta`, `tool_call`, `tool_result`, `usage`), then `done`. | |||
| 16 | + | ||||
| 17 | + | ## Process model | |||
| 18 | + | ||||
| 19 | + | - One `zot rpc` process serves **one cwd, one model, one session**. | |||
| 20 | + | - For multiple projects, spawn multiple processes. | |||
| 21 | + | - Concurrency: at most one prompt or compact in flight at a time. A second one queues until the first finishes; aborting fires immediately. | |||
| 22 | + | - The process exits when stdin closes. | |||
| 23 | + | ||||
| 24 | + | ## Flags | |||
| 25 | + | ||||
| 26 | + | `zot rpc` accepts the same flags as the other modes: `--provider`, `--model`, `--cwd`, `--api-key`, `--base-url`, `--system-prompt`, `--append-system-prompt`, `--reasoning`, `--max-steps`, `--no-tools`, `--tools`. Sessions are disabled by default in RPC mode — the embedding application owns persistence. | |||
| 27 | + | ||||
| 28 | + | ## Auth | |||
| 29 | + | ||||
| 30 | + | If the environment variable `ZOTCORE_RPC_TOKEN` is set on the spawned process, the first line on stdin **must** be a `hello` command containing the matching token: | |||
| 31 | + | ||||
| 32 | + | ```json | |||
| 33 | + | {"id":"0","type":"hello","token":"shared-secret"} | |||
| 34 | + | ``` | |||
| 35 | + | ||||
| 36 | + | If absent or wrong, the response carries `success:false` and the process exits. Without `ZOTCORE_RPC_TOKEN` set, no auth is required (the spawning process is implicitly trusted; if it can spawn `zot` it can also read your `auth.json` directly). | |||
| 37 | + | ||||
| 38 | + | ## Wire format | |||
| 39 | + | ||||
| 40 | + | Every line in either direction is one JSON object terminated by `\n`. Object boundaries follow newline boundaries — no multi-line JSON. | |||
| 41 | + | ||||
| 42 | + | ### Frame types | |||
| 43 | + | ||||
| 44 | + | | `type` | Direction | Description | | |||
| 45 | + | |---|---|---| | |||
| 46 | + | | any command (`prompt`, `abort`, …) | client → server | Request | | |||
| 47 | + | | `response` | server → client | Reply to one command, correlated by `id` | | |||
| 48 | + | | any event (`text_delta`, `tool_call`, …) | server → client | Stream notification (no `id`) | | |||
| 49 | + | ||||
| 50 | + | ## Commands | |||
| 51 | + | ||||
| 52 | + | All commands share an optional `id` field; if present, the matching `response` echoes it. Use `id` to correlate replies with requests, especially when several requests are in flight. | |||
| 53 | + | ||||
| 54 | + | ### `hello` | |||
| 55 | + | ||||
| 56 | + | ```json | |||
| 57 | + | {"id":"0","type":"hello","token":"shared-secret"} | |||
| 58 | + | ``` | |||
| 59 | + | ||||
| 60 | + | Response: | |||
| 61 | + | ||||
| 62 | + | ```json | |||
| 63 | + | {"type":"response","id":"0","command":"hello","success":true, | |||
| 64 | + | "data":{"protocol_version":1,"version":"0.0.4","provider":"anthropic","model":"claude-opus-4-5"}} | |||
| 65 | + | ``` | |||
| 66 | + | ||||
| 67 | + | Required as the first message when `ZOTCORE_RPC_TOKEN` is set; optional otherwise. | |||
| 68 | + | ||||
| 69 | + | ### `prompt` | |||
| 70 | + | ||||
| 71 | + | ```json | |||
| 72 | + | {"id":"1","type":"prompt","message":"fix the failing test","images":[]} | |||
| 73 | + | ``` | |||
| 74 | + | ||||
| 75 | + | Optional `images` is `[{"mime_type":"image/png","data":"<base64>"}]`. | |||
| 76 | + | ||||
| 77 | + | Response is immediate (the turn is starting): | |||
| 78 | + | ||||
| 79 | + | ```json | |||
| 80 | + | {"type":"response","id":"1","command":"prompt","success":true,"data":{"started":true}} | |||
| 81 | + | ``` | |||
| 82 | + | ||||
| 83 | + | Then a stream of event objects (see below) until the turn ends with `{"type":"done"}`. | |||
| 84 | + | ||||
| 85 | + | ### `abort` | |||
| 86 | + | ||||
| 87 | + | Cancel the active prompt or compact. | |||
| 88 | + | ||||
| 89 | + | ```json | |||
| 90 | + | {"id":"2","type":"abort"} | |||
| 91 | + | ``` | |||
| 92 | + | ||||
| 93 | + | Response: `{"type":"response","id":"2","command":"abort","success":true}`. | |||
| 94 | + | ||||
| 95 | + | If the turn was streaming, the next events you see will be a `turn_end` with `stop:"aborted"` then `done`. | |||
| 96 | + | ||||
| 97 | + | ### `compact` | |||
| 98 | + | ||||
| 99 | + | Summarise the current transcript into one synthetic user message. Same lifecycle as `prompt` (immediate response, then events). | |||
| 100 | + | ||||
| 101 | + | ```json | |||
| 102 | + | {"id":"3","type":"compact"} | |||
| 103 | + | ``` | |||
| 104 | + | ||||
| 105 | + | Final event: `{"type":"compact_done","summary":"<text>"}`. | |||
| 106 | + | ||||
| 107 | + | ### `get_state` | |||
| 108 | + | ||||
| 109 | + | Snapshot of the runtime. | |||
| 110 | + | ||||
| 111 | + | ```json | |||
| 112 | + | {"id":"4","type":"get_state"} | |||
| 113 | + | ``` | |||
| 114 | + | ||||
| 115 | + | Response data: | |||
| 116 | + | ||||
| 117 | + | ```json | |||
| 118 | + | { | |||
| 119 | + | "provider": "anthropic", | |||
| 120 | + | "model": "claude-opus-4-5", | |||
| 121 | + | "cwd": "/Users/pat/Developer/zot", | |||
| 122 | + | "message_count": 12, | |||
| 123 | + | "busy": false, | |||
| 124 | + | "usage": {"input": 1234, "output": 567, "cache_read": 890, "cache_write": 0, "cost_usd": 0.0123} | |||
| 125 | + | } | |||
| 126 | + | ``` | |||
| 127 | + | ||||
| 128 | + | ### `get_messages` | |||
| 129 | + | ||||
| 130 | + | Full transcript. | |||
| 131 | + | ||||
| 132 | + | ```json | |||
| 133 | + | {"id":"5","type":"get_messages"} | |||
| 134 | + | ``` | |||
| 135 | + | ||||
| 136 | + | Response data: `{"messages": [<message>, ...]}`. See **message shape** below. | |||
| 137 | + | ||||
| 138 | + | ### `clear` | |||
| 139 | + | ||||
| 140 | + | Drop the entire transcript. Equivalent to the `/clear` slash command. | |||
| 141 | + | ||||
| 142 | + | ```json | |||
| 143 | + | {"id":"6","type":"clear"} | |||
| 144 | + | ``` | |||
| 145 | + | ||||
| 146 | + | ### `set_model` | |||
| 147 | + | ||||
| 148 | + | Switch model within the same provider. | |||
| 149 | + | ||||
| 150 | + | ```json | |||
| 151 | + | {"id":"7","type":"set_model","model":"claude-sonnet-4-5"} | |||
| 152 | + | ``` | |||
| 153 | + | ||||
| 154 | + | Cross-provider swaps require relaunching `zot rpc` with the new `--provider`. | |||
| 155 | + | ||||
| 156 | + | ### `get_models` | |||
| 157 | + | ||||
| 158 | + | List models known for the current provider. | |||
| 159 | + | ||||
| 160 | + | ```json | |||
| 161 | + | {"id":"8","type":"get_models"} | |||
| 162 | + | ``` | |||
| 163 | + | ||||
| 164 | + | Response data: `{"models":[{"id":"...","provider":"...","context_window":200000,"max_output":8192,"reasoning":true}, ...]}`. | |||
| 165 | + | ||||
| 166 | + | ### `ping` | |||
| 167 | + | ||||
| 168 | + | Health check. | |||
| 169 | + | ||||
| 170 | + | ```json | |||
| 171 | + | {"id":"9","type":"ping"} | |||
| 172 | + | ``` | |||
| 173 | + | ||||
| 174 | + | Response: `{"type":"response","id":"9","command":"ping","success":true,"data":{"pong":true}}`. | |||
| 175 | + | ||||
| 176 | + | ## Events | |||
| 177 | + | ||||
| 178 | + | Stream notifications during a `prompt` or `compact`. None carry an `id`. | |||
| 179 | + | ||||
| 180 | + | | `type` | Fields | Meaning | | |||
| 181 | + | |---|---|---| | |||
| 182 | + | | `turn_start` | `step` | Beginning of one model call (max-steps loop iteration) | | |||
| 183 | + | | `user_message` | `content`, `time` | The submitted prompt as it was added to the transcript | | |||
| 184 | + | | `assistant_start` | (none) | About to receive assistant streaming | | |||
| 185 | + | | `text_delta` | `delta` | Partial assistant text. Concatenate to build the full reply | | |||
| 186 | + | | `tool_call` | `id`, `name`, `args` | The model wants to call a tool | | |||
| 187 | + | | `tool_progress` | `id`, `text` | Optional progress line from the tool while it runs | | |||
| 188 | + | | `tool_result` | `id`, `is_error`, `content` | Tool finished | | |||
| 189 | + | | `assistant_message` | `content`, `time` | Final assistant message after the model turn ends | | |||
| 190 | + | | `usage` | `input`, `output`, `cache_read`, `cache_write`, `cost_usd`, `cumulative` | Per-turn + cumulative tokens / cost | | |||
| 191 | + | | `turn_end` | `stop`, optional `error` | One model call finished. `stop` is `end_turn`, `tool_use`, `length`, `error`, or `aborted` | | |||
| 192 | + | | `done` | (none) | The whole prompt/compact completed (success or error) | | |||
| 193 | + | | `error` | `message` | Non-fatal error message | | |||
| 194 | + | | `compact_done` | `summary` | Compaction finished, summary text included | | |||
| 195 | + | ||||
| 196 | + | ## Message shape | |||
| 197 | + | ||||
| 198 | + | Used by `get_messages` and inside `user_message` / `assistant_message` events. | |||
| 199 | + | ||||
| 200 | + | ```json | |||
| 201 | + | { | |||
| 202 | + | "role": "user", | |||
| 203 | + | "content": [<content_block>...], | |||
| 204 | + | "time": "2026-04-19T11:30:00Z" | |||
| 205 | + | } | |||
| 206 | + | ``` | |||
| 207 | + | ||||
| 208 | + | ### Content block types | |||
| 209 | + | ||||
| 210 | + | ```json | |||
| 211 | + | {"type": "text", "text": "..."} | |||
| 212 | + | {"type": "image", "mime_type": "image/png", "bytes": 12345} | |||
| 213 | + | {"type": "tool_call", "id": "toolu_xyz", "name": "read", "args": {"path": "..."}} | |||
| 214 | + | {"type": "tool_result", "call_id": "toolu_xyz", "is_error": false, "content": [<content_block>...]} | |||
| 215 | + | ``` | |||
| 216 | + | ||||
| 217 | + | Image bytes are reported as a count rather than embedded base64 to keep transcript dumps small. Tool results may nest text and image blocks. | |||
| 218 | + | ||||
| 219 | + | ## Reference clients | |||
| 220 | + | ||||
| 221 | + | See `examples/rpc/` for working implementations in: | |||
| 222 | + | ||||
| 223 | + | - `shell` — `bash` + `jq` one-liner | |||
| 224 | + | - `python` — `subprocess.Popen` + `json.loads` per line | |||
| 225 | + | - `node` — `child_process.spawn` + `readline` | |||
| 226 | + | - `go` — direct subprocess wrapper (the `pkg/zotcore` SDK is the in-process Go API) | |||
| 227 | + | ||||
| 228 | + | ## Versioning | |||
| 229 | + | ||||
| 230 | + | The `protocol_version` field in the `hello` response is the major version of this schema. Backwards-incompatible changes bump it. The set of supported events and commands within a major version only grows. | |||
A examples/rpc/go/main.go +75 -0
| @@ -0,0 +1,75 @@ | |||
| 1 | + | // Tiny Go program that drives `zot rpc` as a subprocess. | |
| 2 | + | // | |
| 3 | + | // For an in-process Go embedding, use github.com/patriceckhart/zot/pkg/zotcore | |
| 4 | + | // instead — this example exists to show what consumers in OTHER | |
| 5 | + | // languages have to do. | |
| 6 | + | package main | |
| 7 | + | ||
| 8 | + | import ( | |
| 9 | + | "bufio" | |
| 10 | + | "encoding/json" | |
| 11 | + | "fmt" | |
| 12 | + | "io" | |
| 13 | + | "os" | |
| 14 | + | "os/exec" | |
| 15 | + | "strings" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func main() { | |
| 19 | + | if len(os.Args) < 2 { | |
| 20 | + | fmt.Fprintln(os.Stderr, "usage: rpcdemo <prompt>") | |
| 21 | + | os.Exit(2) | |
| 22 | + | } | |
| 23 | + | prompt := strings.Join(os.Args[1:], " ") | |
| 24 | + | ||
| 25 | + | cmd := exec.Command("zot", "rpc") | |
| 26 | + | cmd.Stderr = os.Stderr | |
| 27 | + | stdin, err := cmd.StdinPipe() | |
| 28 | + | must(err) | |
| 29 | + | stdout, err := cmd.StdoutPipe() | |
| 30 | + | must(err) | |
| 31 | + | must(cmd.Start()) | |
| 32 | + | ||
| 33 | + | if tok := os.Getenv("ZOTCORE_RPC_TOKEN"); tok != "" { | |
| 34 | + | send(stdin, map[string]any{"id": "0", "type": "hello", "token": tok}) | |
| 35 | + | } | |
| 36 | + | send(stdin, map[string]any{ | |
| 37 | + | "id": "1", | |
| 38 | + | "type": "prompt", | |
| 39 | + | "message": prompt, | |
| 40 | + | }) | |
| 41 | + | ||
| 42 | + | sc := bufio.NewScanner(stdout) | |
| 43 | + | sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) | |
| 44 | + | for sc.Scan() { | |
| 45 | + | var ev map[string]any | |
| 46 | + | if err := json.Unmarshal(sc.Bytes(), &ev); err != nil { | |
| 47 | + | continue | |
| 48 | + | } | |
| 49 | + | switch ev["type"] { | |
| 50 | + | case "text_delta": | |
| 51 | + | fmt.Print(ev["delta"]) | |
| 52 | + | case "tool_call": | |
| 53 | + | fmt.Fprintf(os.Stderr, "\n[tool] %v\n", ev["name"]) | |
| 54 | + | case "error": | |
| 55 | + | fmt.Fprintf(os.Stderr, "\n[error] %v\n", ev["message"]) | |
| 56 | + | case "done": | |
| 57 | + | fmt.Println() | |
| 58 | + | stdin.Close() | |
| 59 | + | _ = cmd.Wait() | |
| 60 | + | return | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func send(w io.Writer, m map[string]any) { | |
| 66 | + | b, _ := json.Marshal(m) | |
| 67 | + | _, _ = w.Write(append(b, '\n')) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func must(err error) { | |
| 71 | + | if err != nil { | |
| 72 | + | fmt.Fprintln(os.Stderr, err) | |
| 73 | + | os.Exit(1) | |
| 74 | + | } | |
| 75 | + | } | |
| @@ -0,0 +1,75 @@ | |||||
| 1 | + | // Tiny Go program that drives `zot rpc` as a subprocess. | |||
| 2 | + | // | |||
| 3 | + | // For an in-process Go embedding, use github.com/patriceckhart/zot/pkg/zotcore | |||
| 4 | + | // instead — this example exists to show what consumers in OTHER | |||
| 5 | + | // languages have to do. | |||
| 6 | + | package main | |||
| 7 | + | ||||
| 8 | + | import ( | |||
| 9 | + | "bufio" | |||
| 10 | + | "encoding/json" | |||
| 11 | + | "fmt" | |||
| 12 | + | "io" | |||
| 13 | + | "os" | |||
| 14 | + | "os/exec" | |||
| 15 | + | "strings" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | func main() { | |||
| 19 | + | if len(os.Args) < 2 { | |||
| 20 | + | fmt.Fprintln(os.Stderr, "usage: rpcdemo <prompt>") | |||
| 21 | + | os.Exit(2) | |||
| 22 | + | } | |||
| 23 | + | prompt := strings.Join(os.Args[1:], " ") | |||
| 24 | + | ||||
| 25 | + | cmd := exec.Command("zot", "rpc") | |||
| 26 | + | cmd.Stderr = os.Stderr | |||
| 27 | + | stdin, err := cmd.StdinPipe() | |||
| 28 | + | must(err) | |||
| 29 | + | stdout, err := cmd.StdoutPipe() | |||
| 30 | + | must(err) | |||
| 31 | + | must(cmd.Start()) | |||
| 32 | + | ||||
| 33 | + | if tok := os.Getenv("ZOTCORE_RPC_TOKEN"); tok != "" { | |||
| 34 | + | send(stdin, map[string]any{"id": "0", "type": "hello", "token": tok}) | |||
| 35 | + | } | |||
| 36 | + | send(stdin, map[string]any{ | |||
| 37 | + | "id": "1", | |||
| 38 | + | "type": "prompt", | |||
| 39 | + | "message": prompt, | |||
| 40 | + | }) | |||
| 41 | + | ||||
| 42 | + | sc := bufio.NewScanner(stdout) | |||
| 43 | + | sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) | |||
| 44 | + | for sc.Scan() { | |||
| 45 | + | var ev map[string]any | |||
| 46 | + | if err := json.Unmarshal(sc.Bytes(), &ev); err != nil { | |||
| 47 | + | continue | |||
| 48 | + | } | |||
| 49 | + | switch ev["type"] { | |||
| 50 | + | case "text_delta": | |||
| 51 | + | fmt.Print(ev["delta"]) | |||
| 52 | + | case "tool_call": | |||
| 53 | + | fmt.Fprintf(os.Stderr, "\n[tool] %v\n", ev["name"]) | |||
| 54 | + | case "error": | |||
| 55 | + | fmt.Fprintf(os.Stderr, "\n[error] %v\n", ev["message"]) | |||
| 56 | + | case "done": | |||
| 57 | + | fmt.Println() | |||
| 58 | + | stdin.Close() | |||
| 59 | + | _ = cmd.Wait() | |||
| 60 | + | return | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func send(w io.Writer, m map[string]any) { | |||
| 66 | + | b, _ := json.Marshal(m) | |||
| 67 | + | _, _ = w.Write(append(b, '\n')) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func must(err error) { | |||
| 71 | + | if err != nil { | |||
| 72 | + | fmt.Fprintln(os.Stderr, err) | |||
| 73 | + | os.Exit(1) | |||
| 74 | + | } | |||
| 75 | + | } | |||
A examples/rpc/node/zot-client.js +106 -0
| @@ -0,0 +1,106 @@ | |||
| 1 | + | // Minimal Node client for the `zot rpc` JSON protocol. | |
| 2 | + | // | |
| 3 | + | // Usage: | |
| 4 | + | // node zot-client.js "fix the failing test" | |
| 5 | + | // | |
| 6 | + | // Spawns `zot rpc`, sends one prompt, prints assistant text as it | |
| 7 | + | // streams, and exits when the turn finishes. Pure stdlib — no | |
| 8 | + | // dependencies. See docs/rpc.md for the full schema. | |
| 9 | + | "use strict"; | |
| 10 | + | ||
| 11 | + | const { spawn } = require("node:child_process"); | |
| 12 | + | const readline = require("node:readline"); | |
| 13 | + | const { randomBytes } = require("node:crypto"); | |
| 14 | + | ||
| 15 | + | class ZotClient { | |
| 16 | + | constructor(...flags) { | |
| 17 | + | this.proc = spawn("zot", ["rpc", ...flags], { | |
| 18 | + | stdio: ["pipe", "pipe", "inherit"], | |
| 19 | + | env: process.env, | |
| 20 | + | }); | |
| 21 | + | this.rl = readline.createInterface({ | |
| 22 | + | input: this.proc.stdout, | |
| 23 | + | crlfDelay: Infinity, | |
| 24 | + | }); | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | send(command) { | |
| 28 | + | if (!command.id) command.id = randomBytes(4).toString("hex"); | |
| 29 | + | this.proc.stdin.write(JSON.stringify(command) + "\n"); | |
| 30 | + | return command.id; | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | async *events() { | |
| 34 | + | for await (const line of this.rl) { | |
| 35 | + | const trimmed = line.trim(); | |
| 36 | + | if (!trimmed) continue; | |
| 37 | + | try { | |
| 38 | + | yield JSON.parse(trimmed); | |
| 39 | + | } catch { | |
| 40 | + | // ignore garbled lines | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | close() { | |
| 46 | + | try { | |
| 47 | + | this.proc.stdin.end(); | |
| 48 | + | } catch {} | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | async function main() { | |
| 53 | + | const prompt = process.argv.slice(2).join(" "); | |
| 54 | + | if (!prompt) { | |
| 55 | + | console.error("usage: node zot-client.js <prompt>"); | |
| 56 | + | process.exit(2); | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | const client = new ZotClient(); | |
| 60 | + | ||
| 61 | + | const token = process.env.ZOTCORE_RPC_TOKEN; | |
| 62 | + | if (token) client.send({ type: "hello", token }); | |
| 63 | + | ||
| 64 | + | client.send({ type: "prompt", message: prompt }); | |
| 65 | + | ||
| 66 | + | let exitCode = 0; | |
| 67 | + | try { | |
| 68 | + | for await (const ev of client.events()) { | |
| 69 | + | switch (ev.type) { | |
| 70 | + | case "text_delta": | |
| 71 | + | process.stdout.write(ev.delta || ""); | |
| 72 | + | break; | |
| 73 | + | case "tool_call": | |
| 74 | + | process.stderr.write( | |
| 75 | + | `\n[tool] ${ev.name}(${JSON.stringify(ev.args || {})})`, | |
| 76 | + | ); | |
| 77 | + | break; | |
| 78 | + | case "tool_result": | |
| 79 | + | if (ev.is_error) process.stderr.write("\n[tool error]"); | |
| 80 | + | break; | |
| 81 | + | case "usage": { | |
| 82 | + | const cum = ev.cumulative || {}; | |
| 83 | + | process.stderr.write( | |
| 84 | + | `\n[usage] cum input=${cum.input} output=${cum.output} cost=$${(cum.cost_usd || 0).toFixed(4)}`, | |
| 85 | + | ); | |
| 86 | + | break; | |
| 87 | + | } | |
| 88 | + | case "error": | |
| 89 | + | process.stderr.write(`\n[error] ${ev.message}\n`); | |
| 90 | + | exitCode = 1; | |
| 91 | + | break; | |
| 92 | + | case "done": | |
| 93 | + | process.stdout.write("\n"); | |
| 94 | + | client.close(); | |
| 95 | + | process.exit(exitCode); | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | } finally { | |
| 99 | + | client.close(); | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | main().catch((e) => { | |
| 104 | + | console.error(e); | |
| 105 | + | process.exit(1); | |
| 106 | + | }); | |
| @@ -0,0 +1,106 @@ | |||||
| 1 | + | // Minimal Node client for the `zot rpc` JSON protocol. | |||
| 2 | + | // | |||
| 3 | + | // Usage: | |||
| 4 | + | // node zot-client.js "fix the failing test" | |||
| 5 | + | // | |||
| 6 | + | // Spawns `zot rpc`, sends one prompt, prints assistant text as it | |||
| 7 | + | // streams, and exits when the turn finishes. Pure stdlib — no | |||
| 8 | + | // dependencies. See docs/rpc.md for the full schema. | |||
| 9 | + | "use strict"; | |||
| 10 | + | ||||
| 11 | + | const { spawn } = require("node:child_process"); | |||
| 12 | + | const readline = require("node:readline"); | |||
| 13 | + | const { randomBytes } = require("node:crypto"); | |||
| 14 | + | ||||
| 15 | + | class ZotClient { | |||
| 16 | + | constructor(...flags) { | |||
| 17 | + | this.proc = spawn("zot", ["rpc", ...flags], { | |||
| 18 | + | stdio: ["pipe", "pipe", "inherit"], | |||
| 19 | + | env: process.env, | |||
| 20 | + | }); | |||
| 21 | + | this.rl = readline.createInterface({ | |||
| 22 | + | input: this.proc.stdout, | |||
| 23 | + | crlfDelay: Infinity, | |||
| 24 | + | }); | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | send(command) { | |||
| 28 | + | if (!command.id) command.id = randomBytes(4).toString("hex"); | |||
| 29 | + | this.proc.stdin.write(JSON.stringify(command) + "\n"); | |||
| 30 | + | return command.id; | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | async *events() { | |||
| 34 | + | for await (const line of this.rl) { | |||
| 35 | + | const trimmed = line.trim(); | |||
| 36 | + | if (!trimmed) continue; | |||
| 37 | + | try { | |||
| 38 | + | yield JSON.parse(trimmed); | |||
| 39 | + | } catch { | |||
| 40 | + | // ignore garbled lines | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | close() { | |||
| 46 | + | try { | |||
| 47 | + | this.proc.stdin.end(); | |||
| 48 | + | } catch {} | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | async function main() { | |||
| 53 | + | const prompt = process.argv.slice(2).join(" "); | |||
| 54 | + | if (!prompt) { | |||
| 55 | + | console.error("usage: node zot-client.js <prompt>"); | |||
| 56 | + | process.exit(2); | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | const client = new ZotClient(); | |||
| 60 | + | ||||
| 61 | + | const token = process.env.ZOTCORE_RPC_TOKEN; | |||
| 62 | + | if (token) client.send({ type: "hello", token }); | |||
| 63 | + | ||||
| 64 | + | client.send({ type: "prompt", message: prompt }); | |||
| 65 | + | ||||
| 66 | + | let exitCode = 0; | |||
| 67 | + | try { | |||
| 68 | + | for await (const ev of client.events()) { | |||
| 69 | + | switch (ev.type) { | |||
| 70 | + | case "text_delta": | |||
| 71 | + | process.stdout.write(ev.delta || ""); | |||
| 72 | + | break; | |||
| 73 | + | case "tool_call": | |||
| 74 | + | process.stderr.write( | |||
| 75 | + | `\n[tool] ${ev.name}(${JSON.stringify(ev.args || {})})`, | |||
| 76 | + | ); | |||
| 77 | + | break; | |||
| 78 | + | case "tool_result": | |||
| 79 | + | if (ev.is_error) process.stderr.write("\n[tool error]"); | |||
| 80 | + | break; | |||
| 81 | + | case "usage": { | |||
| 82 | + | const cum = ev.cumulative || {}; | |||
| 83 | + | process.stderr.write( | |||
| 84 | + | `\n[usage] cum input=${cum.input} output=${cum.output} cost=$${(cum.cost_usd || 0).toFixed(4)}`, | |||
| 85 | + | ); | |||
| 86 | + | break; | |||
| 87 | + | } | |||
| 88 | + | case "error": | |||
| 89 | + | process.stderr.write(`\n[error] ${ev.message}\n`); | |||
| 90 | + | exitCode = 1; | |||
| 91 | + | break; | |||
| 92 | + | case "done": | |||
| 93 | + | process.stdout.write("\n"); | |||
| 94 | + | client.close(); | |||
| 95 | + | process.exit(exitCode); | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | } finally { | |||
| 99 | + | client.close(); | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | main().catch((e) => { | |||
| 104 | + | console.error(e); | |||
| 105 | + | process.exit(1); | |||
| 106 | + | }); | |||
A examples/rpc/python/zot_client.py +114 -0
| @@ -0,0 +1,114 @@ | |||
| 1 | + | """Minimal Python client for the `zot rpc` JSON protocol. | |
| 2 | + | ||
| 3 | + | Usage: | |
| 4 | + | python zot_client.py "fix the failing test" | |
| 5 | + | ||
| 6 | + | Spawns `zot rpc`, sends one prompt, prints assistant text as it streams, | |
| 7 | + | and exits when the turn finishes. No external dependencies — stdlib | |
| 8 | + | only. Implements just enough of the protocol to be useful as a | |
| 9 | + | starting point; see docs/rpc.md for the full schema. | |
| 10 | + | """ | |
| 11 | + | from __future__ import annotations | |
| 12 | + | ||
| 13 | + | import json | |
| 14 | + | import os | |
| 15 | + | import subprocess | |
| 16 | + | import sys | |
| 17 | + | import threading | |
| 18 | + | import uuid | |
| 19 | + | ||
| 20 | + | ||
| 21 | + | class ZotClient: | |
| 22 | + | def __init__(self, *flags: str) -> None: | |
| 23 | + | argv = ["zot", "rpc", *flags] | |
| 24 | + | env = os.environ.copy() | |
| 25 | + | self.proc = subprocess.Popen( | |
| 26 | + | argv, | |
| 27 | + | stdin=subprocess.PIPE, | |
| 28 | + | stdout=subprocess.PIPE, | |
| 29 | + | stderr=sys.stderr, | |
| 30 | + | env=env, | |
| 31 | + | text=True, | |
| 32 | + | bufsize=1, # line-buffered | |
| 33 | + | ) | |
| 34 | + | self._lock = threading.Lock() | |
| 35 | + | ||
| 36 | + | def send(self, **command) -> str: | |
| 37 | + | """Send one command, return its `id`.""" | |
| 38 | + | if "id" not in command: | |
| 39 | + | command["id"] = uuid.uuid4().hex[:8] | |
| 40 | + | line = json.dumps(command) | |
| 41 | + | with self._lock: | |
| 42 | + | assert self.proc.stdin is not None | |
| 43 | + | self.proc.stdin.write(line + "\n") | |
| 44 | + | self.proc.stdin.flush() | |
| 45 | + | return command["id"] | |
| 46 | + | ||
| 47 | + | def events(self): | |
| 48 | + | """Yield every JSON object the server emits, until stdout closes.""" | |
| 49 | + | assert self.proc.stdout is not None | |
| 50 | + | for line in self.proc.stdout: | |
| 51 | + | line = line.strip() | |
| 52 | + | if not line: | |
| 53 | + | continue | |
| 54 | + | try: | |
| 55 | + | yield json.loads(line) | |
| 56 | + | except json.JSONDecodeError: | |
| 57 | + | continue | |
| 58 | + | ||
| 59 | + | def close(self) -> None: | |
| 60 | + | if self.proc.stdin is not None: | |
| 61 | + | try: | |
| 62 | + | self.proc.stdin.close() | |
| 63 | + | except Exception: | |
| 64 | + | pass | |
| 65 | + | self.proc.wait(timeout=5) | |
| 66 | + | ||
| 67 | + | ||
| 68 | + | def main() -> int: | |
| 69 | + | if len(sys.argv) < 2: | |
| 70 | + | print("usage: zot_client.py <prompt>", file=sys.stderr) | |
| 71 | + | return 2 | |
| 72 | + | prompt = " ".join(sys.argv[1:]) | |
| 73 | + | ||
| 74 | + | client = ZotClient() | |
| 75 | + | try: | |
| 76 | + | token = os.environ.get("ZOTCORE_RPC_TOKEN") | |
| 77 | + | if token: | |
| 78 | + | client.send(type="hello", token=token) | |
| 79 | + | ||
| 80 | + | client.send(type="prompt", message=prompt) | |
| 81 | + | ||
| 82 | + | for ev in client.events(): | |
| 83 | + | t = ev.get("type") | |
| 84 | + | if t == "text_delta": | |
| 85 | + | sys.stdout.write(ev.get("delta", "")) | |
| 86 | + | sys.stdout.flush() | |
| 87 | + | elif t == "tool_call": | |
| 88 | + | print( | |
| 89 | + | f"\n[tool] {ev.get('name')}({json.dumps(ev.get('args', {}))})", | |
| 90 | + | file=sys.stderr, | |
| 91 | + | ) | |
| 92 | + | elif t == "tool_result": | |
| 93 | + | if ev.get("is_error"): | |
| 94 | + | print("[tool error]", file=sys.stderr) | |
| 95 | + | elif t == "usage": | |
| 96 | + | cum = ev.get("cumulative", {}) | |
| 97 | + | print( | |
| 98 | + | f"\n[usage] cum input={cum.get('input')} " | |
| 99 | + | f"output={cum.get('output')} cost=${cum.get('cost_usd', 0):.4f}", | |
| 100 | + | file=sys.stderr, | |
| 101 | + | ) | |
| 102 | + | elif t == "done": | |
| 103 | + | break | |
| 104 | + | elif t == "error": | |
| 105 | + | print(f"\n[error] {ev.get('message')}", file=sys.stderr) | |
| 106 | + | return 1 | |
| 107 | + | print() | |
| 108 | + | return 0 | |
| 109 | + | finally: | |
| 110 | + | client.close() | |
| 111 | + | ||
| 112 | + | ||
| 113 | + | if __name__ == "__main__": | |
| 114 | + | sys.exit(main()) | |
| @@ -0,0 +1,114 @@ | |||||
| 1 | + | """Minimal Python client for the `zot rpc` JSON protocol. | |||
| 2 | + | ||||
| 3 | + | Usage: | |||
| 4 | + | python zot_client.py "fix the failing test" | |||
| 5 | + | ||||
| 6 | + | Spawns `zot rpc`, sends one prompt, prints assistant text as it streams, | |||
| 7 | + | and exits when the turn finishes. No external dependencies — stdlib | |||
| 8 | + | only. Implements just enough of the protocol to be useful as a | |||
| 9 | + | starting point; see docs/rpc.md for the full schema. | |||
| 10 | + | """ | |||
| 11 | + | from __future__ import annotations | |||
| 12 | + | ||||
| 13 | + | import json | |||
| 14 | + | import os | |||
| 15 | + | import subprocess | |||
| 16 | + | import sys | |||
| 17 | + | import threading | |||
| 18 | + | import uuid | |||
| 19 | + | ||||
| 20 | + | ||||
| 21 | + | class ZotClient: | |||
| 22 | + | def __init__(self, *flags: str) -> None: | |||
| 23 | + | argv = ["zot", "rpc", *flags] | |||
| 24 | + | env = os.environ.copy() | |||
| 25 | + | self.proc = subprocess.Popen( | |||
| 26 | + | argv, | |||
| 27 | + | stdin=subprocess.PIPE, | |||
| 28 | + | stdout=subprocess.PIPE, | |||
| 29 | + | stderr=sys.stderr, | |||
| 30 | + | env=env, | |||
| 31 | + | text=True, | |||
| 32 | + | bufsize=1, # line-buffered | |||
| 33 | + | ) | |||
| 34 | + | self._lock = threading.Lock() | |||
| 35 | + | ||||
| 36 | + | def send(self, **command) -> str: | |||
| 37 | + | """Send one command, return its `id`.""" | |||
| 38 | + | if "id" not in command: | |||
| 39 | + | command["id"] = uuid.uuid4().hex[:8] | |||
| 40 | + | line = json.dumps(command) | |||
| 41 | + | with self._lock: | |||
| 42 | + | assert self.proc.stdin is not None | |||
| 43 | + | self.proc.stdin.write(line + "\n") | |||
| 44 | + | self.proc.stdin.flush() | |||
| 45 | + | return command["id"] | |||
| 46 | + | ||||
| 47 | + | def events(self): | |||
| 48 | + | """Yield every JSON object the server emits, until stdout closes.""" | |||
| 49 | + | assert self.proc.stdout is not None | |||
| 50 | + | for line in self.proc.stdout: | |||
| 51 | + | line = line.strip() | |||
| 52 | + | if not line: | |||
| 53 | + | continue | |||
| 54 | + | try: | |||
| 55 | + | yield json.loads(line) | |||
| 56 | + | except json.JSONDecodeError: | |||
| 57 | + | continue | |||
| 58 | + | ||||
| 59 | + | def close(self) -> None: | |||
| 60 | + | if self.proc.stdin is not None: | |||
| 61 | + | try: | |||
| 62 | + | self.proc.stdin.close() | |||
| 63 | + | except Exception: | |||
| 64 | + | pass | |||
| 65 | + | self.proc.wait(timeout=5) | |||
| 66 | + | ||||
| 67 | + | ||||
| 68 | + | def main() -> int: | |||
| 69 | + | if len(sys.argv) < 2: | |||
| 70 | + | print("usage: zot_client.py <prompt>", file=sys.stderr) | |||
| 71 | + | return 2 | |||
| 72 | + | prompt = " ".join(sys.argv[1:]) | |||
| 73 | + | ||||
| 74 | + | client = ZotClient() | |||
| 75 | + | try: | |||
| 76 | + | token = os.environ.get("ZOTCORE_RPC_TOKEN") | |||
| 77 | + | if token: | |||
| 78 | + | client.send(type="hello", token=token) | |||
| 79 | + | ||||
| 80 | + | client.send(type="prompt", message=prompt) | |||
| 81 | + | ||||
| 82 | + | for ev in client.events(): | |||
| 83 | + | t = ev.get("type") | |||
| 84 | + | if t == "text_delta": | |||
| 85 | + | sys.stdout.write(ev.get("delta", "")) | |||
| 86 | + | sys.stdout.flush() | |||
| 87 | + | elif t == "tool_call": | |||
| 88 | + | print( | |||
| 89 | + | f"\n[tool] {ev.get('name')}({json.dumps(ev.get('args', {}))})", | |||
| 90 | + | file=sys.stderr, | |||
| 91 | + | ) | |||
| 92 | + | elif t == "tool_result": | |||
| 93 | + | if ev.get("is_error"): | |||
| 94 | + | print("[tool error]", file=sys.stderr) | |||
| 95 | + | elif t == "usage": | |||
| 96 | + | cum = ev.get("cumulative", {}) | |||
| 97 | + | print( | |||
| 98 | + | f"\n[usage] cum input={cum.get('input')} " | |||
| 99 | + | f"output={cum.get('output')} cost=${cum.get('cost_usd', 0):.4f}", | |||
| 100 | + | file=sys.stderr, | |||
| 101 | + | ) | |||
| 102 | + | elif t == "done": | |||
| 103 | + | break | |||
| 104 | + | elif t == "error": | |||
| 105 | + | print(f"\n[error] {ev.get('message')}", file=sys.stderr) | |||
| 106 | + | return 1 | |||
| 107 | + | print() | |||
| 108 | + | return 0 | |||
| 109 | + | finally: | |||
| 110 | + | client.close() | |||
| 111 | + | ||||
| 112 | + | ||||
| 113 | + | if __name__ == "__main__": | |||
| 114 | + | sys.exit(main()) | |||
A examples/rpc/shell/prompt.sh +51 -0
| @@ -0,0 +1,51 @@ | |||
| 1 | + | #!/usr/bin/env bash | |
| 2 | + | # Minimal shell client for the `zot rpc` JSON protocol. | |
| 3 | + | # | |
| 4 | + | # Usage: | |
| 5 | + | # ./prompt.sh "fix the failing test" | |
| 6 | + | # | |
| 7 | + | # Sends one prompt, streams assistant text deltas to stdout, exits | |
| 8 | + | # when the turn finishes. Requires `jq`. | |
| 9 | + | ||
| 10 | + | set -euo pipefail | |
| 11 | + | ||
| 12 | + | if [ $# -lt 1 ]; then | |
| 13 | + | echo "usage: $0 <prompt>" >&2 | |
| 14 | + | exit 2 | |
| 15 | + | fi | |
| 16 | + | prompt="$*" | |
| 17 | + | ||
| 18 | + | # Build the prompt command frame with jq so quotes are escaped properly. | |
| 19 | + | cmd=$(jq -nc --arg msg "$prompt" '{id:"1",type:"prompt",message:$msg}') | |
| 20 | + | ||
| 21 | + | # Pipe the command into zot rpc; pipe its stdout through jq to react | |
| 22 | + | # to events. The trailing `cat` keeps the input pipe open until the | |
| 23 | + | # subprocess exits on its own (after `done`). | |
| 24 | + | { | |
| 25 | + | if [ -n "${ZOTCORE_RPC_TOKEN:-}" ]; then | |
| 26 | + | jq -nc --arg t "$ZOTCORE_RPC_TOKEN" '{id:"0",type:"hello",token:$t}' | |
| 27 | + | fi | |
| 28 | + | echo "$cmd" | |
| 29 | + | # Block here so stdin stays open until zot exits. | |
| 30 | + | cat | |
| 31 | + | } | zot rpc | while IFS= read -r line; do | |
| 32 | + | type=$(echo "$line" | jq -r '.type // empty') | |
| 33 | + | case "$type" in | |
| 34 | + | text_delta) | |
| 35 | + | echo "$line" | jq -rj '.delta // ""' | |
| 36 | + | ;; | |
| 37 | + | tool_call) | |
| 38 | + | name=$(echo "$line" | jq -r '.name // ""') | |
| 39 | + | echo >&2 "" | |
| 40 | + | echo >&2 "[tool] $name" | |
| 41 | + | ;; | |
| 42 | + | error) | |
| 43 | + | echo >&2 "[error] $(echo "$line" | jq -r '.message // ""')" | |
| 44 | + | exit 1 | |
| 45 | + | ;; | |
| 46 | + | done) | |
| 47 | + | echo | |
| 48 | + | exit 0 | |
| 49 | + | ;; | |
| 50 | + | esac | |
| 51 | + | done | |
| @@ -0,0 +1,51 @@ | |||||
| 1 | + | #!/usr/bin/env bash | |||
| 2 | + | # Minimal shell client for the `zot rpc` JSON protocol. | |||
| 3 | + | # | |||
| 4 | + | # Usage: | |||
| 5 | + | # ./prompt.sh "fix the failing test" | |||
| 6 | + | # | |||
| 7 | + | # Sends one prompt, streams assistant text deltas to stdout, exits | |||
| 8 | + | # when the turn finishes. Requires `jq`. | |||
| 9 | + | ||||
| 10 | + | set -euo pipefail | |||
| 11 | + | ||||
| 12 | + | if [ $# -lt 1 ]; then | |||
| 13 | + | echo "usage: $0 <prompt>" >&2 | |||
| 14 | + | exit 2 | |||
| 15 | + | fi | |||
| 16 | + | prompt="$*" | |||
| 17 | + | ||||
| 18 | + | # Build the prompt command frame with jq so quotes are escaped properly. | |||
| 19 | + | cmd=$(jq -nc --arg msg "$prompt" '{id:"1",type:"prompt",message:$msg}') | |||
| 20 | + | ||||
| 21 | + | # Pipe the command into zot rpc; pipe its stdout through jq to react | |||
| 22 | + | # to events. The trailing `cat` keeps the input pipe open until the | |||
| 23 | + | # subprocess exits on its own (after `done`). | |||
| 24 | + | { | |||
| 25 | + | if [ -n "${ZOTCORE_RPC_TOKEN:-}" ]; then | |||
| 26 | + | jq -nc --arg t "$ZOTCORE_RPC_TOKEN" '{id:"0",type:"hello",token:$t}' | |||
| 27 | + | fi | |||
| 28 | + | echo "$cmd" | |||
| 29 | + | # Block here so stdin stays open until zot exits. | |||
| 30 | + | cat | |||
| 31 | + | } | zot rpc | while IFS= read -r line; do | |||
| 32 | + | type=$(echo "$line" | jq -r '.type // empty') | |||
| 33 | + | case "$type" in | |||
| 34 | + | text_delta) | |||
| 35 | + | echo "$line" | jq -rj '.delta // ""' | |||
| 36 | + | ;; | |||
| 37 | + | tool_call) | |||
| 38 | + | name=$(echo "$line" | jq -r '.name // ""') | |||
| 39 | + | echo >&2 "" | |||
| 40 | + | echo >&2 "[tool] $name" | |||
| 41 | + | ;; | |||
| 42 | + | error) | |||
| 43 | + | echo >&2 "[error] $(echo "$line" | jq -r '.message // ""')" | |||
| 44 | + | exit 1 | |||
| 45 | + | ;; | |||
| 46 | + | done) | |||
| 47 | + | echo | |||
| 48 | + | exit 0 | |||
| 49 | + | ;; | |||
| 50 | + | esac | |||
| 51 | + | done | |||
A examples/sdk/main.go +45 -0
| @@ -0,0 +1,45 @@ | |||
| 1 | + | // In-process Go embedding of the zot agent runtime via the zotcore SDK. | |
| 2 | + | // Compare to examples/rpc/go which spawns `zot rpc` as a subprocess. | |
| 3 | + | package main | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "strings" | |
| 10 | + | ||
| 11 | + | "github.com/patriceckhart/zot/pkg/zotcore" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func main() { | |
| 15 | + | if len(os.Args) < 2 { | |
| 16 | + | fmt.Fprintln(os.Stderr, "usage: sdkdemo <prompt>") | |
| 17 | + | os.Exit(2) | |
| 18 | + | } | |
| 19 | + | prompt := strings.Join(os.Args[1:], " ") | |
| 20 | + | ||
| 21 | + | rt, err := zotcore.New(zotcore.Config{}) | |
| 22 | + | if err != nil { | |
| 23 | + | fmt.Fprintln(os.Stderr, err) | |
| 24 | + | os.Exit(1) | |
| 25 | + | } | |
| 26 | + | defer rt.Close() | |
| 27 | + | ||
| 28 | + | events, err := rt.Prompt(context.Background(), prompt, nil) | |
| 29 | + | if err != nil { | |
| 30 | + | fmt.Fprintln(os.Stderr, err) | |
| 31 | + | os.Exit(1) | |
| 32 | + | } | |
| 33 | + | for ev := range events { | |
| 34 | + | switch ev.Type { | |
| 35 | + | case "text_delta": | |
| 36 | + | fmt.Print(ev.Delta) | |
| 37 | + | case "tool_call": | |
| 38 | + | fmt.Fprintf(os.Stderr, "\n[tool] %s\n", ev.Name) | |
| 39 | + | case "error": | |
| 40 | + | fmt.Fprintf(os.Stderr, "\n[error] %s\n", ev.Error) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | fmt.Println() | |
| 44 | + | fmt.Fprintf(os.Stderr, "cost so far: $%.4f\n", rt.Cost().CostUSD) | |
| 45 | + | } | |
| @@ -0,0 +1,45 @@ | |||||
| 1 | + | // In-process Go embedding of the zot agent runtime via the zotcore SDK. | |||
| 2 | + | // Compare to examples/rpc/go which spawns `zot rpc` as a subprocess. | |||
| 3 | + | package main | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "fmt" | |||
| 8 | + | "os" | |||
| 9 | + | "strings" | |||
| 10 | + | ||||
| 11 | + | "github.com/patriceckhart/zot/pkg/zotcore" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func main() { | |||
| 15 | + | if len(os.Args) < 2 { | |||
| 16 | + | fmt.Fprintln(os.Stderr, "usage: sdkdemo <prompt>") | |||
| 17 | + | os.Exit(2) | |||
| 18 | + | } | |||
| 19 | + | prompt := strings.Join(os.Args[1:], " ") | |||
| 20 | + | ||||
| 21 | + | rt, err := zotcore.New(zotcore.Config{}) | |||
| 22 | + | if err != nil { | |||
| 23 | + | fmt.Fprintln(os.Stderr, err) | |||
| 24 | + | os.Exit(1) | |||
| 25 | + | } | |||
| 26 | + | defer rt.Close() | |||
| 27 | + | ||||
| 28 | + | events, err := rt.Prompt(context.Background(), prompt, nil) | |||
| 29 | + | if err != nil { | |||
| 30 | + | fmt.Fprintln(os.Stderr, err) | |||
| 31 | + | os.Exit(1) | |||
| 32 | + | } | |||
| 33 | + | for ev := range events { | |||
| 34 | + | switch ev.Type { | |||
| 35 | + | case "text_delta": | |||
| 36 | + | fmt.Print(ev.Delta) | |||
| 37 | + | case "tool_call": | |||
| 38 | + | fmt.Fprintf(os.Stderr, "\n[tool] %s\n", ev.Name) | |||
| 39 | + | case "error": | |||
| 40 | + | fmt.Fprintf(os.Stderr, "\n[error] %s\n", ev.Error) | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | fmt.Println() | |||
| 44 | + | fmt.Fprintf(os.Stderr, "cost so far: $%.4f\n", rt.Cost().CostUSD) | |||
| 45 | + | } | |||
M internal/agent/args.go +4 -0
| @@ -13,6 +13,7 @@ const ( | |||
| 13 | 13 | ModeInteractive Mode = "interactive" | |
| 14 | 14 | ModePrint Mode = "print" | |
| 15 | 15 | ModeJSON Mode = "json" | |
| 16 | + | ModeRPC Mode = "rpc" | |
| 16 | 17 | ) | |
| 17 | 18 | ||
| 18 | 19 | // Args holds parsed command-line options. | |
| @@ -68,6 +69,8 @@ func ParseArgs(in []string) (Args, error) { | |||
| 68 | 69 | a.Mode = ModePrint | |
| 69 | 70 | case "--json": | |
| 70 | 71 | a.Mode = ModeJSON | |
| 72 | + | case "--rpc": | |
| 73 | + | a.Mode = ModeRPC | |
| 71 | 74 | case "-c", "--continue": | |
| 72 | 75 | a.Continue = true | |
| 73 | 76 | case "-r", "--resume": | |
| @@ -188,6 +191,7 @@ usage: | |||
| 188 | 191 | zot "prompt" interactive, pre-filled prompt | |
| 189 | 192 | zot -p "prompt" print final text, exit | |
| 190 | 193 | zot --json "prompt" newline-delimited json events, exit | |
| 194 | + | zot rpc json-rpc loop on stdin/stdout (see docs/rpc.md) | |
| 191 | 195 | zot telegram-bot setup configure a telegram bot (from BotFather) | |
| 192 | 196 | zot telegram-bot run foreground bridge (ctrl+c to stop) | |
| 193 | 197 | zot telegram-bot start background bridge (detached) | |
| @@ -13,6 +13,7 @@ const ( | |||||
| 13 | ModeInteractive Mode = "interactive" | 13 | ModeInteractive Mode = "interactive" | ||
| 14 | ModePrint Mode = "print" | 14 | ModePrint Mode = "print" | ||
| 15 | ModeJSON Mode = "json" | 15 | ModeJSON Mode = "json" | ||
| 16 | + | ModeRPC Mode = "rpc" | |||
| 16 | ) | 17 | ) | ||
| 17 | 18 | ||||
| 18 | // Args holds parsed command-line options. | 19 | // Args holds parsed command-line options. | ||
| @@ -68,6 +69,8 @@ func ParseArgs(in []string) (Args, error) { | |||||
| 68 | a.Mode = ModePrint | 69 | a.Mode = ModePrint | ||
| 69 | case "--json": | 70 | case "--json": | ||
| 70 | a.Mode = ModeJSON | 71 | a.Mode = ModeJSON | ||
| 72 | + | case "--rpc": | |||
| 73 | + | a.Mode = ModeRPC | |||
| 71 | case "-c", "--continue": | 74 | case "-c", "--continue": | ||
| 72 | a.Continue = true | 75 | a.Continue = true | ||
| 73 | case "-r", "--resume": | 76 | case "-r", "--resume": | ||
| @@ -188,6 +191,7 @@ usage: | |||||
| 188 | zot "prompt" interactive, pre-filled prompt | 191 | zot "prompt" interactive, pre-filled prompt | ||
| 189 | zot -p "prompt" print final text, exit | 192 | zot -p "prompt" print final text, exit | ||
| 190 | zot --json "prompt" newline-delimited json events, exit | 193 | zot --json "prompt" newline-delimited json events, exit | ||
| 194 | + | zot rpc json-rpc loop on stdin/stdout (see docs/rpc.md) | |||
| 191 | zot telegram-bot setup configure a telegram bot (from BotFather) | 195 | zot telegram-bot setup configure a telegram bot (from BotFather) | ||
| 192 | zot telegram-bot run foreground bridge (ctrl+c to stop) | 196 | zot telegram-bot run foreground bridge (ctrl+c to stop) | ||
| 193 | zot telegram-bot start background bridge (detached) | 197 | zot telegram-bot start background bridge (detached) | ||
M internal/agent/cli.go +8 -0
| @@ -22,6 +22,12 @@ func Run(rawArgs []string, version string) error { | |||
| 22 | 22 | if handled, err := runBotCommand(rawArgs, version); handled { | |
| 23 | 23 | return err | |
| 24 | 24 | } | |
| 25 | + | // `zot rpc` is shorthand for `zot --rpc` so third-party apps can | |
| 26 | + | // spawn the binary with a clean argv. Strip the leading 'rpc' | |
| 27 | + | // token and let the rest flow through the normal arg parser. | |
| 28 | + | if len(rawArgs) > 0 && rawArgs[0] == "rpc" { | |
| 29 | + | rawArgs = append([]string{"--rpc"}, rawArgs[1:]...) | |
| 30 | + | } | |
| 25 | 31 | ||
| 26 | 32 | args, err := ParseArgs(rawArgs) | |
| 27 | 33 | if err != nil { | |
| @@ -56,6 +62,8 @@ func Run(rawArgs []string, version string) error { | |||
| 56 | 62 | return runPrintMode(ctx, args, version) | |
| 57 | 63 | case ModeJSON: | |
| 58 | 64 | return runJSONMode(ctx, args, version) | |
| 65 | + | case ModeRPC: | |
| 66 | + | return runRPCMode(ctx, args, version) | |
| 59 | 67 | default: | |
| 60 | 68 | return runInteractive(ctx, args, version) | |
| 61 | 69 | } | |
| @@ -22,6 +22,12 @@ func Run(rawArgs []string, version string) error { | |||||
| 22 | if handled, err := runBotCommand(rawArgs, version); handled { | 22 | if handled, err := runBotCommand(rawArgs, version); handled { | ||
| 23 | return err | 23 | return err | ||
| 24 | } | 24 | } | ||
| 25 | + | // `zot rpc` is shorthand for `zot --rpc` so third-party apps can | |||
| 26 | + | // spawn the binary with a clean argv. Strip the leading 'rpc' | |||
| 27 | + | // token and let the rest flow through the normal arg parser. | |||
| 28 | + | if len(rawArgs) > 0 && rawArgs[0] == "rpc" { | |||
| 29 | + | rawArgs = append([]string{"--rpc"}, rawArgs[1:]...) | |||
| 30 | + | } | |||
| 25 | 31 | ||||
| 26 | args, err := ParseArgs(rawArgs) | 32 | args, err := ParseArgs(rawArgs) | ||
| 27 | if err != nil { | 33 | if err != nil { | ||
| @@ -56,6 +62,8 @@ func Run(rawArgs []string, version string) error { | |||||
| 56 | return runPrintMode(ctx, args, version) | 62 | return runPrintMode(ctx, args, version) | ||
| 57 | case ModeJSON: | 63 | case ModeJSON: | ||
| 58 | return runJSONMode(ctx, args, version) | 64 | return runJSONMode(ctx, args, version) | ||
| 65 | + | case ModeRPC: | |||
| 66 | + | return runRPCMode(ctx, args, version) | |||
| 59 | default: | 67 | default: | ||
| 60 | return runInteractive(ctx, args, version) | 68 | return runInteractive(ctx, args, version) | ||
| 61 | } | 69 | } | ||
M internal/agent/modes/interactive.go +74 -4
| @@ -135,6 +135,11 @@ type Interactive struct { | |||
| 135 | 135 | // don't forget they're looking at history. | |
| 136 | 136 | parkedTurn int | |
| 137 | 137 | parkedTotal int | |
| 138 | + | ||
| 139 | + | // lastCtrlC is when the user last pressed ctrl+c. The first press | |
| 140 | + | // clears the editor / cancels a turn / shows a hint; a second press | |
| 141 | + | // within ctrlCExitWindow exits. Mirrors the python-repl convention. | |
| 142 | + | lastCtrlC time.Time | |
| 138 | 143 | } | |
| 139 | 144 | ||
| 140 | 145 | // NewInteractive constructs an Interactive from cfg. | |
| @@ -481,7 +486,7 @@ func (i *Interactive) redraw() { | |||
| 481 | 486 | if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil { | |
| 482 | 487 | ctxMax = m.ContextWindow | |
| 483 | 488 | } | |
| 484 | - | status := tui.StatusBar(tui.StatusBarParams{ | |
| 489 | + | statusLines := tui.StatusBar(tui.StatusBarParams{ | |
| 485 | 490 | Theme: i.cfg.Theme, | |
| 486 | 491 | Provider: i.cfg.Provider, | |
| 487 | 492 | Model: i.cfg.Model, | |
| @@ -515,7 +520,7 @@ func (i *Interactive) redraw() { | |||
| 515 | 520 | bottom = append(bottom, dialog...) | |
| 516 | 521 | bottom = append(bottom, suggest...) | |
| 517 | 522 | bottom = append(bottom, queue...) | |
| 518 | - | bottom = append(bottom, status) | |
| 523 | + | bottom = append(bottom, statusLines...) | |
| 519 | 524 | bottom = append(bottom, edLines...) | |
| 520 | 525 | ||
| 521 | 526 | _, rows := i.cfg.Terminal.Size() | |
| @@ -571,7 +576,7 @@ func (i *Interactive) redraw() { | |||
| 571 | 576 | frame = append(frame, visibleChat...) | |
| 572 | 577 | frame = append(frame, bottom...) | |
| 573 | 578 | ||
| 574 | - | cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + 1 + curR | |
| 579 | + | cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + len(statusLines) + curR | |
| 575 | 580 | cursorCol := curC | |
| 576 | 581 | i.rend.Draw(frame, cursorRow, cursorCol) | |
| 577 | 582 | } | |
| @@ -595,7 +600,44 @@ func truncateLine(s string, n int) string { | |||
| 595 | 600 | return string(runes[:n-1]) + "…" | |
| 596 | 601 | } | |
| 597 | 602 | ||
| 603 | + | // ctrlCExitWindow is how long after a ctrl+c press a *second* press | |
| 604 | + | // will exit instead of just clearing input. Long enough to be | |
| 605 | + | // deliberate (rules out accidental key chord), short enough that the | |
| 606 | + | // hint stays meaningful. | |
| 607 | + | const ctrlCExitWindow = 2 * time.Second | |
| 608 | + | ||
| 609 | + | // armCtrlCExit records the timestamp of the current ctrl+c so the next | |
| 610 | + | // one within ctrlCExitWindow exits. | |
| 611 | + | func (i *Interactive) armCtrlCExit() { | |
| 612 | + | i.mu.Lock() | |
| 613 | + | i.lastCtrlC = time.Now() | |
| 614 | + | i.mu.Unlock() | |
| 615 | + | } | |
| 616 | + | ||
| 617 | + | // ctrlCExitArmed reports whether a previous ctrl+c was recent enough | |
| 618 | + | // that another press should now exit. | |
| 619 | + | func (i *Interactive) ctrlCExitArmed() bool { | |
| 620 | + | i.mu.Lock() | |
| 621 | + | t := i.lastCtrlC | |
| 622 | + | i.mu.Unlock() | |
| 623 | + | return !t.IsZero() && time.Since(t) <= ctrlCExitWindow | |
| 624 | + | } | |
| 625 | + | ||
| 598 | 626 | func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |
| 627 | + | // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so | |
| 628 | + | // pressing ctrl+c then typing then ctrl+c much later doesn't quit | |
| 629 | + | // unexpectedly. The hint message also goes stale; clear it. | |
| 630 | + | if k.Kind != tui.KeyCtrlC { | |
| 631 | + | i.mu.Lock() | |
| 632 | + | if !i.lastCtrlC.IsZero() { | |
| 633 | + | i.lastCtrlC = time.Time{} | |
| 634 | + | if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") { | |
| 635 | + | i.statusOK = "" | |
| 636 | + | } | |
| 637 | + | } | |
| 638 | + | i.mu.Unlock() | |
| 639 | + | } | |
| 640 | + | ||
| 599 | 641 | // Dialogs consume keys while open (except ctrl+c, which always closes them). | |
| 600 | 642 | if i.dialog.Active() { | |
| 601 | 643 | if k.Kind == tui.KeyCtrlC { | |
| @@ -651,11 +693,39 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 651 | 693 | // Global keys. | |
| 652 | 694 | switch k.Kind { | |
| 653 | 695 | case tui.KeyCtrlC: | |
| 696 | + | // While busy: cancel the active turn (same as esc). The exit | |
| 697 | + | // hint stays armed so a quick second ctrl+c after the turn | |
| 698 | + | // dies still exits, matching habits from other repls. | |
| 654 | 699 | if i.busy && i.cancelTurn != nil { | |
| 655 | 700 | i.cancelTurn() | |
| 701 | + | i.armCtrlCExit() | |
| 656 | 702 | return false | |
| 657 | 703 | } | |
| 658 | - | return true | |
| 704 | + | // Idle: first press clears the editor (and any queued | |
| 705 | + | // follow-up messages); a second press within ctrlCExitWindow | |
| 706 | + | // exits. With both an empty editor and no queue the first | |
| 707 | + | // press still just arms — require a deliberate double-tap. | |
| 708 | + | hadInput := !i.ed.IsEmpty() || len(i.queued) > 0 | |
| 709 | + | if hadInput { | |
| 710 | + | i.ed.Clear() | |
| 711 | + | i.suggest.Reset() | |
| 712 | + | i.mu.Lock() | |
| 713 | + | i.queued = nil | |
| 714 | + | i.statusOK = "input cleared — ctrl+c again to exit" | |
| 715 | + | i.statusErr = "" | |
| 716 | + | i.mu.Unlock() | |
| 717 | + | i.armCtrlCExit() | |
| 718 | + | return false | |
| 719 | + | } | |
| 720 | + | if i.ctrlCExitArmed() { | |
| 721 | + | return true | |
| 722 | + | } | |
| 723 | + | i.mu.Lock() | |
| 724 | + | i.statusOK = "press ctrl+c again to exit" | |
| 725 | + | i.statusErr = "" | |
| 726 | + | i.mu.Unlock() | |
| 727 | + | i.armCtrlCExit() | |
| 728 | + | return false | |
| 659 | 729 | case tui.KeyEsc: | |
| 660 | 730 | // Esc interrupts a running turn. When idle, fall through so the | |
| 661 | 731 | // editor can clear itself. | |
| @@ -135,6 +135,11 @@ type Interactive struct { | |||||
| 135 | // don't forget they're looking at history. | 135 | // don't forget they're looking at history. | ||
| 136 | parkedTurn int | 136 | parkedTurn int | ||
| 137 | parkedTotal int | 137 | parkedTotal int | ||
| 138 | + | ||||
| 139 | + | // lastCtrlC is when the user last pressed ctrl+c. The first press | |||
| 140 | + | // clears the editor / cancels a turn / shows a hint; a second press | |||
| 141 | + | // within ctrlCExitWindow exits. Mirrors the python-repl convention. | |||
| 142 | + | lastCtrlC time.Time | |||
| 138 | } | 143 | } | ||
| 139 | 144 | ||||
| 140 | // NewInteractive constructs an Interactive from cfg. | 145 | // NewInteractive constructs an Interactive from cfg. | ||
| @@ -481,7 +486,7 @@ func (i *Interactive) redraw() { | |||||
| 481 | if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil { | 486 | if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil { | ||
| 482 | ctxMax = m.ContextWindow | 487 | ctxMax = m.ContextWindow | ||
| 483 | } | 488 | } | ||
| 484 | - | status := tui.StatusBar(tui.StatusBarParams{ | 489 | + | statusLines := tui.StatusBar(tui.StatusBarParams{ |
| 485 | Theme: i.cfg.Theme, | 490 | Theme: i.cfg.Theme, | ||
| 486 | Provider: i.cfg.Provider, | 491 | Provider: i.cfg.Provider, | ||
| 487 | Model: i.cfg.Model, | 492 | Model: i.cfg.Model, | ||
| @@ -515,7 +520,7 @@ func (i *Interactive) redraw() { | |||||
| 515 | bottom = append(bottom, dialog...) | 520 | bottom = append(bottom, dialog...) | ||
| 516 | bottom = append(bottom, suggest...) | 521 | bottom = append(bottom, suggest...) | ||
| 517 | bottom = append(bottom, queue...) | 522 | bottom = append(bottom, queue...) | ||
| 518 | - | bottom = append(bottom, status) | 523 | + | bottom = append(bottom, statusLines...) |
| 519 | bottom = append(bottom, edLines...) | 524 | bottom = append(bottom, edLines...) | ||
| 520 | 525 | ||||
| 521 | _, rows := i.cfg.Terminal.Size() | 526 | _, rows := i.cfg.Terminal.Size() | ||
| @@ -571,7 +576,7 @@ func (i *Interactive) redraw() { | |||||
| 571 | frame = append(frame, visibleChat...) | 576 | frame = append(frame, visibleChat...) | ||
| 572 | frame = append(frame, bottom...) | 577 | frame = append(frame, bottom...) | ||
| 573 | 578 | ||||
| 574 | - | cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + 1 + curR | 579 | + | cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + len(statusLines) + curR |
| 575 | cursorCol := curC | 580 | cursorCol := curC | ||
| 576 | i.rend.Draw(frame, cursorRow, cursorCol) | 581 | i.rend.Draw(frame, cursorRow, cursorCol) | ||
| 577 | } | 582 | } | ||
| @@ -595,7 +600,44 @@ func truncateLine(s string, n int) string { | |||||
| 595 | return string(runes[:n-1]) + "…" | 600 | return string(runes[:n-1]) + "…" | ||
| 596 | } | 601 | } | ||
| 597 | 602 | ||||
| 603 | + | // ctrlCExitWindow is how long after a ctrl+c press a *second* press | |||
| 604 | + | // will exit instead of just clearing input. Long enough to be | |||
| 605 | + | // deliberate (rules out accidental key chord), short enough that the | |||
| 606 | + | // hint stays meaningful. | |||
| 607 | + | const ctrlCExitWindow = 2 * time.Second | |||
| 608 | + | ||||
| 609 | + | // armCtrlCExit records the timestamp of the current ctrl+c so the next | |||
| 610 | + | // one within ctrlCExitWindow exits. | |||
| 611 | + | func (i *Interactive) armCtrlCExit() { | |||
| 612 | + | i.mu.Lock() | |||
| 613 | + | i.lastCtrlC = time.Now() | |||
| 614 | + | i.mu.Unlock() | |||
| 615 | + | } | |||
| 616 | + | ||||
| 617 | + | // ctrlCExitArmed reports whether a previous ctrl+c was recent enough | |||
| 618 | + | // that another press should now exit. | |||
| 619 | + | func (i *Interactive) ctrlCExitArmed() bool { | |||
| 620 | + | i.mu.Lock() | |||
| 621 | + | t := i.lastCtrlC | |||
| 622 | + | i.mu.Unlock() | |||
| 623 | + | return !t.IsZero() && time.Since(t) <= ctrlCExitWindow | |||
| 624 | + | } | |||
| 625 | + | ||||
| 598 | func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | 626 | func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | ||
| 627 | + | // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so | |||
| 628 | + | // pressing ctrl+c then typing then ctrl+c much later doesn't quit | |||
| 629 | + | // unexpectedly. The hint message also goes stale; clear it. | |||
| 630 | + | if k.Kind != tui.KeyCtrlC { | |||
| 631 | + | i.mu.Lock() | |||
| 632 | + | if !i.lastCtrlC.IsZero() { | |||
| 633 | + | i.lastCtrlC = time.Time{} | |||
| 634 | + | if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") { | |||
| 635 | + | i.statusOK = "" | |||
| 636 | + | } | |||
| 637 | + | } | |||
| 638 | + | i.mu.Unlock() | |||
| 639 | + | } | |||
| 640 | + | ||||
| 599 | // Dialogs consume keys while open (except ctrl+c, which always closes them). | 641 | // Dialogs consume keys while open (except ctrl+c, which always closes them). | ||
| 600 | if i.dialog.Active() { | 642 | if i.dialog.Active() { | ||
| 601 | if k.Kind == tui.KeyCtrlC { | 643 | if k.Kind == tui.KeyCtrlC { | ||
| @@ -651,11 +693,39 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 651 | // Global keys. | 693 | // Global keys. | ||
| 652 | switch k.Kind { | 694 | switch k.Kind { | ||
| 653 | case tui.KeyCtrlC: | 695 | case tui.KeyCtrlC: | ||
| 696 | + | // While busy: cancel the active turn (same as esc). The exit | |||
| 697 | + | // hint stays armed so a quick second ctrl+c after the turn | |||
| 698 | + | // dies still exits, matching habits from other repls. | |||
| 654 | if i.busy && i.cancelTurn != nil { | 699 | if i.busy && i.cancelTurn != nil { | ||
| 655 | i.cancelTurn() | 700 | i.cancelTurn() | ||
| 701 | + | i.armCtrlCExit() | |||
| 656 | return false | 702 | return false | ||
| 657 | } | 703 | } | ||
| 658 | - | return true | 704 | + | // Idle: first press clears the editor (and any queued |
| 705 | + | // follow-up messages); a second press within ctrlCExitWindow | |||
| 706 | + | // exits. With both an empty editor and no queue the first | |||
| 707 | + | // press still just arms — require a deliberate double-tap. | |||
| 708 | + | hadInput := !i.ed.IsEmpty() || len(i.queued) > 0 | |||
| 709 | + | if hadInput { | |||
| 710 | + | i.ed.Clear() | |||
| 711 | + | i.suggest.Reset() | |||
| 712 | + | i.mu.Lock() | |||
| 713 | + | i.queued = nil | |||
| 714 | + | i.statusOK = "input cleared — ctrl+c again to exit" | |||
| 715 | + | i.statusErr = "" | |||
| 716 | + | i.mu.Unlock() | |||
| 717 | + | i.armCtrlCExit() | |||
| 718 | + | return false | |||
| 719 | + | } | |||
| 720 | + | if i.ctrlCExitArmed() { | |||
| 721 | + | return true | |||
| 722 | + | } | |||
| 723 | + | i.mu.Lock() | |||
| 724 | + | i.statusOK = "press ctrl+c again to exit" | |||
| 725 | + | i.statusErr = "" | |||
| 726 | + | i.mu.Unlock() | |||
| 727 | + | i.armCtrlCExit() | |||
| 728 | + | return false | |||
| 659 | case tui.KeyEsc: | 729 | case tui.KeyEsc: | ||
| 660 | // Esc interrupts a running turn. When idle, fall through so the | 730 | // Esc interrupts a running turn. When idle, fall through so the | ||
| 661 | // editor can clear itself. | 731 | // editor can clear itself. | ||
M internal/agent/modes/json.go +13 -9
| @@ -20,7 +20,7 @@ func RunJSON(ctx context.Context, ag *core.Agent, prompt string, images []provid | |||
| 20 | 20 | ||
| 21 | 21 | var runErr error | |
| 22 | 22 | sink := func(ev core.AgentEvent) { | |
| 23 | - | write(eventToJSON(ev)) | |
| 23 | + | write(EventToJSON(ev)) | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | 26 | if err := ag.Prompt(ctx, prompt, images, sink); err != nil { | |
| @@ -33,18 +33,19 @@ func RunJSON(ctx context.Context, ag *core.Agent, prompt string, images []provid | |||
| 33 | 33 | return runErr | |
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | - | // eventToJSON converts an AgentEvent to a JSON-friendly map. The on-wire | |
| 37 | - | // schema is deliberately simple and flat. | |
| 38 | - | func eventToJSON(ev core.AgentEvent) map[string]any { | |
| 36 | + | // EventToJSON converts an AgentEvent to a JSON-friendly map. The on-wire | |
| 37 | + | // schema is deliberately simple and flat. Exported so the RPC mode can | |
| 38 | + | // reuse the same serialisation as `zot --json`. | |
| 39 | + | func EventToJSON(ev core.AgentEvent) map[string]any { | |
| 39 | 40 | m := map[string]any{"type": ev.Type()} | |
| 40 | 41 | switch e := ev.(type) { | |
| 41 | 42 | case core.EvTurnStart: | |
| 42 | 43 | m["step"] = e.Step | |
| 43 | 44 | case core.EvUserMessage: | |
| 44 | - | m["content"] = contentToJSON(e.Message.Content) | |
| 45 | + | m["content"] = ContentToJSON(e.Message.Content) | |
| 45 | 46 | m["time"] = e.Message.Time | |
| 46 | 47 | case core.EvAssistantMessage: | |
| 47 | - | m["content"] = contentToJSON(e.Message.Content) | |
| 48 | + | m["content"] = ContentToJSON(e.Message.Content) | |
| 48 | 49 | m["time"] = e.Message.Time | |
| 49 | 50 | case core.EvTextDelta: | |
| 50 | 51 | m["delta"] = e.Delta | |
| @@ -60,7 +61,7 @@ func eventToJSON(ev core.AgentEvent) map[string]any { | |||
| 60 | 61 | case core.EvToolResult: | |
| 61 | 62 | m["id"] = e.ID | |
| 62 | 63 | m["is_error"] = e.Result.IsError | |
| 63 | - | m["content"] = contentToJSON(e.Result.Content) | |
| 64 | + | m["content"] = ContentToJSON(e.Result.Content) | |
| 64 | 65 | case core.EvUsage: | |
| 65 | 66 | m["input"] = e.Usage.InputTokens | |
| 66 | 67 | m["output"] = e.Usage.OutputTokens | |
| @@ -83,7 +84,10 @@ func eventToJSON(ev core.AgentEvent) map[string]any { | |||
| 83 | 84 | return m | |
| 84 | 85 | } | |
| 85 | 86 | ||
| 86 | - | func contentToJSON(blocks []provider.Content) []map[string]any { | |
| 87 | + | // ContentToJSON serialises a transcript content slice into the same | |
| 88 | + | // shape used in EventToJSON. Exported alongside EventToJSON for the | |
| 89 | + | // RPC mode. | |
| 90 | + | func ContentToJSON(blocks []provider.Content) []map[string]any { | |
| 87 | 91 | out := make([]map[string]any, 0, len(blocks)) | |
| 88 | 92 | for _, b := range blocks { | |
| 89 | 93 | switch v := b.(type) { | |
| @@ -100,7 +104,7 @@ func contentToJSON(blocks []provider.Content) []map[string]any { | |||
| 100 | 104 | "type": "tool_result", | |
| 101 | 105 | "call_id": v.CallID, | |
| 102 | 106 | "is_error": v.IsError, | |
| 103 | - | "content": contentToJSON(v.Content), | |
| 107 | + | "content": ContentToJSON(v.Content), | |
| 104 | 108 | }) | |
| 105 | 109 | } | |
| 106 | 110 | } | |
| @@ -20,7 +20,7 @@ func RunJSON(ctx context.Context, ag *core.Agent, prompt string, images []provid | |||||
| 20 | 20 | ||||
| 21 | var runErr error | 21 | var runErr error | ||
| 22 | sink := func(ev core.AgentEvent) { | 22 | sink := func(ev core.AgentEvent) { | ||
| 23 | - | write(eventToJSON(ev)) | 23 | + | write(EventToJSON(ev)) |
| 24 | } | 24 | } | ||
| 25 | 25 | ||||
| 26 | if err := ag.Prompt(ctx, prompt, images, sink); err != nil { | 26 | if err := ag.Prompt(ctx, prompt, images, sink); err != nil { | ||
| @@ -33,18 +33,19 @@ func RunJSON(ctx context.Context, ag *core.Agent, prompt string, images []provid | |||||
| 33 | return runErr | 33 | return runErr | ||
| 34 | } | 34 | } | ||
| 35 | 35 | ||||
| 36 | - | // eventToJSON converts an AgentEvent to a JSON-friendly map. The on-wire | 36 | + | // EventToJSON converts an AgentEvent to a JSON-friendly map. The on-wire |
| 37 | - | // schema is deliberately simple and flat. | 37 | + | // schema is deliberately simple and flat. Exported so the RPC mode can |
| 38 | - | func eventToJSON(ev core.AgentEvent) map[string]any { | 38 | + | // reuse the same serialisation as `zot --json`. |
| 39 | + | func EventToJSON(ev core.AgentEvent) map[string]any { | |||
| 39 | m := map[string]any{"type": ev.Type()} | 40 | m := map[string]any{"type": ev.Type()} | ||
| 40 | switch e := ev.(type) { | 41 | switch e := ev.(type) { | ||
| 41 | case core.EvTurnStart: | 42 | case core.EvTurnStart: | ||
| 42 | m["step"] = e.Step | 43 | m["step"] = e.Step | ||
| 43 | case core.EvUserMessage: | 44 | case core.EvUserMessage: | ||
| 44 | - | m["content"] = contentToJSON(e.Message.Content) | 45 | + | m["content"] = ContentToJSON(e.Message.Content) |
| 45 | m["time"] = e.Message.Time | 46 | m["time"] = e.Message.Time | ||
| 46 | case core.EvAssistantMessage: | 47 | case core.EvAssistantMessage: | ||
| 47 | - | m["content"] = contentToJSON(e.Message.Content) | 48 | + | m["content"] = ContentToJSON(e.Message.Content) |
| 48 | m["time"] = e.Message.Time | 49 | m["time"] = e.Message.Time | ||
| 49 | case core.EvTextDelta: | 50 | case core.EvTextDelta: | ||
| 50 | m["delta"] = e.Delta | 51 | m["delta"] = e.Delta | ||
| @@ -60,7 +61,7 @@ func eventToJSON(ev core.AgentEvent) map[string]any { | |||||
| 60 | case core.EvToolResult: | 61 | case core.EvToolResult: | ||
| 61 | m["id"] = e.ID | 62 | m["id"] = e.ID | ||
| 62 | m["is_error"] = e.Result.IsError | 63 | m["is_error"] = e.Result.IsError | ||
| 63 | - | m["content"] = contentToJSON(e.Result.Content) | 64 | + | m["content"] = ContentToJSON(e.Result.Content) |
| 64 | case core.EvUsage: | 65 | case core.EvUsage: | ||
| 65 | m["input"] = e.Usage.InputTokens | 66 | m["input"] = e.Usage.InputTokens | ||
| 66 | m["output"] = e.Usage.OutputTokens | 67 | m["output"] = e.Usage.OutputTokens | ||
| @@ -83,7 +84,10 @@ func eventToJSON(ev core.AgentEvent) map[string]any { | |||||
| 83 | return m | 84 | return m | ||
| 84 | } | 85 | } | ||
| 85 | 86 | ||||
| 86 | - | func contentToJSON(blocks []provider.Content) []map[string]any { | 87 | + | // ContentToJSON serialises a transcript content slice into the same |
| 88 | + | // shape used in EventToJSON. Exported alongside EventToJSON for the | |||
| 89 | + | // RPC mode. | |||
| 90 | + | func ContentToJSON(blocks []provider.Content) []map[string]any { | |||
| 87 | out := make([]map[string]any, 0, len(blocks)) | 91 | out := make([]map[string]any, 0, len(blocks)) | ||
| 88 | for _, b := range blocks { | 92 | for _, b := range blocks { | ||
| 89 | switch v := b.(type) { | 93 | switch v := b.(type) { | ||
| @@ -100,7 +104,7 @@ func contentToJSON(blocks []provider.Content) []map[string]any { | |||||
| 100 | "type": "tool_result", | 104 | "type": "tool_result", | ||
| 101 | "call_id": v.CallID, | 105 | "call_id": v.CallID, | ||
| 102 | "is_error": v.IsError, | 106 | "is_error": v.IsError, | ||
| 103 | - | "content": contentToJSON(v.Content), | 107 | + | "content": ContentToJSON(v.Content), |
| 104 | }) | 108 | }) | ||
| 105 | } | 109 | } | ||
| 106 | } | 110 | } | ||
A internal/agent/rpc.go +344 -0
| @@ -0,0 +1,344 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "fmt" | |
| 8 | + | "io" | |
| 9 | + | "os" | |
| 10 | + | "strings" | |
| 11 | + | "sync" | |
| 12 | + | ||
| 13 | + | "github.com/patriceckhart/zot/internal/agent/modes" | |
| 14 | + | "github.com/patriceckhart/zot/internal/core" | |
| 15 | + | "github.com/patriceckhart/zot/internal/provider" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // runRPCMode implements the JSON-over-stdin/stdout RPC protocol. | |
| 19 | + | // | |
| 20 | + | // Wire format: one JSON object per line in both directions. | |
| 21 | + | // | |
| 22 | + | // Commands (stdin): | |
| 23 | + | // | |
| 24 | + | // {"id":"1","type":"prompt","message":"hello","images":[]} | |
| 25 | + | // {"id":"2","type":"abort"} | |
| 26 | + | // {"id":"3","type":"compact"} | |
| 27 | + | // {"id":"4","type":"get_state"} | |
| 28 | + | // {"id":"5","type":"set_model","model":"claude-opus-4-5"} | |
| 29 | + | // {"id":"6","type":"get_messages"} | |
| 30 | + | // {"id":"7","type":"clear"} | |
| 31 | + | // {"id":"8","type":"get_models"} | |
| 32 | + | // | |
| 33 | + | // Responses (stdout): {"type":"response","id":"1","command":"prompt","success":true} | |
| 34 | + | // Events (stdout): one JSON object per AgentEvent (same schema as --json mode). | |
| 35 | + | // | |
| 36 | + | // Auth: if $ZOTCORE_RPC_TOKEN is set, the first command must be | |
| 37 | + | // {"type":"hello","token":"..."} or the connection is closed. | |
| 38 | + | func runRPCMode(ctx context.Context, args Args, version string) error { | |
| 39 | + | r, err := Resolve(args, true) | |
| 40 | + | if err != nil { | |
| 41 | + | return err | |
| 42 | + | } | |
| 43 | + | ag := r.NewAgent() | |
| 44 | + | ||
| 45 | + | server := &rpcServer{ | |
| 46 | + | ctx: ctx, | |
| 47 | + | args: args, | |
| 48 | + | agent: ag, | |
| 49 | + | provider: r.Provider, | |
| 50 | + | model: r.Model, | |
| 51 | + | out: os.Stdout, | |
| 52 | + | version: version, | |
| 53 | + | } | |
| 54 | + | return server.run(os.Stdin) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | type rpcServer struct { | |
| 58 | + | ctx context.Context | |
| 59 | + | args Args | |
| 60 | + | agent *core.Agent | |
| 61 | + | provider string | |
| 62 | + | model string | |
| 63 | + | out io.Writer | |
| 64 | + | version string | |
| 65 | + | ||
| 66 | + | writeMu sync.Mutex | |
| 67 | + | turnMu sync.Mutex // serialises one prompt at a time | |
| 68 | + | activeCancel context.CancelFunc | |
| 69 | + | authed bool | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // run reads NDJSON commands from in and dispatches them. Returns when | |
| 73 | + | // in is closed. Errors writing to stdout are fatal. | |
| 74 | + | func (s *rpcServer) run(in io.Reader) error { | |
| 75 | + | requireToken := os.Getenv("ZOTCORE_RPC_TOKEN") != "" | |
| 76 | + | s.authed = !requireToken | |
| 77 | + | ||
| 78 | + | sc := bufio.NewScanner(in) | |
| 79 | + | sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) | |
| 80 | + | for sc.Scan() { | |
| 81 | + | line := strings.TrimSpace(sc.Text()) | |
| 82 | + | if line == "" { | |
| 83 | + | continue | |
| 84 | + | } | |
| 85 | + | var head struct { | |
| 86 | + | Type string `json:"type"` | |
| 87 | + | ID string `json:"id,omitempty"` | |
| 88 | + | } | |
| 89 | + | if err := json.Unmarshal([]byte(line), &head); err != nil { | |
| 90 | + | s.writeError("", "", fmt.Sprintf("malformed json: %v", err)) | |
| 91 | + | continue | |
| 92 | + | } | |
| 93 | + | if !s.authed { | |
| 94 | + | if head.Type != "hello" { | |
| 95 | + | s.writeError(head.ID, head.Type, "auth required: send hello with token first") | |
| 96 | + | continue | |
| 97 | + | } | |
| 98 | + | var hello struct { | |
| 99 | + | Token string `json:"token"` | |
| 100 | + | } | |
| 101 | + | _ = json.Unmarshal([]byte(line), &hello) | |
| 102 | + | if hello.Token != os.Getenv("ZOTCORE_RPC_TOKEN") { | |
| 103 | + | s.writeError(head.ID, head.Type, "invalid token") | |
| 104 | + | return fmt.Errorf("rpc: bad auth token") | |
| 105 | + | } | |
| 106 | + | s.authed = true | |
| 107 | + | s.writeResponse(head.ID, head.Type, map[string]any{ | |
| 108 | + | "protocol_version": 1, | |
| 109 | + | "version": s.version, | |
| 110 | + | "provider": s.provider, | |
| 111 | + | "model": s.model, | |
| 112 | + | }) | |
| 113 | + | continue | |
| 114 | + | } | |
| 115 | + | s.dispatch(head.Type, head.ID, []byte(line)) | |
| 116 | + | } | |
| 117 | + | return sc.Err() | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // dispatch routes a command. Long-running commands (prompt, compact) | |
| 121 | + | // run on their own goroutine so the read loop stays responsive. | |
| 122 | + | func (s *rpcServer) dispatch(cmd, id string, raw []byte) { | |
| 123 | + | switch cmd { | |
| 124 | + | case "hello": | |
| 125 | + | s.writeResponse(id, cmd, map[string]any{ | |
| 126 | + | "protocol_version": 1, | |
| 127 | + | "version": s.version, | |
| 128 | + | "provider": s.provider, | |
| 129 | + | "model": s.model, | |
| 130 | + | }) | |
| 131 | + | case "prompt": | |
| 132 | + | var req struct { | |
| 133 | + | Message string `json:"message"` | |
| 134 | + | Images []struct { | |
| 135 | + | MimeType string `json:"mime_type"` | |
| 136 | + | Data []byte `json:"data"` | |
| 137 | + | } `json:"images"` | |
| 138 | + | } | |
| 139 | + | if err := json.Unmarshal(raw, &req); err != nil { | |
| 140 | + | s.writeError(id, cmd, err.Error()) | |
| 141 | + | return | |
| 142 | + | } | |
| 143 | + | go s.runPrompt(id, req.Message, req.Images) | |
| 144 | + | ||
| 145 | + | case "abort": | |
| 146 | + | if c := s.takeCancel(); c != nil { | |
| 147 | + | c() | |
| 148 | + | } | |
| 149 | + | s.writeResponse(id, cmd, nil) | |
| 150 | + | ||
| 151 | + | case "compact": | |
| 152 | + | go s.runCompact(id) | |
| 153 | + | ||
| 154 | + | case "get_state": | |
| 155 | + | s.writeResponse(id, cmd, s.snapshotState()) | |
| 156 | + | ||
| 157 | + | case "get_messages": | |
| 158 | + | s.writeResponse(id, cmd, map[string]any{ | |
| 159 | + | "messages": messagesToJSON(s.agent.Messages()), | |
| 160 | + | }) | |
| 161 | + | ||
| 162 | + | case "clear": | |
| 163 | + | s.agent.SetMessages(nil) | |
| 164 | + | s.writeResponse(id, cmd, nil) | |
| 165 | + | ||
| 166 | + | case "set_model": | |
| 167 | + | var req struct { | |
| 168 | + | Model string `json:"model"` | |
| 169 | + | } | |
| 170 | + | if err := json.Unmarshal(raw, &req); err != nil { | |
| 171 | + | s.writeError(id, cmd, err.Error()) | |
| 172 | + | return | |
| 173 | + | } | |
| 174 | + | if _, err := provider.FindModel(s.provider, req.Model); err != nil { | |
| 175 | + | s.writeError(id, cmd, err.Error()) | |
| 176 | + | return | |
| 177 | + | } | |
| 178 | + | s.agent.Model = req.Model | |
| 179 | + | s.model = req.Model | |
| 180 | + | s.writeResponse(id, cmd, map[string]any{"model": req.Model}) | |
| 181 | + | ||
| 182 | + | case "get_models": | |
| 183 | + | out := []map[string]any{} | |
| 184 | + | for _, m := range provider.ModelsForProvider(s.provider) { | |
| 185 | + | out = append(out, map[string]any{ | |
| 186 | + | "id": m.ID, | |
| 187 | + | "provider": m.Provider, | |
| 188 | + | "context_window": m.ContextWindow, | |
| 189 | + | "max_output": m.MaxOutput, | |
| 190 | + | "reasoning": m.Reasoning, | |
| 191 | + | }) | |
| 192 | + | } | |
| 193 | + | s.writeResponse(id, cmd, map[string]any{"models": out}) | |
| 194 | + | ||
| 195 | + | case "ping": | |
| 196 | + | s.writeResponse(id, cmd, map[string]any{"pong": true}) | |
| 197 | + | ||
| 198 | + | default: | |
| 199 | + | s.writeError(id, cmd, "unknown command") | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // runPrompt executes a single prompt turn and streams events out. | |
| 204 | + | // Holds turnMu so a second concurrent prompt blocks until this one | |
| 205 | + | // finishes; the user can abort with the abort command. | |
| 206 | + | func (s *rpcServer) runPrompt(id, message string, images []struct { | |
| 207 | + | MimeType string `json:"mime_type"` | |
| 208 | + | Data []byte `json:"data"` | |
| 209 | + | }) { | |
| 210 | + | s.turnMu.Lock() | |
| 211 | + | defer s.turnMu.Unlock() | |
| 212 | + | ||
| 213 | + | subCtx, cancel := context.WithCancel(s.ctx) | |
| 214 | + | s.setCancel(cancel) | |
| 215 | + | defer s.setCancel(nil) | |
| 216 | + | ||
| 217 | + | s.writeResponse(id, "prompt", map[string]any{"started": true}) | |
| 218 | + | ||
| 219 | + | imgs := make([]provider.ImageBlock, 0, len(images)) | |
| 220 | + | for _, im := range images { | |
| 221 | + | imgs = append(imgs, provider.ImageBlock{MimeType: im.MimeType, Data: im.Data}) | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | err := s.agent.Prompt(subCtx, message, imgs, func(ev core.AgentEvent) { | |
| 225 | + | s.writeEvent(modes.EventToJSON(ev)) | |
| 226 | + | }) | |
| 227 | + | if err != nil { | |
| 228 | + | s.writeEvent(map[string]any{"type": "error", "message": err.Error()}) | |
| 229 | + | } | |
| 230 | + | s.writeEvent(map[string]any{"type": "done"}) | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // runCompact mirrors runPrompt but for compaction. | |
| 234 | + | func (s *rpcServer) runCompact(id string) { | |
| 235 | + | s.turnMu.Lock() | |
| 236 | + | defer s.turnMu.Unlock() | |
| 237 | + | ||
| 238 | + | subCtx, cancel := context.WithCancel(s.ctx) | |
| 239 | + | s.setCancel(cancel) | |
| 240 | + | defer s.setCancel(nil) | |
| 241 | + | ||
| 242 | + | s.writeResponse(id, "compact", map[string]any{"started": true}) | |
| 243 | + | summary, err := s.agent.Compact(subCtx, 4, nil) | |
| 244 | + | if err != nil { | |
| 245 | + | s.writeEvent(map[string]any{"type": "error", "message": err.Error()}) | |
| 246 | + | return | |
| 247 | + | } | |
| 248 | + | s.writeEvent(map[string]any{ | |
| 249 | + | "type": "compact_done", | |
| 250 | + | "summary": summary, | |
| 251 | + | }) | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | // snapshotState builds the get_state response. | |
| 255 | + | func (s *rpcServer) snapshotState() map[string]any { | |
| 256 | + | cum := s.agent.Cost() | |
| 257 | + | return map[string]any{ | |
| 258 | + | "provider": s.provider, | |
| 259 | + | "model": s.model, | |
| 260 | + | "cwd": s.args.CWD, | |
| 261 | + | "message_count": len(s.agent.Messages()), | |
| 262 | + | "busy": s.activeCancel != nil, | |
| 263 | + | "usage": map[string]any{ | |
| 264 | + | "input": cum.InputTokens, | |
| 265 | + | "output": cum.OutputTokens, | |
| 266 | + | "cache_read": cum.CacheReadTokens, | |
| 267 | + | "cache_write": cum.CacheWriteTokens, | |
| 268 | + | "cost_usd": cum.CostUSD, | |
| 269 | + | }, | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | // ---- write helpers (single-line JSON, mutex-guarded) ---- | |
| 274 | + | ||
| 275 | + | func (s *rpcServer) writeResponse(id, cmd string, data any) { | |
| 276 | + | frame := map[string]any{ | |
| 277 | + | "type": "response", | |
| 278 | + | "command": cmd, | |
| 279 | + | "success": true, | |
| 280 | + | } | |
| 281 | + | if id != "" { | |
| 282 | + | frame["id"] = id | |
| 283 | + | } | |
| 284 | + | if data != nil { | |
| 285 | + | frame["data"] = data | |
| 286 | + | } | |
| 287 | + | s.write(frame) | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | func (s *rpcServer) writeError(id, cmd, msg string) { | |
| 291 | + | frame := map[string]any{ | |
| 292 | + | "type": "response", | |
| 293 | + | "command": cmd, | |
| 294 | + | "success": false, | |
| 295 | + | "error": msg, | |
| 296 | + | } | |
| 297 | + | if id != "" { | |
| 298 | + | frame["id"] = id | |
| 299 | + | } | |
| 300 | + | s.write(frame) | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | func (s *rpcServer) writeEvent(payload map[string]any) { | |
| 304 | + | s.write(payload) | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | func (s *rpcServer) write(v any) { | |
| 308 | + | b, err := json.Marshal(v) | |
| 309 | + | if err != nil { | |
| 310 | + | return | |
| 311 | + | } | |
| 312 | + | s.writeMu.Lock() | |
| 313 | + | defer s.writeMu.Unlock() | |
| 314 | + | _, _ = s.out.Write(b) | |
| 315 | + | _, _ = s.out.Write([]byte("\n")) | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | func (s *rpcServer) setCancel(c context.CancelFunc) { | |
| 319 | + | s.writeMu.Lock() | |
| 320 | + | s.activeCancel = c | |
| 321 | + | s.writeMu.Unlock() | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | func (s *rpcServer) takeCancel() context.CancelFunc { | |
| 325 | + | s.writeMu.Lock() | |
| 326 | + | defer s.writeMu.Unlock() | |
| 327 | + | c := s.activeCancel | |
| 328 | + | s.activeCancel = nil | |
| 329 | + | return c | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | // messagesToJSON serialises a transcript using the same schema as the | |
| 333 | + | // --json event mode for cross-format consistency. | |
| 334 | + | func messagesToJSON(msgs []provider.Message) []map[string]any { | |
| 335 | + | out := make([]map[string]any, 0, len(msgs)) | |
| 336 | + | for _, m := range msgs { | |
| 337 | + | out = append(out, map[string]any{ | |
| 338 | + | "role": string(m.Role), | |
| 339 | + | "content": modes.ContentToJSON(m.Content), | |
| 340 | + | "time": m.Time, | |
| 341 | + | }) | |
| 342 | + | } | |
| 343 | + | return out | |
| 344 | + | } | |
| @@ -0,0 +1,344 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "context" | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "fmt" | |||
| 8 | + | "io" | |||
| 9 | + | "os" | |||
| 10 | + | "strings" | |||
| 11 | + | "sync" | |||
| 12 | + | ||||
| 13 | + | "github.com/patriceckhart/zot/internal/agent/modes" | |||
| 14 | + | "github.com/patriceckhart/zot/internal/core" | |||
| 15 | + | "github.com/patriceckhart/zot/internal/provider" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // runRPCMode implements the JSON-over-stdin/stdout RPC protocol. | |||
| 19 | + | // | |||
| 20 | + | // Wire format: one JSON object per line in both directions. | |||
| 21 | + | // | |||
| 22 | + | // Commands (stdin): | |||
| 23 | + | // | |||
| 24 | + | // {"id":"1","type":"prompt","message":"hello","images":[]} | |||
| 25 | + | // {"id":"2","type":"abort"} | |||
| 26 | + | // {"id":"3","type":"compact"} | |||
| 27 | + | // {"id":"4","type":"get_state"} | |||
| 28 | + | // {"id":"5","type":"set_model","model":"claude-opus-4-5"} | |||
| 29 | + | // {"id":"6","type":"get_messages"} | |||
| 30 | + | // {"id":"7","type":"clear"} | |||
| 31 | + | // {"id":"8","type":"get_models"} | |||
| 32 | + | // | |||
| 33 | + | // Responses (stdout): {"type":"response","id":"1","command":"prompt","success":true} | |||
| 34 | + | // Events (stdout): one JSON object per AgentEvent (same schema as --json mode). | |||
| 35 | + | // | |||
| 36 | + | // Auth: if $ZOTCORE_RPC_TOKEN is set, the first command must be | |||
| 37 | + | // {"type":"hello","token":"..."} or the connection is closed. | |||
| 38 | + | func runRPCMode(ctx context.Context, args Args, version string) error { | |||
| 39 | + | r, err := Resolve(args, true) | |||
| 40 | + | if err != nil { | |||
| 41 | + | return err | |||
| 42 | + | } | |||
| 43 | + | ag := r.NewAgent() | |||
| 44 | + | ||||
| 45 | + | server := &rpcServer{ | |||
| 46 | + | ctx: ctx, | |||
| 47 | + | args: args, | |||
| 48 | + | agent: ag, | |||
| 49 | + | provider: r.Provider, | |||
| 50 | + | model: r.Model, | |||
| 51 | + | out: os.Stdout, | |||
| 52 | + | version: version, | |||
| 53 | + | } | |||
| 54 | + | return server.run(os.Stdin) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | type rpcServer struct { | |||
| 58 | + | ctx context.Context | |||
| 59 | + | args Args | |||
| 60 | + | agent *core.Agent | |||
| 61 | + | provider string | |||
| 62 | + | model string | |||
| 63 | + | out io.Writer | |||
| 64 | + | version string | |||
| 65 | + | ||||
| 66 | + | writeMu sync.Mutex | |||
| 67 | + | turnMu sync.Mutex // serialises one prompt at a time | |||
| 68 | + | activeCancel context.CancelFunc | |||
| 69 | + | authed bool | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // run reads NDJSON commands from in and dispatches them. Returns when | |||
| 73 | + | // in is closed. Errors writing to stdout are fatal. | |||
| 74 | + | func (s *rpcServer) run(in io.Reader) error { | |||
| 75 | + | requireToken := os.Getenv("ZOTCORE_RPC_TOKEN") != "" | |||
| 76 | + | s.authed = !requireToken | |||
| 77 | + | ||||
| 78 | + | sc := bufio.NewScanner(in) | |||
| 79 | + | sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) | |||
| 80 | + | for sc.Scan() { | |||
| 81 | + | line := strings.TrimSpace(sc.Text()) | |||
| 82 | + | if line == "" { | |||
| 83 | + | continue | |||
| 84 | + | } | |||
| 85 | + | var head struct { | |||
| 86 | + | Type string `json:"type"` | |||
| 87 | + | ID string `json:"id,omitempty"` | |||
| 88 | + | } | |||
| 89 | + | if err := json.Unmarshal([]byte(line), &head); err != nil { | |||
| 90 | + | s.writeError("", "", fmt.Sprintf("malformed json: %v", err)) | |||
| 91 | + | continue | |||
| 92 | + | } | |||
| 93 | + | if !s.authed { | |||
| 94 | + | if head.Type != "hello" { | |||
| 95 | + | s.writeError(head.ID, head.Type, "auth required: send hello with token first") | |||
| 96 | + | continue | |||
| 97 | + | } | |||
| 98 | + | var hello struct { | |||
| 99 | + | Token string `json:"token"` | |||
| 100 | + | } | |||
| 101 | + | _ = json.Unmarshal([]byte(line), &hello) | |||
| 102 | + | if hello.Token != os.Getenv("ZOTCORE_RPC_TOKEN") { | |||
| 103 | + | s.writeError(head.ID, head.Type, "invalid token") | |||
| 104 | + | return fmt.Errorf("rpc: bad auth token") | |||
| 105 | + | } | |||
| 106 | + | s.authed = true | |||
| 107 | + | s.writeResponse(head.ID, head.Type, map[string]any{ | |||
| 108 | + | "protocol_version": 1, | |||
| 109 | + | "version": s.version, | |||
| 110 | + | "provider": s.provider, | |||
| 111 | + | "model": s.model, | |||
| 112 | + | }) | |||
| 113 | + | continue | |||
| 114 | + | } | |||
| 115 | + | s.dispatch(head.Type, head.ID, []byte(line)) | |||
| 116 | + | } | |||
| 117 | + | return sc.Err() | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | // dispatch routes a command. Long-running commands (prompt, compact) | |||
| 121 | + | // run on their own goroutine so the read loop stays responsive. | |||
| 122 | + | func (s *rpcServer) dispatch(cmd, id string, raw []byte) { | |||
| 123 | + | switch cmd { | |||
| 124 | + | case "hello": | |||
| 125 | + | s.writeResponse(id, cmd, map[string]any{ | |||
| 126 | + | "protocol_version": 1, | |||
| 127 | + | "version": s.version, | |||
| 128 | + | "provider": s.provider, | |||
| 129 | + | "model": s.model, | |||
| 130 | + | }) | |||
| 131 | + | case "prompt": | |||
| 132 | + | var req struct { | |||
| 133 | + | Message string `json:"message"` | |||
| 134 | + | Images []struct { | |||
| 135 | + | MimeType string `json:"mime_type"` | |||
| 136 | + | Data []byte `json:"data"` | |||
| 137 | + | } `json:"images"` | |||
| 138 | + | } | |||
| 139 | + | if err := json.Unmarshal(raw, &req); err != nil { | |||
| 140 | + | s.writeError(id, cmd, err.Error()) | |||
| 141 | + | return | |||
| 142 | + | } | |||
| 143 | + | go s.runPrompt(id, req.Message, req.Images) | |||
| 144 | + | ||||
| 145 | + | case "abort": | |||
| 146 | + | if c := s.takeCancel(); c != nil { | |||
| 147 | + | c() | |||
| 148 | + | } | |||
| 149 | + | s.writeResponse(id, cmd, nil) | |||
| 150 | + | ||||
| 151 | + | case "compact": | |||
| 152 | + | go s.runCompact(id) | |||
| 153 | + | ||||
| 154 | + | case "get_state": | |||
| 155 | + | s.writeResponse(id, cmd, s.snapshotState()) | |||
| 156 | + | ||||
| 157 | + | case "get_messages": | |||
| 158 | + | s.writeResponse(id, cmd, map[string]any{ | |||
| 159 | + | "messages": messagesToJSON(s.agent.Messages()), | |||
| 160 | + | }) | |||
| 161 | + | ||||
| 162 | + | case "clear": | |||
| 163 | + | s.agent.SetMessages(nil) | |||
| 164 | + | s.writeResponse(id, cmd, nil) | |||
| 165 | + | ||||
| 166 | + | case "set_model": | |||
| 167 | + | var req struct { | |||
| 168 | + | Model string `json:"model"` | |||
| 169 | + | } | |||
| 170 | + | if err := json.Unmarshal(raw, &req); err != nil { | |||
| 171 | + | s.writeError(id, cmd, err.Error()) | |||
| 172 | + | return | |||
| 173 | + | } | |||
| 174 | + | if _, err := provider.FindModel(s.provider, req.Model); err != nil { | |||
| 175 | + | s.writeError(id, cmd, err.Error()) | |||
| 176 | + | return | |||
| 177 | + | } | |||
| 178 | + | s.agent.Model = req.Model | |||
| 179 | + | s.model = req.Model | |||
| 180 | + | s.writeResponse(id, cmd, map[string]any{"model": req.Model}) | |||
| 181 | + | ||||
| 182 | + | case "get_models": | |||
| 183 | + | out := []map[string]any{} | |||
| 184 | + | for _, m := range provider.ModelsForProvider(s.provider) { | |||
| 185 | + | out = append(out, map[string]any{ | |||
| 186 | + | "id": m.ID, | |||
| 187 | + | "provider": m.Provider, | |||
| 188 | + | "context_window": m.ContextWindow, | |||
| 189 | + | "max_output": m.MaxOutput, | |||
| 190 | + | "reasoning": m.Reasoning, | |||
| 191 | + | }) | |||
| 192 | + | } | |||
| 193 | + | s.writeResponse(id, cmd, map[string]any{"models": out}) | |||
| 194 | + | ||||
| 195 | + | case "ping": | |||
| 196 | + | s.writeResponse(id, cmd, map[string]any{"pong": true}) | |||
| 197 | + | ||||
| 198 | + | default: | |||
| 199 | + | s.writeError(id, cmd, "unknown command") | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | // runPrompt executes a single prompt turn and streams events out. | |||
| 204 | + | // Holds turnMu so a second concurrent prompt blocks until this one | |||
| 205 | + | // finishes; the user can abort with the abort command. | |||
| 206 | + | func (s *rpcServer) runPrompt(id, message string, images []struct { | |||
| 207 | + | MimeType string `json:"mime_type"` | |||
| 208 | + | Data []byte `json:"data"` | |||
| 209 | + | }) { | |||
| 210 | + | s.turnMu.Lock() | |||
| 211 | + | defer s.turnMu.Unlock() | |||
| 212 | + | ||||
| 213 | + | subCtx, cancel := context.WithCancel(s.ctx) | |||
| 214 | + | s.setCancel(cancel) | |||
| 215 | + | defer s.setCancel(nil) | |||
| 216 | + | ||||
| 217 | + | s.writeResponse(id, "prompt", map[string]any{"started": true}) | |||
| 218 | + | ||||
| 219 | + | imgs := make([]provider.ImageBlock, 0, len(images)) | |||
| 220 | + | for _, im := range images { | |||
| 221 | + | imgs = append(imgs, provider.ImageBlock{MimeType: im.MimeType, Data: im.Data}) | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | err := s.agent.Prompt(subCtx, message, imgs, func(ev core.AgentEvent) { | |||
| 225 | + | s.writeEvent(modes.EventToJSON(ev)) | |||
| 226 | + | }) | |||
| 227 | + | if err != nil { | |||
| 228 | + | s.writeEvent(map[string]any{"type": "error", "message": err.Error()}) | |||
| 229 | + | } | |||
| 230 | + | s.writeEvent(map[string]any{"type": "done"}) | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | // runCompact mirrors runPrompt but for compaction. | |||
| 234 | + | func (s *rpcServer) runCompact(id string) { | |||
| 235 | + | s.turnMu.Lock() | |||
| 236 | + | defer s.turnMu.Unlock() | |||
| 237 | + | ||||
| 238 | + | subCtx, cancel := context.WithCancel(s.ctx) | |||
| 239 | + | s.setCancel(cancel) | |||
| 240 | + | defer s.setCancel(nil) | |||
| 241 | + | ||||
| 242 | + | s.writeResponse(id, "compact", map[string]any{"started": true}) | |||
| 243 | + | summary, err := s.agent.Compact(subCtx, 4, nil) | |||
| 244 | + | if err != nil { | |||
| 245 | + | s.writeEvent(map[string]any{"type": "error", "message": err.Error()}) | |||
| 246 | + | return | |||
| 247 | + | } | |||
| 248 | + | s.writeEvent(map[string]any{ | |||
| 249 | + | "type": "compact_done", | |||
| 250 | + | "summary": summary, | |||
| 251 | + | }) | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | // snapshotState builds the get_state response. | |||
| 255 | + | func (s *rpcServer) snapshotState() map[string]any { | |||
| 256 | + | cum := s.agent.Cost() | |||
| 257 | + | return map[string]any{ | |||
| 258 | + | "provider": s.provider, | |||
| 259 | + | "model": s.model, | |||
| 260 | + | "cwd": s.args.CWD, | |||
| 261 | + | "message_count": len(s.agent.Messages()), | |||
| 262 | + | "busy": s.activeCancel != nil, | |||
| 263 | + | "usage": map[string]any{ | |||
| 264 | + | "input": cum.InputTokens, | |||
| 265 | + | "output": cum.OutputTokens, | |||
| 266 | + | "cache_read": cum.CacheReadTokens, | |||
| 267 | + | "cache_write": cum.CacheWriteTokens, | |||
| 268 | + | "cost_usd": cum.CostUSD, | |||
| 269 | + | }, | |||
| 270 | + | } | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | // ---- write helpers (single-line JSON, mutex-guarded) ---- | |||
| 274 | + | ||||
| 275 | + | func (s *rpcServer) writeResponse(id, cmd string, data any) { | |||
| 276 | + | frame := map[string]any{ | |||
| 277 | + | "type": "response", | |||
| 278 | + | "command": cmd, | |||
| 279 | + | "success": true, | |||
| 280 | + | } | |||
| 281 | + | if id != "" { | |||
| 282 | + | frame["id"] = id | |||
| 283 | + | } | |||
| 284 | + | if data != nil { | |||
| 285 | + | frame["data"] = data | |||
| 286 | + | } | |||
| 287 | + | s.write(frame) | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | func (s *rpcServer) writeError(id, cmd, msg string) { | |||
| 291 | + | frame := map[string]any{ | |||
| 292 | + | "type": "response", | |||
| 293 | + | "command": cmd, | |||
| 294 | + | "success": false, | |||
| 295 | + | "error": msg, | |||
| 296 | + | } | |||
| 297 | + | if id != "" { | |||
| 298 | + | frame["id"] = id | |||
| 299 | + | } | |||
| 300 | + | s.write(frame) | |||
| 301 | + | } | |||
| 302 | + | ||||
| 303 | + | func (s *rpcServer) writeEvent(payload map[string]any) { | |||
| 304 | + | s.write(payload) | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | func (s *rpcServer) write(v any) { | |||
| 308 | + | b, err := json.Marshal(v) | |||
| 309 | + | if err != nil { | |||
| 310 | + | return | |||
| 311 | + | } | |||
| 312 | + | s.writeMu.Lock() | |||
| 313 | + | defer s.writeMu.Unlock() | |||
| 314 | + | _, _ = s.out.Write(b) | |||
| 315 | + | _, _ = s.out.Write([]byte("\n")) | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | func (s *rpcServer) setCancel(c context.CancelFunc) { | |||
| 319 | + | s.writeMu.Lock() | |||
| 320 | + | s.activeCancel = c | |||
| 321 | + | s.writeMu.Unlock() | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | func (s *rpcServer) takeCancel() context.CancelFunc { | |||
| 325 | + | s.writeMu.Lock() | |||
| 326 | + | defer s.writeMu.Unlock() | |||
| 327 | + | c := s.activeCancel | |||
| 328 | + | s.activeCancel = nil | |||
| 329 | + | return c | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | // messagesToJSON serialises a transcript using the same schema as the | |||
| 333 | + | // --json event mode for cross-format consistency. | |||
| 334 | + | func messagesToJSON(msgs []provider.Message) []map[string]any { | |||
| 335 | + | out := make([]map[string]any, 0, len(msgs)) | |||
| 336 | + | for _, m := range msgs { | |||
| 337 | + | out = append(out, map[string]any{ | |||
| 338 | + | "role": string(m.Role), | |||
| 339 | + | "content": modes.ContentToJSON(m.Content), | |||
| 340 | + | "time": m.Time, | |||
| 341 | + | }) | |||
| 342 | + | } | |||
| 343 | + | return out | |||
| 344 | + | } | |||
M internal/provider/anthropic.go +65 -10
| @@ -102,22 +102,25 @@ type anthImageSource struct { | |||
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | 104 | type anthImageBlock struct { | |
| 105 | - | Type string `json:"type"` // "image" | |
| 106 | - | Source anthImageSource `json:"source"` | |
| 105 | + | Type string `json:"type"` // "image" | |
| 106 | + | Source anthImageSource `json:"source"` | |
| 107 | + | CacheControl *anthCacheCtrl `json:"cache_control,omitempty"` | |
| 107 | 108 | } | |
| 108 | 109 | ||
| 109 | 110 | type anthToolUseBlock struct { | |
| 110 | - | Type string `json:"type"` // "tool_use" | |
| 111 | - | ID string `json:"id"` | |
| 112 | - | Name string `json:"name"` | |
| 113 | - | Input json.RawMessage `json:"input"` | |
| 111 | + | Type string `json:"type"` // "tool_use" | |
| 112 | + | ID string `json:"id"` | |
| 113 | + | Name string `json:"name"` | |
| 114 | + | Input json.RawMessage `json:"input"` | |
| 115 | + | CacheControl *anthCacheCtrl `json:"cache_control,omitempty"` | |
| 114 | 116 | } | |
| 115 | 117 | ||
| 116 | 118 | type anthToolResultBlock struct { | |
| 117 | - | Type string `json:"type"` // "tool_result" | |
| 118 | - | ToolUseID string `json:"tool_use_id"` | |
| 119 | - | Content json.RawMessage `json:"content"` // string or array of blocks | |
| 120 | - | IsError bool `json:"is_error,omitempty"` | |
| 119 | + | Type string `json:"type"` // "tool_result" | |
| 120 | + | ToolUseID string `json:"tool_use_id"` | |
| 121 | + | Content json.RawMessage `json:"content"` // string or array of blocks | |
| 122 | + | IsError bool `json:"is_error,omitempty"` | |
| 123 | + | CacheControl *anthCacheCtrl `json:"cache_control,omitempty"` | |
| 121 | 124 | } | |
| 122 | 125 | ||
| 123 | 126 | type anthCacheCtrl struct { | |
| @@ -255,9 +258,61 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) { | |||
| 255 | 258 | } | |
| 256 | 259 | } | |
| 257 | 260 | ||
| 261 | + | // Mark the last two user messages with cache_control so anthropic | |
| 262 | + | // caches the running conversation prefix. Combined with the system | |
| 263 | + | // + tools breakpoints above this is the recommended layout for | |
| 264 | + | // multi-turn caching: turn N writes a cache that turn N+1 reads, | |
| 265 | + | // dropping per-turn input tokens from "system + history" down to | |
| 266 | + | // just the new user message. Anthropic allows up to 4 breakpoints | |
| 267 | + | // per request; we use system + tools + 2 conversation = 4. | |
| 268 | + | tagUserCache(out.Messages) | |
| 269 | + | ||
| 258 | 270 | return out, nil | |
| 259 | 271 | } | |
| 260 | 272 | ||
| 273 | + | // tagUserCache attaches a cache_control marker to the last block of | |
| 274 | + | // the most recent (and second-most-recent, if any) user message in | |
| 275 | + | // msgs. The marker tells the api to checkpoint the prefix at that | |
| 276 | + | // point so subsequent requests can replay everything up to and | |
| 277 | + | // including that block as a cache hit. | |
| 278 | + | func tagUserCache(msgs []anthMessage) { | |
| 279 | + | indexes := make([]int, 0, 2) | |
| 280 | + | for i := len(msgs) - 1; i >= 0 && len(indexes) < 2; i-- { | |
| 281 | + | if msgs[i].Role == "user" { | |
| 282 | + | indexes = append(indexes, i) | |
| 283 | + | } | |
| 284 | + | } | |
| 285 | + | for _, idx := range indexes { | |
| 286 | + | markLastBlockEphemeral(msgs[idx].Content) | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | // markLastBlockEphemeral sets CacheControl on the last entry in blocks | |
| 291 | + | // regardless of whether it's a text, image, tool_use, or tool_result. | |
| 292 | + | // Each block type carries its own CacheControl pointer so we type- | |
| 293 | + | // switch + reassign the slice element. | |
| 294 | + | func markLastBlockEphemeral(blocks []interface{}) { | |
| 295 | + | if len(blocks) == 0 { | |
| 296 | + | return | |
| 297 | + | } | |
| 298 | + | i := len(blocks) - 1 | |
| 299 | + | cc := &anthCacheCtrl{Type: "ephemeral"} | |
| 300 | + | switch v := blocks[i].(type) { | |
| 301 | + | case anthTextBlock: | |
| 302 | + | v.CacheControl = cc | |
| 303 | + | blocks[i] = v | |
| 304 | + | case anthImageBlock: | |
| 305 | + | v.CacheControl = cc | |
| 306 | + | blocks[i] = v | |
| 307 | + | case anthToolUseBlock: | |
| 308 | + | v.CacheControl = cc | |
| 309 | + | blocks[i] = v | |
| 310 | + | case anthToolResultBlock: | |
| 311 | + | v.CacheControl = cc | |
| 312 | + | blocks[i] = v | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | ||
| 261 | 316 | func anthropicReasoningBudget(level string) int { | |
| 262 | 317 | switch strings.ToLower(level) { | |
| 263 | 318 | case "low": | |
| @@ -102,22 +102,25 @@ type anthImageSource struct { | |||||
| 102 | } | 102 | } | ||
| 103 | 103 | ||||
| 104 | type anthImageBlock struct { | 104 | type anthImageBlock struct { | ||
| 105 | - | Type string `json:"type"` // "image" | 105 | + | Type string `json:"type"` // "image" |
| 106 | - | Source anthImageSource `json:"source"` | 106 | + | Source anthImageSource `json:"source"` |
| 107 | + | CacheControl *anthCacheCtrl `json:"cache_control,omitempty"` | |||
| 107 | } | 108 | } | ||
| 108 | 109 | ||||
| 109 | type anthToolUseBlock struct { | 110 | type anthToolUseBlock struct { | ||
| 110 | - | Type string `json:"type"` // "tool_use" | 111 | + | Type string `json:"type"` // "tool_use" |
| 111 | - | ID string `json:"id"` | 112 | + | ID string `json:"id"` |
| 112 | - | Name string `json:"name"` | 113 | + | Name string `json:"name"` |
| 113 | - | Input json.RawMessage `json:"input"` | 114 | + | Input json.RawMessage `json:"input"` |
| 115 | + | CacheControl *anthCacheCtrl `json:"cache_control,omitempty"` | |||
| 114 | } | 116 | } | ||
| 115 | 117 | ||||
| 116 | type anthToolResultBlock struct { | 118 | type anthToolResultBlock struct { | ||
| 117 | - | Type string `json:"type"` // "tool_result" | 119 | + | Type string `json:"type"` // "tool_result" |
| 118 | - | ToolUseID string `json:"tool_use_id"` | 120 | + | ToolUseID string `json:"tool_use_id"` |
| 119 | - | Content json.RawMessage `json:"content"` // string or array of blocks | 121 | + | Content json.RawMessage `json:"content"` // string or array of blocks |
| 120 | - | IsError bool `json:"is_error,omitempty"` | 122 | + | IsError bool `json:"is_error,omitempty"` |
| 123 | + | CacheControl *anthCacheCtrl `json:"cache_control,omitempty"` | |||
| 121 | } | 124 | } | ||
| 122 | 125 | ||||
| 123 | type anthCacheCtrl struct { | 126 | type anthCacheCtrl struct { | ||
| @@ -255,9 +258,61 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) { | |||||
| 255 | } | 258 | } | ||
| 256 | } | 259 | } | ||
| 257 | 260 | ||||
| 261 | + | // Mark the last two user messages with cache_control so anthropic | |||
| 262 | + | // caches the running conversation prefix. Combined with the system | |||
| 263 | + | // + tools breakpoints above this is the recommended layout for | |||
| 264 | + | // multi-turn caching: turn N writes a cache that turn N+1 reads, | |||
| 265 | + | // dropping per-turn input tokens from "system + history" down to | |||
| 266 | + | // just the new user message. Anthropic allows up to 4 breakpoints | |||
| 267 | + | // per request; we use system + tools + 2 conversation = 4. | |||
| 268 | + | tagUserCache(out.Messages) | |||
| 269 | + | ||||
| 258 | return out, nil | 270 | return out, nil | ||
| 259 | } | 271 | } | ||
| 260 | 272 | ||||
| 273 | + | // tagUserCache attaches a cache_control marker to the last block of | |||
| 274 | + | // the most recent (and second-most-recent, if any) user message in | |||
| 275 | + | // msgs. The marker tells the api to checkpoint the prefix at that | |||
| 276 | + | // point so subsequent requests can replay everything up to and | |||
| 277 | + | // including that block as a cache hit. | |||
| 278 | + | func tagUserCache(msgs []anthMessage) { | |||
| 279 | + | indexes := make([]int, 0, 2) | |||
| 280 | + | for i := len(msgs) - 1; i >= 0 && len(indexes) < 2; i-- { | |||
| 281 | + | if msgs[i].Role == "user" { | |||
| 282 | + | indexes = append(indexes, i) | |||
| 283 | + | } | |||
| 284 | + | } | |||
| 285 | + | for _, idx := range indexes { | |||
| 286 | + | markLastBlockEphemeral(msgs[idx].Content) | |||
| 287 | + | } | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | // markLastBlockEphemeral sets CacheControl on the last entry in blocks | |||
| 291 | + | // regardless of whether it's a text, image, tool_use, or tool_result. | |||
| 292 | + | // Each block type carries its own CacheControl pointer so we type- | |||
| 293 | + | // switch + reassign the slice element. | |||
| 294 | + | func markLastBlockEphemeral(blocks []interface{}) { | |||
| 295 | + | if len(blocks) == 0 { | |||
| 296 | + | return | |||
| 297 | + | } | |||
| 298 | + | i := len(blocks) - 1 | |||
| 299 | + | cc := &anthCacheCtrl{Type: "ephemeral"} | |||
| 300 | + | switch v := blocks[i].(type) { | |||
| 301 | + | case anthTextBlock: | |||
| 302 | + | v.CacheControl = cc | |||
| 303 | + | blocks[i] = v | |||
| 304 | + | case anthImageBlock: | |||
| 305 | + | v.CacheControl = cc | |||
| 306 | + | blocks[i] = v | |||
| 307 | + | case anthToolUseBlock: | |||
| 308 | + | v.CacheControl = cc | |||
| 309 | + | blocks[i] = v | |||
| 310 | + | case anthToolResultBlock: | |||
| 311 | + | v.CacheControl = cc | |||
| 312 | + | blocks[i] = v | |||
| 313 | + | } | |||
| 314 | + | } | |||
| 315 | + | ||||
| 261 | func anthropicReasoningBudget(level string) int { | 316 | func anthropicReasoningBudget(level string) int { | ||
| 262 | switch strings.ToLower(level) { | 317 | switch strings.ToLower(level) { | ||
| 263 | case "low": | 318 | case "low": | ||
M internal/tui/view.go +40 -30
| @@ -795,11 +795,25 @@ type StatusBarParams struct { | |||
| 795 | 795 | Cols int // terminal width; drives right-alignment of cwd | |
| 796 | 796 | } | |
| 797 | 797 | ||
| 798 | - | // StatusBar builds the single-line status shown above the editor. | |
| 799 | - | // Format: ↑N ↓N RN WN $cost[(sub)] pct%/ctxMax | |
| 800 | - | func StatusBar(p StatusBarParams) string { | |
| 798 | + | // StatusBar builds the status shown above the editor. Returns one or | |
| 799 | + | // two lines depending on whether the (provider, model, stats, cwd) | |
| 800 | + | // payload fits the terminal width. | |
| 801 | + | // | |
| 802 | + | // Layout: | |
| 803 | + | // | |
| 804 | + | // <busyPrefix> (provider) model stats cwd <- one line if it fits | |
| 805 | + | // | |
| 806 | + | // or, when it doesn't: | |
| 807 | + | // | |
| 808 | + | // <busyPrefix> (provider) model stats <- line 1 | |
| 809 | + | // cwd <- line 2 | |
| 810 | + | // | |
| 811 | + | // The right-side gap is a fixed three spaces, not a stretched-to-fill | |
| 812 | + | // fill. The old "ctrl+c exit - /help" / "esc cancel" hint is gone | |
| 813 | + | // entirely — the slash-command popup and the queued/sliding-in chips | |
| 814 | + | // already cover the discoverability of those keybindings. | |
| 815 | + | func StatusBar(p StatusBarParams) []string { | |
| 801 | 816 | th := p.Theme | |
| 802 | - | sep := " - " | |
| 803 | 817 | ||
| 804 | 818 | // Token stats: only include each segment when non-zero (pi's | |
| 805 | 819 | // behavior). Keeps the bar compact on brand-new sessions. | |
| @@ -817,11 +831,16 @@ func StatusBar(p StatusBarParams) string { | |||
| 817 | 831 | stats = append(stats, fmt.Sprintf("W%s", piFormatTokens(p.Usage.CacheWriteTokens))) | |
| 818 | 832 | } | |
| 819 | 833 | ||
| 834 | + | // Cost: always show the dollar value computed from token counts, | |
| 835 | + | // even on subscription — lets you see what the equivalent api cost | |
| 836 | + | // would be (handy for gauging subscription value). Append "(sub)" | |
| 837 | + | // only as a hint that no real money moved. | |
| 820 | 838 | var costStr string | |
| 821 | - | if p.Subscription { | |
| 822 | - | costStr = "$0.000 (sub)" | |
| 823 | - | } else if p.Usage.CostUSD > 0 { | |
| 839 | + | if p.Usage.CostUSD > 0 || p.Subscription { | |
| 824 | 840 | costStr = fmt.Sprintf("$%.3f", p.Usage.CostUSD) | |
| 841 | + | if p.Subscription { | |
| 842 | + | costStr += " (sub)" | |
| 843 | + | } | |
| 825 | 844 | } | |
| 826 | 845 | if costStr != "" { | |
| 827 | 846 | stats = append(stats, costStr) | |
| @@ -839,13 +858,6 @@ func StatusBar(p StatusBarParams) string { | |||
| 839 | 858 | left := fmt.Sprintf(" (%s) %s ", p.Provider, p.Model) | |
| 840 | 859 | middle := " " + strings.Join(stats, " ") + " " | |
| 841 | 860 | ||
| 842 | - | var hint string | |
| 843 | - | if p.Busy { | |
| 844 | - | hint = " esc cancel " | |
| 845 | - | } else { | |
| 846 | - | hint = " ctrl+c exit " + sep + " /help " | |
| 847 | - | } | |
| 848 | - | ||
| 849 | 861 | var leftBuilder strings.Builder | |
| 850 | 862 | if p.BusyPrefix != "" { | |
| 851 | 863 | leftBuilder.WriteString(th.FG256(th.Accent, " "+p.BusyPrefix+" ")) | |
| @@ -855,28 +867,26 @@ func StatusBar(p StatusBarParams) string { | |||
| 855 | 867 | leftBuilder.WriteString(" ") | |
| 856 | 868 | // `middle` already has colorized context segments; wrap the rest in muted. | |
| 857 | 869 | leftBuilder.WriteString(th.FG256(th.Muted, middle)) | |
| 858 | - | leftBuilder.WriteString(" ") | |
| 859 | - | leftBuilder.WriteString(th.FG256(th.Muted, hint)) | |
| 860 | 870 | ||
| 861 | - | // Compose cwd on the right side. shortenHome abbreviates $HOME to "~". | |
| 862 | - | right := shortenHome(p.CWD) | |
| 863 | - | if p.Locked && right != "" { | |
| 864 | - | right = "· locked · " + right | |
| 871 | + | cwd := shortenHome(p.CWD) | |
| 872 | + | if p.Locked && cwd != "" { | |
| 873 | + | cwd = "· locked · " + cwd | |
| 865 | 874 | } | |
| 866 | - | if right == "" || p.Cols <= 0 { | |
| 867 | - | return leftBuilder.String() | |
| 875 | + | ||
| 876 | + | primary := leftBuilder.String() | |
| 877 | + | if cwd == "" { | |
| 878 | + | return []string{primary} | |
| 868 | 879 | } | |
| 869 | 880 | ||
| 870 | - | leftRendered := leftBuilder.String() | |
| 871 | - | leftWidth := visibleWidth(leftRendered) | |
| 872 | - | rightStr := " " + right + " " | |
| 873 | - | rightWidth := visibleWidth(rightStr) | |
| 881 | + | cwdRendered := th.FG256(th.Muted, cwd) | |
| 882 | + | const gap = " " // exactly three spaces between stats and cwd | |
| 883 | + | combined := primary + gap + cwdRendered | |
| 874 | 884 | ||
| 875 | - | gap := p.Cols - leftWidth - rightWidth | |
| 876 | - | if gap < 1 { | |
| 877 | - | return leftRendered | |
| 885 | + | // Wrap to a second line when the combined width would overflow. | |
| 886 | + | if p.Cols > 0 && visibleWidth(combined) > p.Cols { | |
| 887 | + | return []string{primary, cwdRendered} | |
| 878 | 888 | } | |
| 879 | - | return leftRendered + strings.Repeat(" ", gap) + th.FG256(th.Muted, rightStr) | |
| 889 | + | return []string{combined} | |
| 880 | 890 | } | |
| 881 | 891 | ||
| 882 | 892 | // piContextUsage renders the "N%/ctxMax" fragment, returning the | |
| @@ -795,11 +795,25 @@ type StatusBarParams struct { | |||||
| 795 | Cols int // terminal width; drives right-alignment of cwd | 795 | Cols int // terminal width; drives right-alignment of cwd | ||
| 796 | } | 796 | } | ||
| 797 | 797 | ||||
| 798 | - | // StatusBar builds the single-line status shown above the editor. | 798 | + | // StatusBar builds the status shown above the editor. Returns one or |
| 799 | - | // Format: ↑N ↓N RN WN $cost[(sub)] pct%/ctxMax | 799 | + | // two lines depending on whether the (provider, model, stats, cwd) |
| 800 | - | func StatusBar(p StatusBarParams) string { | 800 | + | // payload fits the terminal width. |
| 801 | + | // | |||
| 802 | + | // Layout: | |||
| 803 | + | // | |||
| 804 | + | // <busyPrefix> (provider) model stats cwd <- one line if it fits | |||
| 805 | + | // | |||
| 806 | + | // or, when it doesn't: | |||
| 807 | + | // | |||
| 808 | + | // <busyPrefix> (provider) model stats <- line 1 | |||
| 809 | + | // cwd <- line 2 | |||
| 810 | + | // | |||
| 811 | + | // The right-side gap is a fixed three spaces, not a stretched-to-fill | |||
| 812 | + | // fill. The old "ctrl+c exit - /help" / "esc cancel" hint is gone | |||
| 813 | + | // entirely — the slash-command popup and the queued/sliding-in chips | |||
| 814 | + | // already cover the discoverability of those keybindings. | |||
| 815 | + | func StatusBar(p StatusBarParams) []string { | |||
| 801 | th := p.Theme | 816 | th := p.Theme | ||
| 802 | - | sep := " - " | |||
| 803 | 817 | ||||
| 804 | // Token stats: only include each segment when non-zero (pi's | 818 | // Token stats: only include each segment when non-zero (pi's | ||
| 805 | // behavior). Keeps the bar compact on brand-new sessions. | 819 | // behavior). Keeps the bar compact on brand-new sessions. | ||
| @@ -817,11 +831,16 @@ func StatusBar(p StatusBarParams) string { | |||||
| 817 | stats = append(stats, fmt.Sprintf("W%s", piFormatTokens(p.Usage.CacheWriteTokens))) | 831 | stats = append(stats, fmt.Sprintf("W%s", piFormatTokens(p.Usage.CacheWriteTokens))) | ||
| 818 | } | 832 | } | ||
| 819 | 833 | ||||
| 834 | + | // Cost: always show the dollar value computed from token counts, | |||
| 835 | + | // even on subscription — lets you see what the equivalent api cost | |||
| 836 | + | // would be (handy for gauging subscription value). Append "(sub)" | |||
| 837 | + | // only as a hint that no real money moved. | |||
| 820 | var costStr string | 838 | var costStr string | ||
| 821 | - | if p.Subscription { | 839 | + | if p.Usage.CostUSD > 0 || p.Subscription { |
| 822 | - | costStr = "$0.000 (sub)" | |||
| 823 | - | } else if p.Usage.CostUSD > 0 { | |||
| 824 | costStr = fmt.Sprintf("$%.3f", p.Usage.CostUSD) | 840 | costStr = fmt.Sprintf("$%.3f", p.Usage.CostUSD) | ||
| 841 | + | if p.Subscription { | |||
| 842 | + | costStr += " (sub)" | |||
| 843 | + | } | |||
| 825 | } | 844 | } | ||
| 826 | if costStr != "" { | 845 | if costStr != "" { | ||
| 827 | stats = append(stats, costStr) | 846 | stats = append(stats, costStr) | ||
| @@ -839,13 +858,6 @@ func StatusBar(p StatusBarParams) string { | |||||
| 839 | left := fmt.Sprintf(" (%s) %s ", p.Provider, p.Model) | 858 | left := fmt.Sprintf(" (%s) %s ", p.Provider, p.Model) | ||
| 840 | middle := " " + strings.Join(stats, " ") + " " | 859 | middle := " " + strings.Join(stats, " ") + " " | ||
| 841 | 860 | ||||
| 842 | - | var hint string | |||
| 843 | - | if p.Busy { | |||
| 844 | - | hint = " esc cancel " | |||
| 845 | - | } else { | |||
| 846 | - | hint = " ctrl+c exit " + sep + " /help " | |||
| 847 | - | } | |||
| 848 | - | ||||
| 849 | var leftBuilder strings.Builder | 861 | var leftBuilder strings.Builder | ||
| 850 | if p.BusyPrefix != "" { | 862 | if p.BusyPrefix != "" { | ||
| 851 | leftBuilder.WriteString(th.FG256(th.Accent, " "+p.BusyPrefix+" ")) | 863 | leftBuilder.WriteString(th.FG256(th.Accent, " "+p.BusyPrefix+" ")) | ||
| @@ -855,28 +867,26 @@ func StatusBar(p StatusBarParams) string { | |||||
| 855 | leftBuilder.WriteString(" ") | 867 | leftBuilder.WriteString(" ") | ||
| 856 | // `middle` already has colorized context segments; wrap the rest in muted. | 868 | // `middle` already has colorized context segments; wrap the rest in muted. | ||
| 857 | leftBuilder.WriteString(th.FG256(th.Muted, middle)) | 869 | leftBuilder.WriteString(th.FG256(th.Muted, middle)) | ||
| 858 | - | leftBuilder.WriteString(" ") | |||
| 859 | - | leftBuilder.WriteString(th.FG256(th.Muted, hint)) | |||
| 860 | 870 | ||||
| 861 | - | // Compose cwd on the right side. shortenHome abbreviates $HOME to "~". | 871 | + | cwd := shortenHome(p.CWD) |
| 862 | - | right := shortenHome(p.CWD) | 872 | + | if p.Locked && cwd != "" { |
| 863 | - | if p.Locked && right != "" { | 873 | + | cwd = "· locked · " + cwd |
| 864 | - | right = "· locked · " + right | |||
| 865 | } | 874 | } | ||
| 866 | - | if right == "" || p.Cols <= 0 { | 875 | + | |
| 867 | - | return leftBuilder.String() | 876 | + | primary := leftBuilder.String() |
| 877 | + | if cwd == "" { | |||
| 878 | + | return []string{primary} | |||
| 868 | } | 879 | } | ||
| 869 | 880 | ||||
| 870 | - | leftRendered := leftBuilder.String() | 881 | + | cwdRendered := th.FG256(th.Muted, cwd) |
| 871 | - | leftWidth := visibleWidth(leftRendered) | 882 | + | const gap = " " // exactly three spaces between stats and cwd |
| 872 | - | rightStr := " " + right + " " | 883 | + | combined := primary + gap + cwdRendered |
| 873 | - | rightWidth := visibleWidth(rightStr) | |||
| 874 | 884 | ||||
| 875 | - | gap := p.Cols - leftWidth - rightWidth | 885 | + | // Wrap to a second line when the combined width would overflow. |
| 876 | - | if gap < 1 { | 886 | + | if p.Cols > 0 && visibleWidth(combined) > p.Cols { |
| 877 | - | return leftRendered | 887 | + | return []string{primary, cwdRendered} |
| 878 | } | 888 | } | ||
| 879 | - | return leftRendered + strings.Repeat(" ", gap) + th.FG256(th.Muted, rightStr) | 889 | + | return []string{combined} |
| 880 | } | 890 | } | ||
| 881 | 891 | ||||
| 882 | // piContextUsage renders the "N%/ctxMax" fragment, returning the | 892 | // piContextUsage renders the "N%/ctxMax" fragment, returning the | ||
A pkg/zotcore/types.go +214 -0
| @@ -0,0 +1,214 @@ | |||
| 1 | + | package zotcore | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | ||
| 6 | + | "github.com/patriceckhart/zot/internal/core" | |
| 7 | + | "github.com/patriceckhart/zot/internal/provider" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // Event is the unit emitted by Runtime.Prompt's channel. Type | |
| 11 | + | // identifies the event kind; remaining fields are populated based on | |
| 12 | + | // kind. The wire schema is the same as the JSON-RPC stream notifications | |
| 13 | + | // emitted by `zot rpc`, so consumers can share parsing code. | |
| 14 | + | type Event struct { | |
| 15 | + | Type string `json:"type"` | |
| 16 | + | ||
| 17 | + | // Common fields. | |
| 18 | + | Step int `json:"step,omitempty"` | |
| 19 | + | Time string `json:"time,omitempty"` | |
| 20 | + | ||
| 21 | + | // text_delta | |
| 22 | + | Delta string `json:"delta,omitempty"` | |
| 23 | + | ||
| 24 | + | // tool_call | |
| 25 | + | ID string `json:"id,omitempty"` | |
| 26 | + | Name string `json:"name,omitempty"` | |
| 27 | + | Args json.RawMessage `json:"args,omitempty"` | |
| 28 | + | ||
| 29 | + | // tool_progress | |
| 30 | + | Text string `json:"text,omitempty"` | |
| 31 | + | ||
| 32 | + | // tool_result | |
| 33 | + | IsError bool `json:"is_error,omitempty"` | |
| 34 | + | Result []ContentBlock `json:"content,omitempty"` | |
| 35 | + | ||
| 36 | + | // usage | |
| 37 | + | Usage *Usage `json:"usage,omitempty"` | |
| 38 | + | Cumulative *Usage `json:"cumulative,omitempty"` | |
| 39 | + | ||
| 40 | + | // user_message / assistant_message | |
| 41 | + | Message *Message `json:"message,omitempty"` | |
| 42 | + | ||
| 43 | + | // turn_end | |
| 44 | + | Stop string `json:"stop,omitempty"` | |
| 45 | + | ||
| 46 | + | // error | |
| 47 | + | Error string `json:"error,omitempty"` | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Message is one transcript entry — a user prompt, assistant reply, | |
| 51 | + | // or tool result. | |
| 52 | + | type Message struct { | |
| 53 | + | Role string `json:"role"` | |
| 54 | + | Content []ContentBlock `json:"content"` | |
| 55 | + | Time string `json:"time,omitempty"` | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // ContentBlock is one piece of message content. Discriminate on Type: | |
| 59 | + | // - "text" → Text | |
| 60 | + | // - "image" → MimeType + Data (or Bytes for size-only) | |
| 61 | + | // - "tool_call" → ID + Name + Args | |
| 62 | + | // - "tool_result" → CallID + IsError + Content (recursive) | |
| 63 | + | type ContentBlock struct { | |
| 64 | + | Type string `json:"type"` | |
| 65 | + | ||
| 66 | + | Text string `json:"text,omitempty"` | |
| 67 | + | MimeType string `json:"mime_type,omitempty"` | |
| 68 | + | Data []byte `json:"data,omitempty"` | |
| 69 | + | Bytes int `json:"bytes,omitempty"` | |
| 70 | + | ID string `json:"id,omitempty"` | |
| 71 | + | Name string `json:"name,omitempty"` | |
| 72 | + | Args json.RawMessage `json:"args,omitempty"` | |
| 73 | + | CallID string `json:"call_id,omitempty"` | |
| 74 | + | IsError bool `json:"is_error,omitempty"` | |
| 75 | + | Content []ContentBlock `json:"content,omitempty"` | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // Image is a single user-attached image for Prompt. | |
| 79 | + | type Image struct { | |
| 80 | + | MimeType string | |
| 81 | + | Data []byte | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // Usage is per-turn or cumulative token / cost counts. | |
| 85 | + | type Usage struct { | |
| 86 | + | Input int `json:"input"` | |
| 87 | + | Output int `json:"output"` | |
| 88 | + | CacheRead int `json:"cache_read"` | |
| 89 | + | CacheWrite int `json:"cache_write"` | |
| 90 | + | CostUSD float64 `json:"cost_usd"` | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // State is a snapshot of the runtime's current state. | |
| 94 | + | type State struct { | |
| 95 | + | Provider string `json:"provider"` | |
| 96 | + | Model string `json:"model"` | |
| 97 | + | CWD string `json:"cwd"` | |
| 98 | + | Busy bool `json:"busy"` | |
| 99 | + | MessageCount int `json:"message_count"` | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // CompactResult describes the outcome of Compact. | |
| 103 | + | type CompactResult struct { | |
| 104 | + | Summary string `json:"summary"` | |
| 105 | + | Messages []Message `json:"messages"` | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // ModelInfo describes one model known to the runtime. | |
| 109 | + | type ModelInfo struct { | |
| 110 | + | ID string `json:"id"` | |
| 111 | + | Provider string `json:"provider"` | |
| 112 | + | ContextWindow int `json:"context_window"` | |
| 113 | + | MaxOutput int `json:"max_output"` | |
| 114 | + | Reasoning bool `json:"reasoning"` | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // ---- internal converters ---- | |
| 118 | + | ||
| 119 | + | func toEvent(ev core.AgentEvent) Event { | |
| 120 | + | out := Event{Type: ev.Type()} | |
| 121 | + | switch e := ev.(type) { | |
| 122 | + | case core.EvTurnStart: | |
| 123 | + | out.Step = e.Step | |
| 124 | + | case core.EvUserMessage: | |
| 125 | + | out.Message = &Message{Role: string(e.Message.Role), Content: convertContent(e.Message.Content)} | |
| 126 | + | case core.EvAssistantMessage: | |
| 127 | + | out.Message = &Message{Role: string(e.Message.Role), Content: convertContent(e.Message.Content)} | |
| 128 | + | case core.EvTextDelta: | |
| 129 | + | out.Delta = e.Delta | |
| 130 | + | case core.EvToolCall: | |
| 131 | + | out.ID = e.ID | |
| 132 | + | out.Name = e.Name | |
| 133 | + | out.Args = e.Args | |
| 134 | + | case core.EvToolProgress: | |
| 135 | + | out.ID = e.ID | |
| 136 | + | out.Text = e.Text | |
| 137 | + | case core.EvToolResult: | |
| 138 | + | out.ID = e.ID | |
| 139 | + | out.IsError = e.Result.IsError | |
| 140 | + | out.Result = convertContent(e.Result.Content) | |
| 141 | + | case core.EvUsage: | |
| 142 | + | out.Usage = &Usage{ | |
| 143 | + | Input: e.Usage.InputTokens, | |
| 144 | + | Output: e.Usage.OutputTokens, | |
| 145 | + | CacheRead: e.Usage.CacheReadTokens, | |
| 146 | + | CacheWrite: e.Usage.CacheWriteTokens, | |
| 147 | + | CostUSD: e.Usage.CostUSD, | |
| 148 | + | } | |
| 149 | + | out.Cumulative = &Usage{ | |
| 150 | + | Input: e.Cumulative.InputTokens, | |
| 151 | + | Output: e.Cumulative.OutputTokens, | |
| 152 | + | CacheRead: e.Cumulative.CacheReadTokens, | |
| 153 | + | CacheWrite: e.Cumulative.CacheWriteTokens, | |
| 154 | + | CostUSD: e.Cumulative.CostUSD, | |
| 155 | + | } | |
| 156 | + | case core.EvTurnEnd: | |
| 157 | + | out.Stop = string(e.Stop) | |
| 158 | + | if e.Err != nil { | |
| 159 | + | out.Error = e.Err.Error() | |
| 160 | + | } | |
| 161 | + | case core.EvError: | |
| 162 | + | if e.Err != nil { | |
| 163 | + | out.Error = e.Err.Error() | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | return out | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func convertContent(blocks []provider.Content) []ContentBlock { | |
| 170 | + | out := make([]ContentBlock, 0, len(blocks)) | |
| 171 | + | for _, b := range blocks { | |
| 172 | + | switch v := b.(type) { | |
| 173 | + | case provider.TextBlock: | |
| 174 | + | out = append(out, ContentBlock{Type: "text", Text: v.Text}) | |
| 175 | + | case provider.ImageBlock: | |
| 176 | + | out = append(out, ContentBlock{Type: "image", MimeType: v.MimeType, Bytes: len(v.Data)}) | |
| 177 | + | case provider.ToolCallBlock: | |
| 178 | + | out = append(out, ContentBlock{Type: "tool_call", ID: v.ID, Name: v.Name, Args: v.Arguments}) | |
| 179 | + | case provider.ToolResultBlock: | |
| 180 | + | out = append(out, ContentBlock{ | |
| 181 | + | Type: "tool_result", | |
| 182 | + | CallID: v.CallID, | |
| 183 | + | IsError: v.IsError, | |
| 184 | + | Content: convertContent(v.Content), | |
| 185 | + | }) | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | return out | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func rebuildContent(blocks []ContentBlock) []provider.Content { | |
| 192 | + | out := make([]provider.Content, 0, len(blocks)) | |
| 193 | + | for _, b := range blocks { | |
| 194 | + | switch b.Type { | |
| 195 | + | case "text": | |
| 196 | + | out = append(out, provider.TextBlock{Text: b.Text}) | |
| 197 | + | case "image": | |
| 198 | + | out = append(out, provider.ImageBlock{MimeType: b.MimeType, Data: b.Data}) | |
| 199 | + | case "tool_call": | |
| 200 | + | args := b.Args | |
| 201 | + | if len(args) == 0 { | |
| 202 | + | args = json.RawMessage("{}") | |
| 203 | + | } | |
| 204 | + | out = append(out, provider.ToolCallBlock{ID: b.ID, Name: b.Name, Arguments: args}) | |
| 205 | + | case "tool_result": | |
| 206 | + | out = append(out, provider.ToolResultBlock{ | |
| 207 | + | CallID: b.CallID, | |
| 208 | + | IsError: b.IsError, | |
| 209 | + | Content: rebuildContent(b.Content), | |
| 210 | + | }) | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | return out | |
| 214 | + | } | |
| @@ -0,0 +1,214 @@ | |||||
| 1 | + | package zotcore | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | ||||
| 6 | + | "github.com/patriceckhart/zot/internal/core" | |||
| 7 | + | "github.com/patriceckhart/zot/internal/provider" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // Event is the unit emitted by Runtime.Prompt's channel. Type | |||
| 11 | + | // identifies the event kind; remaining fields are populated based on | |||
| 12 | + | // kind. The wire schema is the same as the JSON-RPC stream notifications | |||
| 13 | + | // emitted by `zot rpc`, so consumers can share parsing code. | |||
| 14 | + | type Event struct { | |||
| 15 | + | Type string `json:"type"` | |||
| 16 | + | ||||
| 17 | + | // Common fields. | |||
| 18 | + | Step int `json:"step,omitempty"` | |||
| 19 | + | Time string `json:"time,omitempty"` | |||
| 20 | + | ||||
| 21 | + | // text_delta | |||
| 22 | + | Delta string `json:"delta,omitempty"` | |||
| 23 | + | ||||
| 24 | + | // tool_call | |||
| 25 | + | ID string `json:"id,omitempty"` | |||
| 26 | + | Name string `json:"name,omitempty"` | |||
| 27 | + | Args json.RawMessage `json:"args,omitempty"` | |||
| 28 | + | ||||
| 29 | + | // tool_progress | |||
| 30 | + | Text string `json:"text,omitempty"` | |||
| 31 | + | ||||
| 32 | + | // tool_result | |||
| 33 | + | IsError bool `json:"is_error,omitempty"` | |||
| 34 | + | Result []ContentBlock `json:"content,omitempty"` | |||
| 35 | + | ||||
| 36 | + | // usage | |||
| 37 | + | Usage *Usage `json:"usage,omitempty"` | |||
| 38 | + | Cumulative *Usage `json:"cumulative,omitempty"` | |||
| 39 | + | ||||
| 40 | + | // user_message / assistant_message | |||
| 41 | + | Message *Message `json:"message,omitempty"` | |||
| 42 | + | ||||
| 43 | + | // turn_end | |||
| 44 | + | Stop string `json:"stop,omitempty"` | |||
| 45 | + | ||||
| 46 | + | // error | |||
| 47 | + | Error string `json:"error,omitempty"` | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Message is one transcript entry — a user prompt, assistant reply, | |||
| 51 | + | // or tool result. | |||
| 52 | + | type Message struct { | |||
| 53 | + | Role string `json:"role"` | |||
| 54 | + | Content []ContentBlock `json:"content"` | |||
| 55 | + | Time string `json:"time,omitempty"` | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // ContentBlock is one piece of message content. Discriminate on Type: | |||
| 59 | + | // - "text" → Text | |||
| 60 | + | // - "image" → MimeType + Data (or Bytes for size-only) | |||
| 61 | + | // - "tool_call" → ID + Name + Args | |||
| 62 | + | // - "tool_result" → CallID + IsError + Content (recursive) | |||
| 63 | + | type ContentBlock struct { | |||
| 64 | + | Type string `json:"type"` | |||
| 65 | + | ||||
| 66 | + | Text string `json:"text,omitempty"` | |||
| 67 | + | MimeType string `json:"mime_type,omitempty"` | |||
| 68 | + | Data []byte `json:"data,omitempty"` | |||
| 69 | + | Bytes int `json:"bytes,omitempty"` | |||
| 70 | + | ID string `json:"id,omitempty"` | |||
| 71 | + | Name string `json:"name,omitempty"` | |||
| 72 | + | Args json.RawMessage `json:"args,omitempty"` | |||
| 73 | + | CallID string `json:"call_id,omitempty"` | |||
| 74 | + | IsError bool `json:"is_error,omitempty"` | |||
| 75 | + | Content []ContentBlock `json:"content,omitempty"` | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // Image is a single user-attached image for Prompt. | |||
| 79 | + | type Image struct { | |||
| 80 | + | MimeType string | |||
| 81 | + | Data []byte | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // Usage is per-turn or cumulative token / cost counts. | |||
| 85 | + | type Usage struct { | |||
| 86 | + | Input int `json:"input"` | |||
| 87 | + | Output int `json:"output"` | |||
| 88 | + | CacheRead int `json:"cache_read"` | |||
| 89 | + | CacheWrite int `json:"cache_write"` | |||
| 90 | + | CostUSD float64 `json:"cost_usd"` | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // State is a snapshot of the runtime's current state. | |||
| 94 | + | type State struct { | |||
| 95 | + | Provider string `json:"provider"` | |||
| 96 | + | Model string `json:"model"` | |||
| 97 | + | CWD string `json:"cwd"` | |||
| 98 | + | Busy bool `json:"busy"` | |||
| 99 | + | MessageCount int `json:"message_count"` | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // CompactResult describes the outcome of Compact. | |||
| 103 | + | type CompactResult struct { | |||
| 104 | + | Summary string `json:"summary"` | |||
| 105 | + | Messages []Message `json:"messages"` | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // ModelInfo describes one model known to the runtime. | |||
| 109 | + | type ModelInfo struct { | |||
| 110 | + | ID string `json:"id"` | |||
| 111 | + | Provider string `json:"provider"` | |||
| 112 | + | ContextWindow int `json:"context_window"` | |||
| 113 | + | MaxOutput int `json:"max_output"` | |||
| 114 | + | Reasoning bool `json:"reasoning"` | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // ---- internal converters ---- | |||
| 118 | + | ||||
| 119 | + | func toEvent(ev core.AgentEvent) Event { | |||
| 120 | + | out := Event{Type: ev.Type()} | |||
| 121 | + | switch e := ev.(type) { | |||
| 122 | + | case core.EvTurnStart: | |||
| 123 | + | out.Step = e.Step | |||
| 124 | + | case core.EvUserMessage: | |||
| 125 | + | out.Message = &Message{Role: string(e.Message.Role), Content: convertContent(e.Message.Content)} | |||
| 126 | + | case core.EvAssistantMessage: | |||
| 127 | + | out.Message = &Message{Role: string(e.Message.Role), Content: convertContent(e.Message.Content)} | |||
| 128 | + | case core.EvTextDelta: | |||
| 129 | + | out.Delta = e.Delta | |||
| 130 | + | case core.EvToolCall: | |||
| 131 | + | out.ID = e.ID | |||
| 132 | + | out.Name = e.Name | |||
| 133 | + | out.Args = e.Args | |||
| 134 | + | case core.EvToolProgress: | |||
| 135 | + | out.ID = e.ID | |||
| 136 | + | out.Text = e.Text | |||
| 137 | + | case core.EvToolResult: | |||
| 138 | + | out.ID = e.ID | |||
| 139 | + | out.IsError = e.Result.IsError | |||
| 140 | + | out.Result = convertContent(e.Result.Content) | |||
| 141 | + | case core.EvUsage: | |||
| 142 | + | out.Usage = &Usage{ | |||
| 143 | + | Input: e.Usage.InputTokens, | |||
| 144 | + | Output: e.Usage.OutputTokens, | |||
| 145 | + | CacheRead: e.Usage.CacheReadTokens, | |||
| 146 | + | CacheWrite: e.Usage.CacheWriteTokens, | |||
| 147 | + | CostUSD: e.Usage.CostUSD, | |||
| 148 | + | } | |||
| 149 | + | out.Cumulative = &Usage{ | |||
| 150 | + | Input: e.Cumulative.InputTokens, | |||
| 151 | + | Output: e.Cumulative.OutputTokens, | |||
| 152 | + | CacheRead: e.Cumulative.CacheReadTokens, | |||
| 153 | + | CacheWrite: e.Cumulative.CacheWriteTokens, | |||
| 154 | + | CostUSD: e.Cumulative.CostUSD, | |||
| 155 | + | } | |||
| 156 | + | case core.EvTurnEnd: | |||
| 157 | + | out.Stop = string(e.Stop) | |||
| 158 | + | if e.Err != nil { | |||
| 159 | + | out.Error = e.Err.Error() | |||
| 160 | + | } | |||
| 161 | + | case core.EvError: | |||
| 162 | + | if e.Err != nil { | |||
| 163 | + | out.Error = e.Err.Error() | |||
| 164 | + | } | |||
| 165 | + | } | |||
| 166 | + | return out | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | func convertContent(blocks []provider.Content) []ContentBlock { | |||
| 170 | + | out := make([]ContentBlock, 0, len(blocks)) | |||
| 171 | + | for _, b := range blocks { | |||
| 172 | + | switch v := b.(type) { | |||
| 173 | + | case provider.TextBlock: | |||
| 174 | + | out = append(out, ContentBlock{Type: "text", Text: v.Text}) | |||
| 175 | + | case provider.ImageBlock: | |||
| 176 | + | out = append(out, ContentBlock{Type: "image", MimeType: v.MimeType, Bytes: len(v.Data)}) | |||
| 177 | + | case provider.ToolCallBlock: | |||
| 178 | + | out = append(out, ContentBlock{Type: "tool_call", ID: v.ID, Name: v.Name, Args: v.Arguments}) | |||
| 179 | + | case provider.ToolResultBlock: | |||
| 180 | + | out = append(out, ContentBlock{ | |||
| 181 | + | Type: "tool_result", | |||
| 182 | + | CallID: v.CallID, | |||
| 183 | + | IsError: v.IsError, | |||
| 184 | + | Content: convertContent(v.Content), | |||
| 185 | + | }) | |||
| 186 | + | } | |||
| 187 | + | } | |||
| 188 | + | return out | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | func rebuildContent(blocks []ContentBlock) []provider.Content { | |||
| 192 | + | out := make([]provider.Content, 0, len(blocks)) | |||
| 193 | + | for _, b := range blocks { | |||
| 194 | + | switch b.Type { | |||
| 195 | + | case "text": | |||
| 196 | + | out = append(out, provider.TextBlock{Text: b.Text}) | |||
| 197 | + | case "image": | |||
| 198 | + | out = append(out, provider.ImageBlock{MimeType: b.MimeType, Data: b.Data}) | |||
| 199 | + | case "tool_call": | |||
| 200 | + | args := b.Args | |||
| 201 | + | if len(args) == 0 { | |||
| 202 | + | args = json.RawMessage("{}") | |||
| 203 | + | } | |||
| 204 | + | out = append(out, provider.ToolCallBlock{ID: b.ID, Name: b.Name, Arguments: args}) | |||
| 205 | + | case "tool_result": | |||
| 206 | + | out = append(out, provider.ToolResultBlock{ | |||
| 207 | + | CallID: b.CallID, | |||
| 208 | + | IsError: b.IsError, | |||
| 209 | + | Content: rebuildContent(b.Content), | |||
| 210 | + | }) | |||
| 211 | + | } | |||
| 212 | + | } | |||
| 213 | + | return out | |||
| 214 | + | } | |||
A pkg/zotcore/zotcore.go +350 -0
| @@ -0,0 +1,350 @@ | |||
| 1 | + | // Package zotcore is the public Go SDK for embedding the zot agent | |
| 2 | + | // runtime in third-party programs. It is the only stable, importable | |
| 3 | + | // surface the project exposes; everything under internal/ is subject | |
| 4 | + | // to change without notice. | |
| 5 | + | // | |
| 6 | + | // Lifecycle: | |
| 7 | + | // | |
| 8 | + | // rt, err := zotcore.New(zotcore.Config{Provider: "anthropic", Model: "claude-sonnet-4-5"}) | |
| 9 | + | // if err != nil { ... } | |
| 10 | + | // defer rt.Close() | |
| 11 | + | // | |
| 12 | + | // events, err := rt.Prompt(ctx, "fix the failing test", nil) | |
| 13 | + | // if err != nil { ... } | |
| 14 | + | // for ev := range events { | |
| 15 | + | // fmt.Printf("%s: %+v\n", ev.Type(), ev) | |
| 16 | + | // } | |
| 17 | + | // | |
| 18 | + | // Concurrency model: one prompt at a time per Runtime. Spawn one | |
| 19 | + | // Runtime per project / cwd. The Cancel call interrupts the active | |
| 20 | + | // prompt; subsequent prompts work normally. | |
| 21 | + | // | |
| 22 | + | // For a non-Go consumer, run `zot rpc` and speak the same JSON | |
| 23 | + | // schema over stdin/stdout. See docs/rpc.md. | |
| 24 | + | package zotcore | |
| 25 | + | ||
| 26 | + | import ( | |
| 27 | + | "context" | |
| 28 | + | "errors" | |
| 29 | + | "fmt" | |
| 30 | + | "sync" | |
| 31 | + | ||
| 32 | + | "github.com/patriceckhart/zot/internal/agent" | |
| 33 | + | "github.com/patriceckhart/zot/internal/core" | |
| 34 | + | "github.com/patriceckhart/zot/internal/provider" | |
| 35 | + | ) | |
| 36 | + | ||
| 37 | + | // Config configures a Runtime. All fields are optional; sensible | |
| 38 | + | // defaults are read from $ZOT_HOME/config.json, env vars, and the | |
| 39 | + | // resolver chain (the same one the cli uses). | |
| 40 | + | type Config struct { | |
| 41 | + | // Provider is "anthropic" or "openai". Empty = use the user's | |
| 42 | + | // default from config.json or env. | |
| 43 | + | Provider string | |
| 44 | + | ||
| 45 | + | // Model is the model id. Empty = use the provider's default. | |
| 46 | + | Model string | |
| 47 | + | ||
| 48 | + | // CWD is the working directory the agent operates in. Tools are | |
| 49 | + | // confined to this directory when Lock is true. Empty = current | |
| 50 | + | // process cwd. | |
| 51 | + | CWD string | |
| 52 | + | ||
| 53 | + | // SystemPrompt overrides the built-in system prompt. | |
| 54 | + | SystemPrompt string | |
| 55 | + | ||
| 56 | + | // AppendSystemPrompt is appended to the built-in (or overridden) | |
| 57 | + | // system prompt. Useful for project-specific instructions. | |
| 58 | + | AppendSystemPrompt []string | |
| 59 | + | ||
| 60 | + | // Reasoning sets the reasoning effort for models that support it | |
| 61 | + | // ("low", "medium", "high"). Empty = no reasoning. | |
| 62 | + | Reasoning string | |
| 63 | + | ||
| 64 | + | // MaxSteps caps the agent loop iterations per Prompt call. | |
| 65 | + | // 0 uses the default (50). | |
| 66 | + | MaxSteps int | |
| 67 | + | ||
| 68 | + | // APIKey overrides the credential lookup chain. | |
| 69 | + | APIKey string | |
| 70 | + | ||
| 71 | + | // BaseURL overrides the provider base url (for tests / proxies). | |
| 72 | + | BaseURL string | |
| 73 | + | ||
| 74 | + | // Tools is the list of tools to enable. Nil/empty = all four | |
| 75 | + | // (read, write, edit, bash). Pass an empty-but-non-nil slice | |
| 76 | + | // (e.g. []string{}) plus NoTools=true to disable everything. | |
| 77 | + | Tools []string | |
| 78 | + | ||
| 79 | + | // NoTools disables every tool. Useful for chat-only embeddings. | |
| 80 | + | NoTools bool | |
| 81 | + | ||
| 82 | + | // Lock confines tools to CWD. Same effect as the /lock command. | |
| 83 | + | Lock bool | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // Runtime is one zot agent session. Safe for use from one goroutine | |
| 87 | + | // at a time per Runtime; create separate Runtimes for parallel work. | |
| 88 | + | type Runtime struct { | |
| 89 | + | mu sync.Mutex | |
| 90 | + | agent *core.Agent | |
| 91 | + | provider string | |
| 92 | + | model string | |
| 93 | + | cwd string | |
| 94 | + | ||
| 95 | + | // activeCancel is set while a Prompt is streaming. | |
| 96 | + | activeCancel context.CancelFunc | |
| 97 | + | ||
| 98 | + | // closed signals that Close has been called. | |
| 99 | + | closed bool | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // New constructs a Runtime from cfg. Returns an error if no | |
| 103 | + | // credential is available for the requested provider. | |
| 104 | + | func New(cfg Config) (*Runtime, error) { | |
| 105 | + | args := agent.Args{ | |
| 106 | + | Mode: agent.ModeJSON, // headless | |
| 107 | + | Provider: cfg.Provider, | |
| 108 | + | Model: cfg.Model, | |
| 109 | + | CWD: cfg.CWD, | |
| 110 | + | APIKey: cfg.APIKey, | |
| 111 | + | BaseURL: cfg.BaseURL, | |
| 112 | + | SystemPrompt: cfg.SystemPrompt, | |
| 113 | + | AppendSystemPrompt: cfg.AppendSystemPrompt, | |
| 114 | + | Reasoning: cfg.Reasoning, | |
| 115 | + | MaxSteps: cfg.MaxSteps, | |
| 116 | + | Tools: cfg.Tools, | |
| 117 | + | NoTools: cfg.NoTools, | |
| 118 | + | NoSess: true, // SDK callers manage persistence themselves | |
| 119 | + | } | |
| 120 | + | if args.MaxSteps == 0 { | |
| 121 | + | args.MaxSteps = 50 | |
| 122 | + | } | |
| 123 | + | r, err := agent.Resolve(args, true) | |
| 124 | + | if err != nil { | |
| 125 | + | return nil, err | |
| 126 | + | } | |
| 127 | + | if cfg.Lock && r.Sandbox != nil { | |
| 128 | + | r.Sandbox.Lock() | |
| 129 | + | } | |
| 130 | + | ag := r.NewAgent() | |
| 131 | + | return &Runtime{ | |
| 132 | + | agent: ag, | |
| 133 | + | provider: r.Provider, | |
| 134 | + | model: r.Model, | |
| 135 | + | cwd: r.CWD, | |
| 136 | + | }, nil | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // Provider returns the active provider id. | |
| 140 | + | func (r *Runtime) Provider() string { r.mu.Lock(); defer r.mu.Unlock(); return r.provider } | |
| 141 | + | ||
| 142 | + | // Model returns the active model id. | |
| 143 | + | func (r *Runtime) Model() string { r.mu.Lock(); defer r.mu.Unlock(); return r.model } | |
| 144 | + | ||
| 145 | + | // CWD returns the working directory the agent operates in. | |
| 146 | + | func (r *Runtime) CWD() string { r.mu.Lock(); defer r.mu.Unlock(); return r.cwd } | |
| 147 | + | ||
| 148 | + | // Messages returns a copy of the current transcript. | |
| 149 | + | func (r *Runtime) Messages() []Message { | |
| 150 | + | if r.agent == nil { | |
| 151 | + | return nil | |
| 152 | + | } | |
| 153 | + | src := r.agent.Messages() | |
| 154 | + | out := make([]Message, len(src)) | |
| 155 | + | for i, m := range src { | |
| 156 | + | out[i] = Message{ | |
| 157 | + | Role: string(m.Role), | |
| 158 | + | Content: convertContent(m.Content), | |
| 159 | + | Time: m.Time.Format("2006-01-02T15:04:05Z"), | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | return out | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // SetMessages replaces the transcript. Use to seed history from a | |
| 166 | + | // session file or to clear with nil. | |
| 167 | + | func (r *Runtime) SetMessages(msgs []Message) { | |
| 168 | + | if r.agent == nil { | |
| 169 | + | return | |
| 170 | + | } | |
| 171 | + | out := make([]provider.Message, 0, len(msgs)) | |
| 172 | + | for _, m := range msgs { | |
| 173 | + | out = append(out, provider.Message{ | |
| 174 | + | Role: provider.Role(m.Role), | |
| 175 | + | Content: rebuildContent(m.Content), | |
| 176 | + | }) | |
| 177 | + | } | |
| 178 | + | r.agent.SetMessages(out) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | // Cost returns the cumulative usage and dollar cost for this Runtime. | |
| 182 | + | func (r *Runtime) Cost() Usage { | |
| 183 | + | if r.agent == nil { | |
| 184 | + | return Usage{} | |
| 185 | + | } | |
| 186 | + | u := r.agent.Cost() | |
| 187 | + | return Usage{ | |
| 188 | + | Input: u.InputTokens, | |
| 189 | + | Output: u.OutputTokens, | |
| 190 | + | CacheRead: u.CacheReadTokens, | |
| 191 | + | CacheWrite: u.CacheWriteTokens, | |
| 192 | + | CostUSD: u.CostUSD, | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // Prompt sends a user message and runs the agent loop. Returns a | |
| 197 | + | // channel that emits one Event per agent action, closed when the | |
| 198 | + | // turn finishes (cleanly or with error). Only one Prompt may be | |
| 199 | + | // active at a time per Runtime; concurrent calls return ErrBusy. | |
| 200 | + | func (r *Runtime) Prompt(ctx context.Context, text string, images []Image) (<-chan Event, error) { | |
| 201 | + | r.mu.Lock() | |
| 202 | + | if r.closed { | |
| 203 | + | r.mu.Unlock() | |
| 204 | + | return nil, ErrClosed | |
| 205 | + | } | |
| 206 | + | if r.activeCancel != nil { | |
| 207 | + | r.mu.Unlock() | |
| 208 | + | return nil, ErrBusy | |
| 209 | + | } | |
| 210 | + | if r.agent == nil { | |
| 211 | + | r.mu.Unlock() | |
| 212 | + | return nil, fmt.Errorf("zotcore: no agent (login first via /login or set credentials)") | |
| 213 | + | } | |
| 214 | + | subCtx, cancel := context.WithCancel(ctx) | |
| 215 | + | r.activeCancel = cancel | |
| 216 | + | r.mu.Unlock() | |
| 217 | + | ||
| 218 | + | imgBlocks := make([]provider.ImageBlock, 0, len(images)) | |
| 219 | + | for _, img := range images { | |
| 220 | + | imgBlocks = append(imgBlocks, provider.ImageBlock{MimeType: img.MimeType, Data: img.Data}) | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | out := make(chan Event, 16) | |
| 224 | + | go func() { | |
| 225 | + | defer close(out) | |
| 226 | + | defer func() { | |
| 227 | + | r.mu.Lock() | |
| 228 | + | r.activeCancel = nil | |
| 229 | + | r.mu.Unlock() | |
| 230 | + | }() | |
| 231 | + | err := r.agent.Prompt(subCtx, text, imgBlocks, func(ev core.AgentEvent) { | |
| 232 | + | out <- toEvent(ev) | |
| 233 | + | }) | |
| 234 | + | if err != nil && !errors.Is(err, context.Canceled) { | |
| 235 | + | out <- Event{Type: "error", Error: err.Error()} | |
| 236 | + | } | |
| 237 | + | }() | |
| 238 | + | return out, nil | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | // Cancel interrupts the active Prompt, if any. Safe to call when no | |
| 242 | + | // prompt is in flight (no-op). | |
| 243 | + | func (r *Runtime) Cancel() { | |
| 244 | + | r.mu.Lock() | |
| 245 | + | cancel := r.activeCancel | |
| 246 | + | r.mu.Unlock() | |
| 247 | + | if cancel != nil { | |
| 248 | + | cancel() | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | // Compact summarises the current transcript into a single synthetic | |
| 253 | + | // user message, freeing up context. Blocks until done. | |
| 254 | + | func (r *Runtime) Compact(ctx context.Context, customInstructions string) (CompactResult, error) { | |
| 255 | + | if r.agent == nil { | |
| 256 | + | return CompactResult{}, fmt.Errorf("zotcore: no agent") | |
| 257 | + | } | |
| 258 | + | r.mu.Lock() | |
| 259 | + | if r.activeCancel != nil { | |
| 260 | + | r.mu.Unlock() | |
| 261 | + | return CompactResult{}, ErrBusy | |
| 262 | + | } | |
| 263 | + | subCtx, cancel := context.WithCancel(ctx) | |
| 264 | + | r.activeCancel = cancel | |
| 265 | + | r.mu.Unlock() | |
| 266 | + | defer func() { | |
| 267 | + | r.mu.Lock() | |
| 268 | + | r.activeCancel = nil | |
| 269 | + | r.mu.Unlock() | |
| 270 | + | }() | |
| 271 | + | ||
| 272 | + | summary, err := r.agent.Compact(subCtx, 4, nil) | |
| 273 | + | if err != nil { | |
| 274 | + | return CompactResult{}, err | |
| 275 | + | } | |
| 276 | + | return CompactResult{Summary: summary, Messages: r.Messages()}, nil | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | // SetModel switches the active model. Same provider only; for cross- | |
| 280 | + | // provider switches, create a new Runtime. | |
| 281 | + | func (r *Runtime) SetModel(model string) error { | |
| 282 | + | r.mu.Lock() | |
| 283 | + | defer r.mu.Unlock() | |
| 284 | + | if r.agent == nil { | |
| 285 | + | return fmt.Errorf("zotcore: no agent") | |
| 286 | + | } | |
| 287 | + | if _, err := provider.FindModel(r.provider, model); err != nil { | |
| 288 | + | return err | |
| 289 | + | } | |
| 290 | + | r.agent.Model = model | |
| 291 | + | r.model = model | |
| 292 | + | return nil | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | // State returns a snapshot of the runtime's current state. Useful for | |
| 296 | + | // driving UIs. | |
| 297 | + | func (r *Runtime) State() State { | |
| 298 | + | r.mu.Lock() | |
| 299 | + | defer r.mu.Unlock() | |
| 300 | + | return State{ | |
| 301 | + | Provider: r.provider, | |
| 302 | + | Model: r.model, | |
| 303 | + | CWD: r.cwd, | |
| 304 | + | Busy: r.activeCancel != nil, | |
| 305 | + | MessageCount: len(r.Messages()), | |
| 306 | + | } | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | // Close releases resources held by the Runtime. Cancels any active | |
| 310 | + | // prompt. Subsequent calls return ErrClosed. | |
| 311 | + | func (r *Runtime) Close() error { | |
| 312 | + | r.mu.Lock() | |
| 313 | + | if r.closed { | |
| 314 | + | r.mu.Unlock() | |
| 315 | + | return nil | |
| 316 | + | } | |
| 317 | + | r.closed = true | |
| 318 | + | cancel := r.activeCancel | |
| 319 | + | r.mu.Unlock() | |
| 320 | + | if cancel != nil { | |
| 321 | + | cancel() | |
| 322 | + | } | |
| 323 | + | return nil | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | // ListModels returns every model known to the runtime for the | |
| 327 | + | // current provider (catalog + live discovery if cached). | |
| 328 | + | func (r *Runtime) ListModels() []ModelInfo { | |
| 329 | + | models := provider.ModelsForProvider(r.Provider()) | |
| 330 | + | out := make([]ModelInfo, 0, len(models)) | |
| 331 | + | for _, m := range models { | |
| 332 | + | out = append(out, ModelInfo{ | |
| 333 | + | ID: m.ID, | |
| 334 | + | Provider: m.Provider, | |
| 335 | + | ContextWindow: m.ContextWindow, | |
| 336 | + | MaxOutput: m.MaxOutput, | |
| 337 | + | Reasoning: m.Reasoning, | |
| 338 | + | }) | |
| 339 | + | } | |
| 340 | + | return out | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | // ---- public errors ---- | |
| 344 | + | ||
| 345 | + | // ErrBusy is returned when a Prompt or Compact is started while | |
| 346 | + | // another is in flight on the same Runtime. | |
| 347 | + | var ErrBusy = errors.New("zotcore: runtime is busy") | |
| 348 | + | ||
| 349 | + | // ErrClosed is returned by methods on a Runtime after Close. | |
| 350 | + | var ErrClosed = errors.New("zotcore: runtime is closed") | |
| @@ -0,0 +1,350 @@ | |||||
| 1 | + | // Package zotcore is the public Go SDK for embedding the zot agent | |||
| 2 | + | // runtime in third-party programs. It is the only stable, importable | |||
| 3 | + | // surface the project exposes; everything under internal/ is subject | |||
| 4 | + | // to change without notice. | |||
| 5 | + | // | |||
| 6 | + | // Lifecycle: | |||
| 7 | + | // | |||
| 8 | + | // rt, err := zotcore.New(zotcore.Config{Provider: "anthropic", Model: "claude-sonnet-4-5"}) | |||
| 9 | + | // if err != nil { ... } | |||
| 10 | + | // defer rt.Close() | |||
| 11 | + | // | |||
| 12 | + | // events, err := rt.Prompt(ctx, "fix the failing test", nil) | |||
| 13 | + | // if err != nil { ... } | |||
| 14 | + | // for ev := range events { | |||
| 15 | + | // fmt.Printf("%s: %+v\n", ev.Type(), ev) | |||
| 16 | + | // } | |||
| 17 | + | // | |||
| 18 | + | // Concurrency model: one prompt at a time per Runtime. Spawn one | |||
| 19 | + | // Runtime per project / cwd. The Cancel call interrupts the active | |||
| 20 | + | // prompt; subsequent prompts work normally. | |||
| 21 | + | // | |||
| 22 | + | // For a non-Go consumer, run `zot rpc` and speak the same JSON | |||
| 23 | + | // schema over stdin/stdout. See docs/rpc.md. | |||
| 24 | + | package zotcore | |||
| 25 | + | ||||
| 26 | + | import ( | |||
| 27 | + | "context" | |||
| 28 | + | "errors" | |||
| 29 | + | "fmt" | |||
| 30 | + | "sync" | |||
| 31 | + | ||||
| 32 | + | "github.com/patriceckhart/zot/internal/agent" | |||
| 33 | + | "github.com/patriceckhart/zot/internal/core" | |||
| 34 | + | "github.com/patriceckhart/zot/internal/provider" | |||
| 35 | + | ) | |||
| 36 | + | ||||
| 37 | + | // Config configures a Runtime. All fields are optional; sensible | |||
| 38 | + | // defaults are read from $ZOT_HOME/config.json, env vars, and the | |||
| 39 | + | // resolver chain (the same one the cli uses). | |||
| 40 | + | type Config struct { | |||
| 41 | + | // Provider is "anthropic" or "openai". Empty = use the user's | |||
| 42 | + | // default from config.json or env. | |||
| 43 | + | Provider string | |||
| 44 | + | ||||
| 45 | + | // Model is the model id. Empty = use the provider's default. | |||
| 46 | + | Model string | |||
| 47 | + | ||||
| 48 | + | // CWD is the working directory the agent operates in. Tools are | |||
| 49 | + | // confined to this directory when Lock is true. Empty = current | |||
| 50 | + | // process cwd. | |||
| 51 | + | CWD string | |||
| 52 | + | ||||
| 53 | + | // SystemPrompt overrides the built-in system prompt. | |||
| 54 | + | SystemPrompt string | |||
| 55 | + | ||||
| 56 | + | // AppendSystemPrompt is appended to the built-in (or overridden) | |||
| 57 | + | // system prompt. Useful for project-specific instructions. | |||
| 58 | + | AppendSystemPrompt []string | |||
| 59 | + | ||||
| 60 | + | // Reasoning sets the reasoning effort for models that support it | |||
| 61 | + | // ("low", "medium", "high"). Empty = no reasoning. | |||
| 62 | + | Reasoning string | |||
| 63 | + | ||||
| 64 | + | // MaxSteps caps the agent loop iterations per Prompt call. | |||
| 65 | + | // 0 uses the default (50). | |||
| 66 | + | MaxSteps int | |||
| 67 | + | ||||
| 68 | + | // APIKey overrides the credential lookup chain. | |||
| 69 | + | APIKey string | |||
| 70 | + | ||||
| 71 | + | // BaseURL overrides the provider base url (for tests / proxies). | |||
| 72 | + | BaseURL string | |||
| 73 | + | ||||
| 74 | + | // Tools is the list of tools to enable. Nil/empty = all four | |||
| 75 | + | // (read, write, edit, bash). Pass an empty-but-non-nil slice | |||
| 76 | + | // (e.g. []string{}) plus NoTools=true to disable everything. | |||
| 77 | + | Tools []string | |||
| 78 | + | ||||
| 79 | + | // NoTools disables every tool. Useful for chat-only embeddings. | |||
| 80 | + | NoTools bool | |||
| 81 | + | ||||
| 82 | + | // Lock confines tools to CWD. Same effect as the /lock command. | |||
| 83 | + | Lock bool | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // Runtime is one zot agent session. Safe for use from one goroutine | |||
| 87 | + | // at a time per Runtime; create separate Runtimes for parallel work. | |||
| 88 | + | type Runtime struct { | |||
| 89 | + | mu sync.Mutex | |||
| 90 | + | agent *core.Agent | |||
| 91 | + | provider string | |||
| 92 | + | model string | |||
| 93 | + | cwd string | |||
| 94 | + | ||||
| 95 | + | // activeCancel is set while a Prompt is streaming. | |||
| 96 | + | activeCancel context.CancelFunc | |||
| 97 | + | ||||
| 98 | + | // closed signals that Close has been called. | |||
| 99 | + | closed bool | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // New constructs a Runtime from cfg. Returns an error if no | |||
| 103 | + | // credential is available for the requested provider. | |||
| 104 | + | func New(cfg Config) (*Runtime, error) { | |||
| 105 | + | args := agent.Args{ | |||
| 106 | + | Mode: agent.ModeJSON, // headless | |||
| 107 | + | Provider: cfg.Provider, | |||
| 108 | + | Model: cfg.Model, | |||
| 109 | + | CWD: cfg.CWD, | |||
| 110 | + | APIKey: cfg.APIKey, | |||
| 111 | + | BaseURL: cfg.BaseURL, | |||
| 112 | + | SystemPrompt: cfg.SystemPrompt, | |||
| 113 | + | AppendSystemPrompt: cfg.AppendSystemPrompt, | |||
| 114 | + | Reasoning: cfg.Reasoning, | |||
| 115 | + | MaxSteps: cfg.MaxSteps, | |||
| 116 | + | Tools: cfg.Tools, | |||
| 117 | + | NoTools: cfg.NoTools, | |||
| 118 | + | NoSess: true, // SDK callers manage persistence themselves | |||
| 119 | + | } | |||
| 120 | + | if args.MaxSteps == 0 { | |||
| 121 | + | args.MaxSteps = 50 | |||
| 122 | + | } | |||
| 123 | + | r, err := agent.Resolve(args, true) | |||
| 124 | + | if err != nil { | |||
| 125 | + | return nil, err | |||
| 126 | + | } | |||
| 127 | + | if cfg.Lock && r.Sandbox != nil { | |||
| 128 | + | r.Sandbox.Lock() | |||
| 129 | + | } | |||
| 130 | + | ag := r.NewAgent() | |||
| 131 | + | return &Runtime{ | |||
| 132 | + | agent: ag, | |||
| 133 | + | provider: r.Provider, | |||
| 134 | + | model: r.Model, | |||
| 135 | + | cwd: r.CWD, | |||
| 136 | + | }, nil | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // Provider returns the active provider id. | |||
| 140 | + | func (r *Runtime) Provider() string { r.mu.Lock(); defer r.mu.Unlock(); return r.provider } | |||
| 141 | + | ||||
| 142 | + | // Model returns the active model id. | |||
| 143 | + | func (r *Runtime) Model() string { r.mu.Lock(); defer r.mu.Unlock(); return r.model } | |||
| 144 | + | ||||
| 145 | + | // CWD returns the working directory the agent operates in. | |||
| 146 | + | func (r *Runtime) CWD() string { r.mu.Lock(); defer r.mu.Unlock(); return r.cwd } | |||
| 147 | + | ||||
| 148 | + | // Messages returns a copy of the current transcript. | |||
| 149 | + | func (r *Runtime) Messages() []Message { | |||
| 150 | + | if r.agent == nil { | |||
| 151 | + | return nil | |||
| 152 | + | } | |||
| 153 | + | src := r.agent.Messages() | |||
| 154 | + | out := make([]Message, len(src)) | |||
| 155 | + | for i, m := range src { | |||
| 156 | + | out[i] = Message{ | |||
| 157 | + | Role: string(m.Role), | |||
| 158 | + | Content: convertContent(m.Content), | |||
| 159 | + | Time: m.Time.Format("2006-01-02T15:04:05Z"), | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | return out | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // SetMessages replaces the transcript. Use to seed history from a | |||
| 166 | + | // session file or to clear with nil. | |||
| 167 | + | func (r *Runtime) SetMessages(msgs []Message) { | |||
| 168 | + | if r.agent == nil { | |||
| 169 | + | return | |||
| 170 | + | } | |||
| 171 | + | out := make([]provider.Message, 0, len(msgs)) | |||
| 172 | + | for _, m := range msgs { | |||
| 173 | + | out = append(out, provider.Message{ | |||
| 174 | + | Role: provider.Role(m.Role), | |||
| 175 | + | Content: rebuildContent(m.Content), | |||
| 176 | + | }) | |||
| 177 | + | } | |||
| 178 | + | r.agent.SetMessages(out) | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | // Cost returns the cumulative usage and dollar cost for this Runtime. | |||
| 182 | + | func (r *Runtime) Cost() Usage { | |||
| 183 | + | if r.agent == nil { | |||
| 184 | + | return Usage{} | |||
| 185 | + | } | |||
| 186 | + | u := r.agent.Cost() | |||
| 187 | + | return Usage{ | |||
| 188 | + | Input: u.InputTokens, | |||
| 189 | + | Output: u.OutputTokens, | |||
| 190 | + | CacheRead: u.CacheReadTokens, | |||
| 191 | + | CacheWrite: u.CacheWriteTokens, | |||
| 192 | + | CostUSD: u.CostUSD, | |||
| 193 | + | } | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | // Prompt sends a user message and runs the agent loop. Returns a | |||
| 197 | + | // channel that emits one Event per agent action, closed when the | |||
| 198 | + | // turn finishes (cleanly or with error). Only one Prompt may be | |||
| 199 | + | // active at a time per Runtime; concurrent calls return ErrBusy. | |||
| 200 | + | func (r *Runtime) Prompt(ctx context.Context, text string, images []Image) (<-chan Event, error) { | |||
| 201 | + | r.mu.Lock() | |||
| 202 | + | if r.closed { | |||
| 203 | + | r.mu.Unlock() | |||
| 204 | + | return nil, ErrClosed | |||
| 205 | + | } | |||
| 206 | + | if r.activeCancel != nil { | |||
| 207 | + | r.mu.Unlock() | |||
| 208 | + | return nil, ErrBusy | |||
| 209 | + | } | |||
| 210 | + | if r.agent == nil { | |||
| 211 | + | r.mu.Unlock() | |||
| 212 | + | return nil, fmt.Errorf("zotcore: no agent (login first via /login or set credentials)") | |||
| 213 | + | } | |||
| 214 | + | subCtx, cancel := context.WithCancel(ctx) | |||
| 215 | + | r.activeCancel = cancel | |||
| 216 | + | r.mu.Unlock() | |||
| 217 | + | ||||
| 218 | + | imgBlocks := make([]provider.ImageBlock, 0, len(images)) | |||
| 219 | + | for _, img := range images { | |||
| 220 | + | imgBlocks = append(imgBlocks, provider.ImageBlock{MimeType: img.MimeType, Data: img.Data}) | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | out := make(chan Event, 16) | |||
| 224 | + | go func() { | |||
| 225 | + | defer close(out) | |||
| 226 | + | defer func() { | |||
| 227 | + | r.mu.Lock() | |||
| 228 | + | r.activeCancel = nil | |||
| 229 | + | r.mu.Unlock() | |||
| 230 | + | }() | |||
| 231 | + | err := r.agent.Prompt(subCtx, text, imgBlocks, func(ev core.AgentEvent) { | |||
| 232 | + | out <- toEvent(ev) | |||
| 233 | + | }) | |||
| 234 | + | if err != nil && !errors.Is(err, context.Canceled) { | |||
| 235 | + | out <- Event{Type: "error", Error: err.Error()} | |||
| 236 | + | } | |||
| 237 | + | }() | |||
| 238 | + | return out, nil | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | // Cancel interrupts the active Prompt, if any. Safe to call when no | |||
| 242 | + | // prompt is in flight (no-op). | |||
| 243 | + | func (r *Runtime) Cancel() { | |||
| 244 | + | r.mu.Lock() | |||
| 245 | + | cancel := r.activeCancel | |||
| 246 | + | r.mu.Unlock() | |||
| 247 | + | if cancel != nil { | |||
| 248 | + | cancel() | |||
| 249 | + | } | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | // Compact summarises the current transcript into a single synthetic | |||
| 253 | + | // user message, freeing up context. Blocks until done. | |||
| 254 | + | func (r *Runtime) Compact(ctx context.Context, customInstructions string) (CompactResult, error) { | |||
| 255 | + | if r.agent == nil { | |||
| 256 | + | return CompactResult{}, fmt.Errorf("zotcore: no agent") | |||
| 257 | + | } | |||
| 258 | + | r.mu.Lock() | |||
| 259 | + | if r.activeCancel != nil { | |||
| 260 | + | r.mu.Unlock() | |||
| 261 | + | return CompactResult{}, ErrBusy | |||
| 262 | + | } | |||
| 263 | + | subCtx, cancel := context.WithCancel(ctx) | |||
| 264 | + | r.activeCancel = cancel | |||
| 265 | + | r.mu.Unlock() | |||
| 266 | + | defer func() { | |||
| 267 | + | r.mu.Lock() | |||
| 268 | + | r.activeCancel = nil | |||
| 269 | + | r.mu.Unlock() | |||
| 270 | + | }() | |||
| 271 | + | ||||
| 272 | + | summary, err := r.agent.Compact(subCtx, 4, nil) | |||
| 273 | + | if err != nil { | |||
| 274 | + | return CompactResult{}, err | |||
| 275 | + | } | |||
| 276 | + | return CompactResult{Summary: summary, Messages: r.Messages()}, nil | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | // SetModel switches the active model. Same provider only; for cross- | |||
| 280 | + | // provider switches, create a new Runtime. | |||
| 281 | + | func (r *Runtime) SetModel(model string) error { | |||
| 282 | + | r.mu.Lock() | |||
| 283 | + | defer r.mu.Unlock() | |||
| 284 | + | if r.agent == nil { | |||
| 285 | + | return fmt.Errorf("zotcore: no agent") | |||
| 286 | + | } | |||
| 287 | + | if _, err := provider.FindModel(r.provider, model); err != nil { | |||
| 288 | + | return err | |||
| 289 | + | } | |||
| 290 | + | r.agent.Model = model | |||
| 291 | + | r.model = model | |||
| 292 | + | return nil | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | // State returns a snapshot of the runtime's current state. Useful for | |||
| 296 | + | // driving UIs. | |||
| 297 | + | func (r *Runtime) State() State { | |||
| 298 | + | r.mu.Lock() | |||
| 299 | + | defer r.mu.Unlock() | |||
| 300 | + | return State{ | |||
| 301 | + | Provider: r.provider, | |||
| 302 | + | Model: r.model, | |||
| 303 | + | CWD: r.cwd, | |||
| 304 | + | Busy: r.activeCancel != nil, | |||
| 305 | + | MessageCount: len(r.Messages()), | |||
| 306 | + | } | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | // Close releases resources held by the Runtime. Cancels any active | |||
| 310 | + | // prompt. Subsequent calls return ErrClosed. | |||
| 311 | + | func (r *Runtime) Close() error { | |||
| 312 | + | r.mu.Lock() | |||
| 313 | + | if r.closed { | |||
| 314 | + | r.mu.Unlock() | |||
| 315 | + | return nil | |||
| 316 | + | } | |||
| 317 | + | r.closed = true | |||
| 318 | + | cancel := r.activeCancel | |||
| 319 | + | r.mu.Unlock() | |||
| 320 | + | if cancel != nil { | |||
| 321 | + | cancel() | |||
| 322 | + | } | |||
| 323 | + | return nil | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | // ListModels returns every model known to the runtime for the | |||
| 327 | + | // current provider (catalog + live discovery if cached). | |||
| 328 | + | func (r *Runtime) ListModels() []ModelInfo { | |||
| 329 | + | models := provider.ModelsForProvider(r.Provider()) | |||
| 330 | + | out := make([]ModelInfo, 0, len(models)) | |||
| 331 | + | for _, m := range models { | |||
| 332 | + | out = append(out, ModelInfo{ | |||
| 333 | + | ID: m.ID, | |||
| 334 | + | Provider: m.Provider, | |||
| 335 | + | ContextWindow: m.ContextWindow, | |||
| 336 | + | MaxOutput: m.MaxOutput, | |||
| 337 | + | Reasoning: m.Reasoning, | |||
| 338 | + | }) | |||
| 339 | + | } | |||
| 340 | + | return out | |||
| 341 | + | } | |||
| 342 | + | ||||
| 343 | + | // ---- public errors ---- | |||
| 344 | + | ||||
| 345 | + | // ErrBusy is returned when a Prompt or Compact is started while | |||
| 346 | + | // another is in flight on the same Runtime. | |||
| 347 | + | var ErrBusy = errors.New("zotcore: runtime is busy") | |||
| 348 | + | ||||
| 349 | + | // ErrClosed is returned by methods on a Runtime after Close. | |||
| 350 | + | var ErrClosed = errors.New("zotcore: runtime is closed") | |||