From 691e7aae59987ed3a21b71eee7c6480a33836024 Mon Sep 17 00:00:00 2001 From: Tee8z Date: Thu, 5 Nov 2026 10:31:37 -0500 Subject: [PATCH] feat(agents): let a handoff's target return its result to the delegator A coordinating agent could fan work out but never hear back without a human. A plain @mention from a worker is refused (no task ancestry), and a proper handoff back counted against the four-handoff and two-admissions-per-agent budget, so a coordinator with three workers could collect at most one result. A return is a handoff to the agent that sent its parent handoff, derived from the durable receipts: no new event kind or tags. The manager's budget records which handoff a return answers. Each handoff returns once, a return cannot itself be returned, and returns do not count against either limit, so returns never exceed handoffs and work stays bounded. Replying to a result with new work is an ordinary, budgeted handoff. Journals without returns still load; the journal is revalidated on every admission. The relay-side ancestry walk was capped at four links; each handoff and its return is a link, so the cap is now twice the handoff limit. `buzz agents return --from --message ...` addresses the sender automatically and refuses anything but a received handoff. The harness's team instructions now tell shared agents to report results this way rather than by @mention. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tee8z --- crates/buzz-acp/src/runtime.rs | 2 +- crates/buzz-cli/src/commands/agents.rs | 94 ++++++++++++------- .../src/commands/agents_sharing_tests.rs | 72 ++++++++++++++ crates/buzz-cli/src/lib.rs | 13 ++- crates/buzz-sdk/src/sharing/budget.rs | 72 +++++++++++--- crates/buzz-sdk/src/sharing/budget/tests.rs | 84 ++++++++++++++++- crates/buzz-sdk/src/sharing/policy.rs | 5 +- crates/buzz-sdk/src/sharing/policy/tests.rs | 25 +++++ docs/shared-channel-agents.md | 13 +++ voltage/channel-agent-sharing.md | 1 + 10 files changed, 333 insertions(+), 48 deletions(-) diff --git a/crates/buzz-acp/src/runtime.rs b/crates/buzz-acp/src/runtime.rs index 444a0b4e837..292d7f410ae 100644 --- a/crates/buzz-acp/src/runtime.rs +++ b/crates/buzz-acp/src/runtime.rs @@ -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 --from --message ''`. 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 --from --message ''`. 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 --message ''`, 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() }, diff --git a/crates/buzz-cli/src/commands/agents.rs b/crates/buzz-cli/src/commands/agents.rs index 3cf9d16b287..867efb47106 100644 --- a/crates/buzz-cli/src/commands/agents.rs +++ b/crates/buzz-cli/src/commands/agents.rs @@ -13,6 +13,55 @@ 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 { + 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 = 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 { @@ -20,43 +69,24 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli 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 = 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, diff --git a/crates/buzz-cli/src/commands/agents_sharing_tests.rs b/crates/buzz-cli/src/commands/agents_sharing_tests.rs index 72221070fff..c8d546d20cc 100644 --- a/crates/buzz-cli/src/commands/agents_sharing_tests.rs +++ b/crates/buzz-cli/src/commands/agents_sharing_tests.rs @@ -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::::new())); + let app = Router::new() + .route("/query", post(move |Json(filters): Json>| { + 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::>()) + } + })) + .route("/events", post(|State(writes): State>>>, Json(event): Json| 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(); +} diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index d238b5a4f46..f4d000ca206 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -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 { @@ -2568,6 +2578,7 @@ mod tests { "draft-create", "draft-update", "handoff", + "return", "share", "sharing", "unarchive", @@ -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), diff --git a/crates/buzz-sdk/src/sharing/budget.rs b/crates/buzz-sdk/src/sharing/budget.rs index c5178966df2..43c96830fb8 100644 --- a/crates/buzz-sdk/src/sharing/budget.rs +++ b/crates/buzz-sdk/src/sharing/budget.rs @@ -1,5 +1,10 @@ //! 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}; @@ -7,7 +12,7 @@ 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. @@ -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, @@ -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, +} + +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. @@ -70,6 +88,7 @@ impl Budget { agent: task.origin.agent, requester: task.origin_requester, handoff: false, + returns: None, }], }; budget.validate(task, now)?; @@ -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); } @@ -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); } @@ -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(()) } @@ -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 { @@ -169,6 +218,7 @@ impl Budget { agent: task.agent, requester: task.requester, handoff: true, + returns: None, }); Ok(true) } diff --git a/crates/buzz-sdk/src/sharing/budget/tests.rs b/crates/buzz-sdk/src/sharing/budget/tests.rs index d5363906c28..13b3587fc1a 100644 --- a/crates/buzz-sdk/src/sharing/budget/tests.rs +++ b/crates/buzz-sdk/src/sharing/budget/tests.rs @@ -62,8 +62,9 @@ fn receipts_survive_restart_and_enforce_parent_and_per_agent_limits() { 4, ); assert_eq!(recovered.admit(&forged, 104), Err(Error::Parent)); + // `second` returned `first`'s result; a handoff returns once. let reset = next(&base, peer, base.agent, first.trigger, 5); - assert_eq!(recovered.admit(&reset, 104), Err(Error::RepeatedAgent)); + assert_eq!(recovered.admit(&reset, 104), Err(Error::Returned)); let spoofed = next( &base, Keys::generate().public_key(), @@ -118,3 +119,84 @@ fn expiry_cannot_replenish_an_old_request_and_different_humans_cannot_share_a_bu forged.scope.channel = uuid::Uuid::new_v4(); assert_eq!(budget.admit(&forged, 102), Err(Error::Journal)); } + +#[test] +fn a_coordinator_collects_every_result_without_spending_the_handoff_budget() { + let base = task(); + let coordinator = base.agent; + let mut budget = Budget::new(&base, 101).unwrap(); + // Fan out to three workers, then each reports back. + let workers: Vec = (0..3) + .map(|n| { + let handoff = next( + &base, + coordinator, + Keys::generate().public_key(), + base.trigger, + 10 + n, + ); + assert!(budget.admit(&handoff, 102).unwrap()); + handoff + }) + .collect(); + let returns: Vec = workers + .iter() + .enumerate() + .map(|(n, work)| { + let result = next(&base, work.agent, coordinator, work.trigger, 20 + n as u8); + assert!(budget.admit(&result, 103).unwrap(), "return {n}"); + result + }) + .collect(); + // Restart: the journal validates and an exact retry is free. + let mut budget: Budget = + serde_json::from_str(&serde_json::to_string(&budget).unwrap()).unwrap(); + assert!(!budget.admit(&returns[0], 104).unwrap()); + // One return per handoff, and a return cannot itself be returned. + let again = next(&base, workers[0].agent, coordinator, workers[0].trigger, 30); + assert_eq!(budget.admit(&again, 104), Err(Error::Returned)); + // A reply to a result is new work: budgeted, so the fourth handoff fits + // and the fifth does not. + let follow_up = next(&base, coordinator, workers[0].agent, returns[0].trigger, 31); + assert!(budget.admit(&follow_up, 105).unwrap()); + let back = next(&base, workers[0].agent, coordinator, follow_up.trigger, 32); + assert!(budget.admit(&back, 105).unwrap()); + let fifth = next(&base, coordinator, workers[1].agent, returns[1].trigger, 33); + assert_eq!(budget.admit(&fifth, 105), Err(Error::Handoffs)); + // A worker cannot "return" to an agent that did not delegate to it. + let stranger = next( + &base, + workers[1].agent, + workers[2].agent, + workers[1].trigger, + 34, + ); + assert_eq!(budget.admit(&stranger, 105), Err(Error::Handoffs)); +} + +#[test] +fn a_forged_return_in_the_journal_is_refused() { + let base = task(); + let peer = Keys::generate().public_key(); + let mut budget = Budget::new(&base, 101).unwrap(); + let work = next(&base, base.agent, peer, base.trigger, 2); + assert!(budget.admit(&work, 102).unwrap()); + let mut forged: serde_json::Value = serde_json::to_value(&budget).unwrap(); + // A "return" by the delegator itself, answering its own handoff. + forged["receipts"] + .as_array_mut() + .unwrap() + .push(serde_json::json!({ + "event": EventId::from_byte_array([3; 32]), + "agent": peer, + "requester": base.agent, + "handoff": true, + "returns": work.trigger, + })); + let mut forged: Budget = serde_json::from_value(forged).unwrap(); + assert_eq!(forged.admit(&base, 103), Err(Error::Journal)); + // Journals written before returns existed still load. + let legacy = serde_json::to_string(&budget).unwrap(); + assert!(!legacy.contains("returns")); + assert!(serde_json::from_str::(&legacy).is_ok()); +} diff --git a/crates/buzz-sdk/src/sharing/policy.rs b/crates/buzz-sdk/src/sharing/policy.rs index 74a5233836a..c90a2ae2168 100644 --- a/crates/buzz-sdk/src/sharing/policy.rs +++ b/crates/buzz-sdk/src/sharing/policy.rs @@ -197,7 +197,8 @@ where } // Verify the signed relationship as well as the later manager's durable -// receipt. Four links bound both work and relay queries; a cycle never grows. +// receipt. Each handoff and its one return is a link, so the budget bounds +// both work and relay queries; a cycle never grows. async fn ancestry( query: &mut F, event: &Event, @@ -212,7 +213,7 @@ where { let denied = || Error::Denied("handoff parent does not match this task"); let mut child = event.clone(); - for _ in 0..4 { + for _ in 0..2 * super::budget::MAX_HANDOFFS { let claimed = task::origin(&child) .map_err(|_| denied())? .ok_or_else(denied)?; diff --git a/crates/buzz-sdk/src/sharing/policy/tests.rs b/crates/buzz-sdk/src/sharing/policy/tests.rs index ac4a7ac0351..99404deb935 100644 --- a/crates/buzz-sdk/src/sharing/policy/tests.rs +++ b/crates/buzz-sdk/src/sharing/policy/tests.rs @@ -212,6 +212,31 @@ fn teammate_tasks_and_cross_owner_handoffs_preserve_actual_requester() { assert_eq!(back.origin.request, f.request.id); assert_eq!(back.origin.parent, handoff.id); assert_eq!(back.requester, f.peer.public_key()); + // Two more round trips: each handoff and its return is a link, so the + // chain reaches six links within the budget's bound. + let mut source = returned; + for _ in 0..2 { + let (out, back) = ( + (f.agent.clone(), f.peer.clone()), + (f.peer.clone(), f.agent.clone()), + ); + for (from, to) in [out, back] { + let link = task::handoff(&source, from.public_key(), to.public_key(), "more", 103) + .unwrap() + .custom_created_at(Timestamp::from(103)) + .sign_with_keys(&from) + .unwrap(); + f.events.push(link.clone()); + let owner = if to.public_key() == f.agent.public_key() { + f.owner.public_key() + } else { + f.other.public_key() + }; + let admitted = f.check(&link, owner, to.public_key()).unwrap(); + assert_eq!(admitted.origin.parent, source.id); + source = link; + } + } } #[test] diff --git a/docs/shared-channel-agents.md b/docs/shared-channel-agents.md index cb0a84bb233..2254733a930 100644 --- a/docs/shared-channel-agents.md +++ b/docs/shared-channel-agents.md @@ -43,6 +43,19 @@ parent event. A durable manager journal permits four handoffs total and at most two admissions per agent under one human-to-initial-agent task. A duplicate admission consumes no additional budget. The task expires after seven days. +When the target finishes, it reports back to the agent that handed it the work: + +```sh +buzz agents return --from --message 'Merged the price feed in #123' +``` + +A return wakes the delegating agent with the result, so a coordinator can fan +work out and collect every result without a human. A plain @mention of an agent +does not wake it: it is refused as an agent request without task ancestry. +Each handoff returns once, and a return cannot itself be returned. Returns do not +count against the four-handoff or two-admission limits, and there can never be +more returns than handoffs. Replying to a result with new work is a handoff. + ## Schedule remote work In desktop, open **Workflows**, choose a channel, and create a workflow with a diff --git a/voltage/channel-agent-sharing.md b/voltage/channel-agent-sharing.md index e92051cc904..c29c56249f5 100644 --- a/voltage/channel-agent-sharing.md +++ b/voltage/channel-agent-sharing.md @@ -80,6 +80,7 @@ Start with these limits, subject to validation: - Four agent handoffs per human-to-agent task. A human message addressing multiple agents creates separate initial tasks. - At most two task admissions for one agent under that request. +- One return per handoff: the target's result, addressed to the agent that handed it the work, which wakes that agent. A return cannot be returned and does not count against these limits, so returns never exceed handoffs. The ancestry walk is bounded by twice the handoff limit. - Existing per-turn deadlines and five primary sessions per owner. - Existing worker quotas and 100% Spot capacity.