feat: normalized config schema with PersonaRecord.provider and SPROUT_AGENT_MODEL

sprout-agent personas all failed because the desktop never injected
SPROUT_AGENT_PROVIDER or model env vars at spawn time, and the goose
config fallback only understood the old flat format. Extract goose
config compat into an isolated submodule, add support for the newer
active_provider + nested providers format, and wire a normalized
config schema so Sprout injects the right env vars for any runtime.

Key changes:
- PersonaRecord.provider captures the LLM provider independently
- SPROUT_AGENT_MODEL works as a universal model fallback
- provider_env_var and model_env_var always injected at spawn time
- Pack import now stores resolved env vars (was always empty)
- runtime_env_vars() emits runtime-appropriate vars (SPROUT_AGENT_*
  vs GOOSE_*)
This commit is contained in:
Will Pfleger
2026-06-08 11:31:51 -04:00
parent 953f0902dc
commit 7cf61d55f9
19 changed files with 630 additions and 186 deletions
+1 -1
View File
@@ -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() {
+2 -2
View File
@@ -462,7 +462,7 @@ pub struct Config {
pub respond_to: RespondTo,
/// Validated allowlist of pubkey hex strings (used when respond_to == Allowlist).
pub respond_to_allowlist: HashSet<String>,
/// 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.
@@ -760,7 +760,7 @@ impl Config {
(
Some(persona.system_prompt),
persona.model,
persona.goose_env_vars,
persona.runtime_env_vars,
)
}
(Some(_), None) => {
@@ -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<String>,
pub(super) model: Option<String>,
}
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<Self> {
let raw = std::fs::read_to_string(path).ok()?;
let map: HashMap<String, serde_yaml::Value> = serde_yaml::from_str(&raw).ok()?;
Some(Self::from_map(&map))
}
pub(super) fn from_map(map: &HashMap<String, serde_yaml::Value>) -> 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<String, serde_yaml::Value>,
active_provider: &str,
) -> Option<(Option<String>, Option<String>)> {
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<String, serde_yaml::Value>, key: &str) -> Option<String> {
map.get(key)?
.as_str()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
fn goose_config_path() -> Option<PathBuf> {
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));
}
}
}
}
@@ -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;
@@ -94,6 +97,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.
//
@@ -103,23 +112,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
),
@@ -237,64 +249,11 @@ fn req(k: &str) -> Result<String, String> {
env(k).ok_or_else(|| format!("config: {k} required"))
}
#[derive(Default)]
struct GooseDatabricksConfig {
host: Option<String>,
model: Option<String>,
}
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<Self> {
let raw = std::fs::read_to_string(path).ok()?;
let map: HashMap<String, serde_yaml::Value> = serde_yaml::from_str(&raw).ok()?;
Some(Self::from_map(&map))
}
fn from_map(map: &HashMap<String, serde_yaml::Value>) -> 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<String, serde_yaml::Value>, key: &str) -> Option<String> {
map.get(key)?
.as_str()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
fn goose_config_path() -> Option<PathBuf> {
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<String> {
provider_model.or(universal_fallback).map(str::to_owned)
}
fn present_nonempty(v: Option<&str>) -> bool {
@@ -555,72 +514,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!(
@@ -727,4 +620,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());
}
}
+2 -2
View File
@@ -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<String> = persona
.goose_env_vars
.runtime_env_vars
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect();
+72 -17
View File
@@ -60,8 +60,8 @@ pub struct ResolvedPersona {
// Skills (bare names — reserved for future use, not yet wired)
pub skills: Vec<String>,
// 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<ResolvedPack, PackError> {
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<ResolvedHook
/// ACP is responsible for filtering based on operator precedence (level 1):
/// if the operator already set an env var, ACP skips injection so the
/// operator's value wins.
fn project_env_vars(persona: &LoadedPersona) -> 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();
+2 -2
View File
@@ -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")
+2 -2
View File
@@ -30,8 +30,8 @@ const rules = [
// Do not add to this list; split the file instead. Remove each entry as its
// file is broken up. Tracked as a follow-up.
const overrides = new Map([
["src-tauri/src/managed_agents/nest.rs", 1415],
["src-tauri/src/managed_agents/runtime.rs", 1387],
["src-tauri/src/managed_agents/nest.rs", 1420],
["src-tauri/src/managed_agents/runtime.rs", 1440],
["src-tauri/src/huddle/tts.rs", 1364],
["src/shared/api/tauri.ts", 1196],
["src-tauri/src/nostr_convert.rs", 1116],
@@ -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()
@@ -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: &[],
@@ -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,
@@ -502,6 +502,7 @@ fn built_in_persona_records(now: &str) -> Vec<PersonaRecord> {
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(),
})
@@ -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,
+108 -25
View File
@@ -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,
};
@@ -894,29 +894,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<String>)> = 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 persona_record: Option<PersonaRecord> = 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) = match persona_prompt_and_model {
Some((prompt, model)) => (Some(prompt), model),
None => (record.system_prompt.clone(), record.model.clone()),
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 {
@@ -930,10 +932,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 {
@@ -1156,6 +1162,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 {
use crate::managed_agents::known_acp_runtime;
@@ -1337,4 +1369,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"),
]
);
}
}
@@ -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,
@@ -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<String>,
/// 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<String>,
/// 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<String>,
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub name_pool: Vec<String>,
/// Environment variables for agents created from this persona.
#[serde(default)]
@@ -258,6 +265,8 @@ pub struct UpdatePersonaRequest {
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub name_pool: Vec<String>,
/// Environment variables for agents created from this persona.
///
@@ -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<EnvVarsValue>({});
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({
</p>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="persona-provider">
LLM Provider
</label>
<Input
autoCapitalize="none"
autoCorrect="off"
disabled={isPending}
id="persona-provider"
onChange={(event) => setProvider(event.target.value)}
placeholder="e.g. databricks, anthropic, openai"
spellCheck={false}
value={provider}
/>
<p className="text-xs text-muted-foreground">
Optional. Injected as the runtime's provider env var at agent
creation time. Leave blank for auto-detection or provider-locked
runtimes.
</p>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium"
+4
View File
@@ -54,6 +54,7 @@ type RawPersona = {
system_prompt: string;
runtime?: string | null;
model?: string | null;
provider?: string | null;
name_pool?: string[];
is_builtin: boolean;
is_active?: boolean;
@@ -70,6 +71,7 @@ function fromRawPersona(persona: RawPersona): AgentPersona {
systemPrompt: persona.system_prompt,
runtime: persona.runtime ?? null,
model: persona.model ?? null,
provider: persona.provider ?? null,
namePool: persona.name_pool ?? [],
isBuiltIn: persona.is_builtin,
isActive: persona.is_active ?? true,
@@ -94,6 +96,7 @@ export async function createPersona(
systemPrompt: input.systemPrompt,
runtime: input.runtime,
model: input.model,
provider: input.provider,
namePool: input.namePool ?? [],
envVars: input.envVars ?? {},
},
@@ -113,6 +116,7 @@ export async function updatePersona(
systemPrompt: input.systemPrompt,
runtime: input.runtime,
model: input.model,
provider: input.provider,
namePool: input.namePool ?? [],
// Send envVars only when caller explicitly provided it; omitting
// tells the backend "don't touch the stored env vars" so editing
+4
View File
@@ -461,6 +461,8 @@ export type AgentPersona = {
runtime: string | null;
/** Opaque, harness-specific model identifier string. Sprout stores and passes through without interpretation. */
model: string | null;
/** LLM inference provider (e.g. "databricks", "anthropic"). Injected as the runtime's provider env var at spawn time. */
provider: string | null;
namePool: string[];
isBuiltIn: boolean;
isActive: boolean;
@@ -479,6 +481,7 @@ export type CreatePersonaInput = {
systemPrompt: string;
runtime?: string;
model?: string;
provider?: string;
namePool?: string[];
envVars?: Record<string, string>;
};
@@ -490,6 +493,7 @@ export type UpdatePersonaInput = {
systemPrompt: string;
runtime?: string;
model?: string;
provider?: string;
namePool?: string[];
envVars?: Record<string, string>;
};