Skip to content
Merged
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/buzz-acp/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ fn make_prompt_context(
system_prompt: config.system_prompt.clone(),
session_title: config.session_title.clone(),
team_instructions: if sharing.is_some() {
Some(format!("{}\n\nTo delegate to another shared agent, use `buzz agents handoff <agent-pubkey> --from <incoming-event-id> --message '<task>'`. Use the current incoming task's event ID. Delegate specific work or reviews; avoid acknowledgement loops. Sharing preserves the original requester and does not confer owner lifecycle rights.",config.team_instructions.as_deref().unwrap_or("")))
Some(format!("{}\n\nTo delegate to another shared agent, use `buzz agents handoff <agent-pubkey> --from <incoming-event-id> --message '<task>'`. Use the current incoming task's event ID. Delegate specific work or reviews; avoid acknowledgement loops. When you finish work another agent handed you, report the result with `buzz agents return --from <incoming-event-id> --message '<result>'`, which wakes that agent; a plain @mention of an agent does not. Each handoff returns once. Sharing preserves the original requester and does not confer owner lifecycle rights.",config.team_instructions.as_deref().unwrap_or("")))
} else {
config.team_instructions.clone()
},
Expand Down
94 changes: 62 additions & 32 deletions crates/buzz-cli/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,50 +13,80 @@ use crate::{AgentsCmd, RespondToArg};
#[path = "agents_sharing_tests.rs"]
mod sharing_tests;

/// The incoming task `from` names, as this agent's relay connection sees it.
async fn incoming_task(client: &BuzzClient, from: &str) -> Result<nostr::Event, CliError> {
if client.auth_tag_owner_hex().is_none() {
return Err(CliError::Auth("handoff requires an agent identity".into()));
}
let from = crate::validate::parse_event_id(from)?;
let raw = client
.query(&json!({"kinds":[9,40002],"ids":[from],"limit":1}))
.await?;
let mut events: Vec<nostr::Event> = serde_json::from_str(&raw)
.map_err(|_| CliError::Other("invalid incoming task response".into()))?;
if events.len() != 1 {
return Err(CliError::Usage(
"incoming task is not visible to this agent".into(),
));
}
let source = events
.pop()
.ok_or_else(|| CliError::Other("incoming task was not returned".into()))?;
if source.id != from {
return Err(CliError::Other(
"relay returned a different incoming task".into(),
));
}
Ok(source)
}

/// Sign and submit a handoff carrying `source`'s provenance to `target`.
async fn send_handoff(
client: &BuzzClient,
source: &nostr::Event,
target: PublicKey,
message: &str,
) -> Result<(), CliError> {
let builder = buzz_sdk::sharing::task::handoff(
source,
client.keys().public_key(),
target,
&read_or_stdin(message)?,
nostr::Timestamp::now().as_secs(),
)
.map_err(|e| CliError::Usage(e.to_string()))?;
println!(
"{}",
client.submit_event(client.sign_event(builder)?).await?
);
Ok(())
}

pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), CliError> {
match command {
AgentsCmd::Handoff {
agent,
from,
message,
} => {
if client.auth_tag_owner_hex().is_none() {
return Err(CliError::Auth("handoff requires an agent identity".into()));
}
validate_hex64(&agent)?;
let target = PublicKey::from_hex(&agent).map_err(|e| CliError::Usage(e.to_string()))?;
let from = crate::validate::parse_event_id(&from)?;
let raw = client
.query(&json!({"kinds":[9,40002],"ids":[from],"limit":1}))
.await?;
let mut events: Vec<nostr::Event> = serde_json::from_str(&raw)
.map_err(|_| CliError::Other("invalid incoming task response".into()))?;
if events.len() != 1 {
let source = incoming_task(client, &from).await?;
send_handoff(client, &source, target, &message).await
}
AgentsCmd::Return { from, message } => {
let source = incoming_task(client, &from).await?;
if buzz_sdk::sharing::task::origin(&source)
.map_err(|e| CliError::Usage(e.to_string()))?
.is_none()
{
return Err(CliError::Usage(
"incoming task is not visible to this agent".into(),
));
}
let source = events
.pop()
.ok_or_else(|| CliError::Other("incoming task was not returned".into()))?;
if source.id != from {
return Err(CliError::Other(
"relay returned a different incoming task".into(),
"only a handoff from another agent can be returned; reply in the thread instead"
.into(),
));
}
let builder = buzz_sdk::sharing::task::handoff(
&source,
client.keys().public_key(),
target,
&read_or_stdin(&message)?,
nostr::Timestamp::now().as_secs(),
)
.map_err(|e| CliError::Usage(e.to_string()))?;
println!(
"{}",
client.submit_event(client.sign_event(builder)?).await?
);
Ok(())
// Back to the agent that delegated this work.
send_handoff(client, &source, source.pubkey, &message).await
}
AgentsCmd::Share { agent, channel } => sharing(client, &agent, channel, true).await,
AgentsCmd::Unshare { agent, channel } => sharing(client, &agent, channel, false).await,
Expand Down
72 changes: 72 additions & 0 deletions crates/buzz-cli/src/commands/agents_sharing_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,75 @@ async fn share_and_unshare_publish_signed_scoped_heads_after_owner_verification(
}
server.abort();
}

#[tokio::test]
async fn return_answers_the_delegating_agent_and_refuses_a_human_mention() {
let owner = Keys::generate();
let (coordinator, worker, human) = (Keys::generate(), Keys::generate(), Keys::generate());
let channel = uuid::Uuid::new_v4();
let request = EventBuilder::new(Kind::Custom(9), "ship it")
.tag(nostr::Tag::parse(["h".to_owned(), channel.to_string()]).unwrap())
.tag(nostr::Tag::public_key(coordinator.public_key()))
.sign_with_keys(&human)
.unwrap();
let now = nostr::Timestamp::now().as_secs();
let handoff = buzz_sdk::sharing::task::handoff(
&request,
coordinator.public_key(),
worker.public_key(),
"build the price feed",
now,
)
.unwrap()
.sign_with_keys(&coordinator)
.unwrap();
let mention = EventBuilder::new(Kind::Custom(9), "@worker hi")
.tag(nostr::Tag::parse(["h".to_owned(), channel.to_string()]).unwrap())
.tag(nostr::Tag::public_key(worker.public_key()))
.sign_with_keys(&human)
.unwrap();
let known = vec![handoff.clone(), mention.clone()];
let writes = Arc::new(Mutex::new(Vec::<Event>::new()));
let app = Router::new()
.route("/query", post(move |Json(filters): Json<Vec<serde_json::Value>>| {
let known = known.clone();
async move {
let id = filters[0]["ids"][0].as_str().unwrap().to_owned();
Json(known.into_iter().filter(|e| e.id.to_hex() == id).collect::<Vec<_>>())
}
}))
.route("/events", post(|State(writes): State<Arc<Mutex<Vec<Event>>>>, Json(event): Json<Event>| async move {
event.verify().unwrap();
let id = event.id;
writes.lock().unwrap().push(event);
Json(json!({"event_id":id,"accepted":true,"message":""}))
})).with_state(writes.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let auth = buzz_sdk::nip_oa::compute_auth_tag(&owner, &worker.public_key(), "").unwrap();
let client = BuzzClient::new(
url,
worker.clone(),
Some(buzz_sdk::nip_oa::parse_auth_tag(&auth).unwrap()),
None,
)
.unwrap();
let back = |from: &Event| AgentsCmd::Return {
from: from.id.to_hex(),
message: "price feed merged".into(),
};
dispatch(back(&handoff), &client).await.unwrap();
assert!(dispatch(back(&mention), &client).await.is_err());
let events = writes.lock().unwrap();
assert_eq!(events.len(), 1, "the human mention was not returned");
let result = &events[0];
assert_eq!(result.pubkey, worker.public_key());
// Addressed to the coordinator, as the reply to its handoff.
buzz_sdk::sharing::task::scope(result, coordinator.public_key(), now + 1).unwrap();
let origin = buzz_sdk::sharing::task::origin(result).unwrap().unwrap();
assert_eq!(origin.request, request.id);
assert_eq!(origin.agent, coordinator.public_key());
assert_eq!(origin.parent, handoff.id);
server.abort();
}
13 changes: 12 additions & 1 deletion crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,16 @@ pub enum AgentsCmd {
#[arg(long)]
message: String,
},
/// Report the result of a handoff to the agent that sent it, waking that agent.
/// Each handoff returns once and costs no handoff budget.
Return {
/// The incoming handoff event ID you are answering
#[arg(long)]
from: String,
/// Result text, or '-' to read stdin
#[arg(long)]
message: String,
},
/// Let current channel members invoke your agent and its configured tools by explicit mention.
/// Ownership and lifecycle control stay with you; DMs remain private.
Share {
Expand Down Expand Up @@ -2568,6 +2578,7 @@ mod tests {
"draft-create",
"draft-update",
"handoff",
"return",
"share",
"sharing",
"unarchive",
Expand Down Expand Up @@ -2714,7 +2725,7 @@ mod tests {
#[test]
fn subcommand_counts_are_stable() {
let expected: Vec<(&str, usize)> = vec![
("agents", 9),
("agents", 10),
("canvas", 4),
("channels", 16),
("dms", 4),
Expand Down
72 changes: 61 additions & 11 deletions crates/buzz-sdk/src/sharing/budget.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
//! Bounded task admissions. Persist a successful mutation atomically before any
//! launch or turn; retries of the same (task, agent) consume no further budget.
//!
//! A return is a handoff back to the agent that sent its parent handoff: the
//! result of delegated work, waking the delegator. Each handoff allows one
//! return and a return cannot be returned, so returns never exceed handoffs;
//! they do not count against the handoff or per-agent limits.

use super::{policy::Task, task::Scope};
use nostr::{EventId, PublicKey};
use serde::{Deserialize, Serialize};

/// Maximum lifetime of one human request's handoff budget.
pub const LIFETIME_SECONDS: u64 = 7 * 24 * 60 * 60;
const MAX_HANDOFFS: usize = 4;
pub(crate) const MAX_HANDOFFS: usize = 4;
const MAX_AGENT_TASKS: usize = 2;

/// A fixed refusal, safe to report without exposing any credential or task text.
Expand All @@ -25,6 +30,9 @@ pub enum Error {
/// A handoff must refer to an admitted incoming task for its signer.
#[error("handoff parent was not admitted for the sending agent")]
Parent,
/// The delegated work already reported back once.
#[error("this handoff was already returned; ask a human before continuing")]
Returned,
/// Stored provenance does not match the verified task.
#[error("task admission journal is invalid")]
Journal,
Expand All @@ -37,6 +45,16 @@ struct Receipt {
agent: PublicKey,
requester: PublicKey,
handoff: bool,
/// For a return, the handoff it answers.
#[serde(default, skip_serializing_if = "Option::is_none")]
returns: Option<EventId>,
}

impl Receipt {
/// Counts against the handoff and per-agent limits.
fn budgeted(&self) -> bool {
self.returns.is_none()
}
}

/// The durable admission record for one initial human-to-agent task.
Expand Down Expand Up @@ -70,6 +88,7 @@ impl Budget {
agent: task.origin.agent,
requester: task.origin_requester,
handoff: false,
returns: None,
}],
};
budget.validate(task, now)?;
Expand Down Expand Up @@ -99,7 +118,8 @@ impl Budget {
|| self.owner != task.origin_owner
|| self.requested_at != task.requested_at
|| self.receipts.is_empty()
|| self.receipts.len() > MAX_HANDOFFS + 1
|| self.receipts.iter().filter(|r| r.budgeted()).count() > MAX_HANDOFFS + 1
|| self.receipts.len() > 2 * MAX_HANDOFFS + 1
{
return Err(Error::Journal);
}
Expand All @@ -108,6 +128,7 @@ impl Budget {
|| first.agent != self.agent
|| first.requester != task.origin_requester
|| first.handoff
|| first.returns.is_some()
{
return Err(Error::Journal);
}
Expand All @@ -119,12 +140,25 @@ impl Budget {
|| self
.receipts
.iter()
.filter(|r| r.agent == receipt.agent)
.filter(|r| r.agent == receipt.agent && r.budgeted())
.count()
> MAX_AGENT_TASKS
{
return Err(Error::Journal);
}
if let Some(answered) = receipt.returns {
let earlier = &self.receipts[..index];
let valid = earlier.iter().any(|r| {
r.event == answered
&& r.handoff
&& r.budgeted()
&& r.agent == receipt.requester
&& r.requester == receipt.agent
});
if !valid || earlier.iter().any(|r| r.returns == Some(answered)) {
return Err(Error::Journal);
}
}
}
Ok(())
}
Expand All @@ -144,21 +178,36 @@ impl Budget {
Err(Error::Journal)
};
}
if !task.handoff
|| !self
.receipts
.iter()
.any(|r| r.event == task.origin.parent && r.agent == task.requester)
{
let Some(parent) = self
.receipts
.iter()
.find(|r| r.event == task.origin.parent && r.agent == task.requester)
.filter(|_| task.handoff)
else {
return Err(Error::Parent);
};
// Back to the agent that delegated this work: its result.
if parent.handoff && parent.budgeted() && parent.requester == task.agent {
let answered = parent.event;
if self.receipts.iter().any(|r| r.returns == Some(answered)) {
return Err(Error::Returned);
}
self.receipts.push(Receipt {
event: task.trigger,
agent: task.agent,
requester: task.requester,
handoff: true,
returns: Some(answered),
});
return Ok(true);
}
if self.receipts.len() > MAX_HANDOFFS {
if self.receipts.iter().filter(|r| r.budgeted()).count() > MAX_HANDOFFS {
return Err(Error::Handoffs);
}
if self
.receipts
.iter()
.filter(|r| r.agent == task.agent)
.filter(|r| r.agent == task.agent && r.budgeted())
.count()
>= MAX_AGENT_TASKS
{
Expand All @@ -169,6 +218,7 @@ impl Budget {
agent: task.agent,
requester: task.requester,
handoff: true,
returns: None,
});
Ok(true)
}
Expand Down
Loading
Loading