@setoelkahfi / sigit / commits / c7c34ff

Update ACP SDK to v0.11

Seto Elkahfi committed Jun 9, 2026 at 12:43 UTC c7c34ff30c4e273c8388171f93a36f2622a31999
3 files changed +676 -587
Cargo.lock
-1
index 234fd0d..412fc76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5071,7 +5071,6 @@ version = "1.0.4" dependencies = [ "agent-client-protocol", "anyhow", - "async-trait", "crossterm 0.29.0", "futures", "libc",
Cargo.toml
-1
index eab6a39..e1286cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,6 @@ agent-client-protocol = { version = "0.11", features = ["unstable_session_fork", onde = "1.1.2" # Async runtime -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"
src/main.rs
+676 -585
index a80b2bc..c867911 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,10 +11,6 @@ //! Interactive mode is Unix-only — it needs fd redirection to keep logs out //! of the TUI. Windows only gets ACP mode for now. //! -//! The model loads before the ACP `LocalSet` starts because `mistralrs` calls -//! `block_in_place`, which panics inside `spawn_local`. Loading on a regular -//! multi-thread worker sidesteps that. -//! //! On macOS the HF cache lives in the App Group container shared with the //! siGit desktop app. See [`setup`]. //! @@ -44,22 +40,21 @@ use std::sync::Arc; use onde::inference::SamplingConfig; -use agent_client_protocol::{ - Agent, AgentCapabilities, AgentSideConnection, AuthMethod, AuthMethodAgent, - AuthenticateRequest, AuthenticateResponse, CancelNotification, Client, ContentBlock, - ContentChunk, ForkSessionRequest, ForkSessionResponse, Implementation, InitializeRequest, - InitializeResponse, LoadSessionRequest, LoadSessionResponse, Meta, NewSessionRequest, - NewSessionResponse, PromptRequest, PromptResponse, ProtocolVersion, SessionCapabilities, - SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption, - SessionConfigValueId, SessionForkCapabilities, SessionId, SessionNotification, SessionUpdate, +use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder}; +use agent_client_protocol::schema::{ + AgentCapabilities, AuthMethod, AuthMethodAgent, AuthenticateRequest, AuthenticateResponse, + CancelNotification, 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, }; -use futures::future::LocalBoxFuture; use onde::inference::{ChatEngine, GgufModelConfig, ToolDefinition, ToolResult}; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; -use tokio::sync::mpsc; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; use tracing_subscriber::{EnvFilter, fmt as tracing_fmt}; @@ -267,7 +262,6 @@ fn initialize_meta() -> Meta { struct SiGitAgent { engine: Arc<ChatEngine>, - notification_tx: mpsc::Sender<SessionNotification>, /// cwd from the editor — tool calls run here, not where the process started session_cwd: std::sync::Mutex<Option<PathBuf>>, current_model: std::sync::Mutex<GgufModelConfig>, @@ -288,7 +282,6 @@ struct SiGitAgent { impl SiGitAgent { fn new( engine: Arc<ChatEngine>, - notification_tx: mpsc::Sender<SessionNotification>, initial_model: GgufModelConfig, model_ready: Arc<AtomicBool>, startup_model_load_started: Arc<AtomicBool>, @@ -299,7 +292,6 @@ impl SiGitAgent { let startup_model_id = initial_model.model_id.clone(); Self { engine, - notification_tx, session_cwd: std::sync::Mutex::new(None), current_model: std::sync::Mutex::new(initial_model), model_ready, @@ -372,7 +364,11 @@ impl SiGitAgent { } /// block until the startup model is ready, showing progress in the session. - async fn await_model_ready(&self, session_id: &SessionId) -> agent_client_protocol::Result<()> { + async fn await_model_ready( + &self, + cx: &ConnectionTo<Client>, + session_id: &SessionId, + ) -> agent_client_protocol::Result<()> { if self.model_ready.load(Ordering::Acquire) { // already done — might be a stored error from earlier if let Some(err) = self.model_load_error.lock().unwrap().as_ref() { @@ -394,6 +390,7 @@ impl SiGitAgent { }; self.send_tool_call_update( + cx, session_id.clone(), SessionUpdate::ToolCall( ToolCall::new(tool_call_id.clone(), &title) @@ -402,7 +399,7 @@ impl SiGitAgent { .content(vec![format!("{}…", title).into()]), ), ) - .await; + .ok(); let expected_bytes = if self.startup_needs_download { onde::inference::models::SUPPORTED_MODEL_INFO @@ -484,6 +481,7 @@ impl SiGitAgent { }; self.send_tool_call_update( + cx, session_id.clone(), SessionUpdate::ToolCallUpdate(ToolCallUpdate::new( tool_call_id.clone(), @@ -493,13 +491,14 @@ impl SiGitAgent { .content(vec![update_content.into()]), )), ) - .await; + .ok(); } // done — check if it blew up let load_error = self.model_load_error.lock().unwrap().clone(); if let Some(err) = load_error { self.send_tool_call_update( + cx, session_id.clone(), SessionUpdate::ToolCallUpdate(ToolCallUpdate::new( tool_call_id, @@ -509,7 +508,7 @@ impl SiGitAgent { .content(vec![format!("error: {err}").into()]), )), ) - .await; + .ok(); return Err(agent_client_protocol::Error::new( -32603, @@ -524,6 +523,7 @@ impl SiGitAgent { }; self.send_tool_call_update( + cx, session_id.clone(), SessionUpdate::ToolCallUpdate(ToolCallUpdate::new( tool_call_id, @@ -532,26 +532,30 @@ impl SiGitAgent { .status(ToolCallStatus::Completed), )), ) - .await; + .ok(); Ok(()) } - async fn send_assistant_message(&self, session_id: SessionId, text: impl Into<String>) { - let notification = SessionNotification::new( + fn send_assistant_message( + &self, + cx: &ConnectionTo<Client>, + session_id: SessionId, + text: impl Into<String>, + ) -> agent_client_protocol::Result<()> { + cx.send_notification(SessionNotification::new( session_id, SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(text.into()))), - ); - if self.notification_tx.send(notification).await.is_err() { - log::warn!("notification channel closed"); - } + )) } - async fn send_tool_call_update(&self, session_id: SessionId, update: SessionUpdate) { - let notification = SessionNotification::new(session_id, update); - if self.notification_tx.send(notification).await.is_err() { - log::warn!("notification channel closed"); - } + fn send_tool_call_update( + &self, + cx: &ConnectionTo<Client>, + session_id: SessionId, + update: SessionUpdate, + ) -> agent_client_protocol::Result<()> { + cx.send_notification(SessionNotification::new(session_id, update)) } async fn switch_model_by_id( @@ -647,439 +651,145 @@ impl SiGitAgent { } } -/// config option ID for the model picker in Zed's agent panel -const MODEL_CONFIG_ID: &str = "sigit-model"; +// ── ACP handler implementations ─────────────────────────────────────────────── -fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionConfigOption> { - let items = models::build_model_picker_items(); +impl SiGitAgent { + async fn handle_initialize( + &self, + _req: InitializeRequest, + ) -> agent_client_protocol::Result<InitializeResponse> { + log::info!("initialize"); - let options: Vec<SessionConfigSelectOption> = items - .iter() - .filter(|item| item.cache_health != setup::ModelCacheHealth::Incomplete) - .map(|item| { - let mut desc_parts = Vec::new(); - if item.tool_calling { - desc_parts.push("tool calling".to_string()); - } - desc_parts.push(item.description.clone()); - if item.cache_health == setup::ModelCacheHealth::NotDownloaded { - desc_parts.push("↓ download on select".to_string()); - } - let description = desc_parts.join(" - "); - let source_badge = if item.cache_health == setup::ModelCacheHealth::NotDownloaded { - " [↓ Onde]" - } else { - match item.source_label.as_str() { - "Onde" => " [◉ Onde]", - "HuggingFace" => " [○ HuggingFace]", - _ => "", - } - }; - let name = format!("{}{}", item.display_name, source_badge); - SessionConfigSelectOption::new( - SessionConfigValueId::new(item.config.model_id.as_str()), - name, + Ok(InitializeResponse::new(ProtocolVersion::V1) + .agent_info( + Implementation::new("sigit", env!("CARGO_PKG_VERSION")) + .title("siGit — AI Coding Agent"), ) - .description(description) - }) - .collect(); + .auth_methods(vec![AuthMethod::Agent(AuthMethodAgent::new( + "sigit", "siGit", + ))]) + .agent_capabilities( + AgentCapabilities::default() + .load_session(true) + .session_capabilities( + SessionCapabilities::new().fork(SessionForkCapabilities::new()), + ), + ) + .meta(initialize_meta())) + } - if options.is_empty() { - return vec![]; + async fn handle_authenticate( + &self, + _req: AuthenticateRequest, + ) -> agent_client_protocol::Result<AuthenticateResponse> { + log::info!("authenticate"); + Ok(AuthenticateResponse::default()) } - let current_value = SessionConfigValueId::new(current_model.model_id.as_str()); + async fn handle_load_session( + &self, + args: LoadSessionRequest, + ) -> agent_client_protocol::Result<LoadSessionResponse> { + log::info!( + "load_session: id={}, cwd={}, additional_directories={:?}", + args.session_id, + args.cwd.display(), + args.additional_directories + .iter() + .map(|p| p.display().to_string()) + .collect::<Vec<_>>() + ); - vec![ - SessionConfigOption::select(MODEL_CONFIG_ID, "Model", current_value, options) - .category(SessionConfigOptionCategory::Model) - .description("Select the local LLM model for inference"), - ] -} + if let Ok(mut guard) = self.session_cwd.lock() { + *guard = Some(args.cwd.clone()); + } -/// returns `(config, max_tokens, tool_calling)` for a picker model_id, or None -fn resolve_model_config(model_id: &str) -> Option<(GgufModelConfig, u64, bool)> { - let items = models::build_model_picker_items(); - items - .into_iter() - .find(|item| { - item.config.model_id == model_id - && item.cache_health != setup::ModelCacheHealth::Incomplete - }) - .map(|item| (item.config, item.max_tokens, item.tool_calling)) -} + // tool calls use relative paths, so we need to match the editor's cwd + if args.cwd.is_dir() + && let Err(err) = std::env::set_current_dir(&args.cwd) + { + log::warn!("could not set cwd to {}: {err}", args.cwd.display()); + } -#[derive(Debug, Clone)] -enum SlashCommand { - Help, - Clear, - Status, - Models(Option<usize>), - Exit, - Unknown(String), -} + // no session persistence, so "load" just resets + self.engine.clear_history().await; -fn parse_slash(input: &str) -> Option<SlashCommand> { - let trimmed = input.trim(); - if !trimmed.starts_with('/') { - return None; - } - let mut parts = trimmed.splitn(2, char::is_whitespace); - let command = parts.next().unwrap_or(""); - let argument = parts.next().map(str::trim); - Some(match command { - "/help" => SlashCommand::Help, - "/clear" => SlashCommand::Clear, - "/status" => SlashCommand::Status, - "/models" => SlashCommand::Models(argument.and_then(|v| v.parse::<usize>().ok())), - "/exit" | "/quit" | "/q" => SlashCommand::Exit, - other => SlashCommand::Unknown(other.to_string()), - }) -} + self.engine + .push_history(onde::inference::ChatMessage::system(format!( + "The user's project working directory is {}. \ + Always use absolute paths under this directory for all file \ + and directory operations. This is the root of the project \ + the user has open in their editor.", + args.cwd.display() + ))) + .await; -fn format_models_list(current_model: &GgufModelConfig) -> String { - let items = models::build_model_picker_items(); - if items.is_empty() { - return "No local models found. siGit will use the platform default model.".to_string(); - } + let config_options = { + let guard = self.current_model.lock().unwrap(); + build_model_config_options(&guard) + }; - let mut lines = vec!["Available models:".to_string()]; - let mut last_source: Option<&str> = None; + Ok(LoadSessionResponse::new().config_options(config_options)) + } - for (index, item) in items.iter().enumerate() { - let source_key = match item.source_label.as_str() { - "Onde" => "Onde", - "HuggingFace" => "HuggingFace", - _ => "Fallback", - }; + async fn handle_fork_session( + &self, + args: ForkSessionRequest, + ) -> agent_client_protocol::Result<ForkSessionResponse> { + let new_id = SessionId::new(uuid::Uuid::new_v4().to_string()); + log::info!( + "fork_session: from={} new={new_id}, cwd={}, additional_directories={:?}", + args.session_id, + args.cwd.display(), + args.additional_directories + .iter() + .map(|p| p.display().to_string()) + .collect::<Vec<_>>() + ); - if last_source != Some(source_key) { - if last_source.is_some() { - lines.push(String::new()); - } - let section = match source_key { - "Onde" => "Onde Inference", - "HuggingFace" => "Hugging Face cache", - _ => "Fallback", - }; - lines.push(section.to_string()); - last_source = Some(source_key); + if let Ok(mut guard) = self.session_cwd.lock() { + *guard = Some(args.cwd.clone()); + } + if args.cwd.is_dir() + && let Err(err) = std::env::set_current_dir(&args.cwd) + { + log::warn!("could not set cwd to {}: {err}", args.cwd.display()); } - let number = index + 1; - let current_badge = if item.config.model_id == current_model.model_id { - " <- current" - } else { - "" - }; - let tool_badge = if item.tool_calling { - " tool calling" - } else { - "" - }; - let health_badge = match item.cache_health { - setup::ModelCacheHealth::Complete => "", - setup::ModelCacheHealth::Incomplete => " ! incomplete cache", - setup::ModelCacheHealth::NotDownloaded => " ↓ download on select", - }; - let source = match source_key { - "Onde" => " [Onde]", - "HuggingFace" => " [HuggingFace]", - _ => " [default]", + // no persistence, so fork == fresh session + self.engine.clear_history().await; + + self.engine + .push_history(onde::inference::ChatMessage::system(format!( + "The user's project working directory is {}. \ + Always use absolute paths under this directory for all file \ + and directory operations. This is the root of the project \ + the user has open in their editor.", + args.cwd.display() + ))) + .await; + + let config_options = { + let guard = self.current_model.lock().unwrap(); + build_model_config_options(&guard) }; - lines.push(format!( - "{number}. {} {}{}{}{}{}", - item.display_name, item.description, tool_badge, health_badge, current_badge, source, - )); + Ok(ForkSessionResponse::new(new_id).config_options(config_options)) } - lines.push(String::new()); - lines.push("Use /models N to switch models.".to_string()); - lines.join("\n") -} - -async fn exec_slash_acp( - agent: &SiGitAgent, - session_id: SessionId, - command: SlashCommand, -) -> agent_client_protocol::Result<PromptResponse> { - match command { - SlashCommand::Help => { - agent - .send_assistant_message( - session_id, - "/help - show this message\n\ - /models - list available models\n\ - /models N - switch to model N\n\ - /clear - wipe conversation history\n\ - /status - show engine status\n\ - /exit - end this turn", - ) - .await; - } - SlashCommand::Clear => { - let cleared = agent.engine.clear_history().await; - agent - .send_assistant_message( - session_id, - format!("Cleared {cleared} turn(s). History is empty."), - ) - .await; - } - SlashCommand::Status => { - let info = agent.engine.info().await; - let model = info.model_name.as_deref().unwrap_or("(none)"); - let memory = info.approx_memory.as_deref().unwrap_or("unknown"); - agent - .send_assistant_message( - session_id, - format!( - "status: {:?} model: {} memory: {} history: {} turns", - info.status, model, memory, info.history_length, - ), - ) - .await; - } - SlashCommand::Models(None) => { - let current_model = agent.current_model.lock().unwrap().clone(); - agent - .send_assistant_message(session_id, format_models_list(&current_model)) - .await; - } - SlashCommand::Models(Some(number)) => { - let items = models::build_model_picker_items(); - let index = number.saturating_sub(1); - match items.get(index).cloned() { - None => { - agent - .send_assistant_message( - session_id, - format!("error: no model #{number} - type /models to see the list."), - ) - .await; - } - Some(model) => { - if model.cache_health == setup::ModelCacheHealth::Incomplete { - agent - .send_assistant_message( - session_id, - format!( - "error: {} has an incomplete local cache and cannot be selected yet.", - model.display_name - ), - ) - .await; - } else if model.cache_health == setup::ModelCacheHealth::NotDownloaded { - agent - .send_assistant_message( - session_id.clone(), - format!( - "Downloading and loading {} ({})… this may take a few minutes.", - model.display_name, model.description - ), - ) - .await; - - match agent.switch_model_by_id(&model.config.model_id).await { - Ok(new_config) => { - agent.engine.clear_history().await; - agent - .send_assistant_message( - session_id, - format!( - "✓ Downloaded and switched to {}", - new_config.display_name - ), - ) - .await; - } - Err(err) => { - agent - .send_assistant_message( - session_id, - format!("error downloading model: {}", err.message), - ) - .await; - } - } - } else { - agent - .send_assistant_message( - session_id.clone(), - format!("Loading {}...", model.display_name), - ) - .await; - - let switched = agent.switch_model_by_id(&model.config.model_id).await?; - agent.engine.clear_history().await; - - agent - .send_assistant_message( - session_id, - format!("Switched to {}.", switched.display_name), - ) - .await; - } - } - } - } - SlashCommand::Exit => { - agent - .send_assistant_message( - session_id, - "Use the panel controls to close or switch threads.", - ) - .await; - } - SlashCommand::Unknown(command) => { - agent - .send_assistant_message(session_id, format!("unknown command: {command}")) - .await; - } - } - - Ok(PromptResponse::new(StopReason::EndTurn)) -} - -#[async_trait::async_trait(?Send)] -impl Agent for SiGitAgent { - async fn initialize( - &self, - _args: InitializeRequest, - ) -> agent_client_protocol::Result<InitializeResponse> { - log::info!("initialize"); - - Ok(InitializeResponse::new(ProtocolVersion::V1) - .agent_info( - Implementation::new("sigit", env!("CARGO_PKG_VERSION")) - .title("siGit — AI Coding Agent"), - ) - .auth_methods(vec![AuthMethod::Agent(AuthMethodAgent::new( - "sigit", "siGit", - ))]) - .agent_capabilities( - AgentCapabilities::default() - .load_session(true) - .session_capabilities( - SessionCapabilities::new().fork(SessionForkCapabilities::new()), - ), - ) - .meta(initialize_meta())) - } - - async fn authenticate( - &self, - _args: AuthenticateRequest, - ) -> agent_client_protocol::Result<AuthenticateResponse> { - log::info!("authenticate"); - Ok(AuthenticateResponse::default()) - } - - async fn load_session( - &self, - args: LoadSessionRequest, - ) -> agent_client_protocol::Result<LoadSessionResponse> { - log::info!( - "load_session: id={}, cwd={}, additional_directories={:?}", - args.session_id, - args.cwd.display(), - args.additional_directories - .iter() - .map(|p| p.display().to_string()) - .collect::<Vec<_>>() - ); - - if let Ok(mut guard) = self.session_cwd.lock() { - *guard = Some(args.cwd.clone()); - } - - // tool calls use relative paths, so we need to match the editor's cwd - if args.cwd.is_dir() - && let Err(err) = std::env::set_current_dir(&args.cwd) - { - log::warn!("could not set cwd to {}: {err}", args.cwd.display()); - } - - // no session persistence, so "load" just resets - self.engine.clear_history().await; - - self.engine - .push_history(onde::inference::ChatMessage::system(format!( - "The user's project working directory is {}. \ - Always use absolute paths under this directory for all file \ - and directory operations. This is the root of the project \ - the user has open in their editor.", - args.cwd.display() - ))) - .await; - - let config_options = { - let guard = self.current_model.lock().unwrap(); - build_model_config_options(&guard) - }; - - Ok(LoadSessionResponse::new().config_options(config_options)) - } - - async fn fork_session( - &self, - args: ForkSessionRequest, - ) -> agent_client_protocol::Result<ForkSessionResponse> { - let new_id = SessionId::new(uuid::Uuid::new_v4().to_string()); - log::info!( - "fork_session: from={} new={new_id}, cwd={}, additional_directories={:?}", - args.session_id, - args.cwd.display(), - args.additional_directories - .iter() - .map(|p| p.display().to_string()) - .collect::<Vec<_>>() - ); - - if let Ok(mut guard) = self.session_cwd.lock() { - *guard = Some(args.cwd.clone()); - } - if args.cwd.is_dir() - && let Err(err) = std::env::set_current_dir(&args.cwd) - { - log::warn!("could not set cwd to {}: {err}", args.cwd.display()); - } - - // no persistence, so fork == fresh session - self.engine.clear_history().await; - - self.engine - .push_history(onde::inference::ChatMessage::system(format!( - "The user's project working directory is {}. \ - Always use absolute paths under this directory for all file \ - and directory operations. This is the root of the project \ - the user has open in their editor.", - args.cwd.display() - ))) - .await; - - let config_options = { - let guard = self.current_model.lock().unwrap(); - build_model_config_options(&guard) - }; - - Ok(ForkSessionResponse::new(new_id).config_options(config_options)) - } - - async fn new_session( - &self, - args: NewSessionRequest, - ) -> agent_client_protocol::Result<NewSessionResponse> { - let session_id = SessionId::new(uuid::Uuid::new_v4().to_string()); - log::info!( - "new_session: id={session_id}, cwd={}, additional_directories={:?}", - args.cwd.display(), - args.additional_directories - .iter() - .map(|p| p.display().to_string()) - .collect::<Vec<_>>() - ); + async fn handle_new_session( + &self, + args: NewSessionRequest, + ) -> agent_client_protocol::Result<NewSessionResponse> { + let session_id = SessionId::new(uuid::Uuid::new_v4().to_string()); + log::info!( + "new_session: id={session_id}, cwd={}, additional_directories={:?}", + args.cwd.display(), + args.additional_directories + .iter() + .map(|p| p.display().to_string()) + .collect::<Vec<_>>() + ); if let Ok(mut guard) = self.session_cwd.lock() { *guard = Some(args.cwd.clone()); @@ -1110,7 +820,11 @@ impl Agent for SiGitAgent { Ok(NewSessionResponse::new(session_id).config_options(config_options)) } - async fn prompt(&self, args: PromptRequest) -> agent_client_protocol::Result<PromptResponse> { + async fn handle_prompt( + &self, + cx: &ConnectionTo<Client>, + args: PromptRequest, + ) -> agent_client_protocol::Result<PromptResponse> { let session_id = args.session_id.clone(); // log every block so we can debug @ references and file context @@ -1131,9 +845,9 @@ impl Agent for SiGitAgent { session_id, i, match &embedded.resource { - agent_client_protocol::EmbeddedResourceResource::TextResourceContents(t) => + EmbeddedResourceResource::TextResourceContents(t) => format!("TextResource(uri={}, {} chars)", t.uri, t.text.len()), - agent_client_protocol::EmbeddedResourceResource::BlobResourceContents(b) => + EmbeddedResourceResource::BlobResourceContents(b) => format!("BlobResource(uri={})", b.uri), _ => "Unknown".to_string(), } @@ -1171,17 +885,13 @@ impl Agent for SiGitAgent { ContentBlock::Resource(embedded) => { // editor inlined the file content already match &embedded.resource { - agent_client_protocol::EmbeddedResourceResource::TextResourceContents( - text_resource, - ) => { + EmbeddedResourceResource::TextResourceContents(text_resource) => { parts.push(format!( "\n--- {} ---\n{}\n--- end {} ---", text_resource.uri, text_resource.text, text_resource.uri )); } - agent_client_protocol::EmbeddedResourceResource::BlobResourceContents( - blob, - ) => { + EmbeddedResourceResource::BlobResourceContents(blob) => { parts.push(format!("[binary resource: {}]", blob.uri)); } _ => { @@ -1255,7 +965,7 @@ impl Agent for SiGitAgent { } if let Some(command) = parse_slash(&user_text) { - return exec_slash_acp(self, session_id, command).await; + return exec_slash_acp(self, cx, session_id, command).await; } log::info!( @@ -1267,7 +977,7 @@ impl Agent for SiGitAgent { // load the default ACP model lazily so initialize/session/new stay clean // for registry validation and editor startup. self.start_startup_model_load_if_needed(); - self.await_model_ready(&session_id).await?; + self.await_model_ready(cx, &session_id).await?; // ── tool-calling loop ──────────────────────────────────────────── // send message → execute any tool calls → feed results back @@ -1352,26 +1062,25 @@ impl Agent for SiGitAgent { }; if !final_text.is_empty() { - let notification = SessionNotification::new( - session_id.clone(), - SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(final_text))), - ); - if self.notification_tx.send(notification).await.is_err() { - log::warn!("notification channel closed"); - } + self.send_assistant_message(cx, session_id.clone(), final_text) + .ok(); } log::info!("prompt({}) complete — {} tool round(s)", session_id, round); Ok(PromptResponse::new(StopReason::EndTurn)) } - async fn cancel(&self, args: CancelNotification) -> agent_client_protocol::Result<()> { + async fn handle_cancel( + &self, + args: CancelNotification, + ) -> agent_client_protocol::Result<()> { log::info!("cancel requested for session {}", args.session_id); Ok(()) } - async fn set_session_config_option( + async fn handle_set_session_config_option( &self, + cx: &ConnectionTo<Client>, args: SetSessionConfigOptionRequest, ) -> agent_client_protocol::Result<SetSessionConfigOptionResponse> { log::info!( @@ -1445,6 +1154,7 @@ impl Agent for SiGitAgent { }; self.send_tool_call_update( + cx, args.session_id.clone(), SessionUpdate::ToolCall( ToolCall::new( @@ -1461,16 +1171,16 @@ impl Agent for SiGitAgent { ]), ), ) - .await; + .ok(); // poll download progress and update the spinner in Zed - let poller_tx = self.notification_tx.clone(); + let cx_for_poller = cx.clone(); let poller_session = args.session_id.clone(); let poller_model_id = model_id_owned.clone(); let poller_stop = Arc::clone(&stop_flag); let poller_tool_call_id = tool_call_id.clone(); - tokio::task::spawn_local(async move { + cx.spawn(async move { const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; let cache_path = onde::hf_cache::model_cache_path(&poller_model_id); let mut tick: usize = 0; @@ -1527,11 +1237,13 @@ impl Agent for SiGitAgent { .content(vec![msg.into()]), )), ); - if poller_tx.send(notification).await.is_err() { + if cx_for_poller.send_notification(notification).is_err() { break; } } - }); + Ok(()) + }) + .ok(); } // cached models still take 10-30s to load weights; show a spinner @@ -1543,6 +1255,7 @@ impl Agent for SiGitAgent { .unwrap_or_else(|| model_id.to_string()); self.send_tool_call_update( + cx, args.session_id.clone(), SessionUpdate::ToolCall( ToolCall::new( @@ -1554,108 +1267,435 @@ impl Agent for SiGitAgent { .content(vec![format!("Loading {cached_display_name}…").into()]), ), ) - .await; + .ok(); // tick every 5s so the user knows we haven't frozen - let spinner_tx = self.notification_tx.clone(); + let cx_for_spinner = cx.clone(); let spinner_session = args.session_id.clone(); let spinner_name = cached_display_name.clone(); let spinner_stop = Arc::clone(&stop_flag); let spinner_tool_call_id = tool_call_id.clone(); let load_start = std::time::Instant::now(); - tokio::task::spawn_local(async move { - const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - let mut tick: usize = 0; - let mut interval = tokio::time::interval(std::time::Duration::from_secs(5)); - interval.tick().await; // consume the immediate first tick + cx.spawn(async move { + const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + let mut tick: usize = 0; + let mut interval = tokio::time::interval(std::time::Duration::from_secs(5)); + interval.tick().await; // consume the immediate first tick + + while !spinner_stop.load(Ordering::Relaxed) { + interval.tick().await; + + if spinner_stop.load(Ordering::Relaxed) { + break; + } + + let elapsed = load_start.elapsed(); + let elapsed_str = if elapsed.as_secs() >= 60 { + format!("{}m {:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60) + } else { + format!("{}s", elapsed.as_secs()) + }; + let frame = SPINNER[tick % SPINNER.len()]; + tick += 1; + + let msg = format!("{frame} Loading {spinner_name}… ({elapsed_str})"); + let notification = SessionNotification::new( + spinner_session.clone(), + SessionUpdate::ToolCallUpdate(ToolCallUpdate::new( + spinner_tool_call_id.clone(), + ToolCallUpdateFields::new() + .status(ToolCallStatus::InProgress) + .content(vec![msg.into()]), + )), + ); + if cx_for_spinner.send_notification(notification).is_err() { + break; + } + } + Ok(()) + }) + .ok(); + } + + let switch_result = self.switch_model_by_id(model_id).await; + + stop_flag.store(true, Ordering::Relaxed); + + match switch_result { + Ok(new_config) => { + let completion_title = if needs_download { + format!("✓ {} downloaded and loaded", new_config.display_name) + } else { + format!("✓ Switched to {}", new_config.display_name) + }; + let completion_body = if needs_download { + format!("✓ {} downloaded and loaded.", new_config.display_name) + } else { + format!("✓ Switched to {}.", new_config.display_name) + }; + + self.send_tool_call_update( + cx, + args.session_id.clone(), + SessionUpdate::ToolCallUpdate(ToolCallUpdate::new( + tool_call_id, + ToolCallUpdateFields::new() + .title(completion_title) + .status(ToolCallStatus::Completed) + .content(vec![completion_body.into()]), + )), + ) + .ok(); + + let config_options = { + let guard = self.current_model.lock().unwrap(); + build_model_config_options(&guard) + }; + + log::info!("model switch complete"); + Ok(SetSessionConfigOptionResponse::new(config_options)) + } + Err(err) => { + self.send_tool_call_update( + cx, + args.session_id.clone(), + SessionUpdate::ToolCallUpdate(ToolCallUpdate::new( + tool_call_id, + ToolCallUpdateFields::new() + .title("Model switch failed".to_string()) + .status(ToolCallStatus::Failed) + .content(vec![format!("error loading model: {}", err.message).into()]), + )), + ) + .ok(); + + Err(err) + } + } + } +} + +// ── Config option helpers ───────────────────────────────────────────────────── + +/// config option ID for the model picker in Zed's agent panel +const MODEL_CONFIG_ID: &str = "sigit-model"; + +fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionConfigOption> { + let items = models::build_model_picker_items(); + + let options: Vec<SessionConfigSelectOption> = items + .iter() + .filter(|item| item.cache_health != setup::ModelCacheHealth::Incomplete) + .map(|item| { + let mut desc_parts = Vec::new(); + if item.tool_calling { + desc_parts.push("tool calling".to_string()); + } + desc_parts.push(item.description.clone()); + if item.cache_health == setup::ModelCacheHealth::NotDownloaded { + desc_parts.push("↓ download on select".to_string()); + } + let description = desc_parts.join(" - "); + let source_badge = if item.cache_health == setup::ModelCacheHealth::NotDownloaded { + " [↓ Onde]" + } else { + match item.source_label.as_str() { + "Onde" => " [◉ Onde]", + "HuggingFace" => " [○ HuggingFace]", + _ => "", + } + }; + let name = format!("{}{}", item.display_name, source_badge); + SessionConfigSelectOption::new( + SessionConfigValueId::new(item.config.model_id.as_str()), + name, + ) + .description(description) + }) + .collect(); + + if options.is_empty() { + return vec![]; + } + + let current_value = SessionConfigValueId::new(current_model.model_id.as_str()); + + vec![ + SessionConfigOption::select(MODEL_CONFIG_ID, "Model", current_value, options) + .category(SessionConfigOptionCategory::Model) + .description("Select the local LLM model for inference"), + ] +} + +/// returns `(config, max_tokens, tool_calling)` for a picker model_id, or None +fn resolve_model_config(model_id: &str) -> Option<(GgufModelConfig, u64, bool)> { + let items = models::build_model_picker_items(); + items + .into_iter() + .find(|item| { + item.config.model_id == model_id + && item.cache_health != setup::ModelCacheHealth::Incomplete + }) + .map(|item| (item.config, item.max_tokens, item.tool_calling)) +} + +// ── Slash commands ──────────────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +enum SlashCommand { + Help, + Clear, + Status, + Models(Option<usize>), + Exit, + Unknown(String), +} + +fn parse_slash(input: &str) -> Option<SlashCommand> { + let trimmed = input.trim(); + if !trimmed.starts_with('/') { + return None; + } + let mut parts = trimmed.splitn(2, char::is_whitespace); + let command = parts.next().unwrap_or(""); + let argument = parts.next().map(str::trim); + Some(match command { + "/help" => SlashCommand::Help, + "/clear" => SlashCommand::Clear, + "/status" => SlashCommand::Status, + "/models" => SlashCommand::Models(argument.and_then(|v| v.parse::<usize>().ok())), + "/exit" | "/quit" | "/q" => SlashCommand::Exit, + other => SlashCommand::Unknown(other.to_string()), + }) +} + +fn format_models_list(current_model: &GgufModelConfig) -> String { + let items = models::build_model_picker_items(); + if items.is_empty() { + return "No local models found. siGit will use the platform default model.".to_string(); + } + + let mut lines = vec!["Available models:".to_string()]; + let mut last_source: Option<&str> = None; + + for (index, item) in items.iter().enumerate() { + let source_key = match item.source_label.as_str() { + "Onde" => "Onde", + "HuggingFace" => "HuggingFace", + _ => "Fallback", + }; + + if last_source != Some(source_key) { + if last_source.is_some() { + lines.push(String::new()); + } + let section = match source_key { + "Onde" => "Onde Inference", + "HuggingFace" => "Hugging Face cache", + _ => "Fallback", + }; + lines.push(section.to_string()); + last_source = Some(source_key); + } + + let number = index + 1; + let current_badge = if item.config.model_id == current_model.model_id { + " <- current" + } else { + "" + }; + let tool_badge = if item.tool_calling { + " tool calling" + } else { + "" + }; + let health_badge = match item.cache_health { + setup::ModelCacheHealth::Complete => "", + setup::ModelCacheHealth::Incomplete => " ! incomplete cache", + setup::ModelCacheHealth::NotDownloaded => " ↓ download on select", + }; + let source = match source_key { + "Onde" => " [Onde]", + "HuggingFace" => " [HuggingFace]", + _ => " [default]", + }; - while !spinner_stop.load(Ordering::Relaxed) { - interval.tick().await; + lines.push(format!( + "{number}. {} {}{}{}{}{}", + item.display_name, item.description, tool_badge, health_badge, current_badge, source, + )); + } - if spinner_stop.load(Ordering::Relaxed) { - break; - } + lines.push(String::new()); + lines.push("Use /models N to switch models.".to_string()); + lines.join("\n") +} - let elapsed = load_start.elapsed(); - let elapsed_str = if elapsed.as_secs() >= 60 { - format!("{}m {:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60) +async fn exec_slash_acp( + agent: &SiGitAgent, + cx: &ConnectionTo<Client>, + session_id: SessionId, + command: SlashCommand, +) -> agent_client_protocol::Result<PromptResponse> { + match command { + SlashCommand::Help => { + agent + .send_assistant_message( + cx, + session_id, + "/help - show this message\n\ + /models - list available models\n\ + /models N - switch to model N\n\ + /clear - wipe conversation history\n\ + /status - show engine status\n\ + /exit - end this turn", + ) + .ok(); + } + SlashCommand::Clear => { + let cleared = agent.engine.clear_history().await; + agent + .send_assistant_message( + cx, + session_id, + format!("Cleared {cleared} turn(s). History is empty."), + ) + .ok(); + } + SlashCommand::Status => { + let info = agent.engine.info().await; + let model = info.model_name.as_deref().unwrap_or("(none)"); + let memory = info.approx_memory.as_deref().unwrap_or("unknown"); + agent + .send_assistant_message( + cx, + session_id, + format!( + "status: {:?} model: {} memory: {} history: {} turns", + info.status, model, memory, info.history_length, + ), + ) + .ok(); + } + SlashCommand::Models(None) => { + let current_model = agent.current_model.lock().unwrap().clone(); + agent + .send_assistant_message(cx, session_id, format_models_list(&current_model)) + .ok(); + } + SlashCommand::Models(Some(number)) => { + let items = models::build_model_picker_items(); + let index = number.saturating_sub(1); + match items.get(index).cloned() { + None => { + agent + .send_assistant_message( + cx, + session_id, + format!("error: no model #{number} - type /models to see the list."), + ) + .ok(); + } + Some(model) => { + if model.cache_health == setup::ModelCacheHealth::Incomplete { + agent + .send_assistant_message( + cx, + session_id, + format!( + "error: {} has an incomplete local cache and cannot be selected yet.", + model.display_name + ), + ) + .ok(); + } else if model.cache_health == setup::ModelCacheHealth::NotDownloaded { + agent + .send_assistant_message( + cx, + session_id.clone(), + format!( + "Downloading and loading {} ({})… this may take a few minutes.", + model.display_name, model.description + ), + ) + .ok(); + + match agent.switch_model_by_id(&model.config.model_id).await { + Ok(new_config) => { + agent.engine.clear_history().await; + agent + .send_assistant_message( + cx, + session_id, + format!( + "✓ Downloaded and switched to {}", + new_config.display_name + ), + ) + .ok(); + } + Err(err) => { + agent + .send_assistant_message( + cx, + session_id, + format!("error downloading model: {}", err.message), + ) + .ok(); + } + } } else { - format!("{}s", elapsed.as_secs()) - }; - let frame = SPINNER[tick % SPINNER.len()]; - tick += 1; + agent + .send_assistant_message( + cx, + session_id.clone(), + format!("Loading {}...", model.display_name), + ) + .ok(); - let msg = format!("{frame} Loading {spinner_name}… ({elapsed_str})"); - let notification = SessionNotification::new( - spinner_session.clone(), - SessionUpdate::ToolCallUpdate(ToolCallUpdate::new( - spinner_tool_call_id.clone(), - ToolCallUpdateFields::new() - .status(ToolCallStatus::InProgress) - .content(vec![msg.into()]), - )), - ); - if spinner_tx.send(notification).await.is_err() { - break; + let switched = agent.switch_model_by_id(&model.config.model_id).await?; + agent.engine.clear_history().await; + + agent + .send_assistant_message( + cx, + session_id, + format!("Switched to {}.", switched.display_name), + ) + .ok(); } } - }); + } } - - let switch_result = self.switch_model_by_id(model_id).await; - - stop_flag.store(true, Ordering::Relaxed); - - match switch_result { - Ok(new_config) => { - let completion_title = if needs_download { - format!("✓ {} downloaded and loaded", new_config.display_name) - } else { - format!("✓ Switched to {}", new_config.display_name) - }; - let completion_body = if needs_download { - format!("✓ {} downloaded and loaded.", new_config.display_name) - } else { - format!("✓ Switched to {}.", new_config.display_name) - }; - - self.send_tool_call_update( - args.session_id.clone(), - SessionUpdate::ToolCallUpdate(ToolCallUpdate::new( - tool_call_id, - ToolCallUpdateFields::new() - .title(completion_title) - .status(ToolCallStatus::Completed) - .content(vec![completion_body.into()]), - )), + SlashCommand::Exit => { + agent + .send_assistant_message( + cx, + session_id, + "Use the panel controls to close or switch threads.", ) - .await; + .ok(); + } + SlashCommand::Unknown(command) => { + agent + .send_assistant_message(cx, session_id, format!("unknown command: {command}")) + .ok(); + } + } - let config_options = { - let guard = self.current_model.lock().unwrap(); - build_model_config_options(&guard) - }; + Ok(PromptResponse::new(StopReason::EndTurn)) +} - log::info!("model switch complete"); - Ok(SetSessionConfigOptionResponse::new(config_options)) - } - Err(err) => { - self.send_tool_call_update( - args.session_id.clone(), - SessionUpdate::ToolCallUpdate(ToolCallUpdate::new( - tool_call_id, - ToolCallUpdateFields::new() - .title("Model switch failed".to_string()) - .status(ToolCallStatus::Failed) - .content(vec![format!("error loading model: {}", err.message).into()]), - )), - ) - .await; +// ── Request dispatch helper ─────────────────────────────────────────────────── - Err(err) - } - } +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), } } @@ -1887,48 +1927,99 @@ async fn run_acp_server() -> anyhow::Result<()> { let model_load_error: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None)); - let (notification_tx, mut notification_rx) = mpsc::channel::<SessionNotification>(256); - let agent = SiGitAgent::new( + let state = Arc::new(SiGitAgent::new( engine, - notification_tx, config, model_ready, startup_model_load_started, model_load_error, needs_download, - ); + )); - // AgentSideConnection needs futures-io let stdin = tokio::io::stdin().compat(); let stdout = tokio::io::stdout().compat_write(); - - // ACP futures are !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); - }, - ); - - tokio::task::spawn_local(async move { - while let Some(notification) = notification_rx.recv().await { - if let Err(err) = conn.session_notification(notification).await { - log::warn!("session_notification failed: {err}"); - } + let transport = ByteStreams::new(stdout, stdin); + + 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) } - }); - - if let Err(err) = io_task.await { - log::error!("ACP IO error: {err}"); - } - }) - .await; + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let state = Arc::clone(&state); + async move |req: AuthenticateRequest, responder, _cx: ConnectionTo<Client>| { + handle_response(responder, state.handle_authenticate(req).await) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let state = Arc::clone(&state); + async move |req: LoadSessionRequest, responder, _cx: ConnectionTo<Client>| { + handle_response(responder, state.handle_load_session(req).await) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let state = Arc::clone(&state); + async move |req: ForkSessionRequest, responder, _cx: ConnectionTo<Client>| { + handle_response(responder, state.handle_fork_session(req).await) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let state = Arc::clone(&state); + async move |req: NewSessionRequest, responder, _cx: ConnectionTo<Client>| { + handle_response(responder, state.handle_new_session(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!(), + ) + .on_receive_request( + { + let state = Arc::clone(&state); + async move |req: SetSessionConfigOptionRequest, responder, cx: ConnectionTo<Client>| { + handle_response( + responder, + state.handle_set_session_config_option(&cx, req).await, + ) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .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}"))?; log::info!("siGit shutting down"); Ok(())