Skip to content

Commit d07385f

Browse files
feat: Markdown-based persona packs (crate + ACP + desktop) (#297)
1 parent 5e08935 commit d07385f

47 files changed

Lines changed: 8324 additions & 56 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ members = [
1717
"crates/sprout-media",
1818
"crates/sprout-cli",
1919
"crates/sprout-sdk",
20+
"crates/sprout-persona",
2021
]
2122
exclude = ["desktop/src-tauri"]
2223
resolver = "2"

crates/sprout-acp/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ path = "src/main.rs"
1515
# Internal
1616
sprout-core = { workspace = true }
1717
sprout-sdk = { workspace = true }
18+
sprout-persona = { path = "../sprout-persona" }
1819

1920
# Nostr
2021
nostr = { workspace = true }

crates/sprout-acp/src/acp.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,11 @@ impl AcpClient {
174174
/// Spawn the agent binary as a subprocess and connect to its stdio pipes.
175175
///
176176
/// After spawning, call [`initialize`](Self::initialize) before any other method.
177-
pub async fn spawn(command: &str, args: &[String]) -> Result<Self, AcpError> {
177+
pub async fn spawn(
178+
command: &str,
179+
args: &[String],
180+
extra_env: &[(String, String)],
181+
) -> Result<Self, AcpError> {
178182
use std::process::Stdio;
179183

180184
let mut cmd = tokio::process::Command::new(command);
@@ -187,6 +191,14 @@ impl AcpClient {
187191
// Callers MUST still call shutdown().await for guaranteed cleanup.
188192
.kill_on_drop(true);
189193

194+
// Per-persona env vars (e.g., GOOSE_PROVIDER, GOOSE_MODEL).
195+
// Only injected if not already set in parent env (operator precedence).
196+
for (key, value) in extra_env {
197+
if std::env::var(key).is_err() {
198+
cmd.env(key, value);
199+
}
200+
}
201+
190202
// Spawn the agent in its own process group so SIGKILL doesn't propagate
191203
// to the harness's own process group on Unix.
192204
// tokio::process::Command::process_group is a stable tokio API (no extra imports needed).
@@ -1614,7 +1626,7 @@ mod tests {
16141626
// ── Async integration tests with real subprocess ──────────────────────
16151627

16161628
async fn spawn_script(script: &str) -> AcpClient {
1617-
AcpClient::spawn("bash", &["-c".into(), script.into()])
1629+
AcpClient::spawn("bash", &["-c".into(), script.into()], &[])
16181630
.await
16191631
.expect("failed to spawn test script")
16201632
}

crates/sprout-acp/src/config.rs

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,15 @@ pub struct CliArgs {
349349
/// Owner pubkey is always implicitly included.
350350
#[arg(long, env = "SPROUT_ACP_RESPOND_TO_ALLOWLIST", value_delimiter = ',')]
351351
pub respond_to_allowlist: Option<Vec<String>>,
352+
353+
/// Path to a persona pack directory. Used with --persona-name to configure
354+
/// the agent from a .persona.md pack instead of CLI flags.
355+
#[arg(long, env = "SPROUT_ACP_PERSONA_PACK")]
356+
pub persona_pack: Option<PathBuf>,
357+
358+
/// Name of the persona within the pack to use. Required when --persona-pack is set.
359+
#[arg(long, env = "SPROUT_ACP_PERSONA_NAME")]
360+
pub persona_name: Option<String>,
352361
}
353362

354363
// ── Merged NIP-01 filter ──────────────────────────────────────────────────────
@@ -400,6 +409,9 @@ pub struct Config {
400409
pub respond_to: RespondTo,
401410
/// Validated allowlist of pubkey hex strings (used when respond_to == Allowlist).
402411
pub respond_to_allowlist: HashSet<String>,
412+
/// Per-persona env vars to inject at agent spawn time (e.g., GOOSE_PROVIDER, GOOSE_MODEL).
413+
/// Populated from persona pack resolution. Empty when no pack is configured.
414+
pub persona_env_vars: Vec<(String, String)>,
403415
}
404416

405417
/// Validate and deduplicate allowlist entries: each must be exactly 64 hex chars.
@@ -507,7 +519,7 @@ impl Config {
507519
.replace_range(.., &"0".repeat(args.private_key.len()));
508520
args.private_key.clear();
509521

510-
let system_prompt = if let Some(text) = args.system_prompt {
522+
let mut system_prompt = if let Some(text) = args.system_prompt {
511523
Some(text)
512524
} else if let Some(ref path) = args.system_prompt_file {
513525
Some(std::fs::read_to_string(path)?)
@@ -642,6 +654,54 @@ impl Config {
642654
HashSet::new()
643655
};
644656

657+
// ── Persona pack resolution ──────────────────────────────────────────
658+
//
659+
// Precedence: CLI/env args > persona values > built-in defaults.
660+
// Persona fills in what's missing. Explicit flags always win.
661+
let (persona_system_prompt, persona_model, persona_env_vars) =
662+
match (&args.persona_pack, &args.persona_name) {
663+
(Some(pack_dir), Some(name)) => {
664+
let pack = sprout_persona::resolve::resolve_pack(pack_dir).map_err(|e| {
665+
ConfigError::ConfigFile(format!(
666+
"failed to resolve pack {}: {e}",
667+
pack_dir.display()
668+
))
669+
})?;
670+
let persona = pack
671+
.personas
672+
.into_iter()
673+
.find(|p| p.name == *name)
674+
.ok_or_else(|| {
675+
ConfigError::ConfigFile(format!(
676+
"persona '{name}' not found in pack {}",
677+
pack_dir.display()
678+
))
679+
})?;
680+
(
681+
Some(persona.system_prompt),
682+
persona.model,
683+
persona.goose_env_vars,
684+
)
685+
}
686+
(Some(_), None) => {
687+
return Err(ConfigError::ConfigFile(
688+
"--persona-pack requires --persona-name".into(),
689+
));
690+
}
691+
(None, Some(_)) => {
692+
return Err(ConfigError::ConfigFile(
693+
"--persona-name requires --persona-pack".into(),
694+
));
695+
}
696+
(None, None) => (None, None, vec![]),
697+
};
698+
699+
// Apply persona defaults: CLI/env wins, persona fills gaps.
700+
if system_prompt.is_none() {
701+
system_prompt = persona_system_prompt;
702+
}
703+
let model = args.model.or(persona_model);
704+
645705
// ── Multiple-event-handling validation ──────────────────────────────
646706
if matches!(
647707
args.multiple_event_handling,
@@ -682,10 +742,11 @@ impl Config {
682742
max_turns_per_session: args.max_turns_per_session,
683743
presence_enabled: !args.no_presence,
684744
typing_enabled: !args.no_typing,
685-
model: args.model,
745+
model,
686746
permission_mode: args.permission_mode,
687747
respond_to: args.respond_to,
688748
respond_to_allowlist,
749+
persona_env_vars,
689750
};
690751

691752
Ok(config)
@@ -1045,6 +1106,7 @@ mod tests {
10451106
permission_mode: PermissionMode::BypassPermissions,
10461107
respond_to: RespondTo::Anyone,
10471108
respond_to_allowlist: HashSet::new(),
1109+
persona_env_vars: vec![],
10481110
}
10491111
}
10501112

crates/sprout-acp/src/main.rs

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -541,7 +541,12 @@ async fn tokio_main() -> Result<()> {
541541
// This matches the run_models pattern and prevents zombie leaks on
542542
// init timeout (the cancelled future would drop the AcpClient via
543543
// Drop which is best-effort only).
544-
let spawn_result = AcpClient::spawn(&config.agent_command, &config.agent_args).await;
544+
let spawn_result = AcpClient::spawn(
545+
&config.agent_command,
546+
&config.agent_args,
547+
&config.persona_env_vars,
548+
)
549+
.await;
545550
match spawn_result {
546551
Ok(mut acp) => {
547552
match tokio::time::timeout(Duration::from_secs(60), acp.initialize()).await {
@@ -912,9 +917,10 @@ async fn tokio_main() -> Result<()> {
912917
tracing::info!(agent = idx, "slot refill: spawning background respawn");
913918
let cmd = config.agent_command.clone();
914919
let args = config.agent_args.clone();
920+
let env = config.persona_env_vars.clone();
915921
let guard = RespawnGuard::new(idx, respawn_tx.clone());
916922
respawn_tasks.spawn(async move {
917-
let result = spawn_and_init(&cmd, &args).await;
923+
let result = spawn_and_init(&cmd, &args, &env).await;
918924
guard.send(result);
919925
});
920926
}
@@ -1837,12 +1843,13 @@ fn recover_panicked_agent(
18371843
slot.respawn_in_flight = true;
18381844
let cmd = config.agent_command.clone();
18391845
let args = config.agent_args.clone();
1846+
let env = config.persona_env_vars.clone();
18401847
let guard = RespawnGuard::new(i, respawn_tx.clone());
18411848
respawn_tasks.spawn(async move {
18421849
if !delay.is_zero() {
18431850
tokio::time::sleep(delay).await;
18441851
}
1845-
let result = spawn_and_init(&cmd, &args).await;
1852+
let result = spawn_and_init(&cmd, &args, &env).await;
18461853
guard.send(result);
18471854
});
18481855
}
@@ -1983,6 +1990,7 @@ fn spawn_respawn_task(
19831990
// Spawn the actual work (shutdown + sleep + spawn + init) off the main loop.
19841991
let cmd = config.agent_command.clone();
19851992
let args = config.agent_args.clone();
1993+
let env = config.persona_env_vars.clone();
19861994
let guard = RespawnGuard::new(index, respawn_tx.clone());
19871995
respawn_tasks.spawn(async move {
19881996
// Shutdown old agent (reap child, prevent zombie).
@@ -1994,7 +2002,7 @@ fn spawn_respawn_task(
19942002
tokio::time::sleep(delay).await;
19952003
}
19962004

1997-
let result = spawn_and_init(&cmd, &args).await;
2005+
let result = spawn_and_init(&cmd, &args, &env).await;
19982006
guard.send(result);
19992007
});
20002008

@@ -2007,8 +2015,12 @@ fn spawn_respawn_task(
20072015
///
20082016
/// Takes owned args so it can run in a background `tokio::spawn` task without
20092017
/// borrowing `Config`. All respawn/refill paths use this.
2010-
async fn spawn_and_init(command: &str, args: &[String]) -> Result<AcpClient> {
2011-
let mut acp = AcpClient::spawn(command, args)
2018+
async fn spawn_and_init(
2019+
command: &str,
2020+
args: &[String],
2021+
extra_env: &[(String, String)],
2022+
) -> Result<AcpClient> {
2023+
let mut acp = AcpClient::spawn(command, args, extra_env)
20122024
.await
20132025
.map_err(|e| anyhow::anyhow!("failed to spawn agent: {e}"))?;
20142026

@@ -2045,7 +2057,8 @@ async fn run_models(args: ModelsArgs) -> Result<()> {
20452057
.to_string();
20462058

20472059
// Spawn outside the timeout so we always own the child for cleanup.
2048-
let mut client = match AcpClient::spawn(&args.agent_command, &agent_args).await {
2060+
// `models` subcommand doesn't use persona packs — no extra env.
2061+
let mut client = match AcpClient::spawn(&args.agent_command, &agent_args, &[]).await {
20492062
Ok(c) => c,
20502063
Err(e) => {
20512064
eprintln!("error: failed to spawn agent: {e}");

crates/sprout-cli/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,6 @@ base64 = "0.22"
4242

4343
# SHA-256 — NIP-98 payload hash tag
4444
sha2 = "0.11"
45+
46+
# Persona pack parsing, validation, and resolution
47+
sprout-persona = { path = "../sprout-persona" }

crates/sprout-cli/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ pub mod channels;
33
pub mod dms;
44
pub mod feed;
55
pub mod messages;
6+
pub mod pack;
67
pub mod reactions;
78
pub mod social;
89
pub mod users;

0 commit comments

Comments
 (0)