1 ---
2 name: agent-client-protocol
3 description: Implement or debug Agent Client Protocol (ACP) support in Rust for siGit Code. Use when working on ACP JSON-RPC over stdio, the agent-client-protocol crate, session/prompt/fork handlers, config options (model picker), slash commands, streaming notifications, or editor integration.
4 ---
5
6 # Skill: Agent Client Protocol (ACP) — Rust Implementation
7
8 ## Overview
9
10 ACP is a JSON-RPC 2.0 protocol over **stdio** for integrating AI coding agents
11 with editors (Zed, JetBrains, Neovim, etc.). The agent runs as a subprocess;
12 the editor is the client. Communication is newline-delimited JSON on stdin/stdout.
13
14 Crate: `agent-client-protocol = "0.13"` (siGit pins 0.13.0 in `Cargo.lock`)
15 Docs: https://docs.rs/agent-client-protocol
16 Spec: https://agentclientprotocol.com
17
18 > **Big change since 0.10:** the crate moved from a `#[async_trait(?Send)] impl Agent`
19 > model to a **builder** model. You no longer implement a trait. You build an
20 > `Agent` with per-message handler closures and `.connect_to(transport)`. Each
21 > handler receives a `ConnectionTo<Client>` (`cx`) you use to send notifications
22 > and spawn tasks — so the old mpsc "circular dependency" pattern is gone.
23
24 siGit's entire ACP server lives in `src/main.rs` (`run_acp_server`, the
25 `SiGitAgent` struct, and its `handle_*` methods). Read it alongside this skill.
26
27 ---
28
29 ## Dependency setup
30
31 ```toml
32 [dependencies]
33 agent-client-protocol = { version = "0.13", features = [
34 "unstable_session_fork", # session/fork support
35 "unstable_session_additional_directories", # additional_directories on session requests
36 "unstable_auth_methods", # AuthMethod::Agent etc.
37 ] }
38 async-trait = "0.1"
39 tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "io-std", "io-util", "sync", "time"] }
40 tokio-util = { version = "0.7", features = ["compat"] }
41 futures = "0.3"
42 uuid = { version = "1", features = ["v4"] }
43 ```
44
45 The `unstable_*` features gate real types/methods (`ForkSessionRequest`,
46 `additional_directories`, `AuthMethod::Agent`). Without them the corresponding
47 APIs don't exist and you'll get "no variant/method" errors.
48
49 ---
50
51 ## Imports
52
53 Protocol message/data types live under `agent_client_protocol::schema::*`.
54 Connection/runtime types live at the crate root.
55
56 ```rust
57 use agent_client_protocol::schema::{
58 AgentCapabilities, AuthMethod, AuthMethodAgent, AuthenticateRequest, AuthenticateResponse,
59 AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate, CancelNotification,
60 ConfigOptionUpdate, ContentBlock, ContentChunk, EmbeddedResourceResource, ForkSessionRequest,
61 ForkSessionResponse, Implementation, InitializeRequest, InitializeResponse, LoadSessionRequest,
62 LoadSessionResponse, Meta, NewSessionRequest, NewSessionResponse, PromptRequest,
63 PromptResponse, ProtocolVersion, SessionCapabilities, SessionConfigOption,
64 SessionConfigOptionCategory, SessionConfigSelectOption, SessionConfigValueId,
65 SessionForkCapabilities, SessionId, SessionNotification, SessionUpdate,
66 SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, StopReason, ToolCall,
67 ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput,
68 };
69 use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder};
70 ```
71
72 ---
73
74 ## Wiring up the server — the builder
75
76 You do **not** implement a trait. You hold your state in an `Arc<MyState>`, then
77 register one closure per incoming message type on `Agent.builder()`, and finish
78 with `.connect_to(transport).await`. The builder owns the JSON-RPC loop and runs
79 until the client disconnects.
80
81 ```rust
82 use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
83
84 async fn run_acp_server() -> anyhow::Result<()> {
85 let state = Arc::new(SiGitAgent::new(/* … */));
86
87 // Adapt tokio stdio to the futures AsyncRead/AsyncWrite the SDK expects.
88 let stdin = tokio::io::stdin().compat();
89 let stdout = tokio::io::stdout().compat_write();
90 let transport = ByteStreams::new(stdout, stdin); // note: (writer, reader)
91
92 Agent
93 .builder()
94 .on_receive_request(
95 {
96 let state = Arc::clone(&state);
97 async move |req: InitializeRequest, responder, _cx: ConnectionTo<Client>| {
98 handle_response(responder, state.handle_initialize(req).await)
99 }
100 },
101 agent_client_protocol::on_receive_request!(),
102 )
103 .on_receive_request(
104 {
105 let state = Arc::clone(&state);
106 async move |req: PromptRequest, responder, cx: ConnectionTo<Client>| {
107 handle_response(responder, state.handle_prompt(&cx, req).await)
108 }
109 },
110 agent_client_protocol::on_receive_request!(),
111 )
112 // … one .on_receive_request(…) per request type you support …
113 .on_receive_notification(
114 {
115 let state = Arc::clone(&state);
116 async move |notif: CancelNotification, _cx: ConnectionTo<Client>| {
117 state.handle_cancel(notif).await
118 }
119 },
120 agent_client_protocol::on_receive_notification!(),
121 )
122 .connect_to(transport)
123 .await
124 .map_err(|e| anyhow::anyhow!("ACP connection error: {e}"))?;
125
126 Ok(())
127 }
128 ```
129
130 Key points:
131
132 - **`Arc::clone(&state)` per closure.** Each handler closure is `move` and owns
133 its own `Arc` clone of shared state.
134 - **The macro is required.** Each handler is paired with
135 `agent_client_protocol::on_receive_request!()` (or `on_receive_notification!()`).
136 It wires the closure's concrete message type into the dispatcher. Don't omit it.
137 - **Closure signature for requests:** `async move |req: T, responder, cx: ConnectionTo<Client>|`.
138 Use `_cx` when a handler doesn't send notifications (e.g. `initialize`, `authenticate`).
139 - **Closure signature for notifications:** `async move |notif: T, cx: ConnectionTo<Client>|`
140 returning `agent_client_protocol::Result<()>` — no responder (notifications get no reply).
141 - **Unmatched messages fall to the SDK default** — you only register what you support.
142
143 ### The `Responder` + `handle_response` helper
144
145 Requests reply through a `Responder<T>`. siGit funnels every handler's
146 `Result` through one helper:
147
148 ```rust
149 fn handle_response<T: agent_client_protocol::JsonRpcResponse>(
150 responder: Responder<T>,
151 result: agent_client_protocol::Result<T>,
152 ) -> agent_client_protocol::Result<()> {
153 match result {
154 Ok(resp) => responder.respond(resp),
155 Err(err) => responder.respond_with_error(err),
156 }
157 }
158 ```
159
160 So each `handle_*` method just returns `Result<SomeResponse>` and stays free of
161 protocol plumbing.
162
163 ---
164
165 ## `ConnectionTo<Client>` — the `cx`
166
167 The per-handler `cx: ConnectionTo<Client>` replaces the old mpsc-channel forwarder.
168 It is `Clone`. Two things you do with it:
169
170 ```rust
171 // 1. Send a server→client notification (streaming chunks, tool-call updates, …)
172 cx.send_notification(SessionNotification::new(session_id.clone(), update))?;
173
174 // 2. Spawn a background task that keeps using cx (e.g. a progress spinner poller).
175 let cx_for_poller = cx.clone();
176 cx.spawn(async move {
177 loop {
178 // … cx_for_poller.send_notification(progress_update) …
179 # break;
180 }
181 Ok(())
182 }).ok();
183 ```
184
185 Because `cx` is handed to you directly, there is **no circular dependency** between
186 the connection and the agent anymore. Don't reintroduce the mpsc forwarder pattern.
187
188 ---
189
190 ## The handlers siGit implements
191
192 | Message | Method | Notes |
193 |---------|--------|-------|
194 | `InitializeRequest` | `handle_initialize` | capabilities, auth methods, agent info, `meta` |
195 | `AuthenticateRequest` | `handle_authenticate` | verifies stored siGit Code Cloud session |
196 | `NewSessionRequest` | `handle_new_session` | sets cwd, resets history, advertises commands + config options |
197 | `LoadSessionRequest` | `handle_load_session` | like new_session; gated by `load_session(true)` capability |
198 | `ForkSessionRequest` | `handle_fork_session` | gated by `unstable_session_fork` + `SessionForkCapabilities` |
199 | `PromptRequest` | `handle_prompt` | the turn: parse blocks → slash commands or tool-calling loop |
200 | `SetSessionConfigOptionRequest` | `handle_set_session_config_option` | the Zed model picker — switches/downloads models |
201 | `CancelNotification` | `handle_cancel` | notification, no response |
202
203 Everything else is left to the SDK default (method not found).
204
205 ---
206
207 ## Types and their builders
208
209 All `#[non_exhaustive]` structs require builder methods — struct-literal syntax
210 won't compile.
211
212 ### `InitializeResponse`
213
214 ```rust
215 Ok(InitializeResponse::new(ProtocolVersion::V1) // use V1, not args.protocol_version
216 .agent_info(
217 Implementation::new("sigit", env!("CARGO_PKG_VERSION"))
218 .title("siGit Code - AI Coding Agent"),
219 )
220 .auth_methods(vec![AuthMethod::Agent(
221 AuthMethodAgent::new("sigit", "Sign in to siGit Code")
222 .description("Sign in with `/login <email> <password>` in the message box."),
223 )])
224 .agent_capabilities(
225 AgentCapabilities::default()
226 .load_session(true) // enables LoadSessionRequest
227 .session_capabilities(
228 SessionCapabilities::new()
229 .fork(SessionForkCapabilities::new()), // enables ForkSessionRequest
230 ),
231 )
232 .meta(initialize_meta())) // free-form Meta (see below)
233 ```
234
235 `auth_methods` must include at least one `AuthMethod::Agent` or **Zed hangs on
236 "Loading…" forever.** siGit uses `Agent` (not `Terminal`) because Zed advertises
237 terminal-auth for custom agents but never actually spawns the login terminal, so
238 the button would be a silent no-op. With `Agent`, clicking calls `authenticate`.
239
240 ### `Meta` — free-form server metadata
241
242 `Meta` is a string-keyed JSON map you can attach to `InitializeResponse` (siGit
243 publishes the active model there so the editor can show it):
244
245 ```rust
246 let mut meta = Meta::new();
247 meta.insert("sigit".to_string(), serde_json::json!({
248 "active_model": { "display_name": "...", "model_id": "...", "gguf_file": "..." }
249 }));
250 ```
251
252 ### `AuthenticateResponse`
253
254 ```rust
255 Ok(AuthenticateResponse::default()) // success
256 // failure: return an Error — siGit uses -32000 "not signed in …"
257 ```
258
259 ### `NewSessionResponse` / `LoadSessionResponse` / `ForkSessionResponse`
260
261 ```rust
262 let session_id = SessionId::new(uuid::Uuid::new_v4().to_string());
263
264 Ok(NewSessionResponse::new(session_id).config_options(config_options))
265 Ok(LoadSessionResponse::new().config_options(config_options)) // no id arg — it's in the request
266 Ok(ForkSessionResponse::new(new_id).config_options(config_options))
267 ```
268
269 `SessionId` is a newtype with `Clone`, `PartialEq`, `Display`, `Into<String>`,
270 `AsRef<str>`. Store it as-is so `==` works. `config_options` powers the editor's
271 per-session picker (see Config options below).
272
273 The session requests carry `cwd: PathBuf` and (with the feature)
274 `additional_directories: Vec<PathBuf>`. siGit stashes `cwd`, `set_current_dir`s
275 to it, and pushes a system message telling the model to use absolute paths under it.
276
277 ### `PromptRequest` / blocks
278
279 ```rust
280 args.session_id // SessionId
281 args.prompt // Vec<ContentBlock>
282 ```
283
284 Editors send several block kinds — handle the three siGit cares about:
285
286 ```rust
287 for block in &args.prompt {
288 match block {
289 ContentBlock::Text(t) => { /* t.text */ }
290 ContentBlock::Resource(embedded) => match &embedded.resource {
291 // editor already inlined file content
292 EmbeddedResourceResource::TextResourceContents(tr) => { /* tr.uri, tr.text */ }
293 EmbeddedResourceResource::BlobResourceContents(b) => { /* b.uri */ }
294 _ => {}
295 },
296 ContentBlock::ResourceLink(link) => {
297 // a reference (e.g. `@file`); read it yourself.
298 // link.uri is "file:///abs/path#L207:219" (or #L207-219). Strip "file://",
299 // split the "#L<start>:<end>" fragment, read & slice the lines.
300 }
301 _ => {} // non_exhaustive — always a wildcard
302 }
303 }
304 ```
305
306 ### `PromptResponse`
307
308 ```rust
309 Ok(PromptResponse::new(StopReason::EndTurn))
310 // other reasons: MaxTokens, Cancelled, MaxTurnRequests, Refusal
311 ```
312
313 ### Streaming: `ContentChunk` + `SessionUpdate` + `SessionNotification`
314
315 ```rust
316 let chunk = ContentChunk::new(ContentBlock::from(delta_text)); // From<Into<String>>
317 let update = SessionUpdate::AgentMessageChunk(chunk);
318 cx.send_notification(SessionNotification::new(session_id.clone(), update))?;
319 ```
320
321 `SessionUpdate` variants siGit uses:
322
323 - `AgentMessageChunk(ContentChunk)` — assistant text.
324 - `ToolCall(ToolCall)` — start a tool-call card (used for model load/download progress).
325 - `ToolCallUpdate(ToolCallUpdate)` — update that card's title/status/content.
326 - `AvailableCommandsUpdate(AvailableCommandsUpdate)` — advertise slash commands.
327 - `ConfigOptionUpdate(ConfigOptionUpdate)` — refresh the picker mid-session.
328
329 (Other variants exist: `UserMessageChunk`, `AgentThoughtChunk`, `Plan`, …)
330
331 ### `ToolCall` / `ToolCallUpdate` — progress cards
332
333 siGit reuses tool-call cards as a generic progress UI (model loading/download):
334
335 ```rust
336 // open the card
337 SessionUpdate::ToolCall(
338 ToolCall::new(tool_call_id.clone(), "Loading Qwen 2.5 3B")
339 .kind(ToolKind::Think)
340 .status(ToolCallStatus::InProgress)
341 .content(vec!["Loading…".into()]),
342 )
343 // update it (only the fields you set)
344 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
345 tool_call_id.clone(),
346 ToolCallUpdateFields::new()
347 .title("✓ Qwen 2.5 3B loaded")
348 .status(ToolCallStatus::Completed),
349 ))
350 ```
351
352 `ToolCallStatus`: `InProgress`, `Completed`, `Failed`. `ToolKind::Think` is the
353 "thinking/util" kind.
354
355 ### `Error`
356
357 ```rust
358 agent_client_protocol::Error::new(-32603, "internal error message") // there is NO Error::internal()
359 agent_client_protocol::Error::new(-32602, "invalid params: …") // or Error::invalid_params()
360 agent_client_protocol::Error::new(-32000, "not signed in …") // app-defined
361 ```
362
363 ---
364
365 ## Config options — the editor model picker
366
367 ACP lets the agent expose per-session config controls; Zed renders them in the
368 agent panel. siGit uses one `select` option as a model picker.
369
370 ```rust
371 const MODEL_CONFIG_ID: &str = "sigit-model";
372
373 let options: Vec<SessionConfigSelectOption> = models.iter().map(|m| {
374 SessionConfigSelectOption::new(
375 SessionConfigValueId::new(m.model_id.as_str()),
376 format!("{} {badge}", m.display_name),
377 ).description(desc)
378 }).collect();
379
380 let config_options = vec![
381 SessionConfigOption::select(MODEL_CONFIG_ID, "Model", current_value, options)
382 .category(SessionConfigOptionCategory::Model)
383 .description("Select an on-device model or a siGit Code Cloud tier"),
384 ];
385 ```
386
387 Return these from new/load/fork session via `.config_options(config_options)`.
388 When the user picks one, the client sends `SetSessionConfigOptionRequest`:
389
390 ```rust
391 async fn handle_set_session_config_option(&self, cx: &ConnectionTo<Client>,
392 args: SetSessionConfigOptionRequest) -> Result<SetSessionConfigOptionResponse> {
393 if args.config_id.0.as_ref() != MODEL_CONFIG_ID { return Err(Error::new(-32602, "…")); }
394 let model_id = args.value.0.as_ref();
395 // … switch model, streaming ToolCall progress via cx …
396 Ok(SetSessionConfigOptionResponse::new(rebuilt_config_options))
397 }
398 ```
399
400 To refresh the picker mid-session (e.g. after `/reload`), push
401 `SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(config_options))`.
402
403 **Gotcha:** Zed re-fires the last selection on (re)connect. Guard against a no-op
404 re-select of the already-active model, and don't try to load a new model while a
405 startup load is still in flight (the old weights still hold GPU memory → the new
406 load fails with "does not fit"). siGit waits for `model_ready` first.
407
408 ---
409
410 ## Slash commands
411
412 Advertise them so the editor forwards `/`-prefixed input (Zed rejects unknown
413 slash commands client-side):
414
415 ```rust
416 let commands = vec![
417 AvailableCommand::new("help", "Show available commands"),
418 AvailableCommand::new("models", "List available models").input(
419 AvailableCommandInput::Unstructured(UnstructuredCommandInput::new(
420 "model number to switch to (optional)"))),
421 // … login/logout/whoami/reload/clear/status …
422 ];
423 cx.send_notification(SessionNotification::new(
424 session_id,
425 SessionUpdate::AvailableCommandsUpdate(AvailableCommandsUpdate::new(commands)),
426 ))?;
427 ```
428
429 siGit parses slash text out of the prompt itself (`parse_slash`) and dispatches in
430 `exec_slash_acp` before falling through to inference. The command turn still ends
431 with `Ok(PromptResponse::new(StopReason::EndTurn))`.
432
433 ---
434
435 ## Concurrency: the `block_in_place` trap (still real)
436
437 `mistralrs` model loading calls `tokio::task::block_in_place` internally, which
438 **panics off a multi-threaded runtime worker** ("can call blocking only when
439 running on the multi-threaded runtime"). The builder's task context and
440 `cx.spawn` tasks are not safe for this.
441
442 siGit's fix: **do the blocking model load on a dedicated `std::thread` with its
443 own fresh `tokio::runtime::Runtime`**, and signal completion back via an
444 `AtomicBool` / `oneshot` channel. Never call `load_gguf_model` directly inside a
445 prompt handler or a `cx.spawn` task.
446
447 ```rust
448 std::thread::spawn(move || {
449 let rt = tokio::runtime::Runtime::new().unwrap();
450 let result = rt.block_on(loader_engine.load_gguf_model(cfg, prompt, sampling));
451 // store result, flip an AtomicBool / send on a oneshot
452 });
453 ```
454
455 The prompt handler then `await`s readiness (siGit polls `model_ready` on a 1s
456 `tokio::time::interval`, streaming a spinner via `cx.send_notification`).
457
458 ---
459
460 ## Logging
461
462 stdout is the ACP JSON-RPC wire — **log only to stderr.** siGit uses
463 `tracing_subscriber` to stderr:
464
465 ```rust
466 tracing_subscriber::fmt::Subscriber::builder()
467 .with_env_filter(EnvFilter::try_from_default_env()
468 .unwrap_or_else(|_| EnvFilter::new("info")))
469 .with_writer(std::io::stderr)
470 .try_init();
471 ```
472
473 In siGit's interactive TTY mode (not ACP), it goes further and redirects the
474 stdout/stderr **fds** to `$TMPDIR/sigit.log` so mistralrs/native noise can't
475 corrupt the ratatui screen. ACP mode keeps stdout pristine for protocol JSON.
476
477 ---
478
479 ## TTY vs ACP split
480
481 `main()` decides mode from `std::io::stdin().is_terminal()`:
482
483 - **TTY** → interactive ratatui chat (`run_interactive`, Unix-only — needs fd
484 redirection).
485 - **non-TTY**`run_acp_server()` (editor launched it over a pipe).
486
487 Account verbs (`sigit login` / `logout` / `whoami`) are handled before the split,
488 since the editor launches `sigit login` in an embedded terminal.
489
490 ---
491
492 ## Protocol flow
493
494 ```
495 Editor Agent
496 │── initialize ──────────────────────►│ capabilities + auth methods + meta
497 │◄─ InitializeResponse ───────────────│
498 │── authenticate ────────────────────►│ (button → verify stored session)
499 │◄─ AuthenticateResponse ─────────────│
500 │── session/new (or load / fork) ───►│ cwd, reset history
501 │◄─ …Response(config_options) ────────│
502 │◄─ session/update AvailableCommands ─│ advertise slash commands
503 │── session/setConfigOption ─────────►│ (model picker) → ToolCall progress
504 │── session/prompt ──────────────────►│ user message (text + resources)
505 │◄─ session/update (N×) ──────────────│ streaming chunks / tool-call cards
506 │◄─ PromptResponse(EndTurn) ──────────│
507 │── session/cancel (notification) ───►│
508 │── [disconnect] ─────────────────────►│ connect_to future resolves → shutdown
509 ```
510
511 ---
512
513 ## Zed configuration
514
515 ```json
516 {
517 "agent_servers": {
518 "siGit Code": {
519 "type": "custom",
520 "command": "/absolute/path/to/target/release/sigit"
521 }
522 }
523 }
524 ```
525
526 ---
527
528 ## Gotchas
529
530 1. **No `Agent` trait to implement** — it's a builder. Register handler closures
531 with `.on_receive_request(closure, on_receive_request!())` and finish with
532 `.connect_to(transport)`. The `on_receive_request!()` / `on_receive_notification!()`
533 macro is mandatory per handler.
534 2. **`cx: ConnectionTo<Client>` replaces the mpsc forwarder** — send notifications
535 with `cx.send_notification(...)` and background tasks with `cx.spawn(...)`.
536 Don't reintroduce the old channel-based circular-dependency pattern.
537 3. **`Error::internal()` doesn't exist** — use `Error::new(-32603, msg)`.
538 4. **Everything in `agent_client_protocol::schema` is `#[non_exhaustive]`** — use
539 builder methods, never struct literals; add `_ => …` wildcards when matching.
540 5. **`ByteStreams::new(stdout, stdin)`** — writer first, reader second. Adapt
541 tokio stdio with `.compat()` / `.compat_write()` (tokio-util).
542 6. **`block_in_place` panics in handler/`cx.spawn` tasks** — run mistralrs model
543 loads on a dedicated `std::thread` + its own `Runtime`; signal back via
544 `AtomicBool`/`oneshot`. Never load inside a prompt handler directly.
545 7. **Empty `authMethods` hangs Zed** — always include at least one
546 `AuthMethod::Agent(AuthMethodAgent::new("id", "Name"))`. Prefer `Agent` over
547 `Terminal` for custom agents (Zed never spawns the terminal for them).
548 8. **Never write to stdout except JSON-RPC** — log to stderr; in TTY mode siGit
549 redirects fds to `$TMPDIR/sigit.log`. Any stray `println!` or native library
550 stdout write corrupts the wire.
551 9. **Unstable features gate real types**`unstable_session_fork`,
552 `unstable_session_additional_directories`, `unstable_auth_methods` must be on
553 in `Cargo.toml` or `ForkSessionRequest`, `additional_directories`, and
554 `AuthMethod::Agent` won't exist.
555 10. **Zed re-fires the last config selection on connect** — make
556 `setConfigOption` a no-op when the requested model is already active, and
557 never start a model switch while a startup load is still in flight (GPU OOM).
558 11. **Store `SessionId` as `SessionId`**, not `String`, so `==` is clean.
559 12. **`SetSessionConfigOptionResponse::new(config_options)`** — the response
560 carries the *rebuilt* options so the picker reflects the new current value.
561
562 ---
563
564 ## Where to look in the code
565
566 Everything ACP lives in `src/main.rs`:
567
568 - `run_acp_server` — builder wiring + transport.
569 - `SiGitAgent` + `handle_*` — the handlers.
570 - `build_model_config_options` / `resolve_model_config` — picker.
571 - `parse_slash` / `exec_slash_acp` — slash commands.
572 - `handle_response` — the `Responder` helper.
573
574 `src/backend.rs` holds the `InferenceBackend` trait (`LocalBackend` /
575 `OpenAiBackend`) used by `handle_prompt`'s tool-calling loop; `src/tools.rs`
576 defines the agent tools and `execute_tool`.