diff --git a/crates/sprout-acp/src/acp.rs b/crates/sprout-acp/src/acp.rs index 5b4dcd49b..9cd5a2ed7 100644 --- a/crates/sprout-acp/src/acp.rs +++ b/crates/sprout-acp/src/acp.rs @@ -202,7 +202,7 @@ impl AcpClient { // Callers MUST still call shutdown().await for guaranteed cleanup. .kill_on_drop(true); - // Per-persona env vars (e.g., GOOSE_PROVIDER, GOOSE_MODEL). + // Per-persona env vars (e.g., GOOSE_PROVIDER, SPROUT_AGENT_PROVIDER). // Only injected if not already set in parent env (operator precedence). for (key, value) in extra_env { if std::env::var(key).is_err() { diff --git a/crates/sprout-acp/src/config.rs b/crates/sprout-acp/src/config.rs index 2a5053a05..4baed617d 100644 --- a/crates/sprout-acp/src/config.rs +++ b/crates/sprout-acp/src/config.rs @@ -467,7 +467,7 @@ pub struct Config { pub respond_to: RespondTo, /// Validated allowlist of pubkey hex strings (used when respond_to == Allowlist). pub respond_to_allowlist: HashSet, - /// Per-persona env vars to inject at agent spawn time (e.g., GOOSE_PROVIDER, GOOSE_MODEL). + /// Per-persona env vars to inject at agent spawn time (e.g., GOOSE_PROVIDER, GOOSE_MODEL, SPROUT_AGENT_MODEL). /// Populated from persona pack resolution. Empty when no pack is configured. pub persona_env_vars: Vec<(String, String)>, /// Whether to publish encrypted observer frames through the relay. @@ -765,7 +765,7 @@ impl Config { ( Some(persona.system_prompt), persona.model, - persona.goose_env_vars, + persona.runtime_env_vars, ) } (Some(_), None) => { diff --git a/crates/sprout-agent/src/config/goose_compat.rs b/crates/sprout-agent/src/config/goose_compat.rs new file mode 100644 index 000000000..1440d1656 --- /dev/null +++ b/crates/sprout-agent/src/config/goose_compat.rs @@ -0,0 +1,345 @@ +//! Goose config compatibility layer. +//! +//! Reads `~/.config/goose/config.yaml` to extract Databricks credentials +//! as a fallback when env vars aren't set. Bridge code that shrinks as +//! Sprout's spawn-time env injection improves. + +use std::{collections::HashMap, path::PathBuf}; + +#[derive(Default)] +pub(super) struct GooseDatabricksConfig { + pub(super) host: Option, + pub(super) model: Option, +} + +impl GooseDatabricksConfig { + pub(super) fn load_default() -> Self { + goose_config_path() + .and_then(|p| Self::load_from_path(&p)) + .unwrap_or_default() + } + + pub(super) fn load_from_path(path: &std::path::Path) -> Option { + let raw = std::fs::read_to_string(path).ok()?; + let map: HashMap = serde_yaml::from_str(&raw).ok()?; + Some(Self::from_map(&map)) + } + + pub(super) fn from_map(map: &HashMap) -> Self { + let host = yaml_string(map, "DATABRICKS_HOST"); + let explicit_model = yaml_string(map, "DATABRICKS_MODEL"); + let goose_provider = yaml_string(map, "GOOSE_PROVIDER"); + let goose_model = yaml_string(map, "GOOSE_MODEL"); + let goose_mode = yaml_string(map, "GOOSE_MODE"); + + // Flat-key model resolution (existing) + let flat_model = explicit_model.or_else(|| { + if goose_provider + .as_deref() + .is_some_and(|p| p.eq_ignore_ascii_case("databricks")) + { + goose_model.or(goose_mode) + } else { + None + } + }); + + // Nested provider format fallback (active_provider + providers block) + let active_provider = yaml_string(map, "active_provider"); + let (nested_host, nested_model) = active_provider + .as_deref() + .filter(|ap| ap.to_ascii_lowercase().starts_with("databricks")) + .and_then(|ap| nested_provider_config(map, ap)) + .unwrap_or((None, None)); + + Self { + host: host.or(nested_host), + model: flat_model.or(nested_model), + } + } +} + +fn nested_provider_config( + map: &HashMap, + active_provider: &str, +) -> Option<(Option, Option)> { + let providers = map.get("providers").and_then(|v| v.as_mapping())?; + let provider_config = providers + .get(serde_yaml::Value::String(active_provider.to_owned()))? + .as_mapping()?; + + let model = provider_config + .get(serde_yaml::Value::String("model".to_owned())) + .and_then(|v| v.as_str()) + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(str::to_string); + + let host = provider_config + .get(serde_yaml::Value::String("host".to_owned())) + .and_then(|v| v.as_str()) + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(str::to_string); + + Some((host, model)) +} + +fn yaml_string(map: &HashMap, key: &str) -> Option { + map.get(key)? + .as_str() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn goose_config_path() -> Option { + if let Ok(root) = std::env::var("GOOSE_PATH_ROOT") { + return Some(PathBuf::from(root).join("config").join("config.yaml")); + } + let home = std::env::var("HOME").ok()?; + Some( + PathBuf::from(home) + .join(".config") + .join("goose") + .join("config.yaml"), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── Existing flat-key tests ────────────────────────────────────────────── + + #[test] + fn goose_databricks_config_reads_host_and_model() { + let map = HashMap::from([ + ( + "DATABRICKS_HOST".to_string(), + serde_yaml::Value::String("https://dbc.example".into()), + ), + ( + "GOOSE_PROVIDER".to_string(), + serde_yaml::Value::String("databricks".into()), + ), + ( + "GOOSE_MODEL".to_string(), + serde_yaml::Value::String("goose-claude-4-6-sonnet".into()), + ), + ]); + let cfg = GooseDatabricksConfig::from_map(&map); + assert_eq!(cfg.host.as_deref(), Some("https://dbc.example")); + assert_eq!(cfg.model.as_deref(), Some("goose-claude-4-6-sonnet")); + } + + #[test] + fn goose_databricks_config_prefers_explicit_databricks_model() { + let map = HashMap::from([ + ( + "DATABRICKS_HOST".to_string(), + serde_yaml::Value::String("https://dbc.example".into()), + ), + ( + "DATABRICKS_MODEL".to_string(), + serde_yaml::Value::String("explicit-db-model".into()), + ), + ( + "GOOSE_PROVIDER".to_string(), + serde_yaml::Value::String("databricks".into()), + ), + ( + "GOOSE_MODEL".to_string(), + serde_yaml::Value::String("goose-model".into()), + ), + ]); + let cfg = GooseDatabricksConfig::from_map(&map); + assert_eq!(cfg.model.as_deref(), Some("explicit-db-model")); + } + + #[test] + fn goose_databricks_config_ignores_goose_model_for_other_provider() { + let map = HashMap::from([ + ( + "DATABRICKS_HOST".to_string(), + serde_yaml::Value::String("https://dbc.example".into()), + ), + ( + "GOOSE_PROVIDER".to_string(), + serde_yaml::Value::String("anthropic".into()), + ), + ( + "GOOSE_MODEL".to_string(), + serde_yaml::Value::String("claude".into()), + ), + ]); + let cfg = GooseDatabricksConfig::from_map(&map); + assert_eq!(cfg.host.as_deref(), Some("https://dbc.example")); + assert!(cfg.model.is_none()); + } + + // ── Nested active_provider + providers block (newer goose format) ──────── + + #[test] + fn from_map_reads_nested_active_provider_databricks_v2() { + // Simulates: + // active_provider: databricks_v2 + // providers: + // databricks_v2: + // model: goose-claude-4-6-opus + // host: https://dbc.example + let providers_map = { + let mut inner = serde_yaml::Mapping::new(); + let mut provider_entry = serde_yaml::Mapping::new(); + provider_entry.insert( + serde_yaml::Value::String("model".into()), + serde_yaml::Value::String("goose-claude-4-6-opus".into()), + ); + provider_entry.insert( + serde_yaml::Value::String("host".into()), + serde_yaml::Value::String("https://dbc.example".into()), + ); + inner.insert( + serde_yaml::Value::String("databricks_v2".into()), + serde_yaml::Value::Mapping(provider_entry), + ); + serde_yaml::Value::Mapping(inner) + }; + + let map = HashMap::from([ + ( + "active_provider".to_string(), + serde_yaml::Value::String("databricks_v2".into()), + ), + ("providers".to_string(), providers_map), + ]); + + let cfg = GooseDatabricksConfig::from_map(&map); + assert_eq!(cfg.host.as_deref(), Some("https://dbc.example")); + assert_eq!(cfg.model.as_deref(), Some("goose-claude-4-6-opus")); + } + + #[test] + fn from_map_flat_keys_win_over_nested() { + // Flat DATABRICKS_MODEL takes precedence over the nested providers block. + let providers_map = { + let mut inner = serde_yaml::Mapping::new(); + let mut provider_entry = serde_yaml::Mapping::new(); + provider_entry.insert( + serde_yaml::Value::String("model".into()), + serde_yaml::Value::String("nested-model".into()), + ); + provider_entry.insert( + serde_yaml::Value::String("host".into()), + serde_yaml::Value::String("https://nested-host.example".into()), + ); + inner.insert( + serde_yaml::Value::String("databricks_v2".into()), + serde_yaml::Value::Mapping(provider_entry), + ); + serde_yaml::Value::Mapping(inner) + }; + + let map = HashMap::from([ + ( + "active_provider".to_string(), + serde_yaml::Value::String("databricks_v2".into()), + ), + ("providers".to_string(), providers_map), + ( + "DATABRICKS_HOST".to_string(), + serde_yaml::Value::String("https://flat-host.example".into()), + ), + ( + "DATABRICKS_MODEL".to_string(), + serde_yaml::Value::String("flat-model".into()), + ), + ]); + + let cfg = GooseDatabricksConfig::from_map(&map); + // Flat keys win + assert_eq!(cfg.host.as_deref(), Some("https://flat-host.example")); + assert_eq!(cfg.model.as_deref(), Some("flat-model")); + } + + #[test] + fn from_map_non_databricks_active_provider_is_ignored() { + // active_provider = anthropic should not trigger nested lookup + let providers_map = { + let mut inner = serde_yaml::Mapping::new(); + let mut provider_entry = serde_yaml::Mapping::new(); + provider_entry.insert( + serde_yaml::Value::String("model".into()), + serde_yaml::Value::String("claude-opus-4".into()), + ); + inner.insert( + serde_yaml::Value::String("anthropic".into()), + serde_yaml::Value::Mapping(provider_entry), + ); + serde_yaml::Value::Mapping(inner) + }; + + let map = HashMap::from([ + ( + "active_provider".to_string(), + serde_yaml::Value::String("anthropic".into()), + ), + ("providers".to_string(), providers_map), + ]); + + let cfg = GooseDatabricksConfig::from_map(&map); + assert!(cfg.host.is_none()); + assert!(cfg.model.is_none()); + } + + #[test] + fn load_from_path_returns_none_for_nonexistent_file() { + let result = GooseDatabricksConfig::load_from_path(std::path::Path::new( + "/tmp/sprout-test-nonexistent-goose-config-99999999.yaml", + )); + assert!(result.is_none()); + } + + #[test] + fn load_from_path_parses_valid_yaml() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.yaml"); + std::fs::write( + &path, + "DATABRICKS_HOST: https://dbc.example\nGOOSE_PROVIDER: databricks\nGOOSE_MODEL: goose-claude-4-6-sonnet\n", + ) + .unwrap(); + let cfg = GooseDatabricksConfig::load_from_path(&path).unwrap(); + assert_eq!(cfg.host.as_deref(), Some("https://dbc.example")); + assert_eq!(cfg.model.as_deref(), Some("goose-claude-4-6-sonnet")); + } + + #[test] + fn load_from_path_returns_none_for_invalid_yaml() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.yaml"); + std::fs::write(&path, "{{{{not valid yaml at all::::").unwrap(); + let result = GooseDatabricksConfig::load_from_path(&path); + assert!(result.is_none()); + } + + #[test] + fn goose_config_path_falls_back_to_home_when_root_unset() { + // When GOOSE_PATH_ROOT is not set, goose_config_path() constructs a + // path under $HOME. We can verify the suffix without mutating env vars. + // If HOME is set (virtually all environments), the path ends with the + // expected goose config suffix. + if let Ok(home) = std::env::var("HOME") { + // Only run the check when GOOSE_PATH_ROOT is not already set, so + // this test doesn't interfere with the override logic. + if std::env::var("GOOSE_PATH_ROOT").is_err() { + let result = goose_config_path(); + let expected = std::path::PathBuf::from(&home) + .join(".config") + .join("goose") + .join("config.yaml"); + assert_eq!(result, Some(expected)); + } + } + } +} diff --git a/crates/sprout-agent/src/config.rs b/crates/sprout-agent/src/config/mod.rs similarity index 82% rename from crates/sprout-agent/src/config.rs rename to crates/sprout-agent/src/config/mod.rs index 6c873fc09..6eddc6b0c 100644 --- a/crates/sprout-agent/src/config.rs +++ b/crates/sprout-agent/src/config/mod.rs @@ -1,4 +1,7 @@ -use std::{collections::HashMap, path::PathBuf, time::Duration}; +mod goose_compat; +use goose_compat::GooseDatabricksConfig; + +use std::time::Duration; pub const PROTOCOL_VERSION: u32 = 1; @@ -90,6 +93,12 @@ impl Config { databricks_host.as_deref(), databricks_model.as_deref(), )?; + + // Universal model override — any provider will use this when its own + // model env var is absent. Useful for wrapper scripts that set a single + // var regardless of which provider is active. + let sprout_agent_model = env("SPROUT_AGENT_MODEL"); + // OPENAI_COMPAT_API is only read when provider=openai, so a stray // bad value can't break an Anthropic-only deployment. // @@ -99,23 +108,26 @@ impl Config { let (api_key, model, base_url, openai_api) = match provider { Provider::Anthropic => ( req("ANTHROPIC_API_KEY")?, - req("ANTHROPIC_MODEL")?, + resolve_model(env("ANTHROPIC_MODEL").as_deref(), sprout_agent_model.as_deref()) + .ok_or_else(|| "config: ANTHROPIC_MODEL required".to_string())?, env_or("ANTHROPIC_BASE_URL", "https://api.anthropic.com"), OpenAiApi::Auto, // unused for Anthropic ), Provider::OpenAi => ( req("OPENAI_COMPAT_API_KEY")?, - req("OPENAI_COMPAT_MODEL")?, + resolve_model(env("OPENAI_COMPAT_MODEL").as_deref(), sprout_agent_model.as_deref()) + .ok_or_else(|| "config: OPENAI_COMPAT_MODEL required".to_string())?, env_or("OPENAI_COMPAT_BASE_URL", "https://api.openai.com/v1"), parse_openai_api(env("OPENAI_COMPAT_API").as_deref())?, ), Provider::Databricks => ( env("DATABRICKS_TOKEN").unwrap_or_default(), - databricks_model.ok_or_else(|| { - "config: DATABRICKS_MODEL required (or set GOOSE_MODEL in goose config with GOOSE_PROVIDER=databricks)".to_string() - })?, + resolve_model(databricks_model.as_deref(), sprout_agent_model.as_deref()) + .ok_or_else(|| { + "config: DATABRICKS_MODEL required (or configure Databricks in ~/.config/goose/config.yaml)".to_string() + })?, databricks_host.ok_or_else(|| { - "config: DATABRICKS_HOST required (or set DATABRICKS_HOST in goose config)".to_string() + "config: DATABRICKS_HOST required (or configure Databricks in ~/.config/goose/config.yaml)".to_string() })?, OpenAiApi::Chat, // Databricks invocations is chat-shaped ), @@ -226,64 +238,11 @@ fn req(k: &str) -> Result { env(k).ok_or_else(|| format!("config: {k} required")) } -#[derive(Default)] -struct GooseDatabricksConfig { - host: Option, - model: Option, -} - -impl GooseDatabricksConfig { - fn load_default() -> Self { - goose_config_path() - .and_then(|p| Self::load_from_path(&p)) - .unwrap_or_default() - } - - fn load_from_path(path: &std::path::Path) -> Option { - let raw = std::fs::read_to_string(path).ok()?; - let map: HashMap = serde_yaml::from_str(&raw).ok()?; - Some(Self::from_map(&map)) - } - - fn from_map(map: &HashMap) -> Self { - let host = yaml_string(map, "DATABRICKS_HOST"); - let explicit_model = yaml_string(map, "DATABRICKS_MODEL"); - let goose_provider = yaml_string(map, "GOOSE_PROVIDER"); - let goose_model = yaml_string(map, "GOOSE_MODEL"); - let goose_mode = yaml_string(map, "GOOSE_MODE"); - let model = explicit_model.or_else(|| { - if goose_provider - .as_deref() - .is_some_and(|p| p.eq_ignore_ascii_case("databricks")) - { - goose_model.or(goose_mode) - } else { - None - } - }); - Self { host, model } - } -} - -fn yaml_string(map: &HashMap, key: &str) -> Option { - map.get(key)? - .as_str() - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) -} - -fn goose_config_path() -> Option { - if let Ok(root) = std::env::var("GOOSE_PATH_ROOT") { - return Some(PathBuf::from(root).join("config").join("config.yaml")); - } - let home = std::env::var("HOME").ok()?; - Some( - PathBuf::from(home) - .join(".config") - .join("goose") - .join("config.yaml"), - ) +/// Returns the first of `provider_model` or `universal_fallback` that is +/// `Some`, converting to an owned `String`. Returns `None` when both are +/// absent so the caller can supply a provider-specific error message. +fn resolve_model(provider_model: Option<&str>, universal_fallback: Option<&str>) -> Option { + provider_model.or(universal_fallback).map(str::to_owned) } fn present_nonempty(v: Option<&str>) -> bool { @@ -544,72 +503,6 @@ mod tests { assert!(err.contains("OPENAI_COMPAT_API=nope"), "{err}"); } - #[test] - fn goose_databricks_config_reads_host_and_model() { - let map = HashMap::from([ - ( - "DATABRICKS_HOST".to_string(), - serde_yaml::Value::String("https://dbc.example".into()), - ), - ( - "GOOSE_PROVIDER".to_string(), - serde_yaml::Value::String("databricks".into()), - ), - ( - "GOOSE_MODEL".to_string(), - serde_yaml::Value::String("goose-claude-4-6-sonnet".into()), - ), - ]); - let cfg = GooseDatabricksConfig::from_map(&map); - assert_eq!(cfg.host.as_deref(), Some("https://dbc.example")); - assert_eq!(cfg.model.as_deref(), Some("goose-claude-4-6-sonnet")); - } - - #[test] - fn goose_databricks_config_prefers_explicit_databricks_model() { - let map = HashMap::from([ - ( - "DATABRICKS_HOST".to_string(), - serde_yaml::Value::String("https://dbc.example".into()), - ), - ( - "DATABRICKS_MODEL".to_string(), - serde_yaml::Value::String("explicit-db-model".into()), - ), - ( - "GOOSE_PROVIDER".to_string(), - serde_yaml::Value::String("databricks".into()), - ), - ( - "GOOSE_MODEL".to_string(), - serde_yaml::Value::String("goose-model".into()), - ), - ]); - let cfg = GooseDatabricksConfig::from_map(&map); - assert_eq!(cfg.model.as_deref(), Some("explicit-db-model")); - } - - #[test] - fn goose_databricks_config_ignores_goose_model_for_other_provider() { - let map = HashMap::from([ - ( - "DATABRICKS_HOST".to_string(), - serde_yaml::Value::String("https://dbc.example".into()), - ), - ( - "GOOSE_PROVIDER".to_string(), - serde_yaml::Value::String("anthropic".into()), - ), - ( - "GOOSE_MODEL".to_string(), - serde_yaml::Value::String("claude".into()), - ), - ]); - let cfg = GooseDatabricksConfig::from_map(&map); - assert_eq!(cfg.host.as_deref(), Some("https://dbc.example")); - assert!(cfg.model.is_none()); - } - #[test] fn resolve_provider_keeps_requested_provider_when_token_present() { assert_eq!( @@ -716,4 +609,22 @@ mod tests { assert_eq!(is_openai_host(url), want, "url={url}"); } } + + #[test] + fn resolve_model_prefers_provider_specific() { + let result = resolve_model(Some("anthropic-model"), Some("universal-model")); + assert_eq!(result.as_deref(), Some("anthropic-model")); + } + + #[test] + fn resolve_model_falls_back_to_universal() { + let result = resolve_model(None, Some("universal-model")); + assert_eq!(result.as_deref(), Some("universal-model")); + } + + #[test] + fn resolve_model_returns_none_when_both_absent() { + let result = resolve_model(None, None); + assert!(result.is_none()); + } } diff --git a/crates/sprout-cli/src/commands/pack.rs b/crates/sprout-cli/src/commands/pack.rs index e76cabdf8..de5f82fff 100644 --- a/crates/sprout-cli/src/commands/pack.rs +++ b/crates/sprout-cli/src/commands/pack.rs @@ -136,9 +136,9 @@ pub fn cmd_inspect(path: &str) -> Result<(), CliError> { prompt_preview.replace('\n', " ") ); - if !persona.goose_env_vars.is_empty() { + if !persona.runtime_env_vars.is_empty() { let env_str: Vec = persona - .goose_env_vars + .runtime_env_vars .iter() .map(|(k, v)| format!("{k}={v}")) .collect(); diff --git a/crates/sprout-persona/src/resolve.rs b/crates/sprout-persona/src/resolve.rs index 15ac0fd95..d001d50b1 100644 --- a/crates/sprout-persona/src/resolve.rs +++ b/crates/sprout-persona/src/resolve.rs @@ -60,8 +60,8 @@ pub struct ResolvedPersona { // Skills (bare names — reserved for future use, not yet wired) pub skills: Vec, - // Env var projection for goose subprocess - pub goose_env_vars: Vec<(String, String)>, + // Env var projection for agent subprocess + pub runtime_env_vars: Vec<(String, String)>, } /// An MCP server with env values as literals (no interpolation in this PR). @@ -106,7 +106,7 @@ pub struct ResolvedPack { /// Returns a `ResolvedPack` with fully typed, ACP-ready output for each /// persona. All merge policy (levels 3-5) is applied. MCP servers are /// merged with literal env passthrough (no `${VAR}` interpolation). -/// Goose env vars are projected from model/temperature/context config. +/// Env vars are projected from model/temperature/context config. pub fn resolve_pack(pack_dir: &Path) -> Result { let loaded = pack::load_pack(pack_dir)?; resolve_loaded_pack(&loaded) @@ -218,7 +218,7 @@ fn resolve_one_persona( let triggers = resolve_triggers(lp.triggers.as_ref()); let mcp_servers = merge_mcp_servers(shared_mcp, &lp.mcp_servers); let hooks = resolve_hooks(lp.hooks.as_ref()); - let goose_env_vars = project_env_vars(lp); + let runtime_env_vars = runtime_env_vars(lp); // Version: LoadedPersona has no per-persona version field — persona files // don't declare a version in frontmatter. The pack version is used as-is. @@ -246,7 +246,7 @@ fn resolve_one_persona( mcp_servers, hooks, skills: lp.skills.clone(), - goose_env_vars, + runtime_env_vars, } } @@ -381,17 +381,30 @@ fn resolve_hooks(hooks: Option<&crate::merge::HooksData>) -> Option Vec<(String, String)> { +fn runtime_env_vars(persona: &LoadedPersona) -> Vec<(String, String)> { let mut vars = Vec::new(); + let runtime = persona.runtime.as_deref(); if let Some(model_str) = &persona.model { let (provider, model_id) = split_model(model_str); - if let Some(p) = provider { - vars.push(("GOOSE_PROVIDER".to_owned(), p.to_owned())); + + match runtime { + Some("sprout-agent") => { + vars.push(("SPROUT_AGENT_MODEL".to_owned(), model_id.to_owned())); + if let Some(p) = provider { + vars.push(("SPROUT_AGENT_PROVIDER".to_owned(), p.to_owned())); + } + } + _ => { + if let Some(p) = provider { + vars.push(("GOOSE_PROVIDER".to_owned(), p.to_owned())); + } + vars.push(("GOOSE_MODEL".to_owned(), model_id.to_owned())); + } } - vars.push(("GOOSE_MODEL".to_owned(), model_id.to_owned())); } + // temperature and context_limit stay as GOOSE_* (only goose reads them) if let Some(temp) = persona.temperature { vars.push(("GOOSE_TEMPERATURE".to_owned(), temp.to_string())); } @@ -570,12 +583,12 @@ mod tests { assert!(resolve_hooks(None).is_none()); } - // ── project_env_vars ────────────────────────────────────────────────── + // ── runtime_env_vars ────────────────────────────────────────────────── #[test] fn env_vars_projected_from_model() { let lp = stub_persona(Some("anthropic:claude-sonnet-4-20250514"), None, None); - let vars = project_env_vars(&lp); + let vars = runtime_env_vars(&lp); let map: HashMap<&str, &str> = vars.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); assert_eq!(map["GOOSE_PROVIDER"], "anthropic"); assert_eq!(map["GOOSE_MODEL"], "claude-sonnet-4-20250514"); @@ -584,7 +597,7 @@ mod tests { #[test] fn env_vars_model_without_provider() { let lp = stub_persona(Some("gpt-4o"), None, None); - let vars = project_env_vars(&lp); + let vars = runtime_env_vars(&lp); let map: HashMap<&str, &str> = vars.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); assert!(!map.contains_key("GOOSE_PROVIDER")); assert_eq!(map["GOOSE_MODEL"], "gpt-4o"); @@ -593,7 +606,7 @@ mod tests { #[test] fn env_vars_temperature_and_context() { let lp = stub_persona(None, Some(0.7), Some(8192)); - let vars = project_env_vars(&lp); + let vars = runtime_env_vars(&lp); let map: HashMap<&str, &str> = vars.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); assert_eq!(map["GOOSE_TEMPERATURE"], "0.7"); assert_eq!(map["GOOSE_CONTEXT_LIMIT"], "8192"); @@ -602,17 +615,59 @@ mod tests { #[test] fn env_vars_empty_when_no_config() { let lp = stub_persona(None, None, None); - let vars = project_env_vars(&lp); + let vars = runtime_env_vars(&lp); assert!(vars.is_empty()); } #[test] fn env_vars_full_projection() { let lp = stub_persona(Some("openai:gpt-4o"), Some(0.5), Some(16384)); - let vars = project_env_vars(&lp); + let vars = runtime_env_vars(&lp); assert_eq!(vars.len(), 4); // PROVIDER, MODEL, TEMPERATURE, CONTEXT_LIMIT } + #[test] + fn runtime_env_vars_sprout_agent_emits_sprout_agent_vars() { + let mut lp = stub_persona(Some("databricks:goose-claude-4-6-opus"), None, None); + lp.runtime = Some("sprout-agent".to_owned()); + let vars = runtime_env_vars(&lp); + let map: HashMap<&str, &str> = vars.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); + assert_eq!(map["SPROUT_AGENT_MODEL"], "goose-claude-4-6-opus"); + assert_eq!(map["SPROUT_AGENT_PROVIDER"], "databricks"); + assert!(!map.contains_key("GOOSE_MODEL")); + assert!(!map.contains_key("GOOSE_PROVIDER")); + } + + #[test] + fn runtime_env_vars_goose_emits_goose_vars() { + let mut lp = stub_persona(Some("databricks:goose-claude-4-6-opus"), None, None); + lp.runtime = Some("goose".to_owned()); + let vars = runtime_env_vars(&lp); + let map: HashMap<&str, &str> = vars.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); + assert_eq!(map["GOOSE_MODEL"], "goose-claude-4-6-opus"); + assert_eq!(map["GOOSE_PROVIDER"], "databricks"); + assert!(!map.contains_key("SPROUT_AGENT_MODEL")); + } + + #[test] + fn runtime_env_vars_no_runtime_defaults_to_goose() { + let lp = stub_persona(Some("anthropic:claude-sonnet-4-20250514"), None, None); + let vars = runtime_env_vars(&lp); + let map: HashMap<&str, &str> = vars.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); + assert_eq!(map["GOOSE_PROVIDER"], "anthropic"); + assert_eq!(map["GOOSE_MODEL"], "claude-sonnet-4-20250514"); + } + + #[test] + fn runtime_env_vars_sprout_agent_model_without_provider() { + let mut lp = stub_persona(Some("gpt-4o"), None, None); + lp.runtime = Some("sprout-agent".to_owned()); + let vars = runtime_env_vars(&lp); + let map: HashMap<&str, &str> = vars.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); + assert_eq!(map["SPROUT_AGENT_MODEL"], "gpt-4o"); + assert!(!map.contains_key("SPROUT_AGENT_PROVIDER")); + } + // ── Full pipeline (resolve_pack via filesystem) ─────────────────────── #[test] @@ -650,7 +705,7 @@ mod tests { assert!(p.llm_provider.is_none()); assert!(p.triggers.mentions); // built-in default assert!(p.mcp_servers.is_empty()); - assert!(p.goose_env_vars.is_empty()); + assert!(p.runtime_env_vars.is_empty()); } #[test] @@ -694,7 +749,7 @@ mod tests { // Env vars projected let env_map: HashMap<&str, &str> = p - .goose_env_vars + .runtime_env_vars .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); diff --git a/crates/sprout-persona/tests/integration.rs b/crates/sprout-persona/tests/integration.rs index 7e445ee95..83f356e69 100644 --- a/crates/sprout-persona/tests/integration.rs +++ b/crates/sprout-persona/tests/integration.rs @@ -461,8 +461,8 @@ fn resolve_full_pipeline() { assert!(lep.triggers.keywords.contains(&"vulnerability".to_string())); assert!(!lep.triggers.all_messages); - // Goose env vars projected from model - let pip_env: std::collections::HashMap<_, _> = pip.goose_env_vars.iter().cloned().collect(); + // Env vars projected from model + let pip_env: std::collections::HashMap<_, _> = pip.runtime_env_vars.iter().cloned().collect(); assert_eq!( pip_env.get("GOOSE_PROVIDER").map(|s| s.as_str()), Some("anthropic") diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index f5684ff05..e56f29c01 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -54,9 +54,9 @@ const overrides = new Map([ ["src-tauri/src/commands/agents.rs", 910], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring + mcp_toolsets field + NIP-OA auth_tag in deploy payload + relay-mesh client preflight (start_local_agent_with_preflight + StartTarget split: async ensure outside store lock, then sync spawn under lock) ["src-tauri/src/commands/messages.rs", 525], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + edit_message media_tags param (Slack-style attachment-editable edits) + add_reaction custom-emoji branch (build_custom_emoji_reaction when emoji_url is set) ["src-tauri/src/nostr_convert.rs", 1150], // 12 Nostr event→model converters (channels, profiles, members, notes, search, agents, relay members) + rank_user_search_results helper for NIP-50 user search + 33 unit tests - ["src-tauri/src/managed_agents/runtime.rs", 1390], // ... + respond-to gate env (SPROUT_ACP_RESPOND_TO[_ALLOWLIST]) + per-mode env builder + tests + persona/agent env_vars spawn merge (helper + tests now in env_vars.rs) + system-wide orphan sweep (proc_listallpids/proc on macOS, /proc on Linux) + SPROUT_MANAGED_AGENT env marker check (KERN_PROCARGS2 on macOS, /proc/environ on Linux), instance-scoped by app identifier so coexisting Sprout instances don't reap each other's agents + relay_mesh_model_id detector (mesh preset-env match) + tests + ["src-tauri/src/managed_agents/runtime.rs", 1500], // ... + respond-to gate env (SPROUT_ACP_RESPOND_TO[_ALLOWLIST]) + per-mode env builder + tests + persona/agent env_vars spawn merge (helper + tests now in env_vars.rs) + system-wide orphan sweep (proc_listallpids/proc on macOS, /proc on Linux) + SPROUT_MANAGED_AGENT env marker check (KERN_PROCARGS2 on macOS, /proc/environ on Linux), instance-scoped by app identifier so coexisting Sprout instances don't reap each other's agents + relay_mesh_model_id detector (mesh preset-env match) + runtime_metadata_env_vars pure helper + tests ["src-tauri/src/managed_agents/discovery.rs", 698], // KNOWN_ACP_RUNTIMES catalog + resolve_command cache + login_shell_path + classify_provider (four-state: Available/AdapterMissing/CliMissing/NotInstalled) + discover_acp_providers with dynamic install_hint + known_acp_provider/known_acp_provider_exact + normalize_agent_args + 15 unit tests - ["src-tauri/src/managed_agents/types.rs", 745], // ManagedAgentRecord/Summary + Create/Update request structs + AcpRuntimeCatalogEntry + InstallRuntimeResult + RespondTo enum + validate_respond_to_allowlist + tests + persona/agent env_vars field + ["src-tauri/src/managed_agents/types.rs", 755], // ManagedAgentRecord/Summary + Create/Update request structs + AcpRuntimeCatalogEntry + InstallRuntimeResult + RespondTo enum + validate_respond_to_allowlist + tests + persona/agent env_vars field + provider field ["src-tauri/src/managed_agents/backend.rs", 700], // provider IPC, validation, discovery, binary resolution + tests + redact_secrets_with for user env values + env_secrets_from_request + redact_env_values_in (shared with model discovery) ["src/features/huddle/HuddleContext.tsx", 650], // huddle lifecycle context + joinHuddle + connectAndSetupMedia shared helper + activeSpeakers/isReconnecting state + PTT (reusable AudioContext) + TTS subscription + mic level analyser (10fps throttle) + agent pubkey refresh ["src/features/agents/hooks.ts", 550], // agent query/mutation surface + useAvailableAcpRuntimes (type-narrowing filter hook) + useInstallAcpRuntimeMutation + built-in persona library activation @@ -66,11 +66,11 @@ const overrides = new Map([ ["src/features/agents/ui/TeamDialog.tsx", 530], // team create/edit dialog with persona multi-select, import button, window drag detection, removal confirmation ["src/features/agents/ui/TeamImportUpdateDialog.tsx", 660], // team import diff preview with member matching/updating/adding/removing sections, LCS line counts, removal confirmation ["src/features/agents/ui/useTeamActions.ts", 510], // team CRUD + export + import + import-update orchestration with query invalidation - ["src/features/agents/ui/PersonaDialog.tsx", 515], // persona create/edit form + env vars editor + drag-drop file import + runtime provider dropdown with availability warnings + ["src/features/agents/ui/PersonaDialog.tsx", 540], // persona create/edit form + env vars editor + drag-drop file import + runtime provider dropdown with availability warnings + LLM provider field ["src/features/agents/ui/CreateAgentDialog.tsx", 685], // provider selector + config form + schema-typed config coercion + required field validation + locked scopes ["src/features/channels/ui/AddChannelBotDialog.tsx", 690], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display + RespondTo field + reuse guardrail ["src/features/settings/ui/ChannelTemplatesSettingsCard.tsx", 850], // template CRUD card + TemplateFormDialog (persona/team chip selectors + runtime assignments + canvas template) + TemplateTeamSelector + RuntimeAssignments + RuntimeRow - ["src/shared/api/types.ts", 650], // ... + AcpRuntimeCatalogEntry + AcpRuntime (narrowed subtype) + InstallRuntimeResult + RespondToMode + respondTo/respondToAllowlist on ManagedAgent/Create/Update inputs + ["src/shared/api/types.ts", 660], // ... + AcpRuntimeCatalogEntry + AcpRuntime (narrowed subtype) + InstallRuntimeResult + RespondToMode + respondTo/respondToAllowlist on ManagedAgent/Create/Update inputs + provider field on persona types ["src-tauri/src/events.rs", 825], // event builders + build_huddle_guidelines (kind:48106) + post_event_raw transport helper + participant p-tag on join/leave + NIP-43 relay admin builders (add/remove/change-role) + check_relay_role + DM/presence/workflow command builders + NIP-IA identity-archive builders (9035/9036) + .allow_self_tagging() guards (nostr 0.44 strips self-`p` by default; self-archive/unarchive needs it preserved) + spec vector 1 layout test + emoji_tags() NIP-30 builder (mirrors the imeta_tags injection guard, first elem must be "emoji") ["src-tauri/src/huddle/mod.rs", 1020], // huddle state machine + Tauri commands + sync protocol doc; state/relay/pipeline extracted + emit_huddle_state_changed wiring ["src-tauri/src/huddle/models.rs", 950], // model download manager for Parakeet TDT-CTC STT + Pocket TTS with streaming downloads + SHA-256 verification + Rust-native tar extraction + version manifest + atomic swap + hot-start signaling + MODEL_LICENSE.txt sidecar (fail-closed readiness) + idempotent legacy Moonshine dir cleanup + tts_readiness_requires_license_sidecar test + Mary (VCTK p333) reference voice attribution block diff --git a/desktop/src-tauri/src/commands/personas.rs b/desktop/src-tauri/src/commands/personas.rs index 99138797a..b465508f5 100644 --- a/desktop/src-tauri/src/commands/personas.rs +++ b/desktop/src-tauri/src/commands/personas.rs @@ -53,6 +53,7 @@ pub fn create_persona( let avatar_url = trim_optional(input.avatar_url); let runtime = trim_optional(input.runtime); let model = trim_optional(input.model); + let provider = trim_optional(input.provider); let now = now_iso(); let _store_guard = state @@ -74,6 +75,7 @@ pub fn create_persona( system_prompt, runtime, model, + provider, name_pool, is_builtin: false, is_active: true, @@ -100,6 +102,7 @@ pub fn update_persona( let avatar_url = trim_optional(input.avatar_url); let runtime = trim_optional(input.runtime); let model = trim_optional(input.model); + let provider = trim_optional(input.provider); let _store_guard = state .managed_agents_store_lock @@ -119,6 +122,7 @@ pub fn update_persona( persona.system_prompt = system_prompt; persona.runtime = runtime; persona.model = model; + persona.provider = provider; persona.name_pool = input .name_pool .into_iter() diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 9b8070ab6..bd92ec467 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -34,11 +34,10 @@ pub(crate) struct KnownAcpRuntime { /// pointing to the canonical `.agents/skills/sprout-cli`. `None` → this /// runtime reads the canonical path directly or has no skill support. pub skill_dir: Option<&'static str>, + #[allow(dead_code)] pub supports_acp_model_switching: bool, pub model_env_var: Option<&'static str>, - #[allow(dead_code)] pub provider_env_var: Option<&'static str>, - #[allow(dead_code)] pub provider_locked: bool, pub default_env: &'static [(&'static str, &'static str)], } @@ -151,7 +150,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ adapter_install_hint: "", skill_dir: None, supports_acp_model_switching: true, - model_env_var: None, + model_env_var: Some("SPROUT_AGENT_MODEL"), provider_env_var: Some("SPROUT_AGENT_PROVIDER"), provider_locked: false, default_env: &[], diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 7ac81515b..bd5830b29 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -943,6 +943,7 @@ mod tests { system_prompt: String::new(), runtime: None, model: None, + provider: None, name_pool: vec![], is_builtin: false, is_active: true, diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index 5d88be424..be95c82f2 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -502,6 +502,7 @@ fn built_in_persona_records(now: &str) -> Vec { system_prompt: persona.system_prompt.to_string(), runtime: persona.runtime.map(|s| s.to_string()), model: persona.model.map(|s| s.to_string()), + provider: None, name_pool: persona.name_pool.iter().map(|s| s.to_string()).collect(), is_builtin: true, is_active: true, @@ -837,12 +838,13 @@ pub fn import_persona_pack( system_prompt: p.system_prompt.clone(), runtime: p.runtime.clone(), model: p.model.clone(), + provider: p.llm_provider.clone(), name_pool: Vec::new(), is_builtin: false, is_active: true, source_pack: Some(resolved.id.clone()), source_pack_persona_slug: Some(p.name.clone()), - env_vars: std::collections::BTreeMap::new(), + env_vars: p.runtime_env_vars.iter().cloned().collect(), created_at: now.clone(), updated_at: now.clone(), }) diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index 1b8da0b2d..ae1b037bb 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -13,6 +13,7 @@ fn custom_persona(id: &str, display_name: &str) -> PersonaRecord { system_prompt: "Custom prompt".to_string(), runtime: None, model: None, + provider: None, name_pool: Vec::new(), is_builtin: false, is_active: true, diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 46d071166..1b1cb2aba 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -6,7 +6,7 @@ use crate::{ managed_agents::{ append_log_marker, known_acp_runtime, login_shell_path, managed_agent_log_path, missing_command_message, normalize_agent_args, open_log_file, resolve_command, - ManagedAgentProcess, ManagedAgentRecord, ManagedAgentSummary, + ManagedAgentProcess, ManagedAgentRecord, ManagedAgentSummary, PersonaRecord, }, util::now_iso, }; @@ -896,29 +896,31 @@ pub fn spawn_agent_child( command.env("SPROUT_ACP_PERSONA_NAME", persona_name); } - // Resolve system prompt and model: prefer the persona definition (if a - // persona pack is configured and the persona matched), otherwise fall back - // to the record-level overrides. + // Resolve system prompt, model, and provider: prefer the persona definition + // (if a persona pack is configured and the persona matched), otherwise fall + // back to the record-level overrides. Provider always flows from the persona + // when one is linked (the record has no provider field of its own). let has_persona_pack = record.persona_pack_path.is_some() && record.persona_name_in_pack.is_some(); - let persona_prompt_and_model: Option<(String, Option)> = has_persona_pack - .then(|| { - record - .persona_id - .as_deref() - .and_then(|pid| { - super::load_personas(app) - .ok()? - .into_iter() - .find(|p| p.id == pid) - }) - .map(|p| (p.system_prompt, p.model)) - }) - .flatten(); - - let (effective_prompt, effective_model) = match persona_prompt_and_model { - Some((prompt, model)) => (Some(prompt), model), - None => (record.system_prompt.clone(), record.model.clone()), + let persona_record: Option = record.persona_id.as_deref().and_then(|pid| { + super::load_personas(app) + .ok()? + .into_iter() + .find(|p| p.id == pid) + }); + + let (effective_prompt, effective_model, effective_provider) = if has_persona_pack { + match &persona_record { + Some(p) => ( + Some(p.system_prompt.clone()), + p.model.clone(), + p.provider.clone(), + ), + None => (record.system_prompt.clone(), record.model.clone(), None), + } + } else { + let provider = persona_record.as_ref().and_then(|p| p.provider.clone()); + (record.system_prompt.clone(), record.model.clone(), provider) }; if let Some(prompt) = &effective_prompt { @@ -932,10 +934,14 @@ pub fn spawn_agent_child( command.env_remove("SPROUT_ACP_MODEL"); } if let Some(meta) = runtime_meta { - if !meta.supports_acp_model_switching { - if let (Some(env_key), Some(model)) = (meta.model_env_var, &effective_model) { - command.env(env_key, model); - } + for (key, value) in runtime_metadata_env_vars( + meta.model_env_var, + meta.provider_env_var, + meta.provider_locked, + effective_model.as_deref(), + effective_provider.as_deref(), + ) { + command.env(key, value); } } if let Some(toolsets) = &record.mcp_toolsets { @@ -1179,6 +1185,32 @@ pub fn stop_managed_agent_process( Ok(()) } +/// Returns the (key, value) env var pairs that should be forwarded to the +/// agent process for model and provider selection. +/// +/// Model injection is unconditional — even agents that support ACP model +/// switching need the initial bootstrap value. Provider injection is skipped +/// when `provider_locked` is true (e.g. Claude runtimes that only work with +/// Anthropic). +fn runtime_metadata_env_vars<'a>( + model_env_var: Option<&'a str>, + provider_env_var: Option<&'a str>, + provider_locked: bool, + effective_model: Option<&'a str>, + effective_provider: Option<&'a str>, +) -> Vec<(&'a str, &'a str)> { + let mut vars = Vec::new(); + if let (Some(env_key), Some(model)) = (model_env_var, effective_model) { + vars.push((env_key, model)); + } + if !provider_locked { + if let (Some(env_key), Some(provider)) = (provider_env_var, effective_provider) { + vars.push((env_key, provider)); + } + } + vars +} + #[cfg(test)] mod tests { #[cfg(feature = "mesh-llm")] @@ -1394,4 +1426,55 @@ mod tests { let err = build_respond_to_env(&rec, Some("owner")).unwrap_err(); assert!(err.contains("at least one pubkey")); } + + // ── runtime_metadata_env_vars tests ───────────────────────────────────── + + use super::runtime_metadata_env_vars; + + #[test] + fn runtime_metadata_env_vars_injects_model_and_provider() { + let vars = runtime_metadata_env_vars( + Some("GOOSE_MODEL"), + Some("GOOSE_PROVIDER"), + false, + Some("gpt-4o"), + Some("openai"), + ); + assert_eq!( + vars, + vec![("GOOSE_MODEL", "gpt-4o"), ("GOOSE_PROVIDER", "openai")] + ); + } + + #[test] + fn runtime_metadata_env_vars_skips_provider_when_locked() { + let vars = runtime_metadata_env_vars( + None, // claude has no model_env_var + None, // claude has no provider_env_var + true, // provider_locked = true + Some("claude-opus-4-7"), + Some("anthropic"), + ); + assert!(vars.is_empty()); + } + + #[test] + fn runtime_metadata_env_vars_injects_model_even_with_acp_model_switching() { + // sprout-agent has supports_acp_model_switching=true but we still inject + // the model env var because ACP model switching is post-bootstrap + let vars = runtime_metadata_env_vars( + Some("SPROUT_AGENT_MODEL"), + Some("SPROUT_AGENT_PROVIDER"), + false, + Some("goose-claude-4-6-opus"), + Some("databricks"), + ); + assert_eq!( + vars, + vec![ + ("SPROUT_AGENT_MODEL", "goose-claude-4-6-opus"), + ("SPROUT_AGENT_PROVIDER", "databricks"), + ] + ); + } } diff --git a/desktop/src-tauri/src/managed_agents/teams.rs b/desktop/src-tauri/src/managed_agents/teams.rs index 1833eeb4d..45b169970 100644 --- a/desktop/src-tauri/src/managed_agents/teams.rs +++ b/desktop/src-tauri/src/managed_agents/teams.rs @@ -331,6 +331,7 @@ mod tests { system_prompt: prompt.to_string(), runtime: None, model: None, + provider: None, name_pool: Vec::new(), is_builtin: false, is_active: true, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 15707371d..81fad12ef 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -28,6 +28,11 @@ pub struct PersonaRecord { /// direct). Sprout stores and passes through without interpretation. #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, + /// LLM inference provider (e.g., 'databricks', 'anthropic', 'openai'). Optional — when set, + /// injected as the runtime's provider env var at agent creation time. When absent, the runtime + /// falls back to auto-detection (e.g., goose config file or available credentials). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, /// Pool of short, thematic names for bot instances created from this persona. /// When a new copy is added to a channel, a random unused name is picked from this pool. #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -240,6 +245,8 @@ pub struct CreatePersonaRequest { #[serde(default)] pub model: Option, #[serde(default)] + pub provider: Option, + #[serde(default)] pub name_pool: Vec, /// Environment variables for agents created from this persona. #[serde(default)] @@ -258,6 +265,8 @@ pub struct UpdatePersonaRequest { #[serde(default)] pub model: Option, #[serde(default)] + pub provider: Option, + #[serde(default)] pub name_pool: Vec, /// Environment variables for agents created from this persona. /// diff --git a/desktop/src/features/agents/ui/PersonaDialog.tsx b/desktop/src/features/agents/ui/PersonaDialog.tsx index f6fd3d756..4393187f5 100644 --- a/desktop/src/features/agents/ui/PersonaDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaDialog.tsx @@ -66,6 +66,7 @@ export function PersonaDialog({ const [systemPrompt, setSystemPrompt] = React.useState(""); const [runtime, setRuntime] = React.useState(""); const [model, setModel] = React.useState(""); + const [provider, setProvider] = React.useState(""); const [namePoolText, setNamePoolText] = React.useState(""); const [envVars, setEnvVars] = React.useState({}); const [isImportingUpdate, setIsImportingUpdate] = React.useState(false); @@ -90,6 +91,7 @@ export function PersonaDialog({ setSystemPrompt(initialValues.systemPrompt); setRuntime(initialValues.runtime ?? ""); setModel(initialValues.model ?? ""); + setProvider(initialValues.provider ?? ""); setNamePoolText( ("namePool" in initialValues ? (initialValues as { namePool?: string[] }).namePool @@ -216,6 +218,7 @@ export function PersonaDialog({ setSystemPrompt(""); setRuntime(""); setModel(""); + setProvider(""); setNamePoolText(""); setImportErrorMessage(null); setIsImportingUpdate(false); @@ -240,6 +243,7 @@ export function PersonaDialog({ systemPrompt, runtime: runtime.trim() || undefined, model: model.trim() || undefined, + provider: provider.trim() || undefined, namePool: namePool.length > 0 ? namePool : undefined, envVars, }; @@ -405,6 +409,27 @@ export function PersonaDialog({

+
+ + setProvider(event.target.value)} + placeholder="e.g. databricks, anthropic, openai" + spellCheck={false} + value={provider} + /> +

+ Optional. Injected as the runtime's provider env var at agent + creation time. Leave blank for auto-detection or provider-locked + runtimes. +

+
+