From 769202c05b860abf3911abe462dffeb51ed3df3b Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Wed, 18 Mar 2026 12:56:59 -0400 Subject: [PATCH] feat(acp): Model discovery and selection for harness + desktop (#97) --- crates/sprout-acp/src/acp.rs | 322 +++++++++++++++++- crates/sprout-acp/src/config.rs | 45 ++- crates/sprout-acp/src/main.rs | 179 +++++++++- crates/sprout-acp/src/pool.rs | 135 +++++++- desktop/scripts/check-file-sizes.mjs | 2 +- .../src-tauri/src/commands/agent_models.rs | 219 ++++++++++++ desktop/src-tauri/src/commands/agents.rs | 6 + desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/lib.rs | 2 + .../src-tauri/src/managed_agents/runtime.rs | 6 + desktop/src-tauri/src/managed_agents/types.rs | 50 +++ .../features/agents/ui/ManagedAgentCard.tsx | 4 + .../agents/ui/ManagedAgentsSection.tsx | 1 + .../src/features/agents/ui/ModelPicker.tsx | 173 ++++++++++ desktop/src/shared/api/tauri.ts | 21 ++ desktop/src/shared/api/types.ts | 25 ++ desktop/src/testing/e2eBridge.ts | 35 ++ 17 files changed, 1207 insertions(+), 20 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agent_models.rs create mode 100644 desktop/src/features/agents/ui/ModelPicker.tsx diff --git a/crates/sprout-acp/src/acp.rs b/crates/sprout-acp/src/acp.rs index 774b7abd4..a31106f5f 100644 --- a/crates/sprout-acp/src/acp.rs +++ b/crates/sprout-acp/src/acp.rs @@ -119,6 +119,16 @@ pub struct AcpClient { impl AcpClient { // ── Lifecycle ───────────────────────────────────────────────────────── + /// Kill the agent subprocess and wait for it to exit (no zombies). + /// + /// `Drop` only calls `start_kill()` (sends SIGKILL but doesn't reap). + /// Call this when you need guaranteed cleanup — e.g., in `run_models` + /// before process exit. + pub async fn shutdown(&mut self) { + let _ = self.child.start_kill(); + let _ = self.child.wait().await; + } + /// Spawn the agent binary as a subprocess and connect to its stdio pipes. /// /// After spawning, call [`initialize`](Self::initialize) before any other method. @@ -172,14 +182,16 @@ impl AcpClient { Ok(result) } - /// Send `session/new` and return the `sessionId` string. + /// Send `session/new` and return the full response alongside the session ID. /// /// `cwd` must be an absolute path. `mcp_servers` may be empty. - pub async fn session_new( + /// Callers use [`extract_model_config_options`] and [`extract_model_state`] + /// to pull model info from the raw result. + pub async fn session_new_full( &mut self, cwd: &str, mcp_servers: Vec, - ) -> Result { + ) -> Result { let params = serde_json::json!({ "cwd": cwd, "mcpServers": mcp_servers, @@ -190,7 +202,50 @@ impl AcpClient { .ok_or_else(|| AcpError::Protocol("session/new response missing sessionId".into()))? .to_owned(); tracing::info!(target: "acp::session", "session created: {session_id}"); - Ok(session_id) + Ok(SessionNewResponse { + session_id, + raw: result, + }) + } + + /// Send `session/new` and return only the `sessionId` string. + /// + /// Convenience wrapper around [`session_new_full`]. + #[allow(dead_code)] // Public API — callers outside the harness may use this. + pub async fn session_new( + &mut self, + cwd: &str, + mcp_servers: Vec, + ) -> Result { + Ok(self.session_new_full(cwd, mcp_servers).await?.session_id) + } + + /// Send `session/set_config_option` (stable ACP path). + pub async fn session_set_config_option( + &mut self, + session_id: &str, + config_id: &str, + value: &str, + ) -> Result { + let params = serde_json::json!({ + "sessionId": session_id, + "configId": config_id, + "value": value, + }); + self.send_request("session/set_config_option", params).await + } + + /// Send `session/set_model` (unstable ACP path). + pub async fn session_set_model( + &mut self, + session_id: &str, + model_id: &str, + ) -> Result { + let params = serde_json::json!({ + "sessionId": session_id, + "modelId": model_id, + }); + self.send_request("session/set_model", params).await } /// Send `session/prompt` and block until the agent returns a stop reason. @@ -549,6 +604,99 @@ fn permission_response_cancelled(id: &serde_json::Value) -> serde_json::Value { }) } +// ─── Session response types ─────────────────────────────────────────────────── + +/// Full `session/new` response — session ID plus the raw JSON result. +/// +/// Callers use the extractor helpers to pull model info from `raw`. +pub struct SessionNewResponse { + pub session_id: String, + /// The full `result` value from the JSON-RPC response. + pub raw: serde_json::Value, +} + +/// How to switch to a particular model on a session. +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +#[serde(tag = "type")] +pub enum ModelSwitchMethod { + /// Stable: use `session/set_config_option` with these exact values. + ConfigOption { + config_id: String, + option_value: String, + }, + /// Unstable: use `session/set_model` with this model_id. + SetModel { model_id: String }, +} + +/// Extract `configOptions` entries with `category == "model"` from a `session/new` result. +/// +/// Returns the raw JSON array entries. Each entry has `configId`, `displayName`, +/// `options: [{ value, displayName }]`, etc. +pub fn extract_model_config_options(result: &serde_json::Value) -> Vec { + result["configOptions"] + .as_array() + .map(|arr| { + arr.iter() + .filter(|opt| opt.get("category").and_then(|c| c.as_str()) == Some("model")) + .cloned() + .collect() + }) + .unwrap_or_default() +} + +/// Extract `SessionModelState` (unstable path) from a `session/new` result. +/// +/// Returns the `models` object if present: `{ currentModelId, availableModels: [...] }`. +pub fn extract_model_state(result: &serde_json::Value) -> Option { + result.get("models").cloned() +} + +/// Match a desired model ID against a fresh `session/new` response. +/// +/// Returns the correct ACP method to call, or `None` if no match. +/// +/// **Precedence**: stable `configOptions` first (spec-blessed), then unstable +/// `availableModels`. The fresh `session/new` response is always authoritative. +pub fn resolve_model_switch_method( + session_new_result: &serde_json::Value, + desired_model: &str, +) -> Option { + // 1. Search stable configOptions for a "model"-category entry whose + // options contain a value matching desired_model. + for config_opt in extract_model_config_options(session_new_result) { + let config_id = match config_opt.get("configId").and_then(|v| v.as_str()) { + Some(id) => id, + None => continue, + }; + if let Some(options) = config_opt.get("options").and_then(|v| v.as_array()) { + for opt in options { + if opt.get("value").and_then(|v| v.as_str()) == Some(desired_model) { + return Some(ModelSwitchMethod::ConfigOption { + config_id: config_id.to_string(), + option_value: desired_model.to_string(), + }); + } + } + } + } + + // 2. Search unstable availableModels for a matching modelId. + if let Some(models) = extract_model_state(session_new_result) { + if let Some(available) = models.get("availableModels").and_then(|v| v.as_array()) { + for model in available { + if model.get("modelId").and_then(|v| v.as_str()) == Some(desired_model) { + return Some(ModelSwitchMethod::SetModel { + model_id: desired_model.to_string(), + }); + } + } + } + } + + // 3. No match. + None +} + // ─── Drop: kill child process ───────────────────────────────────────────────── impl Drop for AcpClient { @@ -880,4 +1028,170 @@ mod tests { assert_eq!(cancelled_numeric["id"], numeric_id); assert!(cancelled_numeric["id"].is_number()); } + + // ── Model extractor tests ───────────────────────────────────────────── + + #[test] + fn extract_model_config_options_finds_model_category() { + let result = serde_json::json!({ + "sessionId": "sess-1", + "configOptions": [ + { + "configId": "model", + "category": "model", + "displayName": "Model", + "options": [ + { "value": "claude-sonnet-4-20250514", "displayName": "Claude Sonnet 4" }, + { "value": "claude-opus-4-20250514", "displayName": "Claude Opus 4" } + ] + }, + { + "configId": "theme", + "category": "appearance", + "displayName": "Theme", + "options": [{ "value": "dark", "displayName": "Dark" }] + } + ] + }); + let opts = super::extract_model_config_options(&result); + assert_eq!(opts.len(), 1); + assert_eq!(opts[0]["configId"].as_str(), Some("model")); + } + + #[test] + fn extract_model_config_options_empty_when_no_config_options() { + let result = serde_json::json!({ "sessionId": "sess-1" }); + assert!(super::extract_model_config_options(&result).is_empty()); + } + + #[test] + fn extract_model_config_options_empty_when_no_model_category() { + let result = serde_json::json!({ + "configOptions": [ + { "configId": "theme", "category": "appearance" } + ] + }); + assert!(super::extract_model_config_options(&result).is_empty()); + } + + #[test] + fn extract_model_state_returns_models_object() { + let result = serde_json::json!({ + "sessionId": "sess-1", + "models": { + "currentModelId": "gpt-5", + "availableModels": [ + { "modelId": "gpt-5", "name": "GPT-5" }, + { "modelId": "o3-pro", "name": "o3 Pro" } + ] + } + }); + let ms = super::extract_model_state(&result).expect("should have models"); + assert_eq!(ms["currentModelId"].as_str(), Some("gpt-5")); + assert_eq!(ms["availableModels"].as_array().unwrap().len(), 2); + } + + #[test] + fn extract_model_state_none_when_absent() { + let result = serde_json::json!({ "sessionId": "sess-1" }); + assert!(super::extract_model_state(&result).is_none()); + } + + // ── resolve_model_switch_method tests ───────────────────────────────── + + #[test] + fn resolve_prefers_stable_over_unstable() { + let result = serde_json::json!({ + "configOptions": [{ + "configId": "model", + "category": "model", + "options": [ + { "value": "claude-sonnet-4-20250514", "displayName": "Sonnet 4" } + ] + }], + "models": { + "currentModelId": "claude-sonnet-4-20250514", + "availableModels": [ + { "modelId": "claude-sonnet-4-20250514", "name": "Sonnet 4" } + ] + } + }); + let method = super::resolve_model_switch_method(&result, "claude-sonnet-4-20250514"); + assert_eq!( + method, + Some(super::ModelSwitchMethod::ConfigOption { + config_id: "model".to_string(), + option_value: "claude-sonnet-4-20250514".to_string(), + }) + ); + } + + #[test] + fn resolve_falls_back_to_unstable() { + let result = serde_json::json!({ + "models": { + "currentModelId": "gpt-5", + "availableModels": [ + { "modelId": "gpt-5", "name": "GPT-5" }, + { "modelId": "o3-pro", "name": "o3 Pro" } + ] + } + }); + let method = super::resolve_model_switch_method(&result, "o3-pro"); + assert_eq!( + method, + Some(super::ModelSwitchMethod::SetModel { + model_id: "o3-pro".to_string(), + }) + ); + } + + #[test] + fn resolve_returns_none_when_no_match() { + let result = serde_json::json!({ + "configOptions": [{ + "configId": "model", + "category": "model", + "options": [{ "value": "claude-sonnet-4-20250514" }] + }], + "models": { + "availableModels": [{ "modelId": "gpt-5" }] + } + }); + assert!(super::resolve_model_switch_method(&result, "nonexistent-model").is_none()); + } + + #[test] + fn resolve_returns_none_when_no_model_info() { + let result = serde_json::json!({ "sessionId": "sess-1" }); + assert!(super::resolve_model_switch_method(&result, "anything").is_none()); + } + + #[test] + fn resolve_handles_multiple_config_options() { + // Agent could have multiple configOptions with category "model" + // (unlikely but defensive). + let result = serde_json::json!({ + "configOptions": [ + { + "configId": "primary-model", + "category": "model", + "options": [{ "value": "model-a" }] + }, + { + "configId": "fallback-model", + "category": "model", + "options": [{ "value": "model-b" }] + } + ] + }); + let method = super::resolve_model_switch_method(&result, "model-b"); + assert_eq!( + method, + Some(super::ModelSwitchMethod::ConfigOption { + config_id: "fallback-model".to_string(), + option_value: "model-b".to_string(), + }) + ); + } } diff --git a/crates/sprout-acp/src/config.rs b/crates/sprout-acp/src/config.rs index d418ecc44..e088dc1d7 100644 --- a/crates/sprout-acp/src/config.rs +++ b/crates/sprout-acp/src/config.rs @@ -42,6 +42,37 @@ pub enum DedupMode { Queue, } +// ── Models subcommand ───────────────────────────────────────────────────────── + +/// CLI args for `sprout-acp models` — query available models from an agent. +/// +/// This is a standalone `Parser` (not a subcommand variant) because the +/// `models` path must bypass `Config::from_cli()` entirely — no relay, +/// no private key, no harness setup. +#[derive(Debug, Parser)] +#[command( + name = "sprout-acp models", + about = "Query available models from the configured agent" +)] +pub struct ModelsArgs { + /// Agent binary to spawn (e.g. "goose", "claude-agent-acp", "codex-acp"). + #[arg(long, env = "SPROUT_ACP_AGENT_COMMAND", default_value = "goose")] + pub agent_command: String, + + /// Arguments passed to the agent binary. + #[arg( + long, + env = "SPROUT_ACP_AGENT_ARGS", + default_value = "acp", + value_delimiter = ',' + )] + pub agent_args: Vec, + + /// Output structured JSON instead of human-readable text. + #[arg(long)] + pub json: bool, +} + // ── CLI ─────────────────────────────────────────────────────────────────────── #[derive(Debug, Parser)] @@ -161,6 +192,11 @@ pub struct CliArgs { /// Disable typing indicators while agent is processing. #[arg(long, env = "SPROUT_ACP_NO_TYPING")] pub no_typing: bool, + + /// Desired LLM model ID. Applied to every new ACP session after creation. + /// Use `sprout-acp models` to discover available model IDs. + #[arg(long, env = "SPROUT_ACP_MODEL")] + pub model: Option, } // ── Merged NIP-01 filter ────────────────────────────────────────────────────── @@ -200,6 +236,8 @@ pub struct Config { pub context_message_limit: u32, pub presence_enabled: bool, pub typing_enabled: bool, + /// Desired LLM model ID. Applied after every `session_new_full()`. + pub model: Option, } fn normalize_agent_command_identity(command: &str) -> String { @@ -229,7 +267,7 @@ fn default_agent_args(command: &str) -> Option> { } } -fn normalize_agent_args(command: &str, agent_args: Vec) -> Vec { +pub fn normalize_agent_args(command: &str, agent_args: Vec) -> Vec { let normalized = agent_args .into_iter() .map(|arg| arg.trim().to_string()) @@ -337,13 +375,14 @@ impl Config { context_message_limit: args.context_message_limit, presence_enabled: !args.no_presence, typing_enabled: !args.no_typing, + model: args.model, }) } /// Human-readable summary (no secrets). pub fn summary(&self) -> String { format!( - "relay={} pubkey={} agent_cmd={} {} mcp_cmd={} timeout={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} ignore_self={} context_limit={} presence={} typing={}", + "relay={} pubkey={} agent_cmd={} {} mcp_cmd={} timeout={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} ignore_self={} context_limit={} presence={} typing={} model={}", self.relay_url, self.keys.public_key().to_hex(), self.agent_command, @@ -358,6 +397,7 @@ impl Config { self.context_message_limit, self.presence_enabled, self.typing_enabled, + self.model.as_deref().unwrap_or("(agent default)"), ) } } @@ -654,6 +694,7 @@ mod tests { context_message_limit: 12, presence_enabled: true, typing_enabled: true, + model: None, } } diff --git a/crates/sprout-acp/src/main.rs b/crates/sprout-acp/src/main.rs index 4d9bf7704..414a20a6e 100644 --- a/crates/sprout-acp/src/main.rs +++ b/crates/sprout-acp/src/main.rs @@ -13,7 +13,8 @@ use std::time::Duration; use acp::{AcpClient, EnvVar, McpServer}; use anyhow::Result; -use config::{Config, DedupMode, SubscribeMode}; +use clap::Parser; +use config::{Config, DedupMode, ModelsArgs, SubscribeMode}; use filter::SubscriptionRule; use futures_util::FutureExt; use nostr::ToBech32; @@ -28,8 +29,38 @@ use tokio::sync::watch; use tracing_subscriber::EnvFilter; use uuid::Uuid; +// ── Subcommand dispatch ─────────────────────────────────────────────────────── + +/// Check if argv[1] matches a subcommand name, before any clap parsing. +/// +/// This avoids clap rejecting harness flags (like `--private-key`) that aren't +/// declared on the subcommand's `Parser`. The `models` path has its own +/// `ModelsArgs` parser; the default path uses the existing `CliArgs`. +/// +/// **Constraint**: subcommand must be argv[1] — flags before the subcommand +/// name (e.g., `sprout-acp --verbose models`) are not supported. +fn is_subcommand(name: &str) -> bool { + std::env::args().nth(1).map(|a| a == name).unwrap_or(false) +} + +/// Timeout for the `sprout-acp models` subcommand (spawn + init + session/new). +const MODELS_TIMEOUT: Duration = Duration::from_secs(10); + #[tokio::main] async fn main() -> Result<()> { + // ── Subcommand dispatch — before Config::from_cli() or any harness setup ── + if is_subcommand("models") { + // Strip the "models" token so clap doesn't reject it as a positional. + // Keeps argv[0] (binary name) and passes everything after "models". + let filtered: Vec = std::env::args() + .enumerate() + .filter(|(i, _)| *i != 1) + .map(|(_, a)| a) + .collect(); + let args = ModelsArgs::parse_from(&filtered); + return run_models(args).await; + } + tracing_subscriber::fmt() .with_env_filter( EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("sprout_acp=info")), @@ -49,6 +80,8 @@ async fn main() -> Result<()> { acp, sessions: HashMap::new(), heartbeat_session: None, + model_capabilities: None, + desired_model: config.model.clone(), }); } tracing::info!("agent_pool_ready agents={}", agents.len()); @@ -790,6 +823,8 @@ async fn recover_panicked_agent( acp, sessions: HashMap::new(), heartbeat_session: None, + model_capabilities: None, + desired_model: config.model.clone(), }); tracing::info!("respawned agent {i} after panic"); } @@ -900,6 +935,8 @@ async fn respawn_agent_into(old_agent: OwnedAgent, config: &Config) -> Result Result { // ── build_mcp_servers ───────────────────────────────────────────────────────── +// ── run_models ───────────────────────────────────────────────────────────────── + +/// `sprout-acp models` — spawn an agent, query its available models, exit. +/// +/// Flow: spawn → initialize → session/new → print models → shutdown. +/// No relay connection, no MCP servers, no subscriptions. ~2-5s total. +async fn run_models(args: ModelsArgs) -> Result<()> { + use acp::{extract_model_config_options, extract_model_state}; + + let agent_args = config::normalize_agent_args(&args.agent_command, args.agent_args); + let cwd = std::env::current_dir() + .unwrap_or_else(|_| std::path::PathBuf::from("/")) + .to_string_lossy() + .to_string(); + + // Spawn outside the timeout so we always own the child for cleanup. + let mut client = match AcpClient::spawn(&args.agent_command, &agent_args).await { + Ok(c) => c, + Err(e) => { + eprintln!("error: failed to spawn agent: {e}"); + std::process::exit(1); + } + }; + + // Initialize + session/new under a timeout. Client is owned above, + // so shutdown() runs on all paths (success, error, timeout). + let protocol_result = tokio::time::timeout(MODELS_TIMEOUT, async { + let init = client.initialize().await?; + let session = client.session_new_full(&cwd, vec![]).await?; + Ok::<_, acp::AcpError>((init, session)) + }) + .await; + + let (init_result, session_resp) = match protocol_result { + Ok(Ok(tuple)) => tuple, + Ok(Err(e)) => { + client.shutdown().await; + eprintln!("error: agent communication failed: {e}"); + std::process::exit(1); + } + Err(_) => { + client.shutdown().await; + eprintln!("error: agent timed out ({MODELS_TIMEOUT:?})"); + std::process::exit(1); + } + }; + + // Extract agent info from initialize response. + // ACP spec uses "serverInfo" (MCP heritage); some agents may use "agentInfo". + let info_obj = init_result + .get("serverInfo") + .or_else(|| init_result.get("agentInfo")); + let agent_name = info_obj + .and_then(|ai| ai.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let agent_version = info_obj + .and_then(|ai| ai.get("version")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + + // Extract model info from session/new response. + let config_options = extract_model_config_options(&session_resp.raw); + let model_state = extract_model_state(&session_resp.raw); + + if args.json { + // Structured JSON output — consumed by Phase 3 `get_agent_models`. + let output = serde_json::json!({ + "agent": { + "name": agent_name, + "version": agent_version, + }, + "stable": { + "configOptions": config_options, + }, + "unstable": model_state.as_ref().map(|ms| serde_json::json!({ + "currentModelId": ms.get("currentModelId"), + "availableModels": ms.get("availableModels"), + })), + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } else { + // Human-readable output. + println!("Agent: {} v{}", agent_name, agent_version); + println!(); + + let mut has_models = false; + + if !config_options.is_empty() { + println!("Models (stable configOptions):"); + for opt in &config_options { + let config_id = opt.get("configId").and_then(|v| v.as_str()).unwrap_or("?"); + let display = opt + .get("displayName") + .and_then(|v| v.as_str()) + .unwrap_or(config_id); + println!(" {display} (configId: {config_id})"); + if let Some(options) = opt.get("options").and_then(|v| v.as_array()) { + for o in options { + let val = o.get("value").and_then(|v| v.as_str()).unwrap_or("?"); + let name = o.get("displayName").and_then(|v| v.as_str()).unwrap_or(val); + println!(" - {name} (value: {val})"); + } + } + } + has_models = true; + } + + if let Some(ref ms) = model_state { + let current = ms + .get("currentModelId") + .and_then(|v| v.as_str()) + .unwrap_or("(none)"); + println!("Models (unstable SessionModelState):"); + println!(" Current: {current}"); + if let Some(available) = ms.get("availableModels").and_then(|v| v.as_array()) { + println!(" Available:"); + for m in available { + let id = m.get("modelId").and_then(|v| v.as_str()).unwrap_or("?"); + let name = m.get("name").and_then(|v| v.as_str()).unwrap_or(id); + let desc = m.get("description").and_then(|v| v.as_str()).unwrap_or(""); + if desc.is_empty() { + println!(" - {name} (id: {id})"); + } else { + println!(" - {name} (id: {id}) — {desc}"); + } + } + } + has_models = true; + } + + if !has_models { + println!("No model information available from this agent."); + } + } + + client.shutdown().await; + Ok(()) +} + fn build_mcp_servers(config: &Config) -> Vec { vec![McpServer { name: "sprout-mcp".to_string(), diff --git a/crates/sprout-acp/src/pool.rs b/crates/sprout-acp/src/pool.rs index ffe4c5915..790c9ee44 100644 --- a/crates/sprout-acp/src/pool.rs +++ b/crates/sprout-acp/src/pool.rs @@ -28,7 +28,10 @@ use tokio::task::JoinSet; use tokio::time::timeout; use uuid::Uuid; -use crate::acp::{AcpClient, AcpError, McpServer, StopReason}; +use crate::acp::{ + extract_model_config_options, extract_model_state, resolve_model_switch_method, AcpClient, + AcpError, McpServer, ModelSwitchMethod, StopReason, +}; use crate::config::DedupMode; use crate::queue::{ContextMessage, ConversationContext, FlushBatch, PromptChannelInfo}; use crate::relay::{ChannelInfo, RestClient}; @@ -47,6 +50,17 @@ pub struct TaskMeta { pub recoverable_batch: Option, } +/// Agent-level model capabilities. Populated on first session creation. +/// The catalog is the same across all sessions for a given agent process. +/// Fields are read by the desktop's `get_agent_models` Tauri command (Phase 3). +#[allow(dead_code)] // Scaffolding for desktop integration — fields read via serde. +pub struct AgentModelCapabilities { + /// Stable: configOptions with category "model" from session/new. + pub config_options_raw: Vec, + /// Unstable: SessionModelState from session/new. + pub available_models_raw: Option, +} + /// An agent with its session state, owned by the pool or a running task. pub struct OwnedAgent { pub index: usize, @@ -54,6 +68,10 @@ pub struct OwnedAgent { /// channel_id → session_id pub sessions: HashMap, pub heartbeat_session: Option, + /// Model catalog from first session/new. None until first session created. + pub model_capabilities: Option, + /// Desired model ID (from `Config.model`). Applied after every `session_new_full()`. + pub desired_model: Option, } /// Pool of agents with take-and-return ownership semantics. @@ -243,6 +261,107 @@ impl AgentPool { /// Timeout for pre-prompt context fetches (thread/DM history). const CONTEXT_FETCH_TIMEOUT: Duration = Duration::from_millis(500); +/// Timeout for model-switch requests (`session/set_config_option`, `session/set_model`). +const MODEL_SWITCH_TIMEOUT: Duration = Duration::from_secs(5); + +/// Create a new ACP session via `session_new_full()`, populate model capabilities +/// on the agent (first session only), and apply `desired_model` if set. +/// +/// On error from `session_new_full()`, returns the `AcpError` — caller handles +/// error reporting. Model-switch failures are logged and gracefully ignored +/// (the agent proceeds with its default model). +async fn create_session_and_apply_model( + agent: &mut OwnedAgent, + ctx: &PromptContext, +) -> Result { + let resp = agent + .acp + .session_new_full(&ctx.cwd, ctx.mcp_servers.clone()) + .await?; + + // Populate model capabilities on first session creation. + if agent.model_capabilities.is_none() { + agent.model_capabilities = Some(AgentModelCapabilities { + config_options_raw: extract_model_config_options(&resp.raw), + available_models_raw: extract_model_state(&resp.raw), + }); + } + + // Apply desired_model if set, matching against the fresh session/new response. + if let Some(ref desired) = agent.desired_model { + match resolve_model_switch_method(&resp.raw, desired) { + Some(method) => { + apply_model_switch(&mut agent.acp, &resp.session_id, desired, &method).await; + } + None => { + tracing::warn!( + target: "pool::model", + "desired model {desired} not found in agent's available models — proceeding with agent default" + ); + } + } + } + + Ok(resp.session_id) +} + +/// Send the appropriate ACP model-switch request with a timeout. +/// +/// On timeout or error, logs a warning and returns — the caller proceeds +/// with the agent's default model. This is intentionally non-fatal: a stale +/// response from a timed-out request is safely ignored by `read_until_response` +/// (non-matching JSON-RPC IDs are skipped). +async fn apply_model_switch( + acp: &mut AcpClient, + session_id: &str, + desired: &str, + method: &ModelSwitchMethod, +) { + let method_label = match method { + ModelSwitchMethod::ConfigOption { config_id, .. } => { + format!("configOption (configId={config_id})") + } + ModelSwitchMethod::SetModel { .. } => "set_model".to_string(), + }; + + let result = tokio::time::timeout(MODEL_SWITCH_TIMEOUT, async { + match method { + ModelSwitchMethod::ConfigOption { + config_id, + option_value, + } => { + acp.session_set_config_option(session_id, config_id, option_value) + .await + } + ModelSwitchMethod::SetModel { model_id } => { + acp.session_set_model(session_id, model_id).await + } + } + }) + .await; + + match result { + Ok(Ok(_)) => { + tracing::info!( + target: "pool::model", + "applied model {desired} via {method_label} on session {session_id}" + ); + } + Ok(Err(e)) => { + tracing::warn!( + target: "pool::model", + "failed to set model {desired} via {method_label}: {e} — proceeding with agent default" + ); + } + Err(_) => { + tracing::warn!( + target: "pool::model", + "model set via {method_label} timed out ({MODEL_SWITCH_TIMEOUT:?}) — proceeding with agent default" + ); + } + } +} + /// Core async function spawned for each prompt. /// /// Lifecycle: @@ -285,12 +404,8 @@ pub async fn run_prompt_task( if let Some(sid) = agent.sessions.get(cid) { (sid.clone(), false) } else { - // Create new session. - match agent - .acp - .session_new(&ctx.cwd, ctx.mcp_servers.clone()) - .await - { + // Create new session with model application. + match create_session_and_apply_model(&mut agent, &ctx).await { Ok(sid) => { tracing::info!( target: "pool::session", @@ -326,11 +441,7 @@ pub async fn run_prompt_task( if let Some(sid) = &agent.heartbeat_session { (sid.clone(), false) } else { - match agent - .acp - .session_new(&ctx.cwd, ctx.mcp_servers.clone()) - .await - { + match create_session_and_apply_model(&mut agent, &ctx).await { Ok(sid) => { tracing::info!( target: "pool::session", diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 29c2798ac..4b95d48da 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -37,7 +37,7 @@ const overrides = new Map([ ["src/features/sidebar/ui/AppSidebar.tsx", 650], ["src/features/tokens/ui/TokenSettingsCard.tsx", 800], ["src/shared/api/relayClientSession.ts", 725], // durable websocket session manager with reconnect/replay/recovery state - ["src/shared/api/tauri.ts", 950], + ["src/shared/api/tauri.ts", 975], ]); async function walkFiles(directory) { diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs new file mode 100644 index 000000000..97bddecaf --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -0,0 +1,219 @@ +use std::collections::HashSet; + +use tauri::{AppHandle, State}; + +use crate::{ + app_state::AppState, + managed_agents::{ + build_managed_agent_summary, find_managed_agent_mut, load_managed_agents, + missing_command_message, resolve_command, save_managed_agents, + sync_managed_agent_processes, AgentModelInfo, AgentModelsResponse, ManagedAgentSummary, + UpdateManagedAgentRequest, DEFAULT_AGENT_ARG, + }, + util::now_iso, +}; + +/// Query available models from an agent via `sprout-acp models --json`. +/// +/// Spawns a short-lived subprocess (no relay connection needed). The subprocess +/// starts the agent, queries its model catalog, and exits. ~2-5s total. +#[tauri::command] +pub async fn get_agent_models( + pubkey: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let (resolved_acp, agent_command, agent_args, persisted_model) = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(&app)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; + if sync_managed_agent_processes(&mut records, &mut runtimes) { + save_managed_agents(&app, &records)?; + } + + let record = records + .iter() + .find(|r| r.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + + let resolved = resolve_command(&record.acp_command, Some(&app)) + .ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness command"))?; + + let args = if record.agent_args.is_empty() { + vec![DEFAULT_AGENT_ARG.to_string()] + } else { + record.agent_args.clone() + }; + + ( + resolved, + record.agent_command.clone(), + args, + record.model.clone(), + ) + }; // store lock released — subprocess runs without holding the lock + + // Use spawn_blocking because the desktop Tauri crate doesn't enable + // tokio's `process` feature. std::process::Command is synchronous + // but fine for a short-lived subprocess (~2-5s). + let output = tokio::task::spawn_blocking(move || { + std::process::Command::new(&resolved_acp) + .arg("models") + .arg("--json") + .env("SPROUT_ACP_AGENT_COMMAND", &agent_command) + .env("SPROUT_ACP_AGENT_ARGS", agent_args.join(",")) + .env( + "GOOSE_MODE", + std::env::var("GOOSE_MODE").unwrap_or_else(|_| "auto".into()), + ) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .map_err(|e| format!("failed to spawn sprout-acp models: {e}")) + }) + .await + .map_err(|e| format!("model discovery task failed: {e}"))? + .map_err(|e: String| e)?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "sprout-acp models failed (exit {}): {stderr}", + output.status.code().unwrap_or(-1) + )); + } + + let raw: serde_json::Value = serde_json::from_slice(&output.stdout) + .map_err(|e| format!("failed to parse model JSON: {e}"))?; + + Ok(normalize_agent_models(&raw, persisted_model)) +} + +/// Update mutable fields on an existing managed agent record. +/// +/// Does NOT auto-restart the agent. The frontend should prompt the user +/// to restart for model changes to take effect. +#[tauri::command] +pub fn update_managed_agent( + input: UpdateManagedAgentRequest, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(&app)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; + sync_managed_agent_processes(&mut records, &mut runtimes); + + let record = find_managed_agent_mut(&mut records, &input.pubkey)?; + + // Tri-state: None = don't touch, Some(None) = clear, Some(Some(v)) = set + if let Some(model_update) = input.model { + record.model = model_update; + } + if let Some(prompt_update) = input.system_prompt { + record.system_prompt = prompt_update; + } + record.updated_at = now_iso(); + + save_managed_agents(&app, &records)?; + + let record = records + .iter() + .find(|r| r.pubkey == input.pubkey) + .ok_or_else(|| format!("agent {} not found", input.pubkey))?; + build_managed_agent_summary(&app, record, &runtimes) +} + +// ── Model normalization ─────────────────────────────────────────────────────── + +/// Normalize raw `sprout-acp models --json` output into a typed DTO for the frontend. +/// +/// Merges models from both ACP paths (stable configOptions + unstable SessionModelState), +/// deduplicates by ID (stable takes precedence), and returns a unified list. +fn normalize_agent_models( + raw: &serde_json::Value, + persisted_model: Option, +) -> AgentModelsResponse { + let agent_name = raw["agent"]["name"] + .as_str() + .unwrap_or("unknown") + .to_string(); + let agent_version = raw["agent"]["version"] + .as_str() + .unwrap_or("unknown") + .to_string(); + + let mut models: Vec = Vec::new(); + let mut seen_ids: HashSet = HashSet::new(); + + // 1. Stable configOptions (preferred). Only entries with category "model" + // are model options — the CLI pre-filters, but we're defensive here. + if let Some(config_options) = raw["stable"]["configOptions"].as_array() { + for opt in config_options { + if opt.get("category").and_then(|c| c.as_str()) != Some("model") { + continue; + } + if let Some(options) = opt.get("options").and_then(|v| v.as_array()) { + for o in options { + if let Some(value) = o.get("value").and_then(|v| v.as_str()) { + if seen_ids.insert(value.to_string()) { + models.push(AgentModelInfo { + id: value.to_string(), + name: o + .get("displayName") + .and_then(|v| v.as_str()) + .map(str::to_string), + description: None, + }); + } + } + } + } + } + } + + // 2. Unstable availableModels (fallback — skip duplicates from stable). + let mut agent_default_model: Option = None; + if let Some(unstable) = raw.get("unstable") { + agent_default_model = unstable["currentModelId"].as_str().map(str::to_string); + if let Some(available) = unstable["availableModels"].as_array() { + for m in available { + if let Some(id) = m.get("modelId").and_then(|v| v.as_str()) { + if seen_ids.insert(id.to_string()) { + models.push(AgentModelInfo { + id: id.to_string(), + name: m.get("name").and_then(|v| v.as_str()).map(str::to_string), + description: m + .get("description") + .and_then(|v| v.as_str()) + .map(str::to_string), + }); + } + } + } + } + } + + let supports_switching = !models.is_empty(); + + AgentModelsResponse { + agent_name, + agent_version, + models, + agent_default_model, + selected_model: persisted_model, + supports_switching, + } +} diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 5170d445a..0af094d8c 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -225,6 +225,12 @@ pub async fn create_managed_agent( .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string), + model: input + .model + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string), start_on_app_launch: input.start_on_app_launch, runtime_pid: None, created_at: now_iso(), diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 45d3889d0..31b17e00e 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +mod agent_models; mod agent_settings; mod agents; mod channels; @@ -7,6 +8,7 @@ mod messages; mod profile; mod tokens; +pub use agent_models::*; pub use agent_settings::*; pub use agents::*; pub use channels::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 3ac8ed511..5c2784099 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -152,6 +152,8 @@ pub fn run() { delete_managed_agent, mint_managed_agent_token, get_managed_agent_log, + get_agent_models, + update_managed_agent, ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index ff110ea32..0bf69b3f0 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -173,6 +173,7 @@ pub fn build_managed_agent_summary( turn_timeout_seconds: record.turn_timeout_seconds, parallelism: record.parallelism, system_prompt: record.system_prompt.clone(), + model: record.model.clone(), has_api_token: record.api_token.is_some(), status, pid, @@ -273,6 +274,11 @@ pub fn start_managed_agent_process( } else { command.env_remove("SPROUT_ACP_SYSTEM_PROMPT"); } + if let Some(model) = &record.model { + command.env("SPROUT_ACP_MODEL", model); + } else { + command.env_remove("SPROUT_ACP_MODEL"); + } command.env_remove("SPROUT_ACP_PRIVATE_KEY"); command.env_remove("SPROUT_ACP_API_TOKEN"); diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index ca2b9ffeb..8161beec2 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -27,6 +27,11 @@ pub struct ManagedAgentRecord { #[serde(default = "default_agent_parallelism")] pub parallelism: u32, pub system_prompt: Option, + /// Desired LLM model ID. Matches AgentModelInfo.id from discovery. + /// The harness re-discovers the correct ACP switching metadata at session + /// creation by matching this ID against the fresh session/new response. + #[serde(default)] + pub model: Option, #[serde(default = "default_start_on_app_launch")] pub start_on_app_launch: bool, #[serde(default)] @@ -57,6 +62,7 @@ pub struct ManagedAgentSummary { pub turn_timeout_seconds: u64, pub parallelism: u32, pub system_prompt: Option, + pub model: Option, pub has_api_token: bool, pub status: String, pub pid: Option, @@ -83,6 +89,7 @@ pub struct CreateManagedAgentRequest { pub turn_timeout_seconds: Option, pub parallelism: Option, pub system_prompt: Option, + pub model: Option, #[serde(default)] pub mint_token: bool, #[serde(default)] @@ -164,6 +171,49 @@ pub struct SproutAdminMintTokenJsonOutput { pub api_token: String, } +/// Patch request for updating a managed agent's mutable fields. +/// +/// Tri-state nullable semantics via `Option>`: +/// - Field absent in JSON → `None` (don't touch) +/// - `"field": null` → `Some(None)` (clear to default) +/// - `"field": "value"` → `Some(Some("value"))` (set) +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateManagedAgentRequest { + pub pubkey: String, + /// Absent = don't touch. null = clear to agent default. "id" = set. + #[serde(default)] + pub model: Option>, + #[serde(default)] + pub system_prompt: Option>, +} + +/// Response from `get_agent_models` — normalized model info for the frontend. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentModelsResponse { + pub agent_name: String, + pub agent_version: String, + /// Unified model list (merged from both ACP paths, deduplicated by ID). + pub models: Vec, + /// The agent's default model for a fresh session. + pub agent_default_model: Option, + /// The user's persisted model selection (from ManagedAgentRecord.model). + pub selected_model: Option, + /// Whether this agent supports model switching. + pub supports_switching: bool, +} + +/// A single model available from an agent. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentModelInfo { + /// Canonical ID used for persistence and round-tripping. + pub id: String, + pub name: Option, + pub description: Option, +} + pub const DEFAULT_ACP_COMMAND: &str = "sprout-acp"; pub const DEFAULT_AGENT_COMMAND: &str = "goose"; pub const DEFAULT_MCP_COMMAND: &str = "sprout-mcp-server"; diff --git a/desktop/src/features/agents/ui/ManagedAgentCard.tsx b/desktop/src/features/agents/ui/ManagedAgentCard.tsx index 4c64f5f89..12cf1e36f 100644 --- a/desktop/src/features/agents/ui/ManagedAgentCard.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentCard.tsx @@ -11,6 +11,7 @@ import type { ManagedAgent } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { CopyButton } from "./CopyButton"; +import { ModelPicker } from "./ModelPicker"; import { formatTimestamp, truncatePubkey } from "./agentUi"; export function ManagedAgentCard({ @@ -20,6 +21,7 @@ export function ManagedAgentCard({ isSelected, onDelete, onMintToken, + onModelChanged, onSelect, onStart, onStop, @@ -30,6 +32,7 @@ export function ManagedAgentCard({ isSelected: boolean; onDelete: (pubkey: string) => void; onMintToken: (pubkey: string, name: string) => void; + onModelChanged?: () => void; onSelect: (pubkey: string) => void; onStart: (pubkey: string) => void; onStop: (pubkey: string) => void; @@ -97,6 +100,7 @@ export function ManagedAgentCard({ {agent.hasApiToken ? "Bearer token saved" : "Key-only dev mode"}

+
diff --git a/desktop/src/features/agents/ui/ManagedAgentsSection.tsx b/desktop/src/features/agents/ui/ManagedAgentsSection.tsx index e0b1c9a92..b1bffafa3 100644 --- a/desktop/src/features/agents/ui/ManagedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentsSection.tsx @@ -110,6 +110,7 @@ export function ManagedAgentsSection({ onMintToken(pubkey, name); } }} + onModelChanged={onRefresh} onSelect={onSelect} onStart={(pubkey) => { if (!isActionPending) { diff --git a/desktop/src/features/agents/ui/ModelPicker.tsx b/desktop/src/features/agents/ui/ModelPicker.tsx new file mode 100644 index 000000000..a72fdc4b1 --- /dev/null +++ b/desktop/src/features/agents/ui/ModelPicker.tsx @@ -0,0 +1,173 @@ +import { ChevronDown, Loader2 } from "lucide-react"; +import React from "react"; + +import type { AgentModelsResponse, ManagedAgent } from "@/shared/api/types"; +import { getAgentModels, updateManagedAgent } from "@/shared/api/tauri"; +import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; + +export function ModelPicker({ + agent, + onModelChanged, +}: { + agent: ManagedAgent; + onModelChanged?: () => void; +}) { + const [modelsData, setModelsData] = + React.useState(null); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(null); + const [saving, setSaving] = React.useState(false); + const [needsRestart, setNeedsRestart] = React.useState(false); + + const fetchModels = React.useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await getAgentModels(agent.pubkey); + setModelsData(data); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [agent.pubkey]); + + const currentValue = agent.model ?? modelsData?.agentDefaultModel ?? ""; + const displayLabel = + agent.model ?? + (modelsData?.agentDefaultModel + ? `${modelsData.agentDefaultModel} (default)` + : "Select model…"); + + const handleModelChange = async (modelId: string) => { + setSaving(true); + try { + await updateManagedAgent({ + pubkey: agent.pubkey, + model: modelId === modelsData?.agentDefaultModel ? null : modelId, + }); + if (agent.status === "running") { + setNeedsRestart(true); + } + onModelChanged?.(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + } + }; + + if (!modelsData && !loading && !error) { + return ( +
+

+ Model +

+ +
+ ); + } + + if (loading) { + return ( +
+

+ Model +

+
+ + Discovering… +
+
+ ); + } + + if (error) { + return ( +
+

+ Model +

+

{error}

+ +
+ ); + } + + if (!modelsData?.supportsSwitching) { + return ( +
+

+ Model +

+

Not configurable

+
+ ); + } + + return ( +
+

+ Model +

+ + + + + event.preventDefault()} + > + + {modelsData.models.map((model) => ( + + {model.name ?? model.id} + + ))} + + + + {needsRestart ? ( +

+ Restart agent to apply +

+ ) : null} +
+ ); +} diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 05561a4f9..56d605589 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -37,6 +37,8 @@ import type { UsersBatchResponse, CreateManagedAgentInput, CreateManagedAgentResponse, + AgentModelsResponse, + UpdateManagedAgentInput, AcpProvider, CommandAvailability, ManagedAgentPrereqs, @@ -228,6 +230,7 @@ export type RawManagedAgent = { turn_timeout_seconds: number; parallelism: number; system_prompt: string | null; + model: string | null; has_api_token: boolean; status: ManagedAgent["status"]; pid: number | null; @@ -748,6 +751,7 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent { turnTimeoutSeconds: agent.turn_timeout_seconds, parallelism: agent.parallelism, systemPrompt: agent.system_prompt, + model: agent.model, hasApiToken: agent.has_api_token, status: agent.status, pid: agent.pid, @@ -945,3 +949,20 @@ export async function discoverManagedAgentPrereqs(input: { mcp: fromRawCommandAvailability(response.mcp), }; } + +// ── Model discovery ─────────────────────────────────────────────────────────── + +export async function getAgentModels( + pubkey: string, +): Promise { + return invokeTauri("get_agent_models", { pubkey }); +} + +export async function updateManagedAgent( + input: UpdateManagedAgentInput, +): Promise { + const response = await invokeTauri("update_managed_agent", { + input, + }); + return fromRawManagedAgent(response); +} diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 17c24652f..06cc24f46 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -262,6 +262,7 @@ export type ManagedAgent = { turnTimeoutSeconds: number; parallelism: number; systemPrompt: string | null; + model: string | null; hasApiToken: boolean; status: "running" | "stopped"; pid: number | null; @@ -285,6 +286,7 @@ export type CreateManagedAgentInput = { turnTimeoutSeconds?: number; parallelism?: number; systemPrompt?: string; + model?: string; mintToken?: boolean; tokenScopes?: TokenScope[]; tokenName?: string; @@ -335,3 +337,26 @@ export type ManagedAgentPrereqs = { acp: CommandAvailability; mcp: CommandAvailability; }; + +// ── Model discovery types ───────────────────────────────────────────────────── + +export type AgentModelsResponse = { + agentName: string; + agentVersion: string; + models: AgentModelInfo[]; + agentDefaultModel: string | null; + selectedModel: string | null; + supportsSwitching: boolean; +}; + +export type AgentModelInfo = { + id: string; + name: string | null; + description: string | null; +}; + +export type UpdateManagedAgentInput = { + pubkey: string; + model?: string | null; + systemPrompt?: string | null; +}; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index cf6a50f15..bf0d69eb2 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -221,6 +221,7 @@ type RawManagedAgent = { turn_timeout_seconds: number; parallelism: number; system_prompt: string | null; + model: string | null; has_api_token: boolean; status: "running" | "stopped"; pid: number | null; @@ -491,6 +492,7 @@ function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent { turn_timeout_seconds: agent.turn_timeout_seconds, parallelism: agent.parallelism, system_prompt: agent.system_prompt, + model: agent.model, has_api_token: agent.has_api_token, status: agent.status, pid: agent.pid, @@ -2140,6 +2142,7 @@ async function handleCreateManagedAgent(args: { turnTimeoutSeconds?: number; parallelism?: number; systemPrompt?: string; + model?: string; mintToken?: boolean; tokenScopes?: string[]; tokenName?: string; @@ -2172,6 +2175,7 @@ async function handleCreateManagedAgent(args: { turn_timeout_seconds: args.input.turnTimeoutSeconds ?? 300, parallelism: args.input.parallelism ?? 1, system_prompt: args.input.systemPrompt?.trim() || null, + model: args.input.model?.trim() || null, has_api_token: token !== null, status: args.input.spawnAfterCreate ? "running" : "stopped", pid: args.input.spawnAfterCreate ? 42000 + mockManagedAgents.length : null, @@ -2301,6 +2305,24 @@ async function handleGetManagedAgentLog(args: { }; } +async function handleUpdateManagedAgent(args: { + input: { + pubkey: string; + model?: string | null; + systemPrompt?: string | null; + }; +}): Promise { + const agent = getMockManagedAgent(args.input.pubkey); + if (args.input.model !== undefined) { + agent.model = args.input.model; + } + if (args.input.systemPrompt !== undefined) { + agent.system_prompt = args.input.systemPrompt; + } + agent.updated_at = new Date().toISOString(); + return cloneManagedAgent(agent); +} + async function handleSearchMessages( args: { q: string; @@ -2880,6 +2902,19 @@ export function maybeInstallE2eTauriMocks() { return handleGetManagedAgentLog( payload as Parameters[0], ); + case "get_agent_models": + return { + agentName: "mock-agent", + agentVersion: "0.0.0", + models: [], + agentDefaultModel: null, + selectedModel: null, + supportsSwitching: false, + }; + case "update_managed_agent": + return handleUpdateManagedAgent( + payload as Parameters[0], + ); case "create_channel": return handleCreateChannel( payload as Parameters[0],