Skip to content
Merged
61 changes: 57 additions & 4 deletions crates/sprout-acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ sprout-acp

## Configuration

All configuration is via environment variables.
All configuration is via environment variables (or CLI flags — every env var has a matching flag).

### Core

| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
Expand All @@ -108,16 +110,67 @@ All configuration is via environment variables.

**Legacy env vars:** `SPROUT_ACP_PRIVATE_KEY` and `SPROUT_ACP_API_TOKEN` are still accepted as fallbacks.

### Parallel Agents & Heartbeat

| Flag | Env Var | Default | Description |
|------|---------|---------|-------------|
| `--agents` | `SPROUT_ACP_AGENTS` | `1` | Number of agent subprocesses (1–32). |
| `--heartbeat-interval` | `SPROUT_ACP_HEARTBEAT_INTERVAL` | `0` | Seconds between heartbeat prompts. `0` = disabled. Must be `0` or ≥10 when enabled. |
| `--heartbeat-prompt` | `SPROUT_ACP_HEARTBEAT_PROMPT` | (built-in) | Custom heartbeat prompt text. Conflicts with `--heartbeat-prompt-file`. |
| `--heartbeat-prompt-file` | `SPROUT_ACP_HEARTBEAT_PROMPT_FILE` | — | Read heartbeat prompt from a file. Conflicts with `--heartbeat-prompt`. |

### Configuration Examples

**Single agent, no heartbeat (default — backward compatible):**
```bash
sprout-acp
```

**Four agents, no heartbeat (high-throughput event processing):**
```bash
sprout-acp --agents 4
```

**Two agents with 5-minute heartbeat:**
```bash
sprout-acp --agents 2 --heartbeat-interval 300
```

**Custom heartbeat prompt:**
```bash
sprout-acp --agents 2 --heartbeat-interval 300 \
--heartbeat-prompt "Check get_feed_actions() for pending approvals, then get_feed_mentions() for unanswered mentions. If nothing actionable, end your turn immediately."
```

### Shared Identity

All N agents authenticate as the **same Nostr bot identity** — users see one bot regardless of how many agents are running. The same channel is never processed by two agents simultaneously (the queue enforces this). Cross-channel message ordering is not guaranteed when N>1.

### Heartbeat Semantics

When `--heartbeat-interval` is set, the harness fires a prompt on an idle agent at the configured interval. Heartbeat rules:

- **Lower priority than queued events** — if events are pending, they are dispatched first.
- **Skipped when all agents are busy** — no queuing; the tick is simply dropped.
- **At most one heartbeat in flight globally** — the next tick is suppressed until the current one completes.
- **Default prompt** (when `--heartbeat-prompt` is not set) calls `get_feed_actions()` and `get_feed_mentions()` to surface pending work.

Heartbeat is designed for idle periods. Under sustained event load it will rarely fire — that's expected.

### Choosing N

Start with **N=2** for most deployments. Increase if queue depth grows under load. Each agent spawns its own MCP server subprocess, so resource usage scales approximately as N × (agent memory + MCP server memory). Maximum is 32.

## How It Works

1. **Startup** — Spawns the agent subprocess, sends ACP `initialize`, connects to the relay with NIP-42 auth.
1. **Startup** — Spawns N agent subprocesses (default 1), sends ACP `initialize` to each, connects to the relay with NIP-42 auth.
2. **Channel discovery** — Queries the relay REST API for accessible channels, subscribes to each.
3. **Event loop** — Listens for @mention events (kind 9 with the agent's pubkey in a `#p` tag). Events queue per channel.
4. **Prompting** — When events are pending and no prompt is in flight, drains all queued events for the oldest channel into a single batched prompt via ACP `session/prompt`.
4. **Prompting** — When events are pending and no prompt is in flight for that channel, drains all queued events for the oldest channel into a single batched prompt via ACP `session/prompt`.
5. **Agent response** — The agent processes the prompt and uses Sprout MCP tools (`send_message`, `get_channel_history`, etc.) to interact with Sprout.
6. **Recovery** — If the agent crashes, the harness respawns it. If the relay disconnects, the harness reconnects with a `since` filter to avoid missing events.

Only one prompt is in flight at a time (globally, not per-session). This matches the concurrency model of current ACP agents.
Each channel has at most one prompt in flight. Multiple channels can be processed concurrently when agents > 1.

> **Note:** On startup, the harness replays all unprocessed @mentions since the last run. Expect a burst of activity if there are stale events in the channel.

Expand Down
1 change: 1 addition & 0 deletions crates/sprout-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ pub enum AcpError {
#[error("Agent process exited unexpectedly")]
AgentExited,

#[allow(dead_code)]
#[error("Turn timed out")]
Timeout,

Expand Down
128 changes: 127 additions & 1 deletion crates/sprout-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,31 @@ pub struct CliArgs {
)]
pub system_prompt_file: Option<PathBuf>,

/// Number of parallel agent subprocesses.
#[arg(long, env = "SPROUT_ACP_AGENTS", default_value_t = 1,
value_parser = clap::value_parser!(u32).range(1..=32))]
pub agents: u32,

/// Seconds between heartbeat prompts. 0 = disabled.
#[arg(long, env = "SPROUT_ACP_HEARTBEAT_INTERVAL", default_value_t = 0)]
pub heartbeat_interval: u64,

/// Heartbeat prompt text. Conflicts with --heartbeat-prompt-file.
#[arg(
long,
env = "SPROUT_ACP_HEARTBEAT_PROMPT",
conflicts_with = "heartbeat_prompt_file"
)]
pub heartbeat_prompt: Option<String>,

