@setoelkahfi / sigit / commits / 3de9ece

docs(agents): sync skills with current code and add CLAUDE.md

Update the .agents skill files to match the current codebase: - agent-client-protocol: rewrite for agent-client-protocol 0.13 — the builder API (Agent.builder().on_receive_request(...)) replaces the old Agent trait impl, ConnectionTo<Client> replaces the mpsc forwarder, plus session fork, config options (model picker), and slash commands. - tool-calling: tool calling now covers the Qwen 3 family and Qwen 2.5 Coder 7B (not Qwen 3 only); document the InferenceBackend layer (LocalBackend/OpenAiBackend cloud tiers); models live in src/models.rs; onde is a crates.io dep; fix max_tokens and default-model claims. - ai-assisted-coding: onde is published on crates.io; replace the mpsc streaming example with cx.send_notification; fix the resource variant name and the block_in_place guidance. - sigit-code-release: add the release-crates.yml and release-homebrew.yml workflows. Add CLAUDE.md with repo guidance for Claude Code. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

paydii committed Jun 24, 2026 at 20:45 UTC 3de9ecec76bfc2b7eb537a3c02a36b97deb33a76
5 files changed +681 -238
.agents/skills/agent-client-protocol/SKILL.md
+437 -180
@@ -1,6 +1,6 @@
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 or prompt handlers, streaming notifications, or editor integration.
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
@@ -11,265 +11,501 @@ 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.10.4"` (latest as of 2025)
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]
24 -agent-client-protocol = "0.10.4"
25 -async-trait = "0.1"
26 -tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "io-std", "io-util", "sync"] }
27 -tokio-util = { version = "0.7", features = ["compat"] }
28 -futures = "0.3"
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
33 -## The `Agent` trait
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
35 -Declared `#[async_trait::async_trait(?Send)]` — futures are `!Send`.
36 -Your impl needs the same annotation:
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
39 -#[async_trait::async_trait(?Send)]
40 -impl Agent for MyAgent {
41 - async fn initialize(&self, args: InitializeRequest) -> Result<InitializeResponse> { ... }
42 - async fn authenticate(&self, args: AuthenticateRequest) -> Result<AuthenticateResponse> { ... }
43 - async fn new_session(&self, args: NewSessionRequest) -> Result<NewSessionResponse> { ... }
44 - async fn prompt(&self, args: PromptRequest) -> Result<PromptResponse> { ... }
45 - async fn cancel(&self, args: CancelNotification) -> Result<()> { ... }
46 - // All other methods have default impls that return Error::method_not_found()
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
50 -You must implement `initialize`, `authenticate`, `new_session`, `prompt`, and `cancel`.
51 -Everything else (`load_session`, `set_session_mode`, etc.) defaults to `Err(method_not_found)`.
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
57 -All `#[non_exhaustive]` structs require builder methods — struct literal syntax won't compile.
209 +All `#[non_exhaustive]` structs require builder methods — struct-literal syntax
210 +won't compile.
211
59 -### `InitializeRequest` / `InitializeResponse`
212 +### `InitializeResponse`
213
214 ```rust
62 -// Response builder — use ProtocolVersion::V1, NOT args.protocol_version:
63 -InitializeResponse::new(ProtocolVersion::V1)
215 +Ok(InitializeResponse::new(ProtocolVersion::V1) // use V1, not args.protocol_version
216 .agent_info(
65 - Implementation::new("my-agent", env!("CARGO_PKG_VERSION"))
66 - .title("My Agent"),
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 )
68 - .auth_methods(vec![AuthMethod::Agent(AuthMethodAgent::new(
69 - "my-agent", "My Agent",
70 - ))])
71 - .agent_capabilities(AgentCapabilities::default())
232 + .meta(initialize_meta())) // free-form Meta (see below)
233 ```
234
74 -`auth_methods` must include at least one `AuthMethod::Agent` or Zed hangs on
75 -"Loading…" forever. Import `AuthMethod`, `AuthMethodAgent`, and `ProtocolVersion`
76 -from the crate.
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
81 -Ok(AuthenticateResponse::default()) // No auth = just return default
255 +Ok(AuthenticateResponse::default()) // success
256 +// failure: return an Error — siGit uses -32000 "not signed in …"
257 ```
258
84 -### `NewSessionResponse`
259 +### `NewSessionResponse` / `LoadSessionResponse` / `ForkSessionResponse`
260
261 ```rust
262 let session_id = SessionId::new(uuid::Uuid::new_v4().to_string());
88 -Ok(NewSessionResponse::new(session_id))
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>`,
92 -and `AsRef<str>`. Store it as-is (not as `String`) so `==` works directly.
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
94 -### `PromptRequest`
277 +### `PromptRequest` / blocks
278
279 ```rust
97 -args.session_id // type: SessionId
98 -args.prompt // type: Vec<ContentBlock>
280 +args.session_id // SessionId
281 +args.prompt // Vec<ContentBlock>
282 ```
283
101 -Extract user text from the prompt:
284 +Editors send several block kinds — handle the three siGit cares about:
285 +
286 ```rust
103 -let user_text: String = args.prompt.iter()
104 - .filter_map(|block| match block {
105 - ContentBlock::Text(t) => Some(t.text.as_str()),
106 - _ => None,
107 - })
108 - .collect::<Vec<_>>()
109 - .join("\n");
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))
116 -// Other reasons: MaxTokens, Cancelled, MaxTurnRequests, Refusal
310 +// other reasons: MaxTokens, Cancelled, MaxTurnRequests, Refusal
311 ```
312
119 -### `ContentBlock`
313 +### Streaming: `ContentChunk` + `SessionUpdate` + `SessionNotification`
314
315 ```rust
122 -// Text block — use the From impl:
123 -ContentBlock::from("some text") // impl From<T: Into<String>> for ContentBlock
124 -
125 -// Pattern-match incoming blocks:
126 -match block {
127 - ContentBlock::Text(t) => t.text.as_str(),
128 - ContentBlock::ResourceLink(_) => ...,
129 - ContentBlock::Resource(_) => ...,
130 - _ => ..., // non_exhaustive — always need a wildcard
131 -}
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
134 -### `ContentChunk` + `SessionUpdate` — streaming
321 +`SessionUpdate` variants siGit uses:
322
136 -```rust
137 -let chunk = ContentChunk::new(ContentBlock::from(delta_text));
138 -let update = SessionUpdate::AgentMessageChunk(chunk);
139 -// Other variants: UserMessageChunk, AgentThoughtChunk, ToolCall, Plan, ...
140 -```
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
142 -### `SessionNotification` — send streaming content to client
333 +siGit reuses tool-call cards as a generic progress UI (model loading/download):
334
335 ```rust
145 -let notification = SessionNotification::new(session_id.clone(), update);
146 -// Deliver via AgentSideConnection::session_notification()
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
152 -// There is NO Error::internal(msg) method — use:
153 -agent_client_protocol::Error::new(-32603, "your message here")
154 -
155 -// For invalid params:
156 -agent_client_protocol::Error::invalid_params()
157 -
158 -// For method not found (already the trait default):
159 -agent_client_protocol::Error::method_not_found()
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
164 -## Running the agent — `AgentSideConnection`
365 +## Config options — the editor model picker
366
166 -Wraps stdin/stdout with JSON-RPC machinery.
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
169 -use futures::future::LocalBoxFuture;
170 -use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
171 -
172 -// Adapt tokio I/O to futures AsyncRead/AsyncWrite (the SDK expects these)
173 -let stdin = tokio::io::stdin().compat();
174 -let stdout = tokio::io::stdout().compat_write();
175 -
176 -// Must run inside a LocalSet — the spawn fn takes LocalBoxFuture (!Send)
177 -let local = tokio::task::LocalSet::new();
178 -local.run_until(async move {
179 - let (conn, io_task) = AgentSideConnection::new(
180 - agent,
181 - stdout,
182 - stdin,
183 - |fut: LocalBoxFuture<'static, ()>| {
184 - tokio::task::spawn_local(fut); // requires LocalSet context
185 - },
186 - );
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
188 - // ... set up forwarder task using conn ...
387 +Return these from new/load/fork session via `.config_options(config_options)`.
388 +When the user picks one, the client sends `SetSessionConfigOptionRequest`:
389
190 - io_task.await // drives JSON-RPC until client disconnects
191 -}).await;
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
194 -`AgentSideConnection::new` returns `(conn, io_task)` — you need both. `io_task`
195 -drives the actual IO; `conn` sends notifications. The spawn closure gets
196 -`LocalBoxFuture<'static, ()>` (not Send), so use `tokio::task::spawn_local`,
197 -not `tokio::spawn`. Everything must sit inside
198 -`tokio::task::LocalSet::new().run_until(...)`.
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
202 -## Streaming — circular dependency pattern
410 +## Slash commands
411
204 -`Agent::prompt()` needs to send `SessionNotification` through the connection,
205 -but the connection is built *from* the agent. Break the cycle with an mpsc channel:
412 +Advertise them so the editor forwards `/`-prefixed input (Zed rejects unknown
413 +slash commands client-side):
414
415 ```rust
208 -// 1. Create channel BEFORE the agent
209 -let (notification_tx, mut notification_rx) = mpsc::channel::<SessionNotification>(256);
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
211 -// 2. Pass sender into agent
212 -let agent = MyAgent { notification_tx, ... };
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
214 -// 3. Create connection
215 -let (conn, io_task) = AgentSideConnection::new(agent, stdout, stdin, |fut| {
216 - tokio::task::spawn_local(fut);
217 -});
433 +---
434
219 -// 4. Spawn forwarder that holds `conn`
220 -tokio::task::spawn_local(async move {
221 - while let Some(notification) = notification_rx.recv().await {
222 - conn.session_notification(notification).await.ok();
223 - }
224 -});
435 +## Concurrency: the `block_in_place` trap (still real)
436
226 -// 5. Run IO
227 -io_task.await;
228 -```
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
230 -Inside `prompt()`, push chunks through the channel:
447 ```rust
232 -self.notification_tx.send(SessionNotification::new(
233 - session_id.clone(),
234 - SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(delta))),
235 -)).await.ok(); // ignore send errors (channel closed = client gone)
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
242 -Log to **stderr** — stdout is the ACP JSON-RPC wire:
462 +stdout is the ACP JSON-RPC wire — **log only to stderr.** siGit uses
463 +`tracing_subscriber` to stderr:
464
465 ```rust
245 -env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
246 - .target(env_logger::Target::Stderr)
247 - .init();
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 ```
255 -Editor Agent
256 - │ │
257 - │── initialize ────────────────►│ (negotiate version + capabilities)
258 - │◄─ InitializeResponse ─────────│
259 - │ │
260 - │── authenticate ──────────────►│ (method_id from authMethods)
261 - │◄─ AuthenticateResponse ───────│
262 - │ │
263 - │── session/new ───────────────►│ (create session, load model)
264 - │◄─ NewSessionResponse ─────────│
265 - │ │
266 - │── session/prompt ────────────►│ (user message)
267 - │◄─ session/update (N times) ───│ (streaming tokens via notification)
268 - │◄─ PromptResponse ─────────────│ (stop_reason = EndTurn when done)
269 - │ │
270 - │── session/cancel (optional) ──►│
271 - │ │
272 - │── [disconnect] ───────────────►│ (io_task future resolves → shutdown)
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 ---
@@ -279,9 +515,9 @@ Editor Agent
515 ```json
516 {
517 "agent_servers": {
282 - "MyAgent": {
518 + "siGit Code": {
519 "type": "custom",
284 - "command": "/path/to/binary"
520 + "command": "/absolute/path/to/target/release/sigit"
521 }
522 }
523 }
@@ -291,29 +527,50 @@ Editor Agent
527
528 ## Gotchas
529
294 -1. **`Error::internal()` doesn't exist** — use `Error::new(-32603, msg)`.
295 -2. **All protocol structs are `#[non_exhaustive]`** — use builder methods,
296 - never struct literals. Add `_ => ...` wildcards when matching.
297 -3. **`LocalBoxFuture` is `!Send`** — `tokio::spawn` won't work; use
298 - `tokio::task::spawn_local` inside a `LocalSet`.
299 -4. **`tokio::task::spawn_local` panics outside a `LocalSet`** — wrap with
300 - `LocalSet::new().run_until(async { ... }).await`.
301 -5. **Store `SessionId` as `SessionId`**, not `String` — otherwise `==`
302 - comparisons get annoying.
303 -6. **One session per connection is fine for MVP** — reuse the model with
304 - `clear_history()` instead of reloading.
305 -7. **`AgentCapabilities::default()` exists** — all capabilities None/false.
306 -8. **`block_in_place` panics inside `spawn_local`** — dependencies that call
307 - `tokio::task::block_in_place` internally (e.g. `mistralrs`) will blow up
308 - with "can call blocking only when running on the multi-threaded runtime"
309 - from a `spawn_local` task. Fix: do the blocking work *before* entering
310 - the `LocalSet`, while you're still on a normal multi-thread worker, then
311 - pass the result into your agent struct.
312 -9. **Empty `authMethods` hangs Zed** — `InitializeResponse` with an empty
313 - `auth_methods` vec makes Zed show "Loading…" forever. Always include at
314 - least one `AuthMethod::Agent(AuthMethodAgent::new("id", "Name"))`.
315 - Import `AuthMethod`, `AuthMethodAgent`, and `ProtocolVersion` from the crate.
316 -10. **Never write to stdout except JSON-RPC** — any library that prints to
317 - stdout (`mistralrs` model metadata, stray `println!`, whatever) will
318 - corrupt the wire. Redirect diagnostics to stderr. If a dependency writes
319 - to stdout internally, fix it or suppress it before shipping.
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`.
.agents/skills/ai-assisted-coding/SKILL.md
+41 -17
@@ -11,7 +11,7 @@ Building a local AI coding agent in Rust using Onde Inference as the LLM backend
11 Onde wraps mistral.rs with a clean API for model loading, history management, and
12 streaming inference across macOS (Metal), iOS, Android, Linux, and Windows.
13
14 -Crate: `onde = { path = "../onde" }` or from crates.io when published
14 +Crate: `onde = "1.1.2"` (published on crates.io; siGit pins it in `Cargo.toml`)
15 Repo: https://github.com/ondeinference/onde
16 Docs: https://ondeinference.com
17
@@ -165,15 +165,26 @@ GgufModelConfig::qwen25_1_5b() // force 1.5B
165 GgufModelConfig::qwen25_3b() // force 3B
166 GgufModelConfig::qwen25_coder_1_5b() // coder variant 1.5B
167 GgufModelConfig::qwen25_coder_3b() // coder variant 3B
168 +GgufModelConfig::qwen25_coder_7b() // coder variant 7B (tool calling)
169 +GgufModelConfig::qwen3_1_7b() // Qwen 3 1.7B (tool calling)
170 +GgufModelConfig::qwen3_4b() // Qwen 3 4B (tool calling)
171 +GgufModelConfig::qwen3_8b() // Qwen 3 8B (tool calling)
172 +GgufModelConfig::qwen3_14b() // Qwen 3 14B (tool calling)
173 ```
174
175 +Only the Qwen 3 family and Qwen 2.5 Coder 7B support tool calling — see the
176 +`tool-calling` skill. The on-device default is the saved selection, falling back
177 +to `platform_default()` (Qwen 2.5 3B on macOS).
178 +
179 ---
180
181 ## Adding onde as a Rust library dependency
182
183 ```toml
175 -# In your crate's Cargo.toml — onde is a path dep since it's not on crates.io yet
176 -onde = { path = "../onde" }
184 +# In your crate's Cargo.toml — onde is published on crates.io
185 +onde = "1.1.2"
186 +# For local SDK development against a checkout, swap to a path dep:
187 +# onde = { path = "../onde" }
188 ```
189
190 **Important:** `onde` declares `crate-type = ["lib", "cdylib", "staticlib"]`.
@@ -262,20 +273,19 @@ Key principles:
273 ### Streaming tokens to ACP (connecting onde → ACP)
274
275 ```rust
265 -// In Agent::prompt():
276 +// In the prompt handler — cx: &ConnectionTo<Client> is passed in by the builder
277 +// (agent-client-protocol 0.13). No mpsc forwarder; send through cx directly.
278 let mut rx = self.engine.stream_message(user_text).await
279 .map_err(|e| Error::new(-32603, e.to_string()))?;
280
281 while let Some(chunk) = rx.recv().await {
282 if !chunk.delta.is_empty() {
271 - self.notification_tx.send(
272 - SessionNotification::new(
273 - session_id.clone(),
274 - SessionUpdate::AgentMessageChunk(
275 - ContentChunk::new(ContentBlock::from(chunk.delta)),
276 - ),
277 - )
278 - ).await.ok(); // .ok() — ignore if forwarder is gone
283 + cx.send_notification(SessionNotification::new(
284 + session_id.clone(),
285 + SessionUpdate::AgentMessageChunk(
286 + ContentChunk::new(ContentBlock::from(chunk.delta)),
287 + ),
288 + )).ok(); // .ok() — ignore if the client is gone
289 }
290 if chunk.done { break; }
291 }
@@ -285,7 +295,13 @@ Ok(PromptResponse::new(StopReason::EndTurn))
295
296 The `PromptResponse` is returned AFTER the stream finishes. The client receives
297 streaming tokens via `session/update` notifications while blocking on the
288 -`session/prompt` response.
298 +`session/prompt` response. See the `agent-client-protocol` skill for the `cx`
299 +(`ConnectionTo<Client>`) model that replaced the old mpsc-channel forwarder.
300 +
301 +> **Note:** siGit's actual `handle_prompt` does *not* stream token-by-token — it
302 +> runs a tool-calling loop through an `InferenceBackend` and sends the final text
303 +> in one `AgentMessageChunk`. The streaming pattern above still applies if you
304 +> want incremental output. See the `tool-calling` skill for the backend loop.
305
306 ---
307
@@ -305,13 +321,18 @@ let user_text: String = args.prompt.iter()
321 .join("\n");
322 ```
323
308 -For future resource context (e.g. open files provided by Zed):
324 +For resource context (e.g. open files provided by Zed) — note the variant is
325 +`TextResourceContents`, not `Text`:
326 ```rust
327 ContentBlock::Resource(r) => match &r.resource {
311 - EmbeddedResourceResource::Text(t) => Some(t.text.as_str()),
328 + EmbeddedResourceResource::TextResourceContents(t) => Some(t.text.as_str()),
329 + EmbeddedResourceResource::BlobResourceContents(_) => None,
330 _ => None,
331 },
332 ```
333 +siGit also handles `ContentBlock::ResourceLink` (a `file://` reference it reads
334 +from disk, including `#L<start>:<end>` line-range fragments). See the
335 +`tool-calling` skill.
336
337 ---
338
@@ -321,8 +342,11 @@ ContentBlock::Resource(r) => match &r.resource {
342 - Safe to wrap in `Arc<ChatEngine>` and share across tasks.
343 - `stream_message()` spawns a `tokio::spawn` background task internally — the
344 mistralrs model must be `Send`, which it is on all supported platforms.
324 -- Calling `stream_message()` from a `!Send` future (e.g. inside a `LocalSet`) is
325 - fine — the future itself doesn't hold a `!Send` value across `.await`.
345 +- **`block_in_place` trap:** `load_gguf_model` calls `tokio::task::block_in_place`
346 + internally, which panics unless it's on a multi-threaded runtime worker. Run
347 + model loads on a dedicated `std::thread` with its own `tokio::runtime::Runtime`
348 + and signal back via `AtomicBool`/`oneshot`. siGit does exactly this in both ACP
349 + and TUI modes — see the `agent-client-protocol` and `tool-calling` skills.
350
351 ---
352
.agents/skills/sigit-code-release/SKILL.md
+3 -2
@@ -29,7 +29,8 @@ Use this skill when preparing a release for this repository.
29 - Add or update the top changelog entry in `CHANGELOG.md` for the release being cut.
30 - Do not treat `npm/sigit/package.json` `0.0.0-dev` as a bug by default. The npm release workflow rewrites it at publish time using `npm/scripts/render-main-package.cjs` and the release tag.
31 - Do not add a hardcoded version to `pypi/pyproject.toml` for normal releases. PyPI uses `maturin` with `dynamic = ["version"]` and derives the published package version from `Cargo.toml`.
32 -- Release workflows are tag-driven. `release-github.yml`, `release-npm.yml`, and `release-pypi.yml` all derive `RELEASE_VERSION` from a `v*.*.*` tag or a manually supplied tag input.
32 +- Release workflows are tag-driven. `release-github.yml`, `release-npm.yml`, `release-pypi.yml`, `release-crates.yml`, and `release-homebrew.yml` all derive `RELEASE_VERSION` from a `v*.*.*` tag or a manually supplied tag input.
33 +- The crate is published to crates.io (`release-crates.yml`) and the Homebrew tap is updated (`release-homebrew.yml`) as part of the tag-driven flow. Per the siGit release flow, Homebrew is auto-triggered — do not dispatch it manually.
34
35 ## Typical files to inspect
36
@@ -41,7 +42,7 @@ Use this skill when preparing a release for this repository.
42 - `npm/scripts/render-main-package.cjs`
43 - `npm/`
44 - `pypi/`
44 -- `.github/workflows/`
45 +- `.github/workflows/` (`release-github.yml`, `release-npm.yml`, `release-pypi.yml`, `release-crates.yml`, `release-homebrew.yml`)
46
47 ## Release checklist
48
.agents/skills/tool-calling/SKILL.md
+95 -39
@@ -9,33 +9,61 @@ description: Implement or debug tool calling in siGit Code across the app, Onde
9
10 siGit Code supports **agentic tool calling** — the LLM invokes tools (read/write files, run commands, read websites) to operate on the user's codebase. This works in both **interactive TUI mode** and **ACP server mode** (Zed editor).
11
12 -Tool calling spans three layers:
12 +Tool calling spans these layers:
13
14 ```
15 siGit (agent loop + tool execution)
16 - → onde (ChatEngine with tool-aware API)
17 - → mistral.rs (model inference + tool call parsing)
16 + → InferenceBackend (src/backend.rs — LocalBackend or OpenAiBackend/cloud)
17 + → onde ChatEngine (tool-aware API) ── for LocalBackend
18 + → mistral.rs (model inference + tool call parsing)
19 + └ OpenAI-compatible HTTP endpoint ── for OpenAiBackend (siGit Code Cloud)
20 ```
21
22 +The agent loop talks to an `InferenceBackend` trait object, not the engine
23 +directly. `LocalBackend` wraps the on-device `ChatEngine`; `OpenAiBackend` calls
24 +a remote OpenAI-compatible endpoint (the siGit Code Cloud tiers). Both implement
25 +`send_message_with_tools` / `send_tool_results`, so the loop is identical.
26 +
27 ---
28
29 ## Model Requirement
30
24 -**Only Qwen 3 supports tool calling.** Qwen 2.5 does NOT — mistral.rs only has a parser for Qwen 3's `<tool_call>...</tool_call>` XML format.
31 +Tool calling needs a model mistral.rs has a tool-call parser for. The supported
32 +set is the **Qwen 3 family** (`<tool_call>...</tool_call>` XML) plus **Qwen 2.5
33 +Coder 7B**. Plain Qwen 2.5 and the smaller Qwen 2.5 Coder variants do NOT support
34 +tool calling. The authoritative list is `is_tool_calling()` in `src/models.rs`.
35 +
36 +| Model | Constructor | Size | Tool calling |
37 +|-------|-----------|------|:---:|
38 +| Qwen 3 14B (Q4_K_M) | `GgufModelConfig::qwen3_14b()` | ~9 GB | ✅ |
39 +| Qwen 3 8B (Q4_K_M) | `GgufModelConfig::qwen3_8b()` | ~5 GB | ✅ |
40 +| Qwen 3 4B (Q4_K_M) | `GgufModelConfig::qwen3_4b()` | ~2.7 GB | ✅ |
41 +| Qwen 3 1.7B (Q4_K_M) | `GgufModelConfig::qwen3_1_7b()` | ~1.3 GB | ✅ |
42 +| Qwen 2.5 Coder 7B | `GgufModelConfig::qwen25_coder_7b()` | ~5 GB | ✅ |
43 +| Qwen 2.5 Coder 3B | `GgufModelConfig::qwen25_coder_3b()` | ~1.93 GB | ❌ |
44 +| Qwen 2.5 Coder 1.5B | `GgufModelConfig::qwen25_coder_1_5b()` | ~941 MB | ❌ |
45 +| Qwen 2.5 3B / 1.5B | `qwen25_3b()` / `qwen25_1_5b()` | ~1.93 GB / ~941 MB | ❌ |
46 +
47 +### Default model
48 +
49 +There is **no hardcoded default model.** Startup uses the saved selection
50 +(`setup::startup_model_selection`), then the first complete locally-cached model,
51 +falling back to `GgufModelConfig::platform_default()` (Qwen 2.5 3B on macOS) when
52 +nothing is cached. The TUI/ACP code in `main.rs` uses `qwen25_3b()` as that final
53 +fallback. Users pick a tool-calling model via the `/models` picker.
54
26 -| Model | Constructor | Size | Tool calling | Default |
27 -|-------|-----------|------|:---:|:---:|
28 -| Qwen 3 8B (Q4_K_M) | `GgufModelConfig::qwen3_8b()` | ~5 GB | ✅ | ✅ **default** |
29 -| Qwen 3 4B (Q4_K_M) | `GgufModelConfig::qwen3_4b()` | ~2.7 GB | ✅ | |
30 -| Qwen 3 1.7B (Q4_K_M) | `GgufModelConfig::qwen3_1_7b()` | ~1.3 GB | ✅ | |
31 -| Qwen 2.5 Coder 3B | `GgufModelConfig::qwen25_coder_3b()` | ~1.93 GB | ❌ | |
32 -| Qwen 2.5 Coder 1.5B | `GgufModelConfig::qwen25_coder_1_5b()` | ~941 MB | ❌ | |
55 +### max_tokens
56
34 -siGit uses **Qwen 3 8B** by default with `max_tokens: 8192` (set in `main.rs` for both TUI and ACP modes).
57 +`max_tokens_for()` in `src/models.rs` gives tool-calling models **4096** tokens
58 +and non-tool models **512** (tool models need headroom because `<think>` blocks
59 +eat the budget). The TUI startup load in `run_interactive` overrides this to
60 +**8192**. Don't assume a single value.
61
36 -### Why 8B over 4B
62 +### Why prefer 8B+ over 4B for editing
63
38 -4B can't do `edit_file` reliably. It reads a file, then fails to reproduce the exact `old_text` it just saw. This spirals into 7+ retry rounds that burn through `max_tokens` on `<think>` blocks and return nothing. 8B is the smallest model that actually lands edits.
64 +4B struggles with `edit_file`: it reads a file, then fails to reproduce the exact
65 +`old_text` it just saw, spiralling into retry rounds that burn `max_tokens` on
66 +`<think>` blocks and return nothing. 8B (or larger) lands edits far more reliably.
67
68 ### bartowski GGUF naming convention
69
@@ -76,7 +104,9 @@ Defined in `sigit/src/tools.rs` via `all_tools()`:
104
105 ### Tool gating by model
106
79 -In TUI mode, `run_inference_task()` takes a `tools_enabled: bool` parameter. When the model's `ModelOption.tool_calling` is `false` (Qwen 2.5), an empty tool list is passed so the model doesn't receive tool schemas it can't use.
107 +In TUI mode, `run_inference_task()` takes a `tools_enabled: bool` parameter. When the picker item's `tool_calling` (from `models::is_tool_calling`) is `false`, an empty tool list is passed so the model doesn't receive tool schemas it can't use.
108 +
109 +In ACP mode, `handle_prompt` currently always passes the full tool set (`agent_tools_as_specs()`) regardless of the active model — there is no per-model gate on the ACP path.
110
111 ---
112
@@ -109,6 +139,24 @@ In TUI mode, `run_inference_task()` takes a `tools_enabled: bool` parameter. Whe
139 | `send_message_with_tools(msg, &[ToolDefinition])` | Returns `ToolAwareResult` with possible tool calls |
140 | `send_tool_results(Vec<ToolResult>, Option<&[ToolDefinition]>)` | Feed results back; `None` forces text response |
141
142 +#### Layer 2.5: the `InferenceBackend` abstraction (`src/backend.rs`)
143 +
144 +siGit doesn't call the engine directly from the agent loop — it goes through the
145 +`InferenceBackend` trait so on-device and cloud inference share one code path:
146 +
147 +| Item | Purpose |
148 +|------|---------|
149 +| `trait InferenceBackend` | `send_message_with_tools` / `send_tool_results` / `is_remote` |
150 +| `LocalBackend` | wraps `Arc<ChatEngine>` — on-device inference |
151 +| `OpenAiBackend` | OpenAI-compatible HTTP client — siGit Code Cloud tiers |
152 +| `ToolSpec` | backend-level tool definition (`name`, `description`, `parameters_schema`) |
153 +| `ToolCall` / `ToolResult` / `TurnResult` | backend-level request/result types |
154 +
155 +`handle_prompt` snapshots `self.backend.lock().await.clone()` once per turn so a
156 +mid-turn model/tier switch can't split the conversation across backends. When
157 +`backend.is_remote()` it skips the local model load + readiness wait. Cloud tiers
158 +(`fast`, `balanced`, `large`) come from `src/provider.rs` and are sign-in gated.
159 +
160 #### Internal details
161
162 - `attach_tools()` converts `ToolDefinition` → mistral.rs `Tool`, sets `ToolChoice::Auto` and `strict: Some(true)`
@@ -148,10 +196,11 @@ siGit parses this into path `/path/to/index.html` + lines 207–219.
196
197 ## The Agentic Loop
198
151 -Both ACP mode (`SiGitAgent::prompt()`) and TUI mode (`run_inference_task()`) implement:
199 +Both ACP mode (`SiGitAgent::handle_prompt()`) and TUI mode (`run_inference_task()`)
200 +implement the same loop, driven through the active `InferenceBackend`:
201
202 ```
154 -1. engine.send_message_with_tools(user_text, &tools) → ToolAwareResult
203 +1. backend.send_message_with_tools(user_text, &tools) → TurnResult
204 2. while result.tool_calls is non-empty AND round < MAX_TOOL_ROUNDS (10):
205 a. For each tool_call:
206 - Log: → tool_name(arguments)
@@ -161,22 +210,29 @@ Both ACP mode (`SiGitAgent::prompt()`) and TUI mode (`run_inference_task()`) imp
210 b. Decide next_tools:
211 - round < MAX_TOOL_ROUNDS → Some(&tools) (allow more calls)
212 - else → None (force text response)
164 - c. engine.send_tool_results(results, next_tools) → ToolAwareResult
165 -3. Send final result.text to user
213 + c. backend.send_tool_results(results, next_tools) → TurnResult
214 +3. Strip <think> blocks (chat::strip_think_blocks), send final text to user
215 - Empty reply after tool rounds → log warning (ACP) or show error (TUI)
216 ```
217
218 +In ACP mode the final text is sent as one `AgentMessageChunk`; the tool-calling
219 +loop is not streamed token-by-token.
220 +
221 ---
222
223 ## System Prompt
224
173 -The `SYSTEM_PROMPT` in `main.rs` (~122 lines) includes critical instructions:
225 +`main.rs` defines **two** prompts, picked by `system_prompt_for_model(tool_calling)`:
226
175 -- **Never tell the user to run commands** — use `run_command` tool instead
176 -- **Can access websites** — use `read_website` tool (overrides RLHF refusal training)
177 -- **Prefer absolute paths** in all tool arguments
178 -- **Git operations** — always use `run_command` with absolute cwd
179 -- **smbCloud domain knowledge** — auth boundaries, deploy flows, project structure
227 +- **`SYSTEM_PROMPT`** (~120 lines) — the full agentic prompt for tool-calling models:
228 + - **Never tell the user to run commands** — use `run_command` tool instead
229 + - **Can access websites** — use `read_website` tool (overrides RLHF refusal training)
230 + - **Prefer absolute paths** in all tool arguments
231 + - **Git operations** — always use `run_command` with absolute cwd
232 + - **Always re-read a file before `edit_file`** — don't trust stale content
233 + - **smbCloud domain knowledge** — auth boundaries, deploy flows, project structure
234 +- **`SIMPLE_SYSTEM_PROMPT`** — a short prompt for non-tool models; the full one
235 + wastes context and confuses them.
236
237 The session `cwd` is injected as a separate system message at session creation time (not part of the static prompt).
238
@@ -209,8 +265,8 @@ No changes needed in onde or mistral.rs — tool definitions are passed dynamica
265
266 1. **`onde/src/inference/models.rs`** — add `pub const` for repo ID and GGUF filename, add to `SUPPORTED_MODELS` array and `SUPPORTED_MODEL_INFO`
267 2. **`onde/src/inference/engine.rs`** — add `pub fn model_name() -> Self` constructor to `impl GgufModelConfig`
212 -3. **`sigit/src/chat.rs`** — add `ModelOption` entry to `SIGIT_MODELS` with `tool_calling: true/false`
213 -4. **`sigit/src/main.rs`** — update `run_interactive()` and `run_acp_server()` if changing the default
268 +3. **`sigit/src/models.rs`** — add a match arm to `model_id_to_config()` mapping the repo ID to the new constructor; if it supports tool calling, add the repo ID to `is_tool_calling()` (which also drives `max_tokens_for()`). The picker (`build_model_picker_items`) then surfaces it automatically.
269 +4. **`sigit/src/main.rs`** — only if you're changing the fallback default (`qwen25_3b()`)
270
271 ---
272
@@ -266,19 +322,16 @@ could not read ResourceLink file:///path/to/index.html#L207:219: No such file or
322
323 ## Cargo Dependency Note
324
269 -For local development, `sigit/Cargo.toml` must use the path dependency:
270 -
271 -```toml
272 -onde = { path = "../onde" }
273 -```
274 -
275 -For CI/release, switch to the git dependency (after pushing Onde changes):
325 +`onde` is published on crates.io; `sigit/Cargo.toml` pins it:
326
327 ```toml
278 -onde = { git = "https://github.com/ondeinference/onde", branch = "development" }
328 +onde = "1.1.2"
329 ```
330
281 -The `qwen3_8b()` constructor only exists in the local Onde SDK until it's pushed to the `development` branch.
331 +The Qwen 3 / Coder-7B constructors (`qwen3_8b()`, etc.) ship in that release. For
332 +local SDK development against an `onde` checkout, swap to a path dep
333 +(`onde = { path = "../onde" }`) — but the committed form must stay the crates.io
334 +version so CI/release builds resolve.
335
336 ---
337
@@ -287,9 +340,12 @@ The `qwen3_8b()` constructor only exists in the local Onde SDK until it's pushed
340 | File | What it does |
341 |------|-------------|
342 | `sigit/src/tools.rs` | 9 tool schemas (`all_tools()`), `execute_tool()` dispatch, all `exec_*` implementations |
290 -| `sigit/src/main.rs` | `SYSTEM_PROMPT`, `SiGitAgent` struct with `session_cwd`, ACP session handlers (cwd + push_history), `prompt()` with content block parsing, model selection (`qwen3_8b`), `MAX_TOOL_ROUNDS` |
291 -| `sigit/src/chat.rs` | `SIGIT_MODELS` array (4 models), `run_inference_task()` with `tools_enabled` gate, TUI tool loop |
292 -| `sigit/src/setup.rs` | HF cache setup pointing to shared App Group container |
343 +| `sigit/src/main.rs` | `SYSTEM_PROMPT`, `SiGitAgent` struct with `session_cwd` + `backend`, ACP handlers (cwd + push_history), `handle_prompt()` content-block parsing + tool loop, `MAX_TOOL_ROUNDS`, ACP builder wiring |
344 +| `sigit/src/backend.rs` | `InferenceBackend` trait, `LocalBackend`, `OpenAiBackend`, `ToolSpec`/`ToolCall`/`ToolResult`/`TurnResult` |
345 +| `sigit/src/models.rs` | `ModelPickerItem`, `model_id_to_config()`, `is_tool_calling()`, `max_tokens_for()`, `build_model_picker_items()` / `local_picker_items()` |
346 +| `sigit/src/provider.rs` | `CLOUD_TIERS`, `cloud_tier_provider()`, cloud endpoint config |
347 +| `sigit/src/chat.rs` | TUI app, model picker UI (uses `build_model_picker_items`), `run_inference_task()` with `tools_enabled` gate, TUI tool loop |
348 +| `sigit/src/setup.rs` | HF cache setup (shared App Group container), `startup_model_selection()` |
349 | `onde/src/inference/types.rs` | `ToolDefinition`, `ToolCallRequest`, `ToolResult`, `ToolAwareResult` |
350 | `onde/src/inference/engine.rs` | `send_message_with_tools()`, `send_tool_results()`, `attach_tools()`, `parse_tool_calls()`, `replay_history_with_tools()`, `GgufModelConfig::qwen3_8b()` |
351 | `onde/src/inference/models.rs` | Model constants and `SUPPORTED_MODELS` array |
CLAUDE.md new
+105
@@ -0,0 +1,105 @@
1 +# CLAUDE.md
2 +
3 +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4 +
5 +## What this is
6 +
7 +`sigit` ("siGit Code") is a single Rust binary: a local-first AI coding agent that runs LLM
8 +inference on-device (via the `onde` crate / GGUF models) or against a hosted/OpenAI-compatible
9 +endpoint. It exposes itself two ways from the *same* binary, chosen at startup by whether stdin
10 +is a TTY:
11 +
12 +- **ACP mode** (stdin not a TTY): speaks the Agent Client Protocol over stdio for editor
13 + integration (Zed, VS Code ACP Client). Cross-platform.
14 +- **Interactive terminal mode** (stdin is a TTY): a full-screen ratatui chat UI. **Unix-only** —
15 + it relies on fd redirection to keep logs out of the TUI, so Windows gets ACP mode only.
16 +
17 +Before the TTY/ACP split, `main` also dispatches the account subcommands `sigit login`,
18 +`sigit logout`, `sigit whoami` (see `src/main.rs` `main()`).
19 +
20 +## Build / test / lint
21 +
22 +```sh
23 +cargo build # debug build
24 +cargo build --release # release binary at target/release/sigit
25 +cargo run # launches interactive TUI (stdin is a TTY)
26 +cargo test # CI runs: cargo test --locked --target <target>
27 +cargo clippy --tests -- -D warnings # CI gate: clippy is -D warnings on all 4 targets
28 +cargo fmt -- --check # CI gate (edition 2024)
29 +```
30 +
31 +CI (`.github/workflows/ci.yml`) runs fmt + clippy + test across four targets:
32 +`aarch64-apple-darwin`, `x86_64-apple-darwin`, `x86_64-unknown-linux-gnu`,
33 +`x86_64-pc-windows-msvc`. Clippy is `-D warnings`, so warnings fail the build.
34 +
35 +Run a single test: `cargo test <test_name>`.
36 +
37 +## Critical platform constraint: `#[cfg(unix)]` dead code
38 +
39 +The interactive client, the `InferenceBackend` seam (`backend.rs`), and provider resolution
40 +(`provider.rs`) are wired up **only** through `#[cfg(unix)]` code paths. On Windows the binary
41 +runs ACP-only and drives `onde` directly, so much of `backend.rs` and `provider.rs` is
42 +legitimately unused there and the dead-code lint is suppressed *on non-Unix targets only*.
43 +
44 +Consequence: code can pass clippy on macOS/Linux but fail on the Windows target (or vice versa).
45 +When touching `backend.rs`, `provider.rs`, or the interactive path, keep the `cfg` gates intact —
46 +don't "fix" an unused-warning by deleting code that's live on Unix.
47 +
48 +## Architecture
49 +
50 +The agent loop is backend-agnostic. The flow: a turn (messages + tool specs) goes to an
51 +`InferenceBackend`, which returns assistant text and/or tool calls; the loop executes tools and
52 +feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete backend.
53 +
54 +- **`src/main.rs`** — entry point, mode dispatch, the full ACP `Agent` impl (session lifecycle:
55 + new/load/fork/prompt/cancel, config options, slash-command advertisement), and the `SYSTEM_PROMPT`
56 + (note: it bakes in smbCloud-specific context the agent should use when the repo is clearly
57 + smbCloud, and stay general otherwise).
58 +- **`src/backend.rs`** — the `InferenceBackend` trait and neutral types (`ToolSpec`, `ToolCall`,
59 + `ToolResult`, `TurnResult`). Two impls: `LocalBackend` (on-device via `onde::ChatEngine`) and
60 + `OpenAiBackend` (any OpenAI-compatible HTTP endpoint).
61 +- **`src/provider.rs`** — decides *which* backend serves inference. Resolution order, first match
62 + wins: (1) override via `OPENAI_BASE_URL`+`OPENAI_API_KEY` or active profile in
63 + `~/.config/sigit/providers.toml`; (2) siGit Code Cloud when logged in; (3) on-device.
64 +- **`src/tools.rs`** — agent tool schemas + execution: `read_file`, `create_directory`,
65 + `list_directory`, `search_files`, `read_website`, `create_file`, `edit_file`, `delete_file`,
66 + `run_command`. Add a tool in both the spec list and the execute `match`.
67 +- **`src/chat.rs`** — the Unix-only ratatui TUI. Loading-spinner phase then chat; uses
68 + `tokio::select!` to multiplex terminal events with streaming tokens.
69 +- **`src/setup.rs`** — model cache location, local model discovery, selected-model persistence.
70 + Must run (`setup_shared_model_cache`) *before* anything touches `ChatEngine`/`hf-hub`, since
71 + those read env vars once at init.
72 +- **`src/account.rs`** — siGit Code Cloud auth (`/login`, `/logout`, `/whoami`); authenticates
73 + against the account API and stores a session token. Performs no console I/O.
74 +- **`src/credentials.rs`** — local session-token store (TOML, `0600` on Unix).
75 +- **`src/models.rs`** — model-picker types shared across platforms.
76 +
77 +Slash commands (`/help`, `/models`, `/login`, `/logout`, `/whoami`, `/reload`, `/clear`,
78 +`/status`) are advertised via `advertise_commands` in `main.rs` and handled in both the TUI and
79 +ACP sessions.
80 +
81 +## Model cache (macOS)
82 +
83 +On macOS the HF model cache lives in an App Group container shared with the siGit desktop app:
84 +`~/Library/Group Containers/group.com.ondeinference.apps/models/`. Other platforms fall back to
85 +`~/.cache/huggingface/`. The CLI reuses a model the desktop app already downloaded. First run
86 +downloads a GGUF model (~1–2 GB) from Hugging Face.
87 +
88 +## Logging
89 +
90 +In TTY (interactive) mode, *all* output — `log`, `tracing`, stray `println!` — is redirected to
91 +`$TMPDIR/sigit.log` so the ratatui surface stays clean; the TUI holds a separate fd to the real
92 +terminal. In ACP mode, stdout is reserved for protocol JSON and logs go to stderr. Control
93 +verbosity with `RUST_LOG`.
94 +
95 +## Relevant env vars
96 +
97 +`OPENAI_BASE_URL` / `OPENAI_API_KEY` (provider override), `SIGIT_API_URL` (account API base,
98 +default `https://sigit.si`), `SIGIT_CLOUD_URL`, `SIGIT_CONFIG_DIR` (default `~/.config/sigit`),
99 +`SIGIT_MODEL`, `HF_HOME` / `HF_HUB_CACHE`, `RUST_LOG`.
100 +
101 +## Releasing
102 +
103 +Version lives in `Cargo.toml`. The binary is published to five registries via separate workflows
104 +(`release-crates`, `release-github`, `release-homebrew`, `release-npm`, `release-pypi`); the
105 +`npm/` and `pypi/` dirs hold the wrapper-package templates. Update `CHANGELOG.md` for releases.