Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/sprout-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
4 changes: 2 additions & 2 deletions crates/sprout-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -760,7 +760,7 @@ impl Config {
(
Some(persona.system_prompt),
persona.model,
persona.goose_env_vars,
persona.runtime_env_vars,
)
}
(Some(_), None) => {
Expand Down
46 changes: 43 additions & 3 deletions crates/sprout-agent/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,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.
//
Expand All @@ -102,19 +108,28 @@ 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".to_string())?,
resolve_model(databricks_model.as_deref(), sprout_agent_model.as_deref())
.ok_or_else(|| "config: DATABRICKS_MODEL required".to_string())?,
databricks_host.ok_or_else(|| "config: DATABRICKS_HOST required".to_string())?,
OpenAiApi::Chat, // Databricks invocations is chat-shaped
),
Expand Down Expand Up @@ -232,6 +247,13 @@ fn req(k: &str) -> Result<String, String> {
env(k).ok_or_else(|| format!("config: {k} required"))
}

/// 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 {
v.map(str::trim).is_some_and(|s| !s.is_empty())
}
Expand Down Expand Up @@ -596,4 +618,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());
}
}
4 changes: 2 additions & 2 deletions crates/sprout-cli/src/commands/pack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
89 changes: 72 additions & 17 deletions crates/sprout-persona/src/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -246,7 +246,7 @@ fn resolve_one_persona(
mcp_servers,
hooks,
skills: lp.skills.clone(),
goose_env_vars,
runtime_env_vars,
}
}

Expand Down Expand Up @@ -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()));
}
Expand Down Expand Up @@ -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");
Expand All @@ -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");
Expand All @@ -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");
Expand All @@ -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]
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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();
Expand Down
Loading