mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(acp): Model discovery and selection for harness + desktop (#97)
This commit is contained in:
@@ -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<McpServer>,
|
||||
) -> Result<String, AcpError> {
|
||||
) -> Result<SessionNewResponse, AcpError> {
|
||||
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<McpServer>,
|
||||
) -> Result<String, AcpError> {
|
||||
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<serde_json::Value, AcpError> {
|
||||
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<serde_json::Value, AcpError> {
|
||||
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<serde_json::Value> {
|
||||
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<serde_json::Value> {
|
||||
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<ModelSwitchMethod> {
|
||||
// 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(),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
// ── 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<String>,
|
||||
}
|
||||
|
||||
fn normalize_agent_command_identity(command: &str) -> String {
|
||||
@@ -229,7 +267,7 @@ fn default_agent_args(command: &str) -> Option<Vec<String>> {
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_agent_args(command: &str, agent_args: Vec<String>) -> Vec<String> {
|
||||
pub fn normalize_agent_args(command: &str, agent_args: Vec<String>) -> Vec<String> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String> = 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<Ow
|
||||
acp,
|
||||
sessions: HashMap::new(),
|
||||
heartbeat_session: None,
|
||||
model_capabilities: None,
|
||||
desired_model: config.model.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -921,6 +958,146 @@ async fn spawn_and_init(config: &Config) -> Result<AcpClient> {
|
||||
|
||||
// ── 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<McpServer> {
|
||||
vec![McpServer {
|
||||
name: "sprout-mcp".to_string(),
|
||||
|
||||
+123
-12
@@ -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<FlushBatch>,
|
||||
}
|
||||
|
||||
/// 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<serde_json::Value>,
|
||||
/// Unstable: SessionModelState from session/new.
|
||||
pub available_models_raw: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 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<Uuid, String>,
|
||||
pub heartbeat_session: Option<String>,
|
||||
/// Model catalog from first session/new. None until first session created.
|
||||
pub model_capabilities: Option<AgentModelCapabilities>,
|
||||
/// Desired model ID (from `Config.model`). Applied after every `session_new_full()`.
|
||||
pub desired_model: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String, AcpError> {
|
||||
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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<AgentModelsResponse, String> {
|
||||
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<ManagedAgentSummary, String> {
|
||||
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<String>,
|
||||
) -> 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<AgentModelInfo> = Vec::new();
|
||||
let mut seen_ids: HashSet<String> = 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<String> = 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,
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -27,6 +27,11 @@ pub struct ManagedAgentRecord {
|
||||
#[serde(default = "default_agent_parallelism")]
|
||||
pub parallelism: u32,
|
||||
pub system_prompt: Option<String>,
|
||||
/// 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<String>,
|
||||
#[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<String>,
|
||||
pub model: Option<String>,
|
||||
pub has_api_token: bool,
|
||||
pub status: String,
|
||||
pub pid: Option<u32>,
|
||||
@@ -83,6 +89,7 @@ pub struct CreateManagedAgentRequest {
|
||||
pub turn_timeout_seconds: Option<u64>,
|
||||
pub parallelism: Option<u32>,
|
||||
pub system_prompt: Option<String>,
|
||||
pub model: Option<String>,
|
||||
#[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<Option<T>>`:
|
||||
/// - 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<Option<String>>,
|
||||
#[serde(default)]
|
||||
pub system_prompt: Option<Option<String>>,
|
||||
}
|
||||
|
||||
/// 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<AgentModelInfo>,
|
||||
/// The agent's default model for a fresh session.
|
||||
pub agent_default_model: Option<String>,
|
||||
/// The user's persisted model selection (from ManagedAgentRecord.model).
|
||||
pub selected_model: Option<String>,
|
||||
/// 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<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
pub const DEFAULT_ACP_COMMAND: &str = "sprout-acp";
|
||||
pub const DEFAULT_AGENT_COMMAND: &str = "goose";
|
||||
pub const DEFAULT_MCP_COMMAND: &str = "sprout-mcp-server";
|
||||
|
||||
@@ -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"}
|
||||
</p>
|
||||
</div>
|
||||
<ModelPicker agent={agent} onModelChanged={onModelChanged} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
|
||||
@@ -110,6 +110,7 @@ export function ManagedAgentsSection({
|
||||
onMintToken(pubkey, name);
|
||||
}
|
||||
}}
|
||||
onModelChanged={onRefresh}
|
||||
onSelect={onSelect}
|
||||
onStart={(pubkey) => {
|
||||
if (!isActionPending) {
|
||||
|
||||
@@ -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<AgentModelsResponse | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(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 (
|
||||
<div className="rounded-2xl border border-border/60 bg-background/70 px-3 py-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Model
|
||||
</p>
|
||||
<Button
|
||||
className="mt-1 h-7 px-2 text-sm"
|
||||
onClick={fetchModels}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Discover models
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/60 bg-background/70 px-3 py-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Model
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Discovering…
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-3 py-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-destructive">
|
||||
Model
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-destructive">{error}</p>
|
||||
<Button
|
||||
className="mt-1 h-6 px-2 text-xs"
|
||||
onClick={fetchModels}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!modelsData?.supportsSwitching) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/60 bg-background/70 px-3 py-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Model
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Not configurable</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/60 bg-background/70 px-3 py-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Model
|
||||
</p>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="mt-1 h-7 max-w-full justify-start gap-1.5 rounded-full border border-border/50 bg-muted/45 px-2.5 text-xs font-medium text-foreground shadow-none hover:bg-muted/70"
|
||||
disabled={saving}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<span className="truncate">{displayLabel}</span>
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="max-h-64 min-w-48 overflow-y-auto"
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={handleModelChange}
|
||||
value={currentValue}
|
||||
>
|
||||
{modelsData.models.map((model) => (
|
||||
<DropdownMenuRadioItem key={model.id} value={model.id}>
|
||||
{model.name ?? model.id}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{needsRestart ? (
|
||||
<p className="mt-1 text-[10px] text-amber-600 dark:text-amber-400">
|
||||
Restart agent to apply
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<AgentModelsResponse> {
|
||||
return invokeTauri<AgentModelsResponse>("get_agent_models", { pubkey });
|
||||
}
|
||||
|
||||
export async function updateManagedAgent(
|
||||
input: UpdateManagedAgentInput,
|
||||
): Promise<ManagedAgent> {
|
||||
const response = await invokeTauri<RawManagedAgent>("update_managed_agent", {
|
||||
input,
|
||||
});
|
||||
return fromRawManagedAgent(response);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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<RawManagedAgent> {
|
||||
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<typeof handleGetManagedAgentLog>[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<typeof handleUpdateManagedAgent>[0],
|
||||
);
|
||||
case "create_channel":
|
||||
return handleCreateChannel(
|
||||
payload as Parameters<typeof handleCreateChannel>[0],
|
||||
|
||||
Reference in New Issue
Block a user