From ceff662d254d5366b2933892a9307110d9352068 Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 2 Aug 2026 03:42:55 +0200 Subject: [PATCH] recv: drop wait_seconds from the MCP tool, always an immediate peek --- docs/conventions.md | 11 +-- docs/turn-loop/mcp.md | 11 ++- hive-agent-mcp/src/mcp/args.rs | 8 --- hive-agent-mcp/src/mcp/mod.rs | 28 ++++---- hive-agent-mcp/src/mcp/render.rs | 114 +++++++++---------------------- hive-agent/prompts/system.md | 4 +- 6 files changed, 59 insertions(+), 117 deletions(-) diff --git a/docs/conventions.md b/docs/conventions.md index d91da1e3..e06f86c7 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -121,10 +121,13 @@ in `hive-c0re::dashboard_events::DashboardEvent`. agent. Always returns a list (`Messages { messages }`) — empty when nothing's pending, single-pop when `max = None` (default 1, the single-message behaviour), batched up to `max` when caller asks for -more (server-side cap is 5; values above clamp silently). -`wait_seconds` long-polls for the first message; once one arrives — -or one is already pending — the call drains up to `max` in total -before returning, so a single `Recv` call coalesces a burst. +more (server-side cap is 5; values above clamp silently). The wire +request still carries an optional `wait_seconds` (long-poll the first +message, once one arrives — or one is already pending — the call +drains up to `max` in total): the harness's own turn-driving loop +uses it internally (`hive-agent`'s `recv_next`, 180s). The +agent-facing MCP `recv` tool no longer exposes this parameter at all +(#2814) — it always passes `wait_seconds: None`, an immediate peek. Per-row bookkeeping inside the broker: diff --git a/docs/turn-loop/mcp.md b/docs/turn-loop/mcp.md index 06446a7c..d02d9185 100644 --- a/docs/turn-loop/mcp.md +++ b/docs/turn-loop/mcp.md @@ -23,7 +23,7 @@ preset (`AGENT_DEFAULT`) includes `messaging`, `meta`, `inbox`, and ## Core tools (always available) **Messaging** (`messaging` group): `send(to, body, in_reply_to?)`, -`recv(wait_seconds?, max?)`, `ask(question, options?, multi?, +`recv(max?)`, `ask(question, options?, multi?, ttl_seconds?, to?)`, `answer(id, answer)`, `ack_until(up_to)`. - `send` — message a peer (logical name) or the operator @@ -36,11 +36,10 @@ ttl_seconds?, to?)`, `answer(id, answer)`, `ack_until(up_to)`. `[ "operator" ]` to restrict a sub-agent to operator messages only (the topology parent is always reachable regardless of this list — that carve-out is structural, keyed on parent relationship, not name). -- `recv` — drain inbox. Without `wait_seconds` (or `0`) returns - immediately. Positive value parks the turn up to that many seconds - (cap 180) — incoming messages wake instantly. `max` (default 1, cap - 5) drains up to N rows; `wait_seconds` applies to the first, then - drains up to `max` total. Each returned row is prefixed with +- `recv` — drain inbox. Always an immediate peek, never blocks (#2814 + — dropped the `wait_seconds` long-poll param entirely; nobody had a + use case for it that wasn't already better served by ending the + turn). `max` (default 1, cap 5) drains up to N rows. Each returned row is prefixed with `[msg #]` (broker row id; note the highest id seen, then pass it to `ack_until` to bulk-triage the batch). **Graceful shutdown**: when the harness receives a stop signal, the inbox becomes fenced and `recv` returns an diff --git a/hive-agent-mcp/src/mcp/args.rs b/hive-agent-mcp/src/mcp/args.rs index bb2ddb5c..9f7e4e54 100644 --- a/hive-agent-mcp/src/mcp/args.rs +++ b/hive-agent-mcp/src/mcp/args.rs @@ -21,20 +21,12 @@ pub struct SendArgs { #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct RecvArgs { - /// How long to long-poll for the FIRST message before returning - /// the empty marker. Capped at 60s server-side. Default (None) - /// is 30s. Useful when an agent wants to park its turn waiting - /// for any new work — pick a longer wait to coalesce bursts. - #[serde(default)] - pub wait_seconds: Option, /// Maximum number of messages to pop in this round-trip. Default /// (None) is 1 (single-message behaviour — exactly what you want /// when you're called to drive a turn off the first wake). Pass /// a higher value (capped at 5 server-side) when you've been /// told the inbox has more queued (the wake prompt mentions /// pending count) and want to drain everything in one tool call. - /// Once the long-poll wakes up, the call drains up to `max` in - /// total before returning — no extra round-trip needed. #[serde(default)] pub max: Option, } diff --git a/hive-agent-mcp/src/mcp/mod.rs b/hive-agent-mcp/src/mcp/mod.rs index a4876de6..dd78d313 100644 --- a/hive-agent-mcp/src/mcp/mod.rs +++ b/hive-agent-mcp/src/mcp/mod.rs @@ -276,24 +276,21 @@ impl AgentServer { #[tool( description = "Pop messages from this agent's inbox. Returns one or more messages, or \ - an empty marker if nothing is waiting. \n\n\ + an empty marker if nothing is waiting. Always an immediate 'anything pending?' peek \ + — never blocks. \n\n\ **Single-message default**: with no args (or `max: 1`) you get the next message — \ - same behaviour the harness uses to drive a turn. Without `wait_seconds` (or with 0) \ - the call returns immediately — a cheap 'anything pending?' peek. \n\n\ - **When idle, prefer ending the turn over parking here.** Ending the turn is the \ + same behaviour the harness uses to drive a turn. \n\n\ + **When idle, prefer ending the turn over polling here.** Ending the turn is the \ ONLY path that observes an in-container todo wake (bash-task completions, matrix \ unread, forge activity) — that signal reaches the harness loop between turns, never \ - a live `recv` call, so parking in `wait_seconds` while a todo lands means sitting \ - blind until the timeout. Ending the turn also checkpoints your session, and costs no \ - latency vs. parking for a real inbox message either way — the broker wakes the next \ - turn just as fast. Reach for a positive `wait_seconds` (capped at 180) only for a \ - short, deliberate in-turn block — e.g. confirming something you just triggered lands \ - within seconds — not as the default way to wait for more work. \n\n\ + a live `recv` call, so looping on `recv` while idle means sitting blind until \ + something else wakes you. Ending the turn also checkpoints your session, and costs \ + no latency vs. polling for a real inbox message either way — the broker wakes the \ + next turn just as fast. \n\n\ **Batch drain**: pass `max: N` (capped at 5) to drain up to N messages in one \ round-trip. Use this when the wake prompt told you the inbox has more queued, or \ - any time you expect a burst — one tool call beats N consecutive single recvs. \ - `wait_seconds` still applies to the FIRST message; once one arrives the call drains \ - up to `max` in total. Empty result reported the same way regardless of `max`. \n\n\ + any time you expect a burst — one tool call beats N consecutive single recvs. Empty \ + result reported the same way regardless of `max`. \n\n\ After popping, the result appends a `(N more message(s) pending …)` line whenever the \ inbox still has queued messages — so you know whether to drain again (or `ack_until`) \ without a separate status check. No line means the inbox is empty." @@ -301,14 +298,13 @@ impl AgentServer { async fn recv(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); run_tool_envelope("recv", log, async move { - let waited = args.wait_seconds.is_some_and(|w| w > 0); let (resp, retries) = self .dispatch(hive_core_agent_sock::Request::Recv { - wait_seconds: args.wait_seconds, + wait_seconds: None, max: args.max, }) .await; - annotate_retries(format_recv(resp, waited), retries) + annotate_retries(format_recv(resp), retries) }) .await } diff --git a/hive-agent-mcp/src/mcp/render.rs b/hive-agent-mcp/src/mcp/render.rs index ddac1248..8a06d98d 100644 --- a/hive-agent-mcp/src/mcp/render.rs +++ b/hive-agent-mcp/src/mcp/render.rs @@ -39,33 +39,26 @@ pub fn format_ack( /// Format helper for `recv`: renders zero, one, or many popped /// messages. Empty list collapses to "(empty)" so claude doesn't go -/// hunting for content; when `waited` is set (the call parked on a -/// long-poll that timed out) the empty result also carries -/// [`IDLE_WAIT_HINT`] nudging the model toward other work. A single -/// message renders as the historical `from: X\n\nbody` block (banner -/// first if `redelivered`). A multi-message batch renders with a -/// `popped N message(s):` header and `---` separators between bodies -/// so the model can tell where one ends and the next begins; -/// per-message redelivery banners included. +/// hunting for content. A single message renders as the historical +/// `from: X\n\nbody` block (banner first if `redelivered`). A +/// multi-message batch renders with a `popped N message(s):` header +/// and `---` separators between bodies so the model can tell where +/// one ends and the next begins; per-message redelivery banners +/// included. #[must_use] -pub fn format_recv( - resp: Result, - waited: bool, -) -> String { +pub fn format_recv(resp: Result) -> String { match resp { Ok(hive_core_agent_sock::Response::Messages { messages, remaining, - }) => render_recv_messages(&messages, remaining, waited), + }) => render_recv_messages(&messages, remaining), // A graceful stop is pending — the inbox is fenced. Render a single - // explicit directive (not an empty inbox, which claude's "park on recv" - // habit would long-poll again, stalling the stop-checkpoint turn until - // the drain wait times out into a hard stop) so every recv during the - // stop unmissably tells claude to flush + end. `remaining` is forced - // to 0 — the inbox is fenced, so a "N more pending" hint would be - // misleading. + // explicit directive (not an empty inbox, which claude might just + // recv again) so every recv during the stop unmissably tells claude + // to flush + end. `remaining` is forced to 0 — the inbox is fenced, + // so a "N more pending" hint would be misleading. Ok(hive_core_agent_sock::Response::GracefulStop) => { - render_recv_messages(&[graceful_stop_message()], 0, waited) + render_recv_messages(&[graceful_stop_message()], 0) } other => reply_err(other, "recv"), } @@ -92,18 +85,10 @@ fn graceful_stop_message() -> hive_sh4re::DeliveredMessage { /// depth; when non-zero a shared "(N more pending …)" hint (identical to the /// wake prompt's) is appended so an in-turn drain knows more is queued. The /// empty path never carries the hint (nothing was popped). -fn render_recv_messages( - messages: &[hive_sh4re::DeliveredMessage], - remaining: u64, - waited: bool, -) -> String { +fn render_recv_messages(messages: &[hive_sh4re::DeliveredMessage], remaining: u64) -> String { use std::fmt::Write as _; if messages.is_empty() { - return if waited { - format!("(empty){IDLE_WAIT_HINT}") - } else { - "(empty)".to_owned() - }; + return "(empty)".to_owned(); } let mut out = if messages.len() == 1 { let m = &messages[0]; @@ -151,14 +136,6 @@ fn msg_id_tag(id: i64) -> String { } } -/// Appended to the `recv` empty result when the agent parked on a -/// long-poll (`wait_seconds > 0`) that timed out with nothing new. -/// Nudges the model to spend the idle time on other useful work -/// instead of immediately re-blocking on `recv`. -pub const IDLE_WAIT_HINT: &str = " — nothing arrived before the wait timed out. \ -If you have other useful work (assigned issues, in-flight PRs, a docs sweep, \ -notes to update), do that now rather than immediately parking on recv again."; - /// Inner renderer for a `Vec` already extracted from the socket /// reply. Called by the `get_loose_ends` handler, which injects the /// `UnreadMatrix` entry before formatting. @@ -544,7 +521,7 @@ pub fn annotate_retries(mut s: String, retries: u32) -> String { #[cfg(test)] mod tests { - use super::{IDLE_WAIT_HINT, format_recv}; + use super::format_recv; fn msg(id: i64, from: &str, body: &str) -> hive_sh4re::DeliveredMessage { hive_sh4re::DeliveredMessage { @@ -557,39 +534,20 @@ mod tests { } #[test] - fn empty_recv_after_wait_appends_idle_hint() { - let out = format_recv( - Ok(hive_core_agent_sock::Response::Messages { - messages: vec![], - remaining: 0, - }), - true, - ); - assert!(out.starts_with("(empty)")); - assert!(out.contains(IDLE_WAIT_HINT)); - } - - #[test] - fn empty_recv_without_wait_has_no_hint() { - let out = format_recv( - Ok(hive_core_agent_sock::Response::Messages { - messages: vec![], - remaining: 0, - }), - false, - ); + fn empty_recv_renders_bare_empty_marker() { + let out = format_recv(Ok(hive_core_agent_sock::Response::Messages { + messages: vec![], + remaining: 0, + })); assert_eq!(out, "(empty)"); } #[test] fn single_recv_with_remaining_appends_pending_hint() { - let out = format_recv( - Ok(hive_core_agent_sock::Response::Messages { - messages: vec![msg(7, "alice", "hi")], - remaining: 3, - }), - false, - ); + let out = format_recv(Ok(hive_core_agent_sock::Response::Messages { + messages: vec![msg(7, "alice", "hi")], + remaining: 3, + })); assert!(out.starts_with("[msg #7] from: alice")); assert!(out.contains("3 more message(s) pending")); assert!(out.contains("max: 3")); @@ -597,25 +555,19 @@ mod tests { #[test] fn single_recv_no_remaining_has_no_pending_hint() { - let out = format_recv( - Ok(hive_core_agent_sock::Response::Messages { - messages: vec![msg(7, "alice", "hi")], - remaining: 0, - }), - false, - ); + let out = format_recv(Ok(hive_core_agent_sock::Response::Messages { + messages: vec![msg(7, "alice", "hi")], + remaining: 0, + })); assert!(!out.contains("more message(s) pending")); } #[test] fn batch_recv_with_remaining_appends_pending_hint_once() { - let out = format_recv( - Ok(hive_core_agent_sock::Response::Messages { - messages: vec![msg(7, "alice", "hi"), msg(8, "bob", "yo")], - remaining: 9, - }), - false, - ); + let out = format_recv(Ok(hive_core_agent_sock::Response::Messages { + messages: vec![msg(7, "alice", "hi"), msg(8, "bob", "yo")], + remaining: 9, + })); assert!(out.starts_with("popped 2 message(s):")); assert_eq!(out.matches("more message(s) pending").count(), 1); // `max` suggestion is clamped to the server-side recv cap. diff --git a/hive-agent/prompts/system.md b/hive-agent/prompts/system.md index 9b7eecdf..b00a7389 100644 --- a/hive-agent/prompts/system.md +++ b/hive-agent/prompts/system.md @@ -2,7 +2,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity Tools (hyperhive surface). Full signature + behavior for each comes from the tool's own MCP description (you already received it via the MCP tool schema) — this is just the map of what exists and which ones are gated, so you know where to look: -- **Inbox / messaging** (always available): `mcp__hyperhive__recv`, `mcp__hyperhive__ack_until`, `mcp__hyperhive__send`, `mcp__hyperhive__ask`, `mcp__hyperhive__answer`, `mcp__hyperhive__get_loose_ends`, `mcp__hyperhive__cancel_loose_end`, `mcp__hyperhive__remind`, `mcp__hyperhive__set_status`, `mcp__hyperhive__get_agent_meta`. Two habits worth internalizing beyond the tool descriptions themselves: prefer ending the turn over parking in `recv` when idle (only turn-boundaries observe in-container todo wakes — bash-task completions, matrix unread, forge activity — and ending the turn is also your checkpoint); and `ask`/`answer` are async — `ask` returns immediately with a question id, the reply lands later as a `question_answered` system event, never block a turn waiting on it inline. +- **Inbox / messaging** (always available): `mcp__hyperhive__recv`, `mcp__hyperhive__ack_until`, `mcp__hyperhive__send`, `mcp__hyperhive__ask`, `mcp__hyperhive__answer`, `mcp__hyperhive__get_loose_ends`, `mcp__hyperhive__cancel_loose_end`, `mcp__hyperhive__remind`, `mcp__hyperhive__set_status`, `mcp__hyperhive__get_agent_meta`. Two habits worth internalizing beyond the tool descriptions themselves: prefer ending the turn over repeatedly polling `recv` when idle (only turn-boundaries observe in-container todo wakes — bash-task completions, matrix unread, forge activity — and ending the turn is also your checkpoint); and `ask`/`answer` are async — `ask` returns immediately with a question id, the reply lands later as a `question_answered` system event, never block a turn waiting on it inline. - **Extra MCP tools** (some agents only): `mcp____` — agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `hyperhive.extraMcpServers`. First-class tools, already operator-approved at deploy time. - **Lifecycle** (_requires `lifecycle` tool group_, direct children only, no approval needed): `restart`, `kill`, `start`, `update`, `list_containers`. - **Approvals** (_requires `approvals` tool group_, queues an operator approval): `request_init_config`, `request_apply_commit`, `request_update_meta_inputs`. @@ -44,4 +44,4 @@ When your inbox has a message, handle it and stop. Don't narrate intent — act. **Turns are your checkpoint.** The harness runs one claude turn per inbox message; when you stop, it acknowledges that message and your `--continue` session is saved to disk. Ending the turn is how you commit progress — both the session and the inbox acknowledgement. If the container restarts _while a turn is still running_, the message that drove it was never acknowledged, so it gets redelivered on the next boot, prefixed `[redelivered after harness restart — may already be handled]`. A long single turn that does step after step widens the window where a restart loses work and forces that redelivery, so prefer short turns: do a unit of work, write anything durable under `/agents/{label}/state/` **at every natural boundary, not just when a turn happens to end**, and stop. -For multi-step work (long builds, sequential edits) that spans more than one turn: end the turn and let an external wake drive the next one — a new inbox message, a `remind` you scheduled, or a backgrounded bash task's completion. Ending the turn is never a same-turn continuation: it's the checkpoint itself, and the only place an in-container todo wake (bash-task completion, matrix unread, forge activity) can reach you — a long-poll `recv(wait_seconds: …)` blocks _within_ the current turn instead and misses exactly those wakes, so it isn't a substitute for ending the turn. **Keep `set_status` current whenever the work changes** — a stale status is what actually loses context across a restart, not turn length; a status set at the start of a task and never touched again is a bug, not a shortcut. +For multi-step work (long builds, sequential edits) that spans more than one turn: end the turn and let an external wake drive the next one — a new inbox message, a `remind` you scheduled, or a backgrounded bash task's completion. Ending the turn is never a same-turn continuation: it's the checkpoint itself, and the only place an in-container todo wake (bash-task completion, matrix unread, forge activity) can reach you — polling `recv` in a loop happens _within_ the current turn instead and misses exactly those wakes, so it isn't a substitute for ending the turn. **Keep `set_status` current whenever the work changes** — a stale status is what actually loses context across a restart, not turn length; a status set at the start of a task and never touched again is a bug, not a shortcut.