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: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/buzz-acp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ name = "buzz-acp"
path = "src/main.rs"

[dependencies]
fs2 = "0.4"
# Internal
buzz-core = { workspace = true }
buzz-sdk = { workspace = true }
Expand Down Expand Up @@ -77,5 +78,6 @@ evalexpr = { workspace = true }
nix = { version = "0.31", default-features = false, features = ["signal"] }

[dev-dependencies]
tempfile = "3"
tokio = { workspace = true, features = ["test-util"] }
httparse = "1"
32 changes: 32 additions & 0 deletions crates/buzz-acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,38 @@ cargo build --release -p buzz-acp
export PATH="$PWD/target/release:$PATH"
```

## Private reminders

Agents can schedule deferred follow-ups with `buzz reminders create --after 7d
--note 'Inspect experiment X and decide whether to continue' --link
'buzz://message?channel=<uuid>&id=<event>'`. `--at` accepts an absolute RFC3339
time with timezone. `list`, `get`, `snooze`, `complete`, and `cancel` manage the
author's encrypted NIP-ER state; no channel message is published automatically.

On relays advertising NIP-ER and NIP-42, the harness queries the author's current
reminder heads every 30 seconds, with paginated recovery and no creation-time
lower bound. Due work uses the existing private session and agent pool after
queued messages, without interrupting active work. It rechecks the head before
dispatch, so snoozes and cancellations supersede waiting intent. The reminder
note and target provide context when the originating session no longer exists.

A normally completed turn gets a durable delivery receipt; the agent separately
chooses whether to complete, snooze, or cancel the reminder. Pending reminders
already delivered remain inspectable with `buzz reminders list`; they do not
repeatedly wake the agent. Failed, interrupted, or limited turns remain eligible
with per-version backoff. A crash before the receipt is durable can redeliver:
agents should inspect their retained artifacts before repeating side effects.

Receipts default to `$XDG_STATE_HOME/buzz-acp/reminders` or
`$HOME/.local/state/buzz-acp/reminders`. Set `BUZZ_ACP_REMINDER_STATE_DIR` to a
persistent volume in replaceable runtimes. Files are scoped by relay and author;
an exclusive local lock prevents competing consumers sharing that directory.
Keep one harness per identity; simultaneous devices do not have distributed
exactly-once delivery. Losing receipts can redeliver pending reminders, while
done/cancelled state remains on the relay. Bookmark reminders without a due time
never wake an agent. Relays lacking the advertised private-read contract disable
reminder recovery without changing ordinary messaging.

## Generating Keys

Each agent needs a Nostr keypair — this is the agent's identity in Buzz. Use `buzz-admin` to generate one:
Expand Down
13 changes: 13 additions & 0 deletions crates/buzz-acp/src/base_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ
| `buzz dms` | `list`, `open` |
| `buzz users` | `get`, `set-profile`, `presence` |
| `buzz workflows` | `list`, `trigger`, `runs` |
| `buzz reminders` | `create`, `list`, `get`, `snooze`, `complete`, `cancel` |
| `buzz feed` | `get` |
| `buzz social` | `publish`, `notes` |
| `buzz repos` | `create`, `get`, `list` |
Expand Down Expand Up @@ -49,6 +50,18 @@ Open an owner-reviewed draft with `buzz agents draft-create --channel <current-c

## Communication Patterns

### Deferred work

When work becomes useful later, retain its context and set a private reminder:
`buzz reminders create --after 7d --note 'What to revisit, why, and where the evidence lives' --link 'buzz://message?channel=<uuid>&id=<event>'`.
Use `--at` with an RFC3339 timezone for an absolute time. The link is optional.
Due reminders return to your identity in a private session, including after a
harness restart; they do not restore the originating channel's in-memory context.
Inspect current evidence and complete, snooze, or cancel the reminder as appropriate.
Use existing completion notifications for jobs and peer replies; time reminders
are useful when waiting itself lets evidence accumulate. A reminder is your
retained intention to reconsider, not an obligation to carry out a stale plan.

### Mentions

- For a notifying `@mention`, use the person's **exact display name as shown in Buzz** (e.g., `@Alice Smith`, not `@Alice`, when the displayed name is `Alice Smith`). Do not expand a short display name, infer a surname, or spend tool calls looking for a “fuller” name merely to address someone. Partial names fail silently.
Expand Down
79 changes: 69 additions & 10 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ mod prompt_framing;
mod prompt_project;
mod queue;
mod relay;
mod reminder_receipts;
mod reminders;
mod scope;
mod setup_mode;
mod usage;
Expand Down Expand Up @@ -2846,6 +2848,14 @@ async fn tokio_main() -> Result<()> {
None
};
let mut heartbeat_in_flight = false;
let mut reminders = match reminders::Reminders::open(&ctx.rest_client) {
Ok(state) => Some(state),
Err(error) => {
tracing::error!(%error, "reminder delivery disabled: cannot open durable receipts");
None
}
};
let (mut reminder_rx, reminder_poller) = reminders::start_polling(ctx.rest_client.clone());

let mut presence_heartbeat = if config.presence_enabled {
let interval = Duration::from_secs(60);
Expand Down Expand Up @@ -3008,14 +3018,24 @@ async fn tokio_main() -> Result<()> {
}

loop {
if let Some(state) = reminders.as_mut() {
state.recover_missing_turn(pool.task_map().values().map(|meta| meta.turn_id.clone()));
}
let next_reminder = match reminders.as_ref().map(|state| state.next()).transpose() {
Ok(candidate) => candidate.flatten(),
Err(error) => {
tracing::error!(%error, "cannot read reminder receipts; delivery deferred");
None
}
};
// Whether buffered work is waiting on a lazy pool. Also gates the
// retry-deadline sleep arm below: a `Failed` lifecycle keeps its
// (possibly past) `retry_at` until the next wake, so sleeping on it
// unconditionally would complete instantly on every iteration — a
// busy spin — whenever the queued work drained after a failed wake.
let mut lazy_wake_work_pending = false;
if config.lazy_pool && !pool_ready {
lazy_wake_work_pending = queue.has_flushable_work();
lazy_wake_work_pending = queue.has_flushable_work() || next_reminder.is_some();
if let Some(attempt) = pool_lifecycle
.start_wake_if_due(lazy_wake_work_pending, tokio::time::Instant::now())
{
Expand Down Expand Up @@ -3145,6 +3165,22 @@ async fn tokio_main() -> Result<()> {
}
}

if pool_ready && !queue.has_flushable_work() && !heartbeat_in_flight {
if let Some(reminder) = next_reminder {
if let Some(turn_id) = dispatch_private(
&mut pool,
&ctx,
&mut heartbeat_in_flight,
Some(reminder.clone()),
) {
if let Some(state) = reminders.as_mut() {
state.started(turn_id, reminder);
}
last_activity = tokio::time::Instant::now();
}
}
}

// Borrow result_rx and join_set simultaneously via split-borrow helper.
let pool_event: Option<PoolEvent> = {
let (result_rx, join_set) = pool.rx_and_join_set();
Expand Down Expand Up @@ -3666,6 +3702,10 @@ async fn tokio_main() -> Result<()> {
}
None
}
Some(heads) = reminder_rx.recv() => {
if let Some(state) = reminders.as_mut() { state.refresh(heads); }
None
}
_ = async {
match heartbeat.as_mut() {
Some(hb) => hb.tick().await,
Expand Down Expand Up @@ -3742,6 +3782,9 @@ async fn tokio_main() -> Result<()> {

match pool_event {
Some(PoolEvent::Result(result)) => {
if let Some(state) = reminders.as_mut() {
state.finished(&result.turn_id, &result.outcome);
}
// Stop the typing indicator for the completed turn's exact scope,
// not the whole channel — a sibling thread still running in the
// same channel must keep its indicator.
Expand Down Expand Up @@ -4119,6 +4162,7 @@ async fn tokio_main() -> Result<()> {
}

// Cancel any in-flight presence heartbeat before sending offline.
reminder_poller.abort();
if let Some(h) = presence_task.take() {
h.abort();
}
Expand Down Expand Up @@ -4795,7 +4839,7 @@ fn handle_prompt_result(

match &result.source {
PromptSource::Channel(scope) => queue.mark_complete(scope.clone()),
PromptSource::Heartbeat => *heartbeat_in_flight = false,
PromptSource::Heartbeat | PromptSource::Reminder => *heartbeat_in_flight = false,
}

// Strip sessions for channels the agent was removed from while this
Expand Down Expand Up @@ -5188,13 +5232,21 @@ fn dispatch_heartbeat(
ctx: &Arc<PromptContext>,
heartbeat_in_flight: &mut bool,
) {
if dispatch_private(pool, ctx, heartbeat_in_flight, None).is_some() {
tracing::info!("heartbeat_fired");
}
}

fn dispatch_private(
pool: &mut AgentPool,
ctx: &Arc<PromptContext>,
heartbeat_in_flight: &mut bool,
reminder: Option<buzz_sdk::reminders::Reminder>,
) -> Option<String> {
if *heartbeat_in_flight {
return;
return None;
}
let agent = match pool.try_claim(None) {
Some(a) => a,
None => return,
};
let agent = pool.try_claim(None)?;

let prompt_text = ctx
.heartbeat_prompt
Expand All @@ -5207,10 +5259,17 @@ fn dispatch_heartbeat(
let task_turn_id = turn_id.clone();

let abort_handle = pool.join_set.spawn(async move {
if let Some(reminder) = reminder {
reminders::run(agent, reminder, ctx_clone, result_tx, task_turn_id).await;
return;
}
pool::run_prompt_task(
agent,
None,
Some(prompt_text),
Some(pool::PrivatePrompt {
text: prompt_text,
source: PromptSource::Heartbeat,
}),
ctx_clone,
result_tx,
None,
Expand All @@ -5225,15 +5284,15 @@ fn dispatch_heartbeat(
agent_index,
channel_id: None,
scope: None,
turn_id,
turn_id: turn_id.clone(),
recoverable_batch: None,
control_tx: None,
steer_tx: None,
successful_steer_deliveries: HashSet::new(),
},
);
*heartbeat_in_flight = true;
tracing::info!(agent = agent_index, "heartbeat_fired");
Some(turn_id)
}

#[cfg(test)]
Expand Down
Loading