Skip to content
Merged
6 changes: 3 additions & 3 deletions crates/sprout-cli/src/commands/pack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,11 @@ pub fn cmd_inspect(path: &str) -> Result<(), CliError> {
println!(" Display: {}", persona.display_name);
println!(" Description: {}", persona.description);

if let Some(ref provider) = persona.provider {
if let Some(ref llm_provider) = persona.llm_provider {
if let Some(ref model) = persona.model {
println!(" Model: {provider}:{model}");
println!(" Model: {llm_provider}:{model}");
} else {
println!(" Provider: {provider}");
println!(" Provider: {llm_provider}");
}
} else if let Some(ref model) = persona.model {
println!(" Model: {model}");
Expand Down
4 changes: 4 additions & 0 deletions crates/sprout-persona/src/pack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ pub struct LoadedPersona {
pub description: String,
pub avatar: Option<String>,
pub model: Option<String>,
/// Preferred ACP runtime ID from the persona config (e.g., 'goose', 'claude').
pub runtime: Option<String>,
pub temperature: Option<f64>,
pub max_context_tokens: Option<u64>,
pub subscribe: Vec<String>,
Expand Down Expand Up @@ -440,6 +442,7 @@ fn parse_persona_file(
description: pc.description,
avatar: pc.avatar,
model: resolved.model,
runtime: pc.runtime.clone(),
temperature: resolved.temperature,
max_context_tokens: resolved.max_context_tokens,
subscribe: resolved.subscribe.unwrap_or_default(),
Expand Down Expand Up @@ -634,6 +637,7 @@ You are Berry, a fast and direct worker.
description: String::new(),
avatar: None,
model: None,
runtime: None,
temperature: None,
max_context_tokens: None,
subscribe: vec![],
Expand Down
7 changes: 7 additions & 0 deletions crates/sprout-persona/src/persona.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,11 @@ pub struct PersonaConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,

/// Preferred ACP runtime ID (e.g., 'goose', 'claude'). Maps to PersonaRecord.runtime during
/// pack import.
#[serde(skip_serializing_if = "Option::is_none")]
pub runtime: Option<String>,

#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f64>,

Expand Down Expand Up @@ -200,6 +205,7 @@ struct Frontmatter {
#[serde(alias = "respond_to")]
triggers: Option<RespondTo>,
model: Option<String>,
runtime: Option<String>,
temperature: Option<f64>,
max_context_tokens: Option<u64>,
thread_replies: Option<bool>,
Expand Down Expand Up @@ -260,6 +266,7 @@ pub fn parse_persona_md(content: &str) -> Result<PersonaConfig, PersonaError> {
subscribe: fm.subscribe,
triggers: fm.triggers,
model: fm.model,
runtime: fm.runtime,
temperature: fm.temperature,
max_context_tokens: fm.max_context_tokens,
thread_replies: fm.thread_replies,
Expand Down
26 changes: 16 additions & 10 deletions crates/sprout-persona/src/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,12 @@ pub struct ResolvedPersona {

// → Config.model (plain model ID, post-split)
pub model: Option<String>,
// → PersonaRecord.provider (for desktop display / GOOSE_PROVIDER env)
pub provider: Option<String>,
/// LLM inference provider extracted from the model string colon prefix (e.g., 'databricks'
/// from 'databricks:model-id'). Flows into harness-specific env vars (GOOSE_PROVIDER) only.
pub llm_provider: Option<String>,
/// Preferred ACP runtime ID from the persona config (e.g., 'goose', 'claude'). Maps to
/// PersonaRecord.runtime during pack import.
pub runtime: Option<String>,
pub temperature: Option<f64>,
pub max_context_tokens: Option<u64>,

Expand Down Expand Up @@ -200,7 +204,7 @@ fn resolve_one_persona(
let system_prompt = compose_prompt(&lp.prompt, pack_instructions);

// Split "provider:model-id" into separate fields (V3 contract).
let (provider, model) = match lp.model.as_deref() {
let (llm_provider, model) = match lp.model.as_deref() {
Some(s) if !s.trim().is_empty() => {
let (prov, id) = split_model(s);
(
Expand Down Expand Up @@ -231,7 +235,8 @@ fn resolve_one_persona(
version,
system_prompt,
model,
provider,
llm_provider,
runtime: lp.runtime.clone(),
temperature: lp.temperature,
max_context_tokens: lp.max_context_tokens,
subscribe: lp.subscribe.clone(),
Expand Down Expand Up @@ -642,7 +647,7 @@ mod tests {
assert_eq!(p.name, "bot");
assert_eq!(p.system_prompt, "You are Bot.\n");
assert!(p.model.is_none());
assert!(p.provider.is_none());
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());
Expand Down Expand Up @@ -685,7 +690,7 @@ mod tests {

// Model split into separate fields (V3 contract)
assert_eq!(p.model.as_deref(), Some("claude-sonnet-4-20250514"));
assert_eq!(p.provider.as_deref(), Some("anthropic"));
assert_eq!(p.llm_provider.as_deref(), Some("anthropic"));

// Env vars projected
let env_map: HashMap<&str, &str> = p
Expand Down Expand Up @@ -740,10 +745,10 @@ mod tests {

// pip overrides model
assert_eq!(pip.model.as_deref(), Some("claude-4-opus-20250514"));
assert_eq!(pip.provider.as_deref(), Some("anthropic"));
assert_eq!(pip.llm_provider.as_deref(), Some("anthropic"));
// lep inherits model from defaults
assert_eq!(lep.model.as_deref(), Some("claude-sonnet-4-20250514"));
assert_eq!(lep.provider.as_deref(), Some("anthropic"));
assert_eq!(lep.llm_provider.as_deref(), Some("anthropic"));

// pip inherits temperature from defaults
assert_eq!(pip.temperature, Some(0.7));
Expand Down Expand Up @@ -835,7 +840,7 @@ mod tests {
let pack = resolve_pack(dir).unwrap();
let p = &pack.personas[0];
assert_eq!(p.model.as_deref(), Some("gpt-4o"));
assert_eq!(p.provider.as_deref(), Some("openai"));
assert_eq!(p.llm_provider.as_deref(), Some("openai"));
}

#[test]
Expand All @@ -859,7 +864,7 @@ mod tests {
let pack = resolve_pack(dir).unwrap();
let p = &pack.personas[0];
assert_eq!(p.model.as_deref(), Some("gpt-4o"));
assert!(p.provider.is_none());
assert!(p.llm_provider.is_none());
}

// ── Test helpers ──────────────────────────────────────────────────────
Expand All @@ -876,6 +881,7 @@ mod tests {
description: "A test persona.".into(),
avatar: None,
model: model.map(str::to_owned),
runtime: None,
temperature,
max_context_tokens,
subscribe: vec![],
Expand Down
10 changes: 5 additions & 5 deletions crates/sprout-persona/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -426,12 +426,12 @@ fn resolve_full_pipeline() {
assert_eq!(pip.description, "Orchestration agent");
assert_eq!(pip.version, "1.0.0"); // defaults to pack version

// Model split: "anthropic:claude-4-opus-20250514" → provider + model
assert_eq!(pip.provider.as_deref(), Some("anthropic"));
// Model split: "anthropic:claude-4-opus-20250514" → llm_provider + model
assert_eq!(pip.llm_provider.as_deref(), Some("anthropic"));
assert_eq!(pip.model.as_deref(), Some("claude-4-opus-20250514"));

// Lep inherits pack default model
assert_eq!(lep.provider.as_deref(), Some("anthropic"));
assert_eq!(lep.llm_provider.as_deref(), Some("anthropic"));
assert_eq!(lep.model.as_deref(), Some("claude-sonnet-4-20250514"));

// System prompt composed: persona body + pack instructions
Expand Down Expand Up @@ -544,12 +544,12 @@ fn resolve_multi_persona_pack() {
.unwrap();

// Alpha overrides model and temperature
assert_eq!(alpha.provider.as_deref(), Some("anthropic"));
assert_eq!(alpha.llm_provider.as_deref(), Some("anthropic"));
assert_eq!(alpha.model.as_deref(), Some("claude-sonnet-4-20250514"));
assert_eq!(alpha.temperature, Some(0.9));

// Beta inherits all defaults
assert_eq!(beta.provider.as_deref(), Some("openai"));
assert_eq!(beta.llm_provider.as_deref(), Some("openai"));
assert_eq!(beta.model.as_deref(), Some("gpt-4o"));
assert_eq!(beta.temperature, Some(0.5));
assert!(beta.thread_replies);
Expand Down
2 changes: 1 addition & 1 deletion desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const overrides = new Map([
["src/shared/api/tauri.ts", 1196],
["src-tauri/src/nostr_convert.rs", 1116],
["src/shared/api/relayClientSession.ts", 1022],
["src-tauri/src/migration.rs", 1005],
["src-tauri/src/migration.rs", 1130],
]);

await runFileSizeCheck({
Expand Down
32 changes: 16 additions & 16 deletions desktop/src-tauri/src/commands/agent_discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use tauri::State;
use crate::{
app_state::AppState,
managed_agents::{
command_availability, AcpProviderCatalogEntry, DiscoverManagedAgentPrereqsRequest,
command_availability, AcpRuntimeCatalogEntry, DiscoverManagedAgentPrereqsRequest,
InstallRuntimeResult, InstallStepResult, ManagedAgentPrereqsInfo, RelayAgentInfo,
DEFAULT_ACP_COMMAND,
},
Expand All @@ -20,29 +20,29 @@ fn active_installs() -> &'static std::sync::Mutex<std::collections::HashSet<Stri
}

#[tauri::command]
pub fn discover_acp_providers() -> Vec<AcpProviderCatalogEntry> {
pub fn discover_acp_providers() -> Vec<AcpRuntimeCatalogEntry> {
crate::managed_agents::clear_resolve_cache();
crate::managed_agents::discover_acp_providers()
crate::managed_agents::discover_acp_runtimes()
}

#[tauri::command]
pub async fn install_acp_runtime(provider_id: String) -> Result<InstallRuntimeResult, String> {
tokio::task::spawn_blocking(move || install_acp_runtime_blocking(&provider_id))
pub async fn install_acp_runtime(runtime_id: String) -> Result<InstallRuntimeResult, String> {
tokio::task::spawn_blocking(move || install_acp_runtime_blocking(&runtime_id))
.await
.map_err(|e| format!("install task panicked: {e}"))?
}

/// Err(_) = infrastructure failure (panic, concurrency guard).
/// Ok({success: false}) = an install step failed (stderr captured in steps).
fn install_acp_runtime_blocking(provider_id: &str) -> Result<InstallRuntimeResult, String> {
// Prevent concurrent installs for the same provider.
fn install_acp_runtime_blocking(runtime_id: &str) -> Result<InstallRuntimeResult, String> {
// Prevent concurrent installs for the same runtime.
{
let mut set = active_installs()
.lock()
.map_err(|_| "install lock poisoned".to_string())?;
if !set.insert(provider_id.to_string()) {
if !set.insert(runtime_id.to_string()) {
return Err(format!(
"an install is already in progress for {provider_id}"
"an install is already in progress for {runtime_id}"
));
}
}
Expand All @@ -55,17 +55,17 @@ fn install_acp_runtime_blocking(provider_id: &str) -> Result<InstallRuntimeResul
}
}
}
let _guard = Guard(provider_id.to_string());
let _guard = Guard(runtime_id.to_string());

let provider = crate::managed_agents::known_acp_provider_exact(provider_id)
.ok_or_else(|| format!("unknown provider: {provider_id}"))?;
let runtime = crate::managed_agents::known_acp_runtime_exact(runtime_id)
.ok_or_else(|| format!("unknown runtime: {runtime_id}"))?;

let mut steps = Vec::new();

// Phase 1: Install CLI if missing and commands are available.
if let Some(cli) = provider.underlying_cli {
if let Some(cli) = runtime.underlying_cli {
if crate::managed_agents::resolve_command(cli).is_none() {
for cmd in provider.cli_install_commands {
for cmd in runtime.cli_install_commands {
let result = run_install_command("cli", cmd);
let success = result.success;
steps.push(result);
Expand All @@ -80,12 +80,12 @@ fn install_acp_runtime_blocking(provider_id: &str) -> Result<InstallRuntimeResul
}

// Phase 2: Install adapter if missing and commands are available.
let adapter_found = provider
let adapter_found = runtime
.commands
.iter()
.any(|cmd| crate::managed_agents::resolve_command(cmd).is_some());
if !adapter_found {
for cmd in provider.adapter_install_commands {
for cmd in runtime.adapter_install_commands {
let result = run_install_command("adapter", cmd);
let success = result.success;
steps.push(result);
Expand Down
15 changes: 9 additions & 6 deletions desktop/src-tauri/src/commands/agent_models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use crate::{
app_state::AppState,
managed_agents::{
build_managed_agent_summary, default_agent_workdir, find_managed_agent_mut,
load_managed_agents, managed_agent_avatar_url, missing_command_message,
known_acp_runtime, load_managed_agents, managed_agent_avatar_url, missing_command_message,
normalize_agent_args, resolve_command, save_managed_agents, sync_managed_agent_processes,
try_regenerate_nest, AgentModelInfo, AgentModelsResponse, UpdateManagedAgentRequest,
UpdateManagedAgentResponse,
Expand Down Expand Up @@ -84,11 +84,14 @@ pub async fn get_agent_models(
cmd.arg("models")
.arg("--json")
.env("SPROUT_ACP_AGENT_COMMAND", &agent_command)
.env("SPROUT_ACP_AGENT_ARGS", agent_args.join(","))
.env(
"GOOSE_MODE",
std::env::var("GOOSE_MODE").unwrap_or_else(|_| "auto".into()),
);
.env("SPROUT_ACP_AGENT_ARGS", agent_args.join(","));
if let Some(meta) = known_acp_runtime(&agent_command) {
for (key, value) in meta.default_env {
if std::env::var(key).is_err() {
cmd.env(key, value);
}
}
}
// User env layering — written LAST so it overrides any Sprout-set env above.
for (k, v) in &merged_env {
cmd.env(k, v);
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,7 @@ pub async fn create_managed_agent(
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or_else(
|| match crate::managed_agents::known_acp_provider(&agent_command) {
|| match crate::managed_agents::known_acp_runtime(&agent_command) {
Some(p) => p.mcp_command.unwrap_or("").to_string(),
None => String::new(),
},
Expand Down
14 changes: 7 additions & 7 deletions desktop/src-tauri/src/commands/personas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ pub fn create_persona(
let display_name = trim_required(&input.display_name, "Display name")?;
let system_prompt = trim_required(&input.system_prompt, "System prompt")?;
let avatar_url = trim_optional(input.avatar_url);
let provider = trim_optional(input.provider);
let runtime = trim_optional(input.runtime);
let model = trim_optional(input.model);
let now = now_iso();

Expand All @@ -72,7 +72,7 @@ pub fn create_persona(
display_name,
avatar_url,
system_prompt,
provider,
runtime,
model,
name_pool,
is_builtin: false,
Expand All @@ -98,7 +98,7 @@ pub fn update_persona(
let display_name = trim_required(&input.display_name, "Display name")?;
let system_prompt = trim_required(&input.system_prompt, "System prompt")?;
let avatar_url = trim_optional(input.avatar_url);
let provider = trim_optional(input.provider);
let runtime = trim_optional(input.runtime);
let model = trim_optional(input.model);

let _store_guard = state
Expand All @@ -117,7 +117,7 @@ pub fn update_persona(
persona.display_name = display_name;
persona.avatar_url = avatar_url;
persona.system_prompt = system_prompt;
persona.provider = provider;
persona.runtime = runtime;
persona.model = model;
persona.name_pool = input
.name_pool
Expand Down Expand Up @@ -335,7 +335,7 @@ pub async fn export_persona_to_json(
// forked, distributed), and bundling API keys / credentials in them
// would be a significant footgun. Users who import a card and need
// credentials must supply them post-import via the persona dialog.
let (display_name, system_prompt, avatar_url, provider, model, name_pool) = {
let (display_name, system_prompt, avatar_url, runtime, model, name_pool) = {
let _store_guard = state
.managed_agents_store_lock
.lock()
Expand All @@ -349,7 +349,7 @@ pub async fn export_persona_to_json(
persona.display_name.clone(),
persona.system_prompt.clone(),
persona.avatar_url.clone(),
persona.provider.clone(),
persona.runtime.clone(),
persona.model.clone(),
persona.name_pool.clone(),
)
Expand All @@ -359,7 +359,7 @@ pub async fn export_persona_to_json(
&display_name,
&system_prompt,
avatar_url.as_deref(),
provider.as_deref(),
runtime.as_deref(),
model.as_deref(),
&name_pool,
)?;
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,7 @@ pub fn run() {
// restore_managed_agents_on_launch (which reads managed-agents.json).
migration::sync_shared_agent_data(&app_handle);
migration::reconcile_persona_pack_paths(&app_handle);
migration::migrate_persona_provider_to_runtime(&app_handle);

// Resolve persisted identity key (env var → file → generate+save).
// This is fatal — the app should not start with an ephemeral identity
Expand Down
Loading