/// Read heartbeat prompt from file.
#[arg(
long,
env = "SPROUT_ACP_HEARTBEAT_PROMPT_FILE",
conflicts_with = "heartbeat_prompt"
)]
pub heartbeat_prompt_file: Option<PathBuf>,

#[arg(long, env = "SPROUT_ACP_INITIAL_MESSAGE")]
pub initial_message: Option<String>,

Expand Down Expand Up @@ -146,6 +171,9 @@ pub struct Config {
pub agent_args: Vec<String>,
pub mcp_command: String,
pub turn_timeout_secs: u64,
pub agents: u32,
pub heartbeat_interval_secs: u64,
pub heartbeat_prompt: Option<String>,
pub system_prompt: Option<String>,
pub initial_message: Option<String>,
pub subscribe_mode: SubscribeMode,
Expand Down Expand Up @@ -187,6 +215,20 @@ impl Config {
None
};

if args.heartbeat_interval > 0 && args.heartbeat_interval < 10 {
return Err(ConfigError::ConfigFile(
"heartbeat interval must be 0 (disabled) or ≥10 seconds".into(),
));
}

let heartbeat_prompt = if let Some(text) = args.heartbeat_prompt {
Some(text)
} else if let Some(ref path) = args.heartbeat_prompt_file {
Some(std::fs::read_to_string(path)?)
} else {
None
};

if matches!(args.subscribe, SubscribeMode::Config) {
if args.kinds.is_some() {
tracing::warn!("--kinds is ignored in config mode");
Expand All @@ -207,6 +249,9 @@ impl Config {
agent_args: args.agent_args,
mcp_command: args.mcp_command,
turn_timeout_secs: args.turn_timeout,
agents: args.agents,
heartbeat_interval_secs: args.heartbeat_interval,
heartbeat_prompt,
system_prompt,
initial_message: args.initial_message,
subscribe_mode: args.subscribe,
Expand All @@ -222,13 +267,15 @@ impl Config {
/// Human-readable summary (no secrets).
pub fn summary(&self) -> String {
format!(
"relay={} pubkey={} agent_cmd={} {} mcp_cmd={} timeout={}s subscribe={:?} dedup={:?} ignore_self={}",
"relay={} pubkey={} agent_cmd={} {} mcp_cmd={} timeout={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} ignore_self={}",
self.relay_url,
self.keys.public_key().to_hex(),
self.agent_command,
self.agent_args.join(" "),
self.mcp_command,
self.turn_timeout_secs,
self.agents,
self.heartbeat_interval_secs,
self.subscribe_mode,
self.dedup_mode,
self.ignore_self,
Expand Down Expand Up @@ -424,6 +471,9 @@ mod tests {
agent_args: vec!["acp".into()],
mcp_command: "sprout-mcp-server".into(),
turn_timeout_secs: 300,
agents: 1,
heartbeat_interval_secs: 0,
heartbeat_prompt: None,
system_prompt: None,
initial_message: None,
subscribe_mode: mode,
Expand Down Expand Up @@ -832,4 +882,80 @@ channels = "ALL"
assert!(err.to_string().contains("filter too long"));
std::fs::remove_dir_all(&dir).ok();
}

// ── heartbeat validation ─────────────────────────────────────────────────

fn validate_heartbeat_interval(secs: u64) -> Result<(), ConfigError> {
if secs > 0 && secs < 10 {
return Err(ConfigError::ConfigFile(
"heartbeat interval must be 0 (disabled) or ≥10 seconds".into(),
));
}
Ok(())
}

#[test]
fn test_heartbeat_interval_zero_ok() {
assert!(validate_heartbeat_interval(0).is_ok());
}

#[test]
fn test_heartbeat_interval_ten_ok() {
assert!(validate_heartbeat_interval(10).is_ok());
}

#[test]
fn test_heartbeat_interval_large_ok() {
assert!(validate_heartbeat_interval(300).is_ok());
}

#[test]
fn test_heartbeat_interval_five_rejected() {
let err = validate_heartbeat_interval(5).unwrap_err();
assert!(err.to_string().contains("heartbeat interval must be 0"));
}

#[test]
fn test_heartbeat_interval_one_rejected() {
let err = validate_heartbeat_interval(1).unwrap_err();
assert!(err.to_string().contains("heartbeat interval must be 0"));
}

#[test]
fn test_heartbeat_interval_nine_rejected() {
let err = validate_heartbeat_interval(9).unwrap_err();
assert!(err.to_string().contains("heartbeat interval must be 0"));
}

// ── summary includes agents and heartbeat ────────────────────────────────

#[test]
fn test_summary_includes_agents_and_heartbeat() {
let config = test_config(SubscribeMode::Mentions);
let s = config.summary();
assert!(
s.contains("agents=1"),
"summary should include agents=1, got: {s}"
);
assert!(
s.contains("heartbeat=0s"),
"summary should include heartbeat=0s, got: {s}"
);
}

#[test]
fn test_summary_reflects_custom_agents_and_heartbeat() {
let mut config = test_config(SubscribeMode::Mentions);
config.agents = 4;
config.heartbeat_interval_secs = 30;
let s = config.summary();
assert!(
s.contains("agents=4"),
"summary should include agents=4, got: {s}"
);
assert!(
s.contains("heartbeat=30s"),
"summary should include heartbeat=30s, got: {s}"
);
}
}
Loading
Loading