Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6f8f06d
feat: two-layer prompt architecture for managed agents
wpfleger96 May 14, 2026
6b6c52b
fix: address all review feedback on two-layer prompt architecture
wpfleger96 May 14, 2026
2bbc47c
fix: CLI-first base prompt and remove hardcoded model from built-ins
wpfleger96 May 15, 2026
ce47e45
feat(desktop): dynamic nest AGENTS.md regeneration
wpfleger96 May 14, 2026
8d5577f
fix: gate test-only imports behind #[cfg(test)] in nest.rs
wpfleger96 May 14, 2026
ef5765c
fix(desktop): address review findings for nest AGENTS.md regeneration
wpfleger96 May 14, 2026
6b5dad2
style: apply rustfmt to review-fix commit
wpfleger96 May 14, 2026
5a8e543
fix(desktop): add nest.rs to file size check overrides
wpfleger96 May 15, 2026
9d3967a
refactor: consolidate PR #583 into #584, remove CLI_QUICK_REFERENCE d…
wpfleger96 May 15, 2026
8ef41e9
fix: address code review findings from crossfire review
wpfleger96 May 15, 2026
ed6c20e
style: fix formatting and bump nest.rs file size override
wpfleger96 May 22, 2026
f7e2d55
refactor: replace MCP tool references with CLI commands in agent prompts
wpfleger96 May 23, 2026
22d347b
fix: skip AGENTS.md write when managed section content is unchanged
wpfleger96 May 22, 2026
e978496
fix(desktop): gate MCP server by provider discovery + harness-agnosti…
wpfleger96 May 22, 2026
517e846
refactor(desktop): generalize skill symlinks via KnownAcpProvider
wpfleger96 May 23, 2026
b963474
style(desktop): remove stale Claude Code references from nest comments
wpfleger96 May 22, 2026
41fbc0b
fixup: adjust test + size override for merged migration behavior
wpfleger96 May 23, 2026
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
50 changes: 50 additions & 0 deletions crates/sprout-acp/src/base_prompt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
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.

## Communication Patterns

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

## Startup Recovery

1. `sprout feed get` — 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 @@ -352,6 +352,20 @@ pub struct CliArgs {
)]
pub no_memory: 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 @@ -461,6 +475,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 @@ -590,6 +610,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 @@ -798,6 +834,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 @@ -1161,6 +1199,8 @@ mod tests {
persona_env_vars: vec![],
relay_observer: false,
agent_owner: None,
no_base_prompt: false,
base_prompt_content: None,
}
}

Expand Down
45 changes: 37 additions & 8 deletions crates/sprout-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,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 @@ -794,7 +794,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 @@ -1069,13 +1069,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 @@ -2314,6 +2322,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 @@ -2344,14 +2356,15 @@ fn default_heartbeat_prompt() -> String {
You have been awakened for a routine heartbeat. You have NO incoming messages or\n\
active channel context for this turn.\n\n\
Your tasks:\n\
1. Call `get_feed(types='needs_action')` to check for pending workflow approvals or\n\
1. Run `sprout feed get --types needs_action` to check for pending workflow approvals or\n\
high-priority requests addressed to you.\n\
2. Call `get_feed(types='mentions')` to check for unanswered @mentions.\n\
3. If you find actionable items, address them using the appropriate tools\n\
(e.g., `approve_step`, `send_message`, `send_message(parent_event_id=...)`).\n\
2. Run `sprout feed get --types mentions` to check for unanswered @mentions.\n\
3. If you find actionable items, address them using the appropriate CLI commands\n\
(e.g., `sprout workflows approve --token <UUID>`, `sprout messages send`,\n\
`sprout messages send --reply-to <event-id>`).\n\
4. If there are no pending actions or mentions, end your turn immediately.\n\n\
Do not call `list_channels()` or `search()` unless you have a specific reason.\n\
Do not invent work — only act on items surfaced by the feed tools."
Do not run `sprout channels list` or `sprout messages search` unless you have a specific reason.\n\
Do not invent work — only act on items surfaced by the feed commands."
)
}

Expand Down Expand Up @@ -2598,6 +2611,9 @@ async fn run_models(args: ModelsArgs) -> Result<()> {
}

fn build_mcp_servers(config: &Config) -> Vec<McpServer> {
if config.mcp_command.is_empty() {
return vec![];
}
vec![McpServer {
name: "sprout-mcp".to_string(),
command: config.mcp_command.clone(),
Expand Down Expand Up @@ -2808,6 +2824,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 Expand Up @@ -2862,4 +2880,15 @@ mod build_mcp_servers_tests {
"empty SPROUT_AUTH_TAG should not be forwarded"
);
}

#[test]
fn empty_mcp_command_returns_no_servers() {
let mut config = test_config();
config.mcp_command = "".into();
let servers = build_mcp_servers(&config);
assert!(
servers.is_empty(),
"empty mcp_command should produce no MCP servers"
);
}
}
31 changes: 23 additions & 8 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 @@ -192,6 +192,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 @@ -824,11 +831,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 @@ -952,11 +964,14 @@ pub async fn run_prompt_task(
let agent_core_section = agent.state.core_sections.get(&b.channel_id).cloned();
crate::queue::format_prompt(
b,
ctx.system_prompt.as_deref(),
agent_core_section.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(),
agent_core: agent_core_section.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