@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
index 2e9dd5e..efed935 100644 --- a/.agents/skills/agent-client-protocol/SKILL.md +++ b/.agents/skills/agent-client-protocol/SKILL.md @@ -1,6 +1,6 @@ --- name: agent-client-protocol -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. +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. --- # 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 with editors (Zed, JetBrains, Neovim, etc.). The agent runs as a subprocess; the editor is the client. Communication is newline-delimited JSON on stdin/stdout. -Crate: `agent-client-protocol = "0.10.4"` (latest as of 2025) +Crate: `agent-client-protocol = "0.13"` (siGit pins 0.13.0 in `Cargo.lock`) Docs: https://docs.rs/agent-client-protocol Spec: https://agentclientprotocol.com +> **Big change since 0.10:** the crate moved from a `#[async_trait(?Send)] impl Agent` +> model to a **builder** model. You no longer implement a trait. You build an +> `Agent` with per-message handler closures and `.connect_to(transport)`. Each +> handler receives a `ConnectionTo<Client>` (`cx`) you use to send notifications +> and spawn tasks — so the old mpsc "circular dependency" pattern is gone. + +siGit's entire ACP server lives in `src/main.rs` (`run_acp_server`, the +`SiGitAgent` struct, and its `handle_*` methods). Read it alongside this skill. + --- ## Dependency setup ```toml [dependencies] -agent-client-protocol = "0.10.4" -async-trait = "0.1" -tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "io-std", "io-util", "sync"] } -tokio-util = { version = "0.7", features = ["compat"] } -futures = "0.3" +agent-client-protocol = { version = "0.13", features = [ + "unstable_session_fork", # session/fork support + "unstable_session_additional_directories", # additional_directories on session requests + "unstable_auth_methods", # AuthMethod::Agent etc. +] } +async-trait = "0.1" +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "io-std", "io-util", "sync", "time"] } +tokio-util = { version = "0.7", features = ["compat"] } +futures = "0.3" +uuid = { version = "1", features = ["v4"] } ``` +The `unstable_*` features gate real types/methods (`ForkSessionRequest`, +`additional_directories`, `AuthMethod::Agent`). Without them the corresponding +APIs don't exist and you'll get "no variant/method" errors. + --- -## The `Agent` trait +## Imports + +Protocol message/data types live under `agent_client_protocol::schema::*`. +Connection/runtime types live at the crate root. + +```rust +use agent_client_protocol::schema::{ + AgentCapabilities, AuthMethod, AuthMethodAgent, AuthenticateRequest, AuthenticateResponse, + AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate, CancelNotification, + ConfigOptionUpdate, ContentBlock, ContentChunk, EmbeddedResourceResource, ForkSessionRequest, + ForkSessionResponse, Implementation, InitializeRequest, InitializeResponse, LoadSessionRequest, + LoadSessionResponse, Meta, NewSessionRequest, NewSessionResponse, PromptRequest, + PromptResponse, ProtocolVersion, SessionCapabilities, SessionConfigOption, + SessionConfigOptionCategory, SessionConfigSelectOption, SessionConfigValueId, + SessionForkCapabilities, SessionId, SessionNotification, SessionUpdate, + SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, StopReason, ToolCall, + ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput, +}; +use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder}; +``` + +--- + +## Wiring up the server — the builder + +You do **not** implement a trait. You hold your state in an `Arc<MyState>`, then +register one closure per incoming message type on `Agent.builder()`, and finish +with `.connect_to(transport).await`. The builder owns the JSON-RPC loop and runs +until the client disconnects. -Declared `#[async_trait::async_trait(?Send)]` — futures are `!Send`. -Your impl needs the same annotation: +```rust +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; + +async fn run_acp_server() -> anyhow::Result<()> { + let state = Arc::new(SiGitAgent::new(/* … */)); + + // Adapt tokio stdio to the futures AsyncRead/AsyncWrite the SDK expects. + let stdin = tokio::io::stdin().compat(); + let stdout = tokio::io::stdout().compat_write(); + let transport = ByteStreams::new(stdout, stdin); // note: (writer, reader) + + Agent + .builder() + .on_receive_request( + { + let state = Arc::clone(&state); + async move |req: InitializeRequest, responder, _cx: ConnectionTo<Client>| { + handle_response(responder, state.handle_initialize(req).await) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let state = Arc::clone(&state); + async move |req: PromptRequest, responder, cx: ConnectionTo<Client>| { + handle_response(responder, state.handle_prompt(&cx, req).await) + } + }, + agent_client_protocol::on_receive_request!(), + ) + // … one .on_receive_request(…) per request type you support … + .on_receive_notification( + { + let state = Arc::clone(&state); + async move |notif: CancelNotification, _cx: ConnectionTo<Client>| { + state.handle_cancel(notif).await + } + }, + agent_client_protocol::on_receive_notification!(), + ) + .connect_to(transport) + .await + .map_err(|e| anyhow::anyhow!("ACP connection error: {e}"))?; + + Ok(()) +} +``` + +Key points: + +- **`Arc::clone(&state)` per closure.** Each handler closure is `move` and owns + its own `Arc` clone of shared state. +- **The macro is required.** Each handler is paired with + `agent_client_protocol::on_receive_request!()` (or `on_receive_notification!()`). + It wires the closure's concrete message type into the dispatcher. Don't omit it. +- **Closure signature for requests:** `async move |req: T, responder, cx: ConnectionTo<Client>|`. + Use `_cx` when a handler doesn't send notifications (e.g. `initialize`, `authenticate`). +- **Closure signature for notifications:** `async move |notif: T, cx: ConnectionTo<Client>|` + returning `agent_client_protocol::Result<()>` — no responder (notifications get no reply). +- **Unmatched messages fall to the SDK default** — you only register what you support. + +### The `Responder` + `handle_response` helper + +Requests reply through a `Responder<T>`. siGit funnels every handler's +`Result` through one helper: ```rust -#[async_trait::async_trait(?Send)] -impl Agent for MyAgent { - async fn initialize(&self, args: InitializeRequest) -> Result<InitializeResponse> { ... } - async fn authenticate(&self, args: AuthenticateRequest) -> Result<AuthenticateResponse> { ... } - async fn new_session(&self, args: NewSessionRequest) -> Result<NewSessionResponse> { ... } - async fn prompt(&self, args: PromptRequest) -> Result<PromptResponse> { ... } - async fn cancel(&self, args: CancelNotification) -> Result<()> { ... } - // All other methods have default impls that return Error::method_not_found() +fn handle_response<T: agent_client_protocol::JsonRpcResponse>( + responder: Responder<T>, + result: agent_client_protocol::Result<T>, +) -> agent_client_protocol::Result<()> { + match result { + Ok(resp) => responder.respond(resp), + Err(err) => responder.respond_with_error(err), + } } ``` -You must implement `initialize`, `authenticate`, `new_session`, `prompt`, and `cancel`. -Everything else (`load_session`, `set_session_mode`, etc.) defaults to `Err(method_not_found)`. +So each `handle_*` method just returns `Result<SomeResponse>` and stays free of +protocol plumbing. + +--- + +## `ConnectionTo<Client>` — the `cx` + +The per-handler `cx: ConnectionTo<Client>` replaces the old mpsc-channel forwarder. +It is `Clone`. Two things you do with it: + +```rust +// 1. Send a server→client notification (streaming chunks, tool-call updates, …) +cx.send_notification(SessionNotification::new(session_id.clone(), update))?; + +// 2. Spawn a background task that keeps using cx (e.g. a progress spinner poller). +let cx_for_poller = cx.clone(); +cx.spawn(async move { + loop { + // … cx_for_poller.send_notification(progress_update) … + # break; + } + Ok(()) +}).ok(); +``` + +Because `cx` is handed to you directly, there is **no circular dependency** between +the connection and the agent anymore. Don't reintroduce the mpsc forwarder pattern. + +--- + +## The handlers siGit implements + +| Message | Method | Notes | +|---------|--------|-------| +| `InitializeRequest` | `handle_initialize` | capabilities, auth methods, agent info, `meta` | +| `AuthenticateRequest` | `handle_authenticate` | verifies stored siGit Code Cloud session | +| `NewSessionRequest` | `handle_new_session` | sets cwd, resets history, advertises commands + config options | +| `LoadSessionRequest` | `handle_load_session` | like new_session; gated by `load_session(true)` capability | +| `ForkSessionRequest` | `handle_fork_session` | gated by `unstable_session_fork` + `SessionForkCapabilities` | +| `PromptRequest` | `handle_prompt` | the turn: parse blocks → slash commands or tool-calling loop | +| `SetSessionConfigOptionRequest` | `handle_set_session_config_option` | the Zed model picker — switches/downloads models | +| `CancelNotification` | `handle_cancel` | notification, no response | + +Everything else is left to the SDK default (method not found). --- ## Types and their builders -All `#[non_exhaustive]` structs require builder methods — struct literal syntax won't compile. +All `#[non_exhaustive]` structs require builder methods — struct-literal syntax +won't compile. -### `InitializeRequest` / `InitializeResponse` +### `InitializeResponse` ```rust -// Response builder — use ProtocolVersion::V1, NOT args.protocol_version: -InitializeResponse::new(ProtocolVersion::V1) +Ok(InitializeResponse::new(ProtocolVersion::V1) // use V1, not args.protocol_version .agent_info( - Implementation::new("my-agent", env!("CARGO_PKG_VERSION")) - .title("My Agent"), + Implementation::new("sigit", env!("CARGO_PKG_VERSION")) + .title("siGit Code - AI Coding Agent"), + ) + .auth_methods(vec![AuthMethod::Agent( + AuthMethodAgent::new("sigit", "Sign in to siGit Code") + .description("Sign in with `/login <email> <password>` in the message box."), + )]) + .agent_capabilities( + AgentCapabilities::default() + .load_session(true) // enables LoadSessionRequest + .session_capabilities( + SessionCapabilities::new() + .fork(SessionForkCapabilities::new()), // enables ForkSessionRequest + ), ) - .auth_methods(vec![AuthMethod::Agent(AuthMethodAgent::new( - "my-agent", "My Agent", - ))]) - .agent_capabilities(AgentCapabilities::default()) + .meta(initialize_meta())) // free-form Meta (see below) ``` -`auth_methods` must include at least one `AuthMethod::Agent` or Zed hangs on -"Loading…" forever. Import `AuthMethod`, `AuthMethodAgent`, and `ProtocolVersion` -from the crate. +`auth_methods` must include at least one `AuthMethod::Agent` or **Zed hangs on +"Loading…" forever.** siGit uses `Agent` (not `Terminal`) because Zed advertises +terminal-auth for custom agents but never actually spawns the login terminal, so +the button would be a silent no-op. With `Agent`, clicking calls `authenticate`. + +### `Meta` — free-form server metadata + +`Meta` is a string-keyed JSON map you can attach to `InitializeResponse` (siGit +publishes the active model there so the editor can show it): + +```rust +let mut meta = Meta::new(); +meta.insert("sigit".to_string(), serde_json::json!({ + "active_model": { "display_name": "...", "model_id": "...", "gguf_file": "..." } +})); +``` ### `AuthenticateResponse` ```rust -Ok(AuthenticateResponse::default()) // No auth = just return default +Ok(AuthenticateResponse::default()) // success +// failure: return an Error — siGit uses -32000 "not signed in …" ``` -### `NewSessionResponse` +### `NewSessionResponse` / `LoadSessionResponse` / `ForkSessionResponse` ```rust let session_id = SessionId::new(uuid::Uuid::new_v4().to_string()); -Ok(NewSessionResponse::new(session_id)) + +Ok(NewSessionResponse::new(session_id).config_options(config_options)) +Ok(LoadSessionResponse::new().config_options(config_options)) // no id arg — it's in the request +Ok(ForkSessionResponse::new(new_id).config_options(config_options)) ``` `SessionId` is a newtype with `Clone`, `PartialEq`, `Display`, `Into<String>`, -and `AsRef<str>`. Store it as-is (not as `String`) so `==` works directly. +`AsRef<str>`. Store it as-is so `==` works. `config_options` powers the editor's +per-session picker (see Config options below). + +The session requests carry `cwd: PathBuf` and (with the feature) +`additional_directories: Vec<PathBuf>`. siGit stashes `cwd`, `set_current_dir`s +to it, and pushes a system message telling the model to use absolute paths under it. -### `PromptRequest` +### `PromptRequest` / blocks ```rust -args.session_id // type: SessionId -args.prompt // type: Vec<ContentBlock> +args.session_id // SessionId +args.prompt // Vec<ContentBlock> ``` -Extract user text from the prompt: +Editors send several block kinds — handle the three siGit cares about: + ```rust -let user_text: String = args.prompt.iter() - .filter_map(|block| match block { - ContentBlock::Text(t) => Some(t.text.as_str()), - _ => None, - }) - .collect::<Vec<_>>() - .join("\n"); +for block in &args.prompt { + match block { + ContentBlock::Text(t) => { /* t.text */ } + ContentBlock::Resource(embedded) => match &embedded.resource { + // editor already inlined file content + EmbeddedResourceResource::TextResourceContents(tr) => { /* tr.uri, tr.text */ } + EmbeddedResourceResource::BlobResourceContents(b) => { /* b.uri */ } + _ => {} + }, + ContentBlock::ResourceLink(link) => { + // a reference (e.g. `@file`); read it yourself. + // link.uri is "file:///abs/path#L207:219" (or #L207-219). Strip "file://", + // split the "#L<start>:<end>" fragment, read & slice the lines. + } + _ => {} // non_exhaustive — always a wildcard + } +} ``` ### `PromptResponse` ```rust Ok(PromptResponse::new(StopReason::EndTurn)) -// Other reasons: MaxTokens, Cancelled, MaxTurnRequests, Refusal +// other reasons: MaxTokens, Cancelled, MaxTurnRequests, Refusal ``` -### `ContentBlock` +### Streaming: `ContentChunk` + `SessionUpdate` + `SessionNotification` ```rust -// Text block — use the From impl: -ContentBlock::from("some text") // impl From<T: Into<String>> for ContentBlock - -// Pattern-match incoming blocks: -match block { - ContentBlock::Text(t) => t.text.as_str(), - ContentBlock::ResourceLink(_) => ..., - ContentBlock::Resource(_) => ..., - _ => ..., // non_exhaustive — always need a wildcard -} +let chunk = ContentChunk::new(ContentBlock::from(delta_text)); // From<Into<String>> +let update = SessionUpdate::AgentMessageChunk(chunk); +cx.send_notification(SessionNotification::new(session_id.clone(), update))?; ``` -### `ContentChunk` + `SessionUpdate` — streaming +`SessionUpdate` variants siGit uses: -```rust -let chunk = ContentChunk::new(ContentBlock::from(delta_text)); -let update = SessionUpdate::AgentMessageChunk(chunk); -// Other variants: UserMessageChunk, AgentThoughtChunk, ToolCall, Plan, ... -``` +- `AgentMessageChunk(ContentChunk)` — assistant text. +- `ToolCall(ToolCall)` — start a tool-call card (used for model load/download progress). +- `ToolCallUpdate(ToolCallUpdate)` — update that card's title/status/content. +- `AvailableCommandsUpdate(AvailableCommandsUpdate)` — advertise slash commands. +- `ConfigOptionUpdate(ConfigOptionUpdate)` — refresh the picker mid-session. + +(Other variants exist: `UserMessageChunk`, `AgentThoughtChunk`, `Plan`, …) + +### `ToolCall` / `ToolCallUpdate` — progress cards -### `SessionNotification` — send streaming content to client +siGit reuses tool-call cards as a generic progress UI (model loading/download): ```rust -let notification = SessionNotification::new(session_id.clone(), update); -// Deliver via AgentSideConnection::session_notification() +// open the card +SessionUpdate::ToolCall( + ToolCall::new(tool_call_id.clone(), "Loading Qwen 2.5 3B") + .kind(ToolKind::Think) + .status(ToolCallStatus::InProgress) + .content(vec!["Loading…".into()]), +) +// update it (only the fields you set) +SessionUpdate::ToolCallUpdate(ToolCallUpdate::new( + tool_call_id.clone(), + ToolCallUpdateFields::new() + .title("✓ Qwen 2.5 3B loaded") + .status(ToolCallStatus::Completed), +)) ``` +`ToolCallStatus`: `InProgress`, `Completed`, `Failed`. `ToolKind::Think` is the +"thinking/util" kind. + ### `Error` ```rust -// There is NO Error::internal(msg) method — use: -agent_client_protocol::Error::new(-32603, "your message here") - -// For invalid params: -agent_client_protocol::Error::invalid_params() - -// For method not found (already the trait default): -agent_client_protocol::Error::method_not_found() +agent_client_protocol::Error::new(-32603, "internal error message") // there is NO Error::internal() +agent_client_protocol::Error::new(-32602, "invalid params: …") // or Error::invalid_params() +agent_client_protocol::Error::new(-32000, "not signed in …") // app-defined ``` --- -## Running the agent — `AgentSideConnection` +## Config options — the editor model picker -Wraps stdin/stdout with JSON-RPC machinery. +ACP lets the agent expose per-session config controls; Zed renders them in the +agent panel. siGit uses one `select` option as a model picker. ```rust -use futures::future::LocalBoxFuture; -use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; - -// Adapt tokio I/O to futures AsyncRead/AsyncWrite (the SDK expects these) -let stdin = tokio::io::stdin().compat(); -let stdout = tokio::io::stdout().compat_write(); - -// Must run inside a LocalSet — the spawn fn takes LocalBoxFuture (!Send) -let local = tokio::task::LocalSet::new(); -local.run_until(async move { - let (conn, io_task) = AgentSideConnection::new( - agent, - stdout, - stdin, - |fut: LocalBoxFuture<'static, ()>| { - tokio::task::spawn_local(fut); // requires LocalSet context - }, - ); +const MODEL_CONFIG_ID: &str = "sigit-model"; + +let options: Vec<SessionConfigSelectOption> = models.iter().map(|m| { + SessionConfigSelectOption::new( + SessionConfigValueId::new(m.model_id.as_str()), + format!("{} {badge}", m.display_name), + ).description(desc) +}).collect(); + +let config_options = vec![ + SessionConfigOption::select(MODEL_CONFIG_ID, "Model", current_value, options) + .category(SessionConfigOptionCategory::Model) + .description("Select an on-device model or a siGit Code Cloud tier"), +]; +``` - // ... set up forwarder task using conn ... +Return these from new/load/fork session via `.config_options(config_options)`. +When the user picks one, the client sends `SetSessionConfigOptionRequest`: - io_task.await // drives JSON-RPC until client disconnects -}).await; +```rust +async fn handle_set_session_config_option(&self, cx: &ConnectionTo<Client>, + args: SetSessionConfigOptionRequest) -> Result<SetSessionConfigOptionResponse> { + if args.config_id.0.as_ref() != MODEL_CONFIG_ID { return Err(Error::new(-32602, "…")); } + let model_id = args.value.0.as_ref(); + // … switch model, streaming ToolCall progress via cx … + Ok(SetSessionConfigOptionResponse::new(rebuilt_config_options)) +} ``` -`AgentSideConnection::new` returns `(conn, io_task)` — you need both. `io_task` -drives the actual IO; `conn` sends notifications. The spawn closure gets -`LocalBoxFuture<'static, ()>` (not Send), so use `tokio::task::spawn_local`, -not `tokio::spawn`. Everything must sit inside -`tokio::task::LocalSet::new().run_until(...)`. +To refresh the picker mid-session (e.g. after `/reload`), push +`SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(config_options))`. + +**Gotcha:** Zed re-fires the last selection on (re)connect. Guard against a no-op +re-select of the already-active model, and don't try to load a new model while a +startup load is still in flight (the old weights still hold GPU memory → the new +load fails with "does not fit"). siGit waits for `model_ready` first. --- -## Streaming — circular dependency pattern +## Slash commands -`Agent::prompt()` needs to send `SessionNotification` through the connection, -but the connection is built *from* the agent. Break the cycle with an mpsc channel: +Advertise them so the editor forwards `/`-prefixed input (Zed rejects unknown +slash commands client-side): ```rust -// 1. Create channel BEFORE the agent -let (notification_tx, mut notification_rx) = mpsc::channel::<SessionNotification>(256); +let commands = vec![ + AvailableCommand::new("help", "Show available commands"), + AvailableCommand::new("models", "List available models").input( + AvailableCommandInput::Unstructured(UnstructuredCommandInput::new( + "model number to switch to (optional)"))), + // … login/logout/whoami/reload/clear/status … +]; +cx.send_notification(SessionNotification::new( + session_id, + SessionUpdate::AvailableCommandsUpdate(AvailableCommandsUpdate::new(commands)), +))?; +``` -// 2. Pass sender into agent -let agent = MyAgent { notification_tx, ... }; +siGit parses slash text out of the prompt itself (`parse_slash`) and dispatches in +`exec_slash_acp` before falling through to inference. The command turn still ends +with `Ok(PromptResponse::new(StopReason::EndTurn))`. -// 3. Create connection -let (conn, io_task) = AgentSideConnection::new(agent, stdout, stdin, |fut| { - tokio::task::spawn_local(fut); -}); +--- -// 4. Spawn forwarder that holds `conn` -tokio::task::spawn_local(async move { - while let Some(notification) = notification_rx.recv().await { - conn.session_notification(notification).await.ok(); - } -}); +## Concurrency: the `block_in_place` trap (still real) -// 5. Run IO -io_task.await; -``` +`mistralrs` model loading calls `tokio::task::block_in_place` internally, which +**panics off a multi-threaded runtime worker** ("can call blocking only when +running on the multi-threaded runtime"). The builder's task context and +`cx.spawn` tasks are not safe for this. + +siGit's fix: **do the blocking model load on a dedicated `std::thread` with its +own fresh `tokio::runtime::Runtime`**, and signal completion back via an +`AtomicBool` / `oneshot` channel. Never call `load_gguf_model` directly inside a +prompt handler or a `cx.spawn` task. -Inside `prompt()`, push chunks through the channel: ```rust -self.notification_tx.send(SessionNotification::new( - session_id.clone(), - SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(delta))), -)).await.ok(); // ignore send errors (channel closed = client gone) +std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(loader_engine.load_gguf_model(cfg, prompt, sampling)); + // store result, flip an AtomicBool / send on a oneshot +}); ``` +The prompt handler then `await`s readiness (siGit polls `model_ready` on a 1s +`tokio::time::interval`, streaming a spinner via `cx.send_notification`). + --- ## Logging -Log to **stderr** — stdout is the ACP JSON-RPC wire: +stdout is the ACP JSON-RPC wire — **log only to stderr.** siGit uses +`tracing_subscriber` to stderr: ```rust -env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) - .target(env_logger::Target::Stderr) - .init(); +tracing_subscriber::fmt::Subscriber::builder() + .with_env_filter(EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("info"))) + .with_writer(std::io::stderr) + .try_init(); ``` +In siGit's interactive TTY mode (not ACP), it goes further and redirects the +stdout/stderr **fds** to `$TMPDIR/sigit.log` so mistralrs/native noise can't +corrupt the ratatui screen. ACP mode keeps stdout pristine for protocol JSON. + +--- + +## TTY vs ACP split + +`main()` decides mode from `std::io::stdin().is_terminal()`: + +- **TTY** → interactive ratatui chat (`run_interactive`, Unix-only — needs fd + redirection). +- **non-TTY** → `run_acp_server()` (editor launched it over a pipe). + +Account verbs (`sigit login` / `logout` / `whoami`) are handled before the split, +since the editor launches `sigit login` in an embedded terminal. + --- ## Protocol flow ``` -Editor Agent - │ │ - │── initialize ────────────────►│ (negotiate version + capabilities) - │◄─ InitializeResponse ─────────│ - │ │ - │── authenticate ──────────────►│ (method_id from authMethods) - │◄─ AuthenticateResponse ───────│ - │ │ - │── session/new ───────────────►│ (create session, load model) - │◄─ NewSessionResponse ─────────│ - │ │ - │── session/prompt ────────────►│ (user message) - │◄─ session/update (N times) ───│ (streaming tokens via notification) - │◄─ PromptResponse ─────────────│ (stop_reason = EndTurn when done) - │ │ - │── session/cancel (optional) ──►│ - │ │ - │── [disconnect] ───────────────►│ (io_task future resolves → shutdown) +Editor Agent + │── initialize ──────────────────────►│ capabilities + auth methods + meta + │◄─ InitializeResponse ───────────────│ + │── authenticate ────────────────────►│ (button → verify stored session) + │◄─ AuthenticateResponse ─────────────│ + │── session/new (or load / fork) ───►│ cwd, reset history + │◄─ …Response(config_options) ────────│ + │◄─ session/update AvailableCommands ─│ advertise slash commands + │── session/setConfigOption ─────────►│ (model picker) → ToolCall progress + │── session/prompt ──────────────────►│ user message (text + resources) + │◄─ session/update (N×) ──────────────│ streaming chunks / tool-call cards + │◄─ PromptResponse(EndTurn) ──────────│ + │── session/cancel (notification) ───►│ + │── [disconnect] ─────────────────────►│ connect_to future resolves → shutdown ``` --- @@ -279,9 +515,9 @@ Editor Agent ```json { "agent_servers": { - "MyAgent": { + "siGit Code": { "type": "custom", - "command": "/path/to/binary" + "command": "/absolute/path/to/target/release/sigit" } } } @@ -291,29 +527,50 @@ Editor Agent ## Gotchas -1. **`Error::internal()` doesn't exist** — use `Error::new(-32603, msg)`. -2. **All protocol structs are `#[non_exhaustive]`** — use builder methods, - never struct literals. Add `_ => ...` wildcards when matching. -3. **`LocalBoxFuture` is `!Send`** — `tokio::spawn` won't work; use - `tokio::task::spawn_local` inside a `LocalSet`. -4. **`tokio::task::spawn_local` panics outside a `LocalSet`** — wrap with - `LocalSet::new().run_until(async { ... }).await`. -5. **Store `SessionId` as `SessionId`**, not `String` — otherwise `==` - comparisons get annoying. -6. **One session per connection is fine for MVP** — reuse the model with - `clear_history()` instead of reloading. -7. **`AgentCapabilities::default()` exists** — all capabilities None/false. -8. **`block_in_place` panics inside `spawn_local`** — dependencies that call - `tokio::task::block_in_place` internally (e.g. `mistralrs`) will blow up - with "can call blocking only when running on the multi-threaded runtime" - from a `spawn_local` task. Fix: do the blocking work *before* entering - the `LocalSet`, while you're still on a normal multi-thread worker, then - pass the result into your agent struct. -9. **Empty `authMethods` hangs Zed** — `InitializeResponse` with an empty - `auth_methods` vec makes Zed show "Loading…" forever. Always include at - least one `AuthMethod::Agent(AuthMethodAgent::new("id", "Name"))`. - Import `AuthMethod`, `AuthMethodAgent`, and `ProtocolVersion` from the crate. -10. **Never write to stdout except JSON-RPC** — any library that prints to - stdout (`mistralrs` model metadata, stray `println!`, whatever) will - corrupt the wire. Redirect diagnostics to stderr. If a dependency writes - to stdout internally, fix it or suppress it before shipping. +1. **No `Agent` trait to implement** — it's a builder. Register handler closures + with `.on_receive_request(closure, on_receive_request!())` and finish with + `.connect_to(transport)`. The `on_receive_request!()` / `on_receive_notification!()` + macro is mandatory per handler. +2. **`cx: ConnectionTo<Client>` replaces the mpsc forwarder** — send notifications + with `cx.send_notification(...)` and background tasks with `cx.spawn(...)`. + Don't reintroduce the old channel-based circular-dependency pattern. +3. **`Error::internal()` doesn't exist** — use `Error::new(-32603, msg)`. +4. **Everything in `agent_client_protocol::schema` is `#[non_exhaustive]`** — use + builder methods, never struct literals; add `_ => …` wildcards when matching. +5. **`ByteStreams::new(stdout, stdin)`** — writer first, reader second. Adapt + tokio stdio with `.compat()` / `.compat_write()` (tokio-util). +6. **`block_in_place` panics in handler/`cx.spawn` tasks** — run mistralrs model + loads on a dedicated `std::thread` + its own `Runtime`; signal back via + `AtomicBool`/`oneshot`. Never load inside a prompt handler directly. +7. **Empty `authMethods` hangs Zed** — always include at least one + `AuthMethod::Agent(AuthMethodAgent::new("id", "Name"))`. Prefer `Agent` over + `Terminal` for custom agents (Zed never spawns the terminal for them). +8. **Never write to stdout except JSON-RPC** — log to stderr; in TTY mode siGit + redirects fds to `$TMPDIR/sigit.log`. Any stray `println!` or native library + stdout write corrupts the wire. +9. **Unstable features gate real types** — `unstable_session_fork`, + `unstable_session_additional_directories`, `unstable_auth_methods` must be on + in `Cargo.toml` or `ForkSessionRequest`, `additional_directories`, and + `AuthMethod::Agent` won't exist. +10. **Zed re-fires the last config selection on connect** — make + `setConfigOption` a no-op when the requested model is already active, and + never start a model switch while a startup load is still in flight (GPU OOM). +11. **Store `SessionId` as `SessionId`**, not `String`, so `==` is clean. +12. **`SetSessionConfigOptionResponse::new(config_options)`** — the response + carries the *rebuilt* options so the picker reflects the new current value. + +--- + +## Where to look in the code + +Everything ACP lives in `src/main.rs`: + +- `run_acp_server` — builder wiring + transport. +- `SiGitAgent` + `handle_*` — the handlers. +- `build_model_config_options` / `resolve_model_config` — picker. +- `parse_slash` / `exec_slash_acp` — slash commands. +- `handle_response` — the `Responder` helper. + +`src/backend.rs` holds the `InferenceBackend` trait (`LocalBackend` / +`OpenAiBackend`) used by `handle_prompt`'s tool-calling loop; `src/tools.rs` +defines the agent tools and `execute_tool`.
.agents/skills/ai-assisted-coding/SKILL.md
+41 -17
index c488cfb..fedac16 100644 --- a/.agents/skills/ai-assisted-coding/SKILL.md +++ b/.agents/skills/ai-assisted-coding/SKILL.md @@ -11,7 +11,7 @@ Building a local AI coding agent in Rust using Onde Inference as the LLM backend Onde wraps mistral.rs with a clean API for model loading, history management, and streaming inference across macOS (Metal), iOS, Android, Linux, and Windows. -Crate: `onde = { path = "../onde" }` or from crates.io when published +Crate: `onde = "1.1.2"` (published on crates.io; siGit pins it in `Cargo.toml`) Repo: https://github.com/ondeinference/onde Docs: https://ondeinference.com @@ -165,15 +165,26 @@ GgufModelConfig::qwen25_1_5b() // force 1.5B GgufModelConfig::qwen25_3b() // force 3B GgufModelConfig::qwen25_coder_1_5b() // coder variant 1.5B GgufModelConfig::qwen25_coder_3b() // coder variant 3B +GgufModelConfig::qwen25_coder_7b() // coder variant 7B (tool calling) +GgufModelConfig::qwen3_1_7b() // Qwen 3 1.7B (tool calling) +GgufModelConfig::qwen3_4b() // Qwen 3 4B (tool calling) +GgufModelConfig::qwen3_8b() // Qwen 3 8B (tool calling) +GgufModelConfig::qwen3_14b() // Qwen 3 14B (tool calling) ``` +Only the Qwen 3 family and Qwen 2.5 Coder 7B support tool calling — see the +`tool-calling` skill. The on-device default is the saved selection, falling back +to `platform_default()` (Qwen 2.5 3B on macOS). + --- ## Adding onde as a Rust library dependency ```toml -# In your crate's Cargo.toml — onde is a path dep since it's not on crates.io yet -onde = { path = "../onde" } +# In your crate's Cargo.toml — onde is published on crates.io +onde = "1.1.2" +# For local SDK development against a checkout, swap to a path dep: +# onde = { path = "../onde" } ``` **Important:** `onde` declares `crate-type = ["lib", "cdylib", "staticlib"]`. @@ -262,20 +273,19 @@ Key principles: ### Streaming tokens to ACP (connecting onde → ACP) ```rust -// In Agent::prompt(): +// In the prompt handler — cx: &ConnectionTo<Client> is passed in by the builder +// (agent-client-protocol 0.13). No mpsc forwarder; send through cx directly. let mut rx = self.engine.stream_message(user_text).await .map_err(|e| Error::new(-32603, e.to_string()))?; while let Some(chunk) = rx.recv().await { if !chunk.delta.is_empty() { - self.notification_tx.send( - SessionNotification::new( - session_id.clone(), - SessionUpdate::AgentMessageChunk( - ContentChunk::new(ContentBlock::from(chunk.delta)), - ), - ) - ).await.ok(); // .ok() — ignore if forwarder is gone + cx.send_notification(SessionNotification::new( + session_id.clone(), + SessionUpdate::AgentMessageChunk( + ContentChunk::new(ContentBlock::from(chunk.delta)), + ), + )).ok(); // .ok() — ignore if the client is gone } if chunk.done { break; } } @@ -285,7 +295,13 @@ Ok(PromptResponse::new(StopReason::EndTurn)) The `PromptResponse` is returned AFTER the stream finishes. The client receives streaming tokens via `session/update` notifications while blocking on the -`session/prompt` response. +`session/prompt` response. See the `agent-client-protocol` skill for the `cx` +(`ConnectionTo<Client>`) model that replaced the old mpsc-channel forwarder. + +> **Note:** siGit's actual `handle_prompt` does *not* stream token-by-token — it +> runs a tool-calling loop through an `InferenceBackend` and sends the final text +> in one `AgentMessageChunk`. The streaming pattern above still applies if you +> want incremental output. See the `tool-calling` skill for the backend loop. --- @@ -305,13 +321,18 @@ let user_text: String = args.prompt.iter() .join("\n"); ``` -For future resource context (e.g. open files provided by Zed): +For resource context (e.g. open files provided by Zed) — note the variant is +`TextResourceContents`, not `Text`: ```rust ContentBlock::Resource(r) => match &r.resource { - EmbeddedResourceResource::Text(t) => Some(t.text.as_str()), + EmbeddedResourceResource::TextResourceContents(t) => Some(t.text.as_str()), + EmbeddedResourceResource::BlobResourceContents(_) => None, _ => None, }, ``` +siGit also handles `ContentBlock::ResourceLink` (a `file://` reference it reads +from disk, including `#L<start>:<end>` line-range fragments). See the +`tool-calling` skill. --- @@ -321,8 +342,11 @@ ContentBlock::Resource(r) => match &r.resource { - Safe to wrap in `Arc<ChatEngine>` and share across tasks. - `stream_message()` spawns a `tokio::spawn` background task internally — the mistralrs model must be `Send`, which it is on all supported platforms. -- Calling `stream_message()` from a `!Send` future (e.g. inside a `LocalSet`) is - fine — the future itself doesn't hold a `!Send` value across `.await`. +- **`block_in_place` trap:** `load_gguf_model` calls `tokio::task::block_in_place` + internally, which panics unless it's on a multi-threaded runtime worker. Run + model loads on a dedicated `std::thread` with its own `tokio::runtime::Runtime` + and signal back via `AtomicBool`/`oneshot`. siGit does exactly this in both ACP + and TUI modes — see the `agent-client-protocol` and `tool-calling` skills. ---
.agents/skills/sigit-code-release/SKILL.md
+3 -2
index 83ce33c..7dbd758 100644 --- a/.agents/skills/sigit-code-release/SKILL.md +++ b/.agents/skills/sigit-code-release/SKILL.md @@ -29,7 +29,8 @@ Use this skill when preparing a release for this repository. - Add or update the top changelog entry in `CHANGELOG.md` for the release being cut. - 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. - 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`. -- 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. +- 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. +- 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. ## Typical files to inspect @@ -41,7 +42,7 @@ Use this skill when preparing a release for this repository. - `npm/scripts/render-main-package.cjs` - `npm/` - `pypi/` -- `.github/workflows/` +- `.github/workflows/` (`release-github.yml`, `release-npm.yml`, `release-pypi.yml`, `release-crates.yml`, `release-homebrew.yml`) ## Release checklist
.agents/skills/tool-calling/SKILL.md
+95 -39
index b1ed924..4ed8c0c 100644 --- a/.agents/skills/tool-calling/SKILL.md +++ b/.agents/skills/tool-calling/SKILL.md @@ -9,33 +9,61 @@ description: Implement or debug tool calling in siGit Code across the app, Onde 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). -Tool calling spans three layers: +Tool calling spans these layers: ``` siGit (agent loop + tool execution) - → onde (ChatEngine with tool-aware API) - → mistral.rs (model inference + tool call parsing) + → InferenceBackend (src/backend.rs — LocalBackend or OpenAiBackend/cloud) + → onde ChatEngine (tool-aware API) ── for LocalBackend + → mistral.rs (model inference + tool call parsing) + └ OpenAI-compatible HTTP endpoint ── for OpenAiBackend (siGit Code Cloud) ``` +The agent loop talks to an `InferenceBackend` trait object, not the engine +directly. `LocalBackend` wraps the on-device `ChatEngine`; `OpenAiBackend` calls +a remote OpenAI-compatible endpoint (the siGit Code Cloud tiers). Both implement +`send_message_with_tools` / `send_tool_results`, so the loop is identical. + --- ## Model Requirement -**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. +Tool calling needs a model mistral.rs has a tool-call parser for. The supported +set is the **Qwen 3 family** (`<tool_call>...</tool_call>` XML) plus **Qwen 2.5 +Coder 7B**. Plain Qwen 2.5 and the smaller Qwen 2.5 Coder variants do NOT support +tool calling. The authoritative list is `is_tool_calling()` in `src/models.rs`. + +| Model | Constructor | Size | Tool calling | +|-------|-----------|------|:---:| +| Qwen 3 14B (Q4_K_M) | `GgufModelConfig::qwen3_14b()` | ~9 GB | ✅ | +| Qwen 3 8B (Q4_K_M) | `GgufModelConfig::qwen3_8b()` | ~5 GB | ✅ | +| Qwen 3 4B (Q4_K_M) | `GgufModelConfig::qwen3_4b()` | ~2.7 GB | ✅ | +| Qwen 3 1.7B (Q4_K_M) | `GgufModelConfig::qwen3_1_7b()` | ~1.3 GB | ✅ | +| Qwen 2.5 Coder 7B | `GgufModelConfig::qwen25_coder_7b()` | ~5 GB | ✅ | +| Qwen 2.5 Coder 3B | `GgufModelConfig::qwen25_coder_3b()` | ~1.93 GB | ❌ | +| Qwen 2.5 Coder 1.5B | `GgufModelConfig::qwen25_coder_1_5b()` | ~941 MB | ❌ | +| Qwen 2.5 3B / 1.5B | `qwen25_3b()` / `qwen25_1_5b()` | ~1.93 GB / ~941 MB | ❌ | + +### Default model + +There is **no hardcoded default model.** Startup uses the saved selection +(`setup::startup_model_selection`), then the first complete locally-cached model, +falling back to `GgufModelConfig::platform_default()` (Qwen 2.5 3B on macOS) when +nothing is cached. The TUI/ACP code in `main.rs` uses `qwen25_3b()` as that final +fallback. Users pick a tool-calling model via the `/models` picker. -| Model | Constructor | Size | Tool calling | Default | -|-------|-----------|------|:---:|:---:| -| Qwen 3 8B (Q4_K_M) | `GgufModelConfig::qwen3_8b()` | ~5 GB | ✅ | ✅ **default** | -| Qwen 3 4B (Q4_K_M) | `GgufModelConfig::qwen3_4b()` | ~2.7 GB | ✅ | | -| Qwen 3 1.7B (Q4_K_M) | `GgufModelConfig::qwen3_1_7b()` | ~1.3 GB | ✅ | | -| Qwen 2.5 Coder 3B | `GgufModelConfig::qwen25_coder_3b()` | ~1.93 GB | ❌ | | -| Qwen 2.5 Coder 1.5B | `GgufModelConfig::qwen25_coder_1_5b()` | ~941 MB | ❌ | | +### max_tokens -siGit uses **Qwen 3 8B** by default with `max_tokens: 8192` (set in `main.rs` for both TUI and ACP modes). +`max_tokens_for()` in `src/models.rs` gives tool-calling models **4096** tokens +and non-tool models **512** (tool models need headroom because `<think>` blocks +eat the budget). The TUI startup load in `run_interactive` overrides this to +**8192**. Don't assume a single value. -### Why 8B over 4B +### Why prefer 8B+ over 4B for editing -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. +4B struggles with `edit_file`: it reads a file, then fails to reproduce the exact +`old_text` it just saw, spiralling into retry rounds that burn `max_tokens` on +`<think>` blocks and return nothing. 8B (or larger) lands edits far more reliably. ### bartowski GGUF naming convention @@ -76,7 +104,9 @@ Defined in `sigit/src/tools.rs` via `all_tools()`: ### Tool gating by model -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. +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. + +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. --- @@ -109,6 +139,24 @@ In TUI mode, `run_inference_task()` takes a `tools_enabled: bool` parameter. Whe | `send_message_with_tools(msg, &[ToolDefinition])` | Returns `ToolAwareResult` with possible tool calls | | `send_tool_results(Vec<ToolResult>, Option<&[ToolDefinition]>)` | Feed results back; `None` forces text response | +#### Layer 2.5: the `InferenceBackend` abstraction (`src/backend.rs`) + +siGit doesn't call the engine directly from the agent loop — it goes through the +`InferenceBackend` trait so on-device and cloud inference share one code path: + +| Item | Purpose | +|------|---------| +| `trait InferenceBackend` | `send_message_with_tools` / `send_tool_results` / `is_remote` | +| `LocalBackend` | wraps `Arc<ChatEngine>` — on-device inference | +| `OpenAiBackend` | OpenAI-compatible HTTP client — siGit Code Cloud tiers | +| `ToolSpec` | backend-level tool definition (`name`, `description`, `parameters_schema`) | +| `ToolCall` / `ToolResult` / `TurnResult` | backend-level request/result types | + +`handle_prompt` snapshots `self.backend.lock().await.clone()` once per turn so a +mid-turn model/tier switch can't split the conversation across backends. When +`backend.is_remote()` it skips the local model load + readiness wait. Cloud tiers +(`fast`, `balanced`, `large`) come from `src/provider.rs` and are sign-in gated. + #### Internal details - `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. ## The Agentic Loop -Both ACP mode (`SiGitAgent::prompt()`) and TUI mode (`run_inference_task()`) implement: +Both ACP mode (`SiGitAgent::handle_prompt()`) and TUI mode (`run_inference_task()`) +implement the same loop, driven through the active `InferenceBackend`: ``` -1. engine.send_message_with_tools(user_text, &tools) → ToolAwareResult +1. backend.send_message_with_tools(user_text, &tools) → TurnResult 2. while result.tool_calls is non-empty AND round < MAX_TOOL_ROUNDS (10): a. For each tool_call: - Log: → tool_name(arguments) @@ -161,22 +210,29 @@ Both ACP mode (`SiGitAgent::prompt()`) and TUI mode (`run_inference_task()`) imp b. Decide next_tools: - round < MAX_TOOL_ROUNDS → Some(&tools) (allow more calls) - else → None (force text response) - c. engine.send_tool_results(results, next_tools) → ToolAwareResult -3. Send final result.text to user + c. backend.send_tool_results(results, next_tools) → TurnResult +3. Strip <think> blocks (chat::strip_think_blocks), send final text to user - Empty reply after tool rounds → log warning (ACP) or show error (TUI) ``` +In ACP mode the final text is sent as one `AgentMessageChunk`; the tool-calling +loop is not streamed token-by-token. + --- ## System Prompt -The `SYSTEM_PROMPT` in `main.rs` (~122 lines) includes critical instructions: +`main.rs` defines **two** prompts, picked by `system_prompt_for_model(tool_calling)`: -- **Never tell the user to run commands** — use `run_command` tool instead -- **Can access websites** — use `read_website` tool (overrides RLHF refusal training) -- **Prefer absolute paths** in all tool arguments -- **Git operations** — always use `run_command` with absolute cwd -- **smbCloud domain knowledge** — auth boundaries, deploy flows, project structure +- **`SYSTEM_PROMPT`** (~120 lines) — the full agentic prompt for tool-calling models: + - **Never tell the user to run commands** — use `run_command` tool instead + - **Can access websites** — use `read_website` tool (overrides RLHF refusal training) + - **Prefer absolute paths** in all tool arguments + - **Git operations** — always use `run_command` with absolute cwd + - **Always re-read a file before `edit_file`** — don't trust stale content + - **smbCloud domain knowledge** — auth boundaries, deploy flows, project structure +- **`SIMPLE_SYSTEM_PROMPT`** — a short prompt for non-tool models; the full one + wastes context and confuses them. The session `cwd` is injected as a separate system message at session creation time (not part of the static prompt). @@ -209,8 +265,8 @@ No changes needed in onde or mistral.rs — tool definitions are passed dynamica 1. **`onde/src/inference/models.rs`** — add `pub const` for repo ID and GGUF filename, add to `SUPPORTED_MODELS` array and `SUPPORTED_MODEL_INFO` 2. **`onde/src/inference/engine.rs`** — add `pub fn model_name() -> Self` constructor to `impl GgufModelConfig` -3. **`sigit/src/chat.rs`** — add `ModelOption` entry to `SIGIT_MODELS` with `tool_calling: true/false` -4. **`sigit/src/main.rs`** — update `run_interactive()` and `run_acp_server()` if changing the default +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. +4. **`sigit/src/main.rs`** — only if you're changing the fallback default (`qwen25_3b()`) --- @@ -266,19 +322,16 @@ could not read ResourceLink file:///path/to/index.html#L207:219: No such file or ## Cargo Dependency Note -For local development, `sigit/Cargo.toml` must use the path dependency: - -```toml -onde = { path = "../onde" } -``` - -For CI/release, switch to the git dependency (after pushing Onde changes): +`onde` is published on crates.io; `sigit/Cargo.toml` pins it: ```toml -onde = { git = "https://github.com/ondeinference/onde", branch = "development" } +onde = "1.1.2" ``` -The `qwen3_8b()` constructor only exists in the local Onde SDK until it's pushed to the `development` branch. +The Qwen 3 / Coder-7B constructors (`qwen3_8b()`, etc.) ship in that release. For +local SDK development against an `onde` checkout, swap to a path dep +(`onde = { path = "../onde" }`) — but the committed form must stay the crates.io +version so CI/release builds resolve. --- @@ -287,9 +340,12 @@ The `qwen3_8b()` constructor only exists in the local Onde SDK until it's pushed | File | What it does | |------|-------------| | `sigit/src/tools.rs` | 9 tool schemas (`all_tools()`), `execute_tool()` dispatch, all `exec_*` implementations | -| `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` | -| `sigit/src/chat.rs` | `SIGIT_MODELS` array (4 models), `run_inference_task()` with `tools_enabled` gate, TUI tool loop | -| `sigit/src/setup.rs` | HF cache setup pointing to shared App Group container | +| `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 | +| `sigit/src/backend.rs` | `InferenceBackend` trait, `LocalBackend`, `OpenAiBackend`, `ToolSpec`/`ToolCall`/`ToolResult`/`TurnResult` | +| `sigit/src/models.rs` | `ModelPickerItem`, `model_id_to_config()`, `is_tool_calling()`, `max_tokens_for()`, `build_model_picker_items()` / `local_picker_items()` | +| `sigit/src/provider.rs` | `CLOUD_TIERS`, `cloud_tier_provider()`, cloud endpoint config | +| `sigit/src/chat.rs` | TUI app, model picker UI (uses `build_model_picker_items`), `run_inference_task()` with `tools_enabled` gate, TUI tool loop | +| `sigit/src/setup.rs` | HF cache setup (shared App Group container), `startup_model_selection()` | | `onde/src/inference/types.rs` | `ToolDefinition`, `ToolCallRequest`, `ToolResult`, `ToolAwareResult` | | `onde/src/inference/engine.rs` | `send_message_with_tools()`, `send_tool_results()`, `attach_tools()`, `parse_tool_calls()`, `replay_history_with_tools()`, `GgufModelConfig::qwen3_8b()` | | `onde/src/inference/models.rs` | Model constants and `SUPPORTED_MODELS` array |
CLAUDE.md
+105
new file mode 100644 index 0000000..6f6c995 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,105 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +`sigit` ("siGit Code") is a single Rust binary: a local-first AI coding agent that runs LLM +inference on-device (via the `onde` crate / GGUF models) or against a hosted/OpenAI-compatible +endpoint. It exposes itself two ways from the *same* binary, chosen at startup by whether stdin +is a TTY: + +- **ACP mode** (stdin not a TTY): speaks the Agent Client Protocol over stdio for editor + integration (Zed, VS Code ACP Client). Cross-platform. +- **Interactive terminal mode** (stdin is a TTY): a full-screen ratatui chat UI. **Unix-only** — + it relies on fd redirection to keep logs out of the TUI, so Windows gets ACP mode only. + +Before the TTY/ACP split, `main` also dispatches the account subcommands `sigit login`, +`sigit logout`, `sigit whoami` (see `src/main.rs` `main()`). + +## Build / test / lint + +```sh +cargo build # debug build +cargo build --release # release binary at target/release/sigit +cargo run # launches interactive TUI (stdin is a TTY) +cargo test # CI runs: cargo test --locked --target <target> +cargo clippy --tests -- -D warnings # CI gate: clippy is -D warnings on all 4 targets +cargo fmt -- --check # CI gate (edition 2024) +``` + +CI (`.github/workflows/ci.yml`) runs fmt + clippy + test across four targets: +`aarch64-apple-darwin`, `x86_64-apple-darwin`, `x86_64-unknown-linux-gnu`, +`x86_64-pc-windows-msvc`. Clippy is `-D warnings`, so warnings fail the build. + +Run a single test: `cargo test <test_name>`. + +## Critical platform constraint: `#[cfg(unix)]` dead code + +The interactive client, the `InferenceBackend` seam (`backend.rs`), and provider resolution +(`provider.rs`) are wired up **only** through `#[cfg(unix)]` code paths. On Windows the binary +runs ACP-only and drives `onde` directly, so much of `backend.rs` and `provider.rs` is +legitimately unused there and the dead-code lint is suppressed *on non-Unix targets only*. + +Consequence: code can pass clippy on macOS/Linux but fail on the Windows target (or vice versa). +When touching `backend.rs`, `provider.rs`, or the interactive path, keep the `cfg` gates intact — +don't "fix" an unused-warning by deleting code that's live on Unix. + +## Architecture + +The agent loop is backend-agnostic. The flow: a turn (messages + tool specs) goes to an +`InferenceBackend`, which returns assistant text and/or tool calls; the loop executes tools and +feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete backend. + +- **`src/main.rs`** — entry point, mode dispatch, the full ACP `Agent` impl (session lifecycle: + new/load/fork/prompt/cancel, config options, slash-command advertisement), and the `SYSTEM_PROMPT` + (note: it bakes in smbCloud-specific context the agent should use when the repo is clearly + smbCloud, and stay general otherwise). +- **`src/backend.rs`** — the `InferenceBackend` trait and neutral types (`ToolSpec`, `ToolCall`, + `ToolResult`, `TurnResult`). Two impls: `LocalBackend` (on-device via `onde::ChatEngine`) and + `OpenAiBackend` (any OpenAI-compatible HTTP endpoint). +- **`src/provider.rs`** — decides *which* backend serves inference. Resolution order, first match + wins: (1) override via `OPENAI_BASE_URL`+`OPENAI_API_KEY` or active profile in + `~/.config/sigit/providers.toml`; (2) siGit Code Cloud when logged in; (3) on-device. +- **`src/tools.rs`** — agent tool schemas + execution: `read_file`, `create_directory`, + `list_directory`, `search_files`, `read_website`, `create_file`, `edit_file`, `delete_file`, + `run_command`. Add a tool in both the spec list and the execute `match`. +- **`src/chat.rs`** — the Unix-only ratatui TUI. Loading-spinner phase then chat; uses + `tokio::select!` to multiplex terminal events with streaming tokens. +- **`src/setup.rs`** — model cache location, local model discovery, selected-model persistence. + Must run (`setup_shared_model_cache`) *before* anything touches `ChatEngine`/`hf-hub`, since + those read env vars once at init. +- **`src/account.rs`** — siGit Code Cloud auth (`/login`, `/logout`, `/whoami`); authenticates + against the account API and stores a session token. Performs no console I/O. +- **`src/credentials.rs`** — local session-token store (TOML, `0600` on Unix). +- **`src/models.rs`** — model-picker types shared across platforms. + +Slash commands (`/help`, `/models`, `/login`, `/logout`, `/whoami`, `/reload`, `/clear`, +`/status`) are advertised via `advertise_commands` in `main.rs` and handled in both the TUI and +ACP sessions. + +## Model cache (macOS) + +On macOS the HF model cache lives in an App Group container shared with the siGit desktop app: +`~/Library/Group Containers/group.com.ondeinference.apps/models/`. Other platforms fall back to +`~/.cache/huggingface/`. The CLI reuses a model the desktop app already downloaded. First run +downloads a GGUF model (~1–2 GB) from Hugging Face. + +## Logging + +In TTY (interactive) mode, *all* output — `log`, `tracing`, stray `println!` — is redirected to +`$TMPDIR/sigit.log` so the ratatui surface stays clean; the TUI holds a separate fd to the real +terminal. In ACP mode, stdout is reserved for protocol JSON and logs go to stderr. Control +verbosity with `RUST_LOG`. + +## Relevant env vars + +`OPENAI_BASE_URL` / `OPENAI_API_KEY` (provider override), `SIGIT_API_URL` (account API base, +default `https://sigit.si`), `SIGIT_CLOUD_URL`, `SIGIT_CONFIG_DIR` (default `~/.config/sigit`), +`SIGIT_MODEL`, `HF_HOME` / `HF_HUB_CACHE`, `RUST_LOG`. + +## Releasing + +Version lives in `Cargo.toml`. The binary is published to five registries via separate workflows +(`release-crates`, `release-github`, `release-homebrew`, `release-npm`, `release-pypi`); the +`npm/` and `pypi/` dirs hold the wrapper-package templates. Update `CHANGELOG.md` for releases.