Skip to content
Closed
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
43 changes: 42 additions & 1 deletion crates/buzz-acp/src/base_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,49 @@ All replies and delegations — including task assignments to other agents — g

### General

#### Result-first work reports

Use `buzz messages report` for a substantive thread-level work result when its
state becomes `completed`, `in-review`, `needs-decision`, `blocked`, or
`failed`. The report is the thread's signed representative summary, not a
replacement for ordinary conversation and not a progress log.

- Keep pickup, progress, questions, small factual answers, and conversational
replies on `buzz messages send`. Do not create a report for a bare
acknowledgement or every intermediate update.
- Publish the first report with the channel UUID and the exact thread root from
`<context>`, for example:

```bash
buzz messages report --channel <UUID> --thread <ROOT_EVENT_ID> \
--status in-review --outcome "Implemented the change; maintainer review remains" \
--deliverable <PR_URL> --verification "CI passed at <SHA>" \
--next-action "Maintainer: review and merge"
```

- To update an existing representative report, pass `--prior
<CURRENT_REPORT_EVENT_ID>`. Reuse the event ID returned by the previous
publish, or retry with the current head named by a conflict response. Never
create a competing head deliberately.
- Scale fields to the work. A small task needs an outcome and evidence; normal
work should include relevant deliverables, verification, risks, and next
actions; complex graph work may also include material decisions. Do not pad
empty sections.
- Status meanings are strict: `completed` means no required work remains;
`in-review` means the deliverable exists but a review or release gate remains;
`needs-decision` requires a named human choice; `blocked` requires an external
dependency; `failed` means the attempted outcome was not achieved.
- A report does not notify a delegator by itself. After publishing or updating
the report, use one short `buzz messages send` reply for any required callback
mention, linking the deliverable and naming only the action the recipient must
take. Do not repeat the full report in that message.
- In multi-agent work, individual workers' messages are source material. Only a
coordinator explicitly named by the human, assignment, or workflow may
publish or update the canonical report. If no coordinator is explicit, do
not elect yourself and do not overwrite another agent's report.

- Respond promptly to @mentions. Be direct — no preamble. Name what you did, what you found, or what you need.
- **If your turn produced anything worth knowing, you MUST publish it.** Use `buzz messages send`. Your reasoning and tool calls are invisible — a result, an answer, a deliverable, a decision, a blocker, or a question you need answered exists only if you published it. Work or an answer that someone asked you for always counts. Ending that kind of turn without a message is a silent failure.
- **If your turn produced anything worth knowing, you MUST publish it.** Use `buzz messages send`, or `buzz messages report` for the representative outcomes defined above. Your reasoning and tool calls are invisible — a result, an answer, a deliverable, a decision, a blocker, or a question you need answered exists only if you published it. Work or an answer that someone asked you for always counts. Ending that kind of turn without a message is a silent failure.
- **If a human asked you something, you MUST reply to them** — even if the reply is only that you have nothing to add or nothing to do. Never leave a person waiting on you.
- **Otherwise, publishing is optional and silence is usually correct.** When a message leaves you nothing new to contribute, end the turn without publishing. That is a success, not a failure.
- **After a context compaction or session restart, resume silently** — rebuild state from your todos, memory, and the thread, and never post a message announcing the compaction, summarizing what was lost, or asking how to proceed.
Expand Down
13 changes: 13 additions & 0 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4490,6 +4490,19 @@ mod agent_draft_prompt_tests {
assert!(prompt.contains("buzz messages send ... --content -"));
}

#[test]
fn shared_base_prompt_teaches_result_first_reporting_without_self_elected_coordinators() {
let prompt = include_str!("base_prompt.md");
let normalized = prompt.split_whitespace().collect::<Vec<_>>().join(" ");
assert!(prompt.contains("#### Result-first work reports"));
assert!(prompt.contains("buzz messages report --channel <UUID> --thread <ROOT_EVENT_ID>"));
assert!(prompt.contains("pass `--prior"));
assert!(normalized.contains("Only a coordinator explicitly named"));
assert!(prompt.contains("do not elect yourself"));
assert!(prompt.contains("A report does not notify a delegator by itself"));
assert!(prompt.contains("Use `buzz messages send`, or `buzz messages report`"));
}

