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.
+
+
+