Skip to content
Open
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/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ Forum event kinds:
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 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.
5. **Agent response** — The agent processes the prompt and uses Sprout MCP tools (`send_message`, `get_messages`, 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.

Each channel has at most one prompt in flight. Multiple channels can be processed concurrently when agents > 1.
Expand Down
52 changes: 52 additions & 0 deletions crates/sprout-acp/src/base_prompt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
You are operating inside the Sprout platform — a Nostr-based messaging platform for human-agent collaboration. The sprout-acp harness routes channel events to your session.

## Sprout CLI

The `sprout` CLI is your primary interface. Auth env vars: `SPROUT_RELAY_URL`, `SPROUT_PRIVATE_KEY`, `SPROUT_AUTH_TAG`. Exit codes: 0 ok, 1 user error, 2 network, 3 auth, 4 other. Output is structured JSON — pipe through `jq` as needed.

| Group | Key commands |
|-------|-------------|
| `sprout messages` | `send`, `get`, `thread`, `search` |
| `sprout channels` | `list`, `get`, `create`, `join`, `members` |
| `sprout canvas` | `get`, `set` |
| `sprout reactions` | `add`, `remove` |
| `sprout dms` | `list`, `open` |
| `sprout users` | `get`, `set-profile`, `presence` |
| `sprout workflows` | `list`, `trigger`, `runs` |
| `sprout feed` | `get` |
| `sprout social` | `publish`, `notes` |
| `sprout repos` | `create`, `get`, `list` |
| `sprout upload` | `file` |

Run `sprout --help` or `sprout <group> --help` for full usage.

MCP tools (via `sprout-mcp`) are also available but the CLI is preferred for batch operations and scripting.

## Communication Patterns

- Address agents and humans with plain `@name` — do NOT bold or italicize mention text (formatting prevents alert delivery).
- Use `sprout messages thread` or MCP `get_thread()` when responding in-thread; post new messages for new topics.
- No push notifications — poll with `sprout messages get --channel <UUID> --since <ts>` or MCP `get_messages(channel_id, since=<ts>)`. When `since` is set without `before`, results are oldest-first (chronological).

## Startup Recovery

1. `sprout feed get` (or MCP `get_feed()`) — surface pending mentions and action items. Filter by type: `mentions`, `needs_action`, `activity`, `agent_activity`.
2. `sprout messages get --channel <UUID>` on assigned channels — catch up on recent history.
3. Check `AGENTS.md` in your working directory for team context.
4. Check `RESEARCH/`, `GUIDES/`, `PLANS/` before searching externally. Use `sprout messages search --query "..."` for cross-channel keyword lookups.

## Workspace Layout

Your persistent workspace is in your working directory:

| Dir | Purpose |
|-----|---------|
| `RESEARCH/` | Findings and reference material |
| `PLANS/` | Project and task plans |
| `GUIDES/` | How-to documentation |
| `WORK_LOGS/` | Timestamped activity logs |
| `OUTBOX/` | Drafts pending review or send |
| `REPOS/` | Checked-out source repositories |
| `.scratch/` | Ephemeral working files |

Knowledge files use `ALL_CAPS_WITH_UNDERSCORES.md` naming. `AGENTS.md` lists active agents and roles. See `AGENTS.md` in your working directory for full workspace conventions.
40 changes: 40 additions & 0 deletions crates/sprout-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,20 @@ pub struct CliArgs {
#[arg(long, env = "SPROUT_ACP_NO_TYPING")]
pub no_typing: bool,

/// Disable the [Base] platform-context section prepended to every prompt.
/// When set, agents receive only the persona [System] prompt with no Sprout orientation.
#[arg(long, env = "SPROUT_ACP_NO_BASE_PROMPT")]
pub no_base_prompt: bool,

/// Path to a custom base prompt file. Overrides the compiled-in default.
/// Mutually exclusive with --no-base-prompt.
#[arg(
long,
env = "SPROUT_ACP_BASE_PROMPT_FILE",
conflicts_with = "no_base_prompt"
)]
pub base_prompt_file: Option<PathBuf>,

/// Desired LLM model ID. Applied to every new ACP session after creation.
/// Use `sprout-acp models` to discover available model IDs.
#[arg(long, env = "SPROUT_ACP_MODEL")]
Expand Down Expand Up @@ -432,6 +446,12 @@ pub struct Config {
/// Agent owner pubkey (hex). Used for `--respond-to=owner-only` gate.
/// Replaces the old REST-based owner lookup.
pub agent_owner: Option<String>,
/// Disable the [Base] platform-context section prepended to every prompt.
pub no_base_prompt: bool,
/// Resolved content from `--base-prompt-file`, read and validated in
/// `from_cli()`. `None` when using the compiled-in default or when
/// `--no-base-prompt` is set.
pub base_prompt_content: Option<String>,
}

/// Validate and deduplicate allowlist entries: each must be exactly 64 hex chars.
Expand Down Expand Up @@ -561,6 +581,22 @@ impl Config {
None
};

let base_prompt_content = if args.no_base_prompt {
None
} else if let Some(ref path) = args.base_prompt_file {
let content = std::fs::read_to_string(path)?;
if content.len() > 1_048_576 {
return Err(ConfigError::ConfigFile(format!(
"base prompt file {} exceeds 1 MB limit ({} bytes)",
path.display(),
content.len()
)));
}
Some(content)
} else {
None
};

if matches!(args.subscribe, SubscribeMode::Config) {
if args.kinds.is_some() {
tracing::warn!("--kinds is ignored in config mode");
Expand Down Expand Up @@ -768,6 +804,8 @@ impl Config {
persona_env_vars,
relay_observer: args.relay_observer,
agent_owner: args.agent_owner.map(|s| s.trim().to_ascii_lowercase()),
no_base_prompt: args.no_base_prompt,
base_prompt_content,
};

Ok(config)
Expand Down Expand Up @@ -1129,6 +1167,8 @@ mod tests {
persona_env_vars: vec![],
relay_observer: false,
agent_owner: None,
no_base_prompt: false,
base_prompt_content: None,
}
}

Expand Down
18 changes: 16 additions & 2 deletions crates/sprout-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ use pool::{
AgentPool, CancelMode, OwnedAgent, PromptContext, PromptOutcome, PromptResult, PromptSource,
SessionState,
};
use queue::{EventQueue, QueuedEvent, ThreadTags};
use queue::{prepend_base_prompt, EventQueue, QueuedEvent, ThreadTags};
use relay::{HarnessRelay, RelayEventPublisher};
use sprout_core::kind::{
KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_STREAM_MESSAGE,
Expand Down Expand Up @@ -792,7 +792,7 @@ async fn tokio_main() -> Result<()> {
.compact()
.init();

let config = Config::from_cli().map_err(|e| anyhow::anyhow!("configuration error: {e}"))?;
let mut config = Config::from_cli().map_err(|e| anyhow::anyhow!("configuration error: {e}"))?;
tracing::info!("sprout-acp starting: {}", config.summary());

let observer = config
Expand Down Expand Up @@ -1067,13 +1067,21 @@ async fn tokio_main() -> Result<()> {
let dedup_mode = config.dedup_mode;
let mut queue = EventQueue::new(dedup_mode);

let base_prompt_content = config.base_prompt_content.take();
let ctx = Arc::new(PromptContext {
mcp_servers: build_mcp_servers(&config),
initial_message: config.initial_message.clone(),
idle_timeout: Duration::from_secs(config.idle_timeout_secs),
max_turn_duration: Duration::from_secs(config.max_turn_duration_secs),
dedup_mode: config.dedup_mode,
system_prompt: config.system_prompt.clone(),
base_prompt: if config.no_base_prompt {
None
} else if let Some(content) = base_prompt_content {
Some(Box::leak(content.into_boxed_str()))
} else {
Some(include_str!("base_prompt.md"))
},
heartbeat_prompt: config.heartbeat_prompt.clone(),
cwd: std::env::current_dir()
.unwrap_or_else(|_| std::path::PathBuf::from("/"))
Expand Down Expand Up @@ -2262,6 +2270,10 @@ fn dispatch_heartbeat(
.heartbeat_prompt
.clone()
.unwrap_or_else(default_heartbeat_prompt);
let prompt_text = match ctx.base_prompt {
Some(bp) => prepend_base_prompt(bp, &prompt_text),
None => prompt_text,
};
let result_tx = pool.result_tx();
let ctx_clone = Arc::clone(ctx);
let agent_index = agent.index;
Expand Down Expand Up @@ -2755,6 +2767,8 @@ mod build_mcp_servers_tests {
persona_env_vars: vec![],
relay_observer: false,
agent_owner: None,
no_base_prompt: false,
base_prompt_content: None,
}
}

Expand Down
29 changes: 22 additions & 7 deletions crates/sprout-acp/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ use crate::acp::{
use crate::config::{DedupMode, PermissionMode};
use crate::observer;
use crate::queue::{
ContextMessage, ConversationContext, FlushBatch, PromptChannelInfo, PromptProfile,
PromptProfileLookup,
prepend_base_prompt, ContextMessage, ConversationContext, FlushBatch, PromptChannelInfo,
PromptProfile, PromptProfileLookup,
};
use crate::relay::{ChannelInfo, RestClient};

Expand Down Expand Up @@ -187,6 +187,13 @@ pub struct PromptContext {
pub dedup_mode: DedupMode,
pub system_prompt: Option<String>,
pub heartbeat_prompt: Option<String>,
/// Base prompt content, or `None` if `--no-base-prompt` was passed.
///
/// `'static` because `PromptContext` is `Arc`-shared across async tasks.
/// Content from `--base-prompt-file` is promoted via `Box::leak` in `main.rs`
/// after validated file read in `Config::from_cli()`. The compiled-in default
/// (`include_str!`) is inherently `'static`.
pub base_prompt: Option<&'static str>,
pub cwd: String,
/// REST client for pre-prompt context fetches (thread/DM history).
pub rest_client: RestClient,
Expand Down Expand Up @@ -746,11 +753,16 @@ pub async fn run_prompt_task(
target: "pool::session",
"sending initial_message to session {session_id} for channel {cid}"
);
// Prepend base prompt to initial_message for platform orientation.
let init_msg = match ctx.base_prompt {
Some(bp) => prepend_base_prompt(bp, initial_msg),
None => initial_msg.to_string(),
};
let init_result = agent
.acp
.session_prompt_with_idle_timeout(
&session_id,
initial_msg,
&init_msg,
ctx.idle_timeout,
ctx.max_turn_duration,
)
Expand Down Expand Up @@ -873,10 +885,13 @@ pub async fn run_prompt_task(

crate::queue::format_prompt(
b,
ctx.system_prompt.as_deref(),
channel_info.as_ref(),
conversation_context.as_ref(),
profile_lookup.as_ref(),
&crate::queue::FormatPromptArgs {
base_prompt: ctx.base_prompt,
system_prompt: ctx.system_prompt.as_deref(),
channel_info: channel_info.as_ref(),
conversation_context: conversation_context.as_ref(),
profile_lookup: profile_lookup.as_ref(),
},
)
} else {
// Should not happen — batch is None only for heartbeats which have prompt_text.
Expand Down
Loading
Loading