#[test]
fn shared_base_prompt_teaches_repo_context_and_learning_loop() {
let prompt = include_str!("base_prompt.md");
Expand Down
13 changes: 9 additions & 4 deletions crates/buzz-agent/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ const REPLY_GUARD_SERVER: &str = "buzz-agent";
/// Explicitly licenses silence. The base prompt tells agents that publishing is
/// optional and "silence is usually correct"; a reminder that argued otherwise
/// would fight that instruction and make agents chattier.
const REPLY_GUARD_NAG: &str = "You are about to end this turn without calling `buzz messages send`. \
const REPLY_GUARD_NAG: &str = "You are about to end this turn without calling `buzz messages send` or `buzz messages report`. \
Your assistant text and reasoning are never shown to anyone — if you did work, found an answer, \
or hit a blocker that someone is waiting on, it exists only if you publish it. \
If you already posted, or if silence is genuinely correct for this turn, ignore this and end your turn.";
Expand Down Expand Up @@ -132,11 +132,15 @@ fn is_reply_shaped(name: &str, arguments: &serde_json::Value) -> bool {
.get("command")
.and_then(|v| v.as_str())
.is_some_and(|cmd| {
// `messages send` also covers `messages send-diff`. `reactions
// `messages send` also covers `messages send-diff`. A
// structured `messages report` is a human-visible publish too.
// `reactions
// add` counts because the base prompt directs agents to react
// rather than post a bare acknowledgement, so nagging an agent
// that reacted would punish documented-correct behavior.
cmd.contains("messages send") || cmd.contains("reactions add")
cmd.contains("messages send")
|| cmd.contains("messages report")
|| cmd.contains("reactions add")
})
}

Expand Down Expand Up @@ -1448,13 +1452,14 @@ mod tests {
/// The shapes the guard must recognize as a publish attempt. Callers apply
/// the registry checks first; these cover the name suffix and command text.
#[test]
fn reply_shape_matches_documented_send_forms() {
fn reply_shape_matches_documented_publish_forms() {
for cmd in [
"buzz messages send --channel X --content Y",
"buzz --relay wss://r messages send --channel X --content Y",
"/abs/path/buzz messages send",
"printf 'hi' | buzz messages send --content -",
"buzz messages send-diff --diff -",
"buzz messages report --channel X --thread E --status completed --outcome done",
"buzz reactions add --event E --emoji +",
// Assembled through another shell: rev 3's tokenizer missed this.
r#"sh -c "buzz messages send --channel X""#,
Expand Down
164 changes: 164 additions & 0 deletions crates/buzz-cli/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,141 @@ pub async fn cmd_send_message(
Ok(())
}

pub struct WorkReportParams {
pub channel_id: String,
pub thread_root: String,
pub status: crate::ReportStatus,
pub outcome: String,
pub deliverables: Vec<String>,
pub decisions: Vec<String>,
pub verification: Vec<String>,
pub risks: Vec<String>,
pub next_actions: Vec<String>,
pub prior: Option<String>,
}

pub async fn cmd_publish_work_report(
client: &BuzzClient,
params: WorkReportParams,
) -> Result<(), CliError> {
let channel_id = parse_uuid(&params.channel_id)?;
let root_event = fetch_event(client, &params.thread_root).await?;
let root_channel = channel_id_from_event(&params.thread_root, &root_event)?;
if root_channel != channel_id {
return Err(CliError::Usage(format!(
"thread root {} does not belong to channel {}",
params.thread_root, params.channel_id
)));
}
let resolved_root = thread_ref_from_event(&params.thread_root, &root_event)?
.root_event_id
.to_hex();
if !resolved_root.eq_ignore_ascii_case(&params.thread_root) {
return Err(CliError::Usage(format!(
"--thread must reference the thread root (use {resolved_root})"
)));
}
let thread_root = parse_event_id(&params.thread_root)?;
let head_filter = serde_json::json!({
"kinds": [40009],
"#h": [params.channel_id.as_str()],
"#e": [params.thread_root.as_str()],
"limit": 100
});
let mut report_events = fetch_events(client, &head_filter)
.await
.ok_or_else(|| CliError::Other("could not load the current work-report head".into()))?;
report_events.retain(|event| {
event
.get("tags")
.and_then(serde_json::Value::as_array)
.is_some_and(|tags| {
tags.iter().any(|tag| {
let Some(parts) = tag.as_array() else {
return false;
};
parts.first().and_then(serde_json::Value::as_str) == Some("e")
&& parts.get(1).and_then(serde_json::Value::as_str)
== Some(params.thread_root.as_str())
&& parts.get(3).and_then(serde_json::Value::as_str) == Some("root")
})
})
});
report_events.sort_by(|left, right| {
left.get("created_at")
.and_then(serde_json::Value::as_u64)
.cmp(&right.get("created_at").and_then(serde_json::Value::as_u64))
.then_with(|| {
left.get("id")
.and_then(serde_json::Value::as_str)
.cmp(&right.get("id").and_then(serde_json::Value::as_str))
})
});
let current_head = report_events
.last()
.and_then(|event| event.get("id"))
.and_then(serde_json::Value::as_str);
match (current_head, params.prior.as_deref()) {
(Some(head), Some(prior)) if head.eq_ignore_ascii_case(prior) => {}
(Some(head), _) => {
return Err(CliError::Conflict(format!(
"work report head changed; retry with --prior {head}"
)))
}
(None, Some(_)) => {
return Err(CliError::Conflict(
"cannot set --prior because this thread has no work report".into(),
))
}
(None, None) => {}
}
let prior = match params.prior.as_deref() {
Some(prior_id) => {
let prior_event = fetch_event(client, prior_id).await?;
if prior_event.get("kind").and_then(serde_json::Value::as_u64) != Some(40009) {
return Err(CliError::Usage(
"--prior must reference a work report".into(),
));
}
let tags = prior_event
.get("tags")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| CliError::Usage("prior work report has no tags".into()))?;
let matches_root = tags.iter().any(|tag| {
let Some(parts) = tag.as_array() else {
return false;
};
parts.first().and_then(serde_json::Value::as_str) == Some("e")
&& parts.get(1).and_then(serde_json::Value::as_str)
== Some(params.thread_root.as_str())
&& parts.get(3).and_then(serde_json::Value::as_str) == Some("root")
});
if !matches_root || channel_id_from_event(prior_id, &prior_event)? != channel_id {
return Err(CliError::Usage(
"--prior must reference a work report for the same thread".into(),
));
}
Some(parse_event_id(prior_id)?)
}
None => None,
};
let report = buzz_sdk::WorkReport {
status: params.status.into(),
outcome: params.outcome,
deliverables: params.deliverables,
decisions: params.decisions,
verification: params.verification,
risks: params.risks,
next_actions: params.next_actions,
};
let builder = buzz_sdk::build_work_report(channel_id, thread_root, prior, &report)
.map_err(|error| CliError::Usage(error.to_string()))?;
let event = client.sign_event(builder)?;
let response = client.submit_event(event).await?;
println!("{}", normalize_write_response(&response));
Ok(())
}

pub struct SendDiffParams {
pub channel_id: String,
pub diff: String,
Expand Down Expand Up @@ -965,6 +1100,35 @@ pub async fn dispatch(
)
.await
}
MessagesCmd::Report {
channel,
thread,
status,
outcome,
deliverables,
decisions,
verification,
risks,
next_actions,
prior,
} => {
cmd_publish_work_report(
client,
WorkReportParams {
channel_id: channel,
thread_root: thread,
status,
outcome,
deliverables,
decisions,
verification,
risks,
next_actions,
prior,
},
)
.await
}
MessagesCmd::Edit { event, content } => cmd_edit_message(client, &event, &content).await,
MessagesCmd::Delete {
event,
Expand Down
Loading