mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: provider/model selection for personas and runtime-aware env injection (#794)
This commit is contained in:
@@ -30,14 +30,15 @@ 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/commands/agents.rs", 1190],
|
||||
["src-tauri/src/managed_agents/nest.rs", 1417],
|
||||
["src-tauri/src/managed_agents/runtime.rs", 1387],
|
||||
["src-tauri/src/commands/agents.rs", 1208],
|
||||
["src-tauri/src/managed_agents/nest.rs", 1420],
|
||||
["src-tauri/src/managed_agents/runtime.rs", 1465],
|
||||
["src-tauri/src/managed_agents/persona_card.rs", 1050],
|
||||
["src-tauri/src/huddle/tts.rs", 1364],
|
||||
["src/shared/api/tauri.ts", 1196],
|
||||
["src-tauri/src/nostr_convert.rs", 1116],
|
||||
["src/shared/api/relayClientSession.ts", 1022],
|
||||
["src-tauri/src/migration.rs", 1130],
|
||||
["src-tauri/src/migration.rs", 1295],
|
||||
]);
|
||||
|
||||
await runFileSizeCheck({
|
||||
|
||||
@@ -131,6 +131,23 @@ fn build_deploy_payload(
|
||||
crate::managed_agents::resolve_persona_env(app, record.persona_id.as_deref())?;
|
||||
let merged_env = crate::managed_agents::merged_user_env(&persona_env, &record.env_vars);
|
||||
|
||||
// Resolve effective model/provider from the persona's structured fields.
|
||||
// Agent record's model takes precedence (user override via UI).
|
||||
let (effective_model, effective_provider) = if let Some(ref pid) = record.persona_id {
|
||||
let personas = load_personas(app).map_err(|e| {
|
||||
format!("failed to load personas for deploy payload model resolution: {e}")
|
||||
})?;
|
||||
let persona = personas.iter().find(|p| p.id == *pid);
|
||||
let model = record
|
||||
.model
|
||||
.clone()
|
||||
.or_else(|| persona.and_then(|p| p.model.clone()));
|
||||
let provider = persona.and_then(|p| p.provider.clone());
|
||||
(model, provider)
|
||||
} else {
|
||||
(record.model.clone(), None)
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"name": &record.name,
|
||||
"relay_url": &record.relay_url,
|
||||
@@ -139,7 +156,8 @@ fn build_deploy_payload(
|
||||
"agent_command": &record.agent_command,
|
||||
"agent_args": &record.agent_args,
|
||||
"system_prompt": &record.system_prompt,
|
||||
"model": &record.model,
|
||||
"model": effective_model,
|
||||
"provider": effective_provider,
|
||||
"turn_timeout_seconds": record.turn_timeout_seconds,
|
||||
"idle_timeout_seconds": record.idle_timeout_seconds,
|
||||
"max_turn_duration_seconds": record.max_turn_duration_seconds,
|
||||
|
||||
@@ -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()
|
||||
@@ -335,7 +339,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, runtime, model, name_pool) = {
|
||||
let (display_name, system_prompt, avatar_url, runtime, model, provider, name_pool) = {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
@@ -351,6 +355,7 @@ pub async fn export_persona_to_json(
|
||||
persona.avatar_url.clone(),
|
||||
persona.runtime.clone(),
|
||||
persona.model.clone(),
|
||||
persona.provider.clone(),
|
||||
persona.name_pool.clone(),
|
||||
)
|
||||
};
|
||||
@@ -361,6 +366,7 @@ pub async fn export_persona_to_json(
|
||||
avatar_url.as_deref(),
|
||||
runtime.as_deref(),
|
||||
model.as_deref(),
|
||||
provider.as_deref(),
|
||||
&name_pool,
|
||||
)?;
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ pub(crate) struct KnownAcpRuntime {
|
||||
pub commands: &'static [&'static str],
|
||||
pub aliases: &'static [&'static str],
|
||||
pub avatar_url: &'static str,
|
||||
/// MCP server binary for this runtime, or `None` for no MCP server.
|
||||
/// Legacy MCP server binary field. Vestigial — all agents now use sprout CLI
|
||||
/// directly. Will be removed when runtime discovery is simplified.
|
||||
pub mcp_command: Option<&'static str>,
|
||||
/// Whether to enable MCP hook tools (`_Stop`, `_PostCompact`) for this agent.
|
||||
pub mcp_hooks: bool,
|
||||
@@ -32,11 +33,13 @@ 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>,
|
||||
/// Whether this runtime handles model switching via ACP protocol natively.
|
||||
/// Currently unused — env var injection runs unconditionally regardless of
|
||||
/// this value. Retained as scaffolding for when ACP model switching matures.
|
||||
#[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)],
|
||||
}
|
||||
@@ -149,7 +152,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: &[],
|
||||
|
||||
@@ -14,6 +14,47 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Env var keys that are *derived* from the structured `PersonaRecord.provider`
|
||||
/// and `PersonaRecord.model` fields at spawn/deploy time. These must NOT be
|
||||
/// persisted in `PersonaRecord.env_vars` because they would shadow the
|
||||
/// structured fields after the user edits provider/model in the UI.
|
||||
///
|
||||
/// At local spawn time, `runtime_metadata_env_vars` re-derives these from the
|
||||
/// current structured fields, so they are always up-to-date. At remote deploy
|
||||
/// time, `build_deploy_payload` projects the structured fields directly.
|
||||
///
|
||||
/// Non-structured knobs (`GOOSE_TEMPERATURE`, `GOOSE_CONTEXT_LIMIT`) are NOT
|
||||
/// in this list — they have no structured counterpart and must be preserved.
|
||||
pub(crate) const DERIVED_PROVIDER_MODEL_ENV_KEYS: &[&str] = &[
|
||||
"GOOSE_MODEL",
|
||||
"GOOSE_PROVIDER",
|
||||
"SPROUT_AGENT_MODEL",
|
||||
"SPROUT_AGENT_PROVIDER",
|
||||
];
|
||||
|
||||
/// Returns `true` if `key` is a derived provider/model env key that should be
|
||||
/// filtered out of persisted `PersonaRecord.env_vars` at pack import time.
|
||||
pub(crate) fn is_derived_provider_model_key(key: &str) -> bool {
|
||||
DERIVED_PROVIDER_MODEL_ENV_KEYS
|
||||
.iter()
|
||||
.any(|k| k.eq_ignore_ascii_case(key))
|
||||
}
|
||||
|
||||
/// Strip derived provider/model env keys from a pack persona's `runtime_env_vars`
|
||||
/// before persisting them in `PersonaRecord.env_vars`.
|
||||
///
|
||||
/// The structured `PersonaRecord.provider` / `PersonaRecord.model` fields are
|
||||
/// the authoritative source of truth. Keeping the derived copies would cause
|
||||
/// stale env values to override updated structured fields at spawn/deploy time.
|
||||
pub(crate) fn filter_derived_provider_model_env_vars(
|
||||
env_vars: impl IntoIterator<Item = (String, String)>,
|
||||
) -> BTreeMap<String, String> {
|
||||
env_vars
|
||||
.into_iter()
|
||||
.filter(|(k, _)| !is_derived_provider_model_key(k))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Env var keys that Sprout sets itself and users must not override from
|
||||
/// the persona/agent env_vars UI. Three categories:
|
||||
///
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::{
|
||||
display_invalid_key, is_reserved_env_key, is_well_formed_env_key, merged_user_env,
|
||||
validate_user_env_keys, MAX_ENV_TOTAL_BYTES, MAX_ENV_VALUE_BYTES, RESERVED_ENV_KEYS,
|
||||
display_invalid_key, filter_derived_provider_model_env_vars, is_derived_provider_model_key,
|
||||
is_reserved_env_key, is_well_formed_env_key, merged_user_env, validate_user_env_keys,
|
||||
DERIVED_PROVIDER_MODEL_ENV_KEYS, MAX_ENV_TOTAL_BYTES, MAX_ENV_VALUE_BYTES, RESERVED_ENV_KEYS,
|
||||
};
|
||||
|
||||
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
|
||||
@@ -415,3 +416,88 @@ fn merged_env_drops_oversize_value() {
|
||||
assert!(!merged.contains_key("HUGE"));
|
||||
assert_eq!(merged.get("LEGIT").map(String::as_str), Some("v"));
|
||||
}
|
||||
|
||||
// ── derived provider/model key filter ──────────────────────────────
|
||||
//
|
||||
// Pack import must strip derived env keys (GOOSE_MODEL, GOOSE_PROVIDER,
|
||||
// SPROUT_AGENT_MODEL, SPROUT_AGENT_PROVIDER) so they don't shadow the
|
||||
// structured PersonaRecord.model / PersonaRecord.provider fields after
|
||||
// the user edits them in the UI.
|
||||
|
||||
#[test]
|
||||
fn is_derived_key_matches_all_known_keys() {
|
||||
for key in DERIVED_PROVIDER_MODEL_ENV_KEYS {
|
||||
assert!(
|
||||
is_derived_provider_model_key(key),
|
||||
"{key} should be recognized as derived"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_derived_key_is_case_insensitive() {
|
||||
assert!(is_derived_provider_model_key("goose_model"));
|
||||
assert!(is_derived_provider_model_key("Goose_Provider"));
|
||||
assert!(is_derived_provider_model_key("sprout_agent_model"));
|
||||
assert!(is_derived_provider_model_key("SPROUT_AGENT_PROVIDER"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_derived_key_does_not_match_unrelated_keys() {
|
||||
assert!(!is_derived_provider_model_key("GOOSE_TEMPERATURE"));
|
||||
assert!(!is_derived_provider_model_key("GOOSE_CONTEXT_LIMIT"));
|
||||
assert!(!is_derived_provider_model_key("ANTHROPIC_API_KEY"));
|
||||
assert!(!is_derived_provider_model_key("SPROUT_PRIVATE_KEY"));
|
||||
assert!(!is_derived_provider_model_key("MODEL"));
|
||||
assert!(!is_derived_provider_model_key("PROVIDER"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_derived_strips_provider_model_keys_preserves_rest() {
|
||||
let input = vec![
|
||||
(
|
||||
"GOOSE_MODEL".to_string(),
|
||||
"claude-sonnet-4-20250514".to_string(),
|
||||
),
|
||||
("GOOSE_PROVIDER".to_string(), "anthropic".to_string()),
|
||||
("SPROUT_AGENT_MODEL".to_string(), "gpt-4o".to_string()),
|
||||
("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()),
|
||||
("GOOSE_TEMPERATURE".to_string(), "0.7".to_string()),
|
||||
("ANTHROPIC_API_KEY".to_string(), "sk-test".to_string()),
|
||||
];
|
||||
let filtered = filter_derived_provider_model_env_vars(input);
|
||||
assert_eq!(filtered.len(), 2);
|
||||
assert_eq!(
|
||||
filtered.get("GOOSE_TEMPERATURE").map(String::as_str),
|
||||
Some("0.7")
|
||||
);
|
||||
assert_eq!(
|
||||
filtered.get("ANTHROPIC_API_KEY").map(String::as_str),
|
||||
Some("sk-test")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_derived_empty_input_returns_empty() {
|
||||
let filtered = filter_derived_provider_model_env_vars(std::iter::empty());
|
||||
assert!(filtered.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_derived_env_does_not_override_structured_fields() {
|
||||
// Documents that merged_user_env is transparent to derived keys — it
|
||||
// does NOT strip them. The defense is the import filter
|
||||
// (filter_derived_provider_model_env_vars) which prevents them from
|
||||
// being persisted in the first place. If a stale record somehow has
|
||||
// them, they flow through merged_user_env unchanged — the spawn-time
|
||||
// re-derivation from structured fields writes AFTER merged env.
|
||||
let persona_env = map(&[("GOOSE_MODEL", "stale-model"), ("LEGIT", "v")]);
|
||||
let merged = merged_user_env(&persona_env, &BTreeMap::new());
|
||||
// merged_user_env does NOT filter derived keys — that's by design.
|
||||
// The import filter is the boundary defense.
|
||||
assert_eq!(
|
||||
merged.get("GOOSE_MODEL").map(String::as_str),
|
||||
Some("stale-model")
|
||||
);
|
||||
assert_eq!(merged.get("LEGIT").map(String::as_str), Some("v"));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -15,6 +15,7 @@ pub struct ParsedPersonaPreview {
|
||||
pub avatar_data_url: Option<String>,
|
||||
pub runtime: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub provider: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub name_pool: Vec<String>,
|
||||
pub source_file: String,
|
||||
@@ -82,6 +83,7 @@ pub fn parse_png_persona(png_bytes: &[u8]) -> Result<ParsedPersonaPreview, Strin
|
||||
avatar_data_url,
|
||||
runtime: fields.runtime,
|
||||
model: fields.model,
|
||||
provider: fields.provider,
|
||||
name_pool: fields.name_pool,
|
||||
source_file: String::new(),
|
||||
})
|
||||
@@ -101,6 +103,7 @@ struct SproutPersonaFields {
|
||||
avatar_url: Option<String>,
|
||||
runtime: Option<String>,
|
||||
model: Option<String>,
|
||||
provider: Option<String>,
|
||||
name_pool: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -149,6 +152,15 @@ fn extract_sprout_fields(v: &Value) -> Result<SproutPersonaFields, String> {
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
// "llmProvider" is the LLM inference provider (e.g. "databricks", "anthropic").
|
||||
// Distinct from "runtime" (the ACP harness) and from the legacy "provider" key
|
||||
// (which mapped to runtime for backward compat).
|
||||
let provider = v
|
||||
.get("llmProvider")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
let name_pool = v
|
||||
.get("namePool")
|
||||
.and_then(|v| v.as_array())
|
||||
@@ -166,6 +178,7 @@ fn extract_sprout_fields(v: &Value) -> Result<SproutPersonaFields, String> {
|
||||
avatar_url,
|
||||
runtime,
|
||||
model,
|
||||
provider,
|
||||
name_pool,
|
||||
})
|
||||
}
|
||||
@@ -210,6 +223,7 @@ fn parse_chara_payload(b64: &str) -> Result<SproutPersonaFields, String> {
|
||||
avatar_url: None,
|
||||
runtime: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
name_pool: Vec::new(),
|
||||
})
|
||||
}
|
||||
@@ -228,6 +242,7 @@ pub fn parse_json_persona(json_bytes: &[u8]) -> Result<ParsedPersonaPreview, Str
|
||||
avatar_data_url: fields.avatar_url,
|
||||
runtime: fields.runtime,
|
||||
model: fields.model,
|
||||
provider: fields.provider,
|
||||
name_pool: fields.name_pool,
|
||||
source_file: String::new(),
|
||||
})
|
||||
@@ -239,6 +254,7 @@ pub fn encode_persona_json(
|
||||
avatar_url: Option<&str>,
|
||||
runtime: Option<&str>,
|
||||
model: Option<&str>,
|
||||
provider: Option<&str>,
|
||||
name_pool: &[String],
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let mut map = serde_json::Map::new();
|
||||
@@ -254,6 +270,9 @@ pub fn encode_persona_json(
|
||||
if let Some(m) = model {
|
||||
map.insert("model".to_string(), serde_json::json!(m));
|
||||
}
|
||||
if let Some(p) = provider {
|
||||
map.insert("llmProvider".to_string(), serde_json::json!(p));
|
||||
}
|
||||
if !name_pool.is_empty() {
|
||||
map.insert("namePool".to_string(), serde_json::json!(name_pool));
|
||||
}
|
||||
@@ -287,6 +306,7 @@ pub fn parse_md_persona(md_bytes: &[u8]) -> Result<ParsedPersonaPreview, String>
|
||||
avatar_data_url: None, // .persona.md avatars are paths, not data URIs
|
||||
runtime: config.runtime,
|
||||
model,
|
||||
provider: None, // .persona.md format does not carry llmProvider
|
||||
name_pool: Vec::new(),
|
||||
source_file: String::new(),
|
||||
})
|
||||
@@ -387,6 +407,7 @@ pub fn parse_zip_pack(zip_bytes: &[u8]) -> Result<ParsePersonaFilesResult, Strin
|
||||
avatar_data_url: None,
|
||||
runtime: p.runtime.clone(),
|
||||
model: p.model.clone(),
|
||||
provider: None, // persona packs do not carry llmProvider
|
||||
name_pool: Vec::new(),
|
||||
source_file: format!("{} ({})", p.name, resolved.name),
|
||||
})
|
||||
@@ -791,8 +812,6 @@ mod tests {
|
||||
assert!(err.contains("exceeds 100MB"));
|
||||
}
|
||||
|
||||
// --- JSON persona tests ---
|
||||
|
||||
#[test]
|
||||
fn parse_json_round_trip() {
|
||||
let bytes = encode_persona_json(
|
||||
@@ -801,6 +820,7 @@ mod tests {
|
||||
Some("https://example.com/ada.png"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
@@ -816,7 +836,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_json_round_trip_no_avatar() {
|
||||
let bytes = encode_persona_json("Bob", "You are Bob.", None, None, None, &[]).unwrap();
|
||||
let bytes =
|
||||
encode_persona_json("Bob", "You are Bob.", None, None, None, None, &[]).unwrap();
|
||||
let result = parse_json_persona(&bytes).unwrap();
|
||||
assert_eq!(result.display_name, "Bob");
|
||||
assert_eq!(result.system_prompt, "You are Bob.");
|
||||
@@ -826,8 +847,16 @@ mod tests {
|
||||
#[test]
|
||||
fn parse_json_round_trip_data_uri_avatar() {
|
||||
let data_uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==";
|
||||
let bytes = encode_persona_json("Carol", "You are Carol.", Some(data_uri), None, None, &[])
|
||||
.unwrap();
|
||||
let bytes = encode_persona_json(
|
||||
"Carol",
|
||||
"You are Carol.",
|
||||
Some(data_uri),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
let result = parse_json_persona(&bytes).unwrap();
|
||||
assert_eq!(result.display_name, "Carol");
|
||||
assert_eq!(result.avatar_data_url.as_deref(), Some(data_uri));
|
||||
@@ -841,6 +870,7 @@ mod tests {
|
||||
None,
|
||||
Some("goose"),
|
||||
Some("claude-sonnet-4"),
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
@@ -854,7 +884,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_json_round_trip_without_runtime_and_model() {
|
||||
let bytes = encode_persona_json("Bob", "You are Bob.", None, None, None, &[]).unwrap();
|
||||
let bytes =
|
||||
encode_persona_json("Bob", "You are Bob.", None, None, None, None, &[]).unwrap();
|
||||
let result = parse_json_persona(&bytes).unwrap();
|
||||
assert_eq!(result.display_name, "Bob");
|
||||
assert!(result.runtime.is_none());
|
||||
@@ -930,8 +961,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_zip_with_json() {
|
||||
let j1 = encode_persona_json("Alice", "Prompt A", None, None, None, &[]).unwrap();
|
||||
let j2 = encode_persona_json("Bob", "Prompt B", None, None, None, &[]).unwrap();
|
||||
let j1 = encode_persona_json("Alice", "Prompt A", None, None, None, None, &[]).unwrap();
|
||||
let j2 = encode_persona_json("Bob", "Prompt B", None, None, None, None, &[]).unwrap();
|
||||
let zip = make_test_zip(&[("alice.persona.json", &j1), ("bob.persona.json", &j2)]);
|
||||
let result = parse_zip_personas(&zip).unwrap();
|
||||
assert_eq!(result.personas.len(), 2);
|
||||
@@ -944,7 +975,7 @@ mod tests {
|
||||
fn parse_zip_mixed_png_and_json() {
|
||||
let png = make_test_persona_png("PngPersona", "PNG prompt");
|
||||
let json =
|
||||
encode_persona_json("JsonPersona", "JSON prompt", None, None, None, &[]).unwrap();
|
||||
encode_persona_json("JsonPersona", "JSON prompt", None, None, None, None, &[]).unwrap();
|
||||
let zip = make_test_zip(&[
|
||||
("persona.png", &png),
|
||||
("persona.json", &json),
|
||||
@@ -961,8 +992,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_zip_ignores_macos_resource_forks() {
|
||||
let j1 = encode_persona_json("Frank", "You are Frank.", None, None, None, &[]).unwrap();
|
||||
let j2 = encode_persona_json("Jackie", "You are Jackie.", None, None, None, &[]).unwrap();
|
||||
let j1 =
|
||||
encode_persona_json("Frank", "You are Frank.", None, None, None, None, &[]).unwrap();
|
||||
let j2 =
|
||||
encode_persona_json("Jackie", "You are Jackie.", None, None, None, None, &[]).unwrap();
|
||||
let zip = make_test_zip(&[
|
||||
("frank-costanza.persona.json", &j1),
|
||||
("jackie-chiles.persona.json", &j2),
|
||||
|
||||
@@ -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,17 @@ 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(),
|
||||
// Filter derived provider/model env keys at import time so stale
|
||||
// values don't shadow the structured fields after UI edits.
|
||||
env_vars: crate::managed_agents::env_vars::filter_derived_provider_model_env_vars(
|
||||
p.runtime_env_vars.iter().cloned(),
|
||||
),
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -864,6 +864,7 @@ pub fn spawn_agent_child(
|
||||
}
|
||||
}
|
||||
// Enable MCP hook tools (_Stop, _PostCompact) for agents that need them.
|
||||
// Uses "*" because build_mcp_servers() hard-codes the server name to "sprout-mcp".
|
||||
let runtime_meta = known_acp_runtime(&record.agent_command);
|
||||
if runtime_meta.is_some_and(|r| r.mcp_hooks) {
|
||||
command.env("MCP_HOOK_SERVERS", "*");
|
||||
@@ -899,29 +900,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 {
|
||||
@@ -935,10 +938,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 {
|
||||
@@ -1185,6 +1192,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;
|
||||
@@ -1375,4 +1408,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")]
|
||||
@@ -279,6 +284,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)]
|
||||
@@ -297,6 +304,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.
|
||||
///
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
//! `SPROUT_SHARE_IDENTITY=1` and `SPROUT_PRIVATE_KEY` is set. All dev
|
||||
//! instances share the same physical files — edits in any worktree are
|
||||
//! immediately visible to all others.
|
||||
//!
|
||||
//! **Provider reconciliation** (`reconcile_provider_mcp_commands`): Per-launch
|
||||
//! fix-up of `mcp_command` values in `managed-agents.json` against the
|
||||
//! discovery table. Ensures known providers always have their canonical
|
||||
//! `mcp_command`; unknown/custom agents are left untouched.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::Manager;
|
||||
@@ -697,6 +702,169 @@ mod tests {
|
||||
serde_json::from_str(&content).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_clears_mcp_command_for_goose() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_agents_json(
|
||||
dir.path(),
|
||||
&serde_json::json!([{
|
||||
"name": "Scout",
|
||||
"agent_command": "goose",
|
||||
"mcp_command": "sprout-mcp-server"
|
||||
}]),
|
||||
);
|
||||
reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json"));
|
||||
let records = read_agents_json(dir.path());
|
||||
assert_eq!(records[0]["mcp_command"], "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_clears_mcp_command_for_claude() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_agents_json(
|
||||
dir.path(),
|
||||
&serde_json::json!([{
|
||||
"name": "Claude Agent",
|
||||
"agent_command": "claude-agent-acp",
|
||||
"mcp_command": "sprout-mcp-server"
|
||||
}]),
|
||||
);
|
||||
reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json"));
|
||||
let records = read_agents_json(dir.path());
|
||||
assert_eq!(records[0]["mcp_command"], "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_preserves_sprout_dev_mcp() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_agents_json(
|
||||
dir.path(),
|
||||
&serde_json::json!([{
|
||||
"name": "Solo",
|
||||
"agent_command": "sprout-agent",
|
||||
"mcp_command": "sprout-dev-mcp"
|
||||
}]),
|
||||
);
|
||||
let before =
|
||||
std::fs::read_to_string(dir.path().join("agents/managed-agents.json")).unwrap();
|
||||
reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json"));
|
||||
let after = std::fs::read_to_string(dir.path().join("agents/managed-agents.json")).unwrap();
|
||||
assert_eq!(
|
||||
before, after,
|
||||
"file should not be rewritten when already correct"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_fixes_sprout_agent_if_stale() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_agents_json(
|
||||
dir.path(),
|
||||
&serde_json::json!([{
|
||||
"name": "Solo",
|
||||
"agent_command": "sprout-agent",
|
||||
"mcp_command": "sprout-mcp-server"
|
||||
}]),
|
||||
);
|
||||
reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json"));
|
||||
let records = read_agents_json(dir.path());
|
||||
assert_eq!(records[0]["mcp_command"], "sprout-dev-mcp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_leaves_unknown_agent_untouched() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_agents_json(
|
||||
dir.path(),
|
||||
&serde_json::json!([{
|
||||
"name": "Custom Bot",
|
||||
"agent_command": "my-custom-agent",
|
||||
"mcp_command": "my-custom-mcp"
|
||||
}]),
|
||||
);
|
||||
reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json"));
|
||||
let records = read_agents_json(dir.path());
|
||||
assert_eq!(records[0]["mcp_command"], "my-custom-mcp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_is_idempotent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_agents_json(
|
||||
dir.path(),
|
||||
&serde_json::json!([{
|
||||
"name": "Scout",
|
||||
"agent_command": "goose",
|
||||
"mcp_command": "sprout-mcp-server"
|
||||
}]),
|
||||
);
|
||||
let path = dir.path().join("agents/managed-agents.json");
|
||||
reconcile_mcp_commands_in_file(&path);
|
||||
let after_first = std::fs::read_to_string(&path).unwrap();
|
||||
reconcile_mcp_commands_in_file(&path);
|
||||
let after_second = std::fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(after_first, after_second);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_handles_mixed_records() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_agents_json(
|
||||
dir.path(),
|
||||
&serde_json::json!([
|
||||
{"name": "Scout", "agent_command": "goose", "mcp_command": "sprout-mcp-server"},
|
||||
{"name": "Claude", "agent_command": "claude-agent-acp", "mcp_command": "sprout-mcp-server"},
|
||||
{"name": "Solo", "agent_command": "sprout-agent", "mcp_command": "sprout-dev-mcp"},
|
||||
{"name": "Custom", "agent_command": "my-bot", "mcp_command": "my-mcp"},
|
||||
{"name": "Codex", "agent_command": "codex-acp", "mcp_command": "sprout-mcp-server"}
|
||||
]),
|
||||
);
|
||||
reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json"));
|
||||
let records = read_agents_json(dir.path());
|
||||
assert_eq!(records[0]["mcp_command"], "", "goose should be cleared");
|
||||
assert_eq!(records[1]["mcp_command"], "", "claude should be cleared");
|
||||
assert_eq!(
|
||||
records[2]["mcp_command"], "sprout-dev-mcp",
|
||||
"sprout-agent preserved"
|
||||
);
|
||||
assert_eq!(
|
||||
records[3]["mcp_command"], "my-mcp",
|
||||
"custom agent untouched"
|
||||
);
|
||||
assert_eq!(records[4]["mcp_command"], "", "codex should be cleared");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_adds_mcp_command_when_key_absent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_agents_json(
|
||||
dir.path(),
|
||||
&serde_json::json!([{
|
||||
"name": "Solo",
|
||||
"agent_command": "sprout-agent"
|
||||
}]),
|
||||
);
|
||||
reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json"));
|
||||
let records = read_agents_json(dir.path());
|
||||
assert_eq!(records[0]["mcp_command"], "sprout-dev-mcp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_treats_null_mcp_command_as_empty() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_agents_json(
|
||||
dir.path(),
|
||||
&serde_json::json!([{
|
||||
"name": "Solo",
|
||||
"agent_command": "sprout-agent",
|
||||
"mcp_command": null
|
||||
}]),
|
||||
);
|
||||
reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json"));
|
||||
let records = read_agents_json(dir.path());
|
||||
assert_eq!(records[0]["mcp_command"], "sprout-dev-mcp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_creates_packs_directory_symlink() {
|
||||
let (_parent, canonical, worktree) = setup_sync_layout();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -31,6 +31,7 @@ test("duplicatePersonaDialogState copies persona fields into a new draft", () =>
|
||||
systemPrompt: "Be direct.",
|
||||
runtime: "provider-a",
|
||||
model: "model-a",
|
||||
provider: null,
|
||||
isBuiltIn: false,
|
||||
isActive: true,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
@@ -43,6 +44,7 @@ test("duplicatePersonaDialogState copies persona fields into a new draft", () =>
|
||||
systemPrompt: "Be direct.",
|
||||
runtime: "provider-a",
|
||||
model: "model-a",
|
||||
provider: undefined,
|
||||
namePool: [],
|
||||
envVars: {},
|
||||
});
|
||||
@@ -82,6 +84,7 @@ test("editPersonaDialogState preserves the persona id for updates", () => {
|
||||
systemPrompt: "Keep it weird.",
|
||||
runtime: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
isBuiltIn: true,
|
||||
isActive: true,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
@@ -98,6 +101,7 @@ test("editPersonaDialogState preserves the persona id for updates", () => {
|
||||
systemPrompt: "Keep it weird.",
|
||||
runtime: undefined,
|
||||
model: undefined,
|
||||
provider: undefined,
|
||||
namePool: [],
|
||||
envVars: {},
|
||||
});
|
||||
@@ -132,6 +136,8 @@ test("importPersonaDialogState maps parsed persona previews into create drafts",
|
||||
systemPrompt: "Imported prompt",
|
||||
runtime: null,
|
||||
model: "model-b",
|
||||
provider: null,
|
||||
namePool: [],
|
||||
sourceFile: "import.persona.json",
|
||||
});
|
||||
|
||||
@@ -142,5 +148,83 @@ test("importPersonaDialogState maps parsed persona previews into create drafts",
|
||||
systemPrompt: "Imported prompt",
|
||||
runtime: undefined,
|
||||
model: "model-b",
|
||||
provider: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
// ── Provider round-trip tests ─────────────────────────────────────────────────
|
||||
|
||||
test("editPersonaDialogState preserves provider=databricks", () => {
|
||||
const state = editPersonaDialogState({
|
||||
id: "persona-provider",
|
||||
displayName: "DB Agent",
|
||||
avatarUrl: null,
|
||||
systemPrompt: "Use databricks.",
|
||||
runtime: "goose",
|
||||
model: "dbrx",
|
||||
provider: "databricks",
|
||||
isBuiltIn: false,
|
||||
isActive: true,
|
||||
namePool: [],
|
||||
envVars: {},
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-02T00:00:00Z",
|
||||
});
|
||||
|
||||
assert.equal(state.initialValues.provider, "databricks");
|
||||
});
|
||||
|
||||
test("editPersonaDialogState maps provider=null to undefined", () => {
|
||||
const state = editPersonaDialogState({
|
||||
id: "persona-no-provider",
|
||||
displayName: "Plain",
|
||||
avatarUrl: null,
|
||||
systemPrompt: "No provider.",
|
||||
runtime: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
isBuiltIn: false,
|
||||
isActive: true,
|
||||
namePool: [],
|
||||
envVars: {},
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-02T00:00:00Z",
|
||||
});
|
||||
|
||||
assert.equal(state.initialValues.provider, undefined);
|
||||
});
|
||||
|
||||
test("duplicatePersonaDialogState preserves provider=databricks", () => {
|
||||
const state = duplicatePersonaDialogState({
|
||||
id: "persona-dup-provider",
|
||||
displayName: "DB Agent",
|
||||
avatarUrl: null,
|
||||
systemPrompt: "Use databricks.",
|
||||
runtime: "goose",
|
||||
model: "dbrx",
|
||||
provider: "databricks",
|
||||
isBuiltIn: false,
|
||||
isActive: true,
|
||||
namePool: [],
|
||||
envVars: {},
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-02T00:00:00Z",
|
||||
});
|
||||
|
||||
assert.equal(state.initialValues.provider, "databricks");
|
||||
});
|
||||
|
||||
test("importPersonaDialogState preserves provider=anthropic", () => {
|
||||
const state = importPersonaDialogState({
|
||||
displayName: "Imported With Provider",
|
||||
avatarDataUrl: null,
|
||||
systemPrompt: "Anthropic agent.",
|
||||
runtime: "goose",
|
||||
model: "claude-sonnet",
|
||||
provider: "anthropic",
|
||||
namePool: [],
|
||||
sourceFile: "provider-test.persona.json",
|
||||
});
|
||||
|
||||
assert.equal(state.initialValues.provider, "anthropic");
|
||||
});
|
||||
|
||||
@@ -44,6 +44,7 @@ export function duplicatePersonaDialogState(
|
||||
systemPrompt: persona.systemPrompt,
|
||||
runtime: persona.runtime ?? undefined,
|
||||
model: persona.model ?? undefined,
|
||||
provider: persona.provider ?? undefined,
|
||||
// Carry envVars and namePool into the duplicate. Without this, a
|
||||
// duplicated persona that relies on an API key in env_vars would
|
||||
// silently fail at spawn until the user re-entered every credential.
|
||||
@@ -69,6 +70,7 @@ export function editPersonaDialogState(
|
||||
systemPrompt: persona.systemPrompt,
|
||||
runtime: persona.runtime ?? undefined,
|
||||
model: persona.model ?? undefined,
|
||||
provider: persona.provider ?? undefined,
|
||||
// Seed both namePool and envVars from the loaded persona so editing
|
||||
// unrelated fields doesn't submit an empty value that wipes them.
|
||||
// (Persona update treats Some(empty) as "clear all" intentionally;
|
||||
@@ -92,6 +94,7 @@ export function importPersonaDialogState(
|
||||
systemPrompt: persona.systemPrompt,
|
||||
runtime: persona.runtime ?? undefined,
|
||||
model: persona.model ?? undefined,
|
||||
provider: persona.provider ?? undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ type RawParsedPersonaPreview = {
|
||||
avatar_data_url: string | null;
|
||||
runtime: string | null;
|
||||
model: string | null;
|
||||
provider: string | null;
|
||||
name_pool?: string[];
|
||||
source_file: string;
|
||||
};
|
||||
@@ -33,6 +34,7 @@ export type ParsedPersonaPreview = {
|
||||
avatarDataUrl: string | null;
|
||||
runtime: string | null;
|
||||
model: string | null;
|
||||
provider: string | null;
|
||||
namePool: string[];
|
||||
sourceFile: string;
|
||||
};
|
||||
@@ -54,6 +56,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 +73,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 +98,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 +118,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
|
||||
@@ -151,6 +157,7 @@ export async function parsePersonaFiles(
|
||||
avatarDataUrl: p.avatar_data_url,
|
||||
runtime: p.runtime,
|
||||
model: p.model,
|
||||
provider: p.provider,
|
||||
namePool: p.name_pool ?? [],
|
||||
sourceFile: p.source_file,
|
||||
})),
|
||||
|
||||
@@ -469,6 +469,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;
|
||||
@@ -487,6 +489,7 @@ export type CreatePersonaInput = {
|
||||
systemPrompt: string;
|
||||
runtime?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
namePool?: string[];
|
||||
envVars?: Record<string, string>;
|
||||
};
|
||||
@@ -498,6 +501,7 @@ export type UpdatePersonaInput = {
|
||||
systemPrompt: string;
|
||||
runtime?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
namePool?: string[];
|
||||
envVars?: Record<string, string>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user