recv: fold batch drain into recv(max) — one tool, uniform list response

This commit is contained in:
damocles 2026-05-19 01:07:30 +02:00
parent 77b89bf2c6
commit 5d27ae3048
8 changed files with 271 additions and 417 deletions

View file

@ -176,20 +176,23 @@ async fn serve(
// `None` as "peek, don't wait", which would tight-loop on
// sleep(interval). The harness wants to park until a
// message arrives, so opt into the full 180s cap.
// `max: None` (= 1) — the serve loop drives one turn per
// wake; claude itself calls recv(max: N) in-turn to drain
// a burst when the wake prompt mentions pending.
client::request(
socket,
&AgentRequest::Recv {
wait_seconds: Some(180),
max: None,
},
)
.await;
match recv {
Ok(AgentResponse::Message {
from,
body,
id: _,
redelivered,
}) => {
Ok(AgentResponse::Messages { messages }) if !messages.is_empty() => {
let first = messages.into_iter().next().expect("checked non-empty");
let from = first.from;
let body = first.body;
let redelivered = first.redelivered;
tracing::info!(%from, %body, %redelivered, "inbox");
let unread = inbox_unread(socket).await;
bus.emit(LiveEvent::TurnStart {
@ -255,17 +258,15 @@ async fn serve(
tracing::info!(%pending, "pending messages after turn; fetching next");
}
}
Ok(AgentResponse::Empty) => {
// Idle: brief sleep before next poll to avoid busy-looping
// on consecutive Empty responses. The recv() call already
// waits up to 180s for messages, so this is just for
// responsiveness if recv() times out.
Ok(AgentResponse::Messages { .. }) => {
// Idle: empty list = nothing pending. Brief sleep
// before next poll so a stretch of empty long-poll
// returns doesn't tight-loop.
tokio::time::sleep(interval).await;
}
Ok(
AgentResponse::Ok
| AgentResponse::Status { .. }
| AgentResponse::Batch { .. }
| AgentResponse::Recent { .. }
| AgentResponse::QuestionQueued { .. }
| AgentResponse::LooseEnds { .. }
@ -302,7 +303,7 @@ fn format_wake_prompt(from: &str, body: &str, unread: u64, redelivered: bool) ->
String::new()
} else {
format!(
"\n\n({unread} more message(s) pending in your inbox — call `mcp__hyperhive__recv_batch` \
"\n\n({unread} more message(s) pending in your inbox — call `mcp__hyperhive__recv` \
with `max: {unread}` to drain them all in one round-trip before acting.)"
)
};

View file

@ -129,21 +129,23 @@ async fn serve(
let recv: Result<ManagerResponse> =
// Explicit long-poll: see hive-ag3nt's serve loop for the
// rationale — recv now defaults to peek when wait_seconds
// is None.
// is None. `max: None` (= 1) keeps the serve loop driving
// one turn per wake; claude calls recv(max: N) in-turn to
// drain a burst when the wake prompt mentions pending.
client::request(
socket,
&ManagerRequest::Recv {
wait_seconds: Some(180),
max: None,
},
)
.await;
match recv {
Ok(ManagerResponse::Message {
from,
body,
id: _,
redelivered,
}) => {
Ok(ManagerResponse::Messages { messages }) if !messages.is_empty() => {
let first = messages.into_iter().next().expect("checked non-empty");
let from = first.from;
let body = first.body;
let redelivered = first.redelivered;
if from == SYSTEM_SENDER {
// Helper events (ApprovalResolved / Spawned / Rebuilt /
// Killed / Destroyed) — these are FYI for the manager;
@ -214,14 +216,14 @@ async fn serve(
tracing::info!(%pending, "pending messages after turn; fetching next");
}
}
Ok(ManagerResponse::Empty) => {
// Idle: sleep briefly before next long-poll attempt.
Ok(ManagerResponse::Messages { .. }) => {
// Idle: empty list = nothing pending. Brief sleep
// before the next long-poll attempt.
tokio::time::sleep(interval).await;
}
Ok(
ManagerResponse::Ok
| ManagerResponse::Status { .. }
| ManagerResponse::Batch { .. }
| ManagerResponse::QuestionQueued { .. }
| ManagerResponse::Recent { .. }
| ManagerResponse::Logs { .. }
@ -257,7 +259,7 @@ fn format_wake_prompt(from: &str, body: &str, unread: u64, redelivered: bool) ->
String::new()
} else {
format!(
"\n\n({unread} more message(s) pending in your inbox — call `mcp__hyperhive__recv_batch` \
"\n\n({unread} more message(s) pending in your inbox — call `mcp__hyperhive__recv` \
with `max: {unread}` to drain them all in one round-trip before acting.)"
)
};

View file

@ -34,23 +34,14 @@ use crate::client;
pub enum SocketReply {
Ok,
Err(String),
/// `id` is the broker's row id — not surfaced to claude but
/// useful for harness-side bookkeeping (not used in this module
/// today; the bin loops drive ack via `AckTurn` instead of
/// per-id). `redelivered` triggers the "may already be handled"
/// hint in `format_recv` so claude sees it when draining the
/// inbox in-turn.
Message {
from: String,
body: String,
id: i64,
redelivered: bool,
},
/// Result of `recv_batch`: zero or more messages popped in one
/// round-trip. Empty vec is the equivalent of `Empty` for a
/// single `recv` — the format helper collapses it to "(empty)".
Batch(Vec<hive_sh4re::DeliveredMessage>),
Empty,
/// Unified `recv` result: zero or more messages popped in one
/// round-trip. Empty vec = "(empty)" path; single-message = the
/// standard wake body; multi = batch render with per-message
/// separators. Per-row `id` is opaque to claude (the bin loops
/// drive ack via `AckTurn`, not per-id); `redelivered` triggers
/// the "may already be handled" banner in `format_recv` for that
/// specific row.
Messages(Vec<hive_sh4re::DeliveredMessage>),
Status(u64),
QuestionQueued(i64),
Recent(Vec<hive_sh4re::InboxRow>),
@ -69,19 +60,7 @@ impl From<hive_sh4re::AgentResponse> for SocketReply {
match r {
hive_sh4re::AgentResponse::Ok => Self::Ok,
hive_sh4re::AgentResponse::Err { message } => Self::Err(message),
hive_sh4re::AgentResponse::Message {
from,
body,
id,
redelivered,
} => Self::Message {
from,
body,
id,
redelivered,
},
hive_sh4re::AgentResponse::Empty => Self::Empty,
hive_sh4re::AgentResponse::Batch { messages } => Self::Batch(messages),
hive_sh4re::AgentResponse::Messages { messages } => Self::Messages(messages),
hive_sh4re::AgentResponse::Status { unread } => Self::Status(unread),
hive_sh4re::AgentResponse::Recent { rows } => Self::Recent(rows),
hive_sh4re::AgentResponse::QuestionQueued { id } => Self::QuestionQueued(id),
@ -107,19 +86,7 @@ impl From<hive_sh4re::ManagerResponse> for SocketReply {
match r {
hive_sh4re::ManagerResponse::Ok => Self::Ok,
hive_sh4re::ManagerResponse::Err { message } => Self::Err(message),
hive_sh4re::ManagerResponse::Message {
from,
body,
id,
redelivered,
} => Self::Message {
from,
body,
id,
redelivered,
},
hive_sh4re::ManagerResponse::Empty => Self::Empty,
hive_sh4re::ManagerResponse::Batch { messages } => Self::Batch(messages),
hive_sh4re::ManagerResponse::Messages { messages } => Self::Messages(messages),
hive_sh4re::ManagerResponse::Status { unread } => Self::Status(unread),
hive_sh4re::ManagerResponse::QuestionQueued { id } => Self::QuestionQueued(id),
hive_sh4re::ManagerResponse::Recent { rows } => Self::Recent(rows),
@ -153,55 +120,30 @@ pub fn format_ack(resp: Result<SocketReply, anyhow::Error>, tool: &str, ok_msg:
}
}
/// Format helper for `recv` tools: `Message` → from + body block;
/// `Empty` → marker; anything else surfaces as an error. When the
/// broker tags the row as `redelivered` (popped before, never acked,
/// resurfaced after a harness restart) a short banner is prepended
/// so claude knows the side-effects of any previous handling may
/// already have happened.
/// Format helper for `recv`: renders zero, one, or many popped
/// messages. Empty list collapses to "(empty)" so claude doesn't go
/// 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.
pub fn format_recv(resp: Result<SocketReply, anyhow::Error>) -> String {
match resp {
Ok(SocketReply::Message {
from,
body,
redelivered,
..
}) => {
let banner = if redelivered { REDELIVERY_HINT } else { "" };
format!("{banner}from: {from}\n\n{body}")
}
Ok(SocketReply::Empty) => "(empty)".into(),
Ok(SocketReply::Err(m)) => format!("recv failed: {m}"),
Ok(other) => format!("recv unexpected response: {other:?}"),
Err(e) => format!("recv transport error: {e:#}"),
}
}
/// Header prepended to message bodies that were popped by a prior
/// harness session, never acked (turn crash / OOM / restart), and
/// resurfaced by `RequeueInflight` on this session's boot. Same
/// string surfaces in the wake prompt (see the bin loops) and the
/// in-turn `recv` tool result so claude sees the warning either way.
pub const REDELIVERY_HINT: &str =
"[redelivered after harness restart — may already be handled]\n";
/// Format helper for `recv_batch`: renders zero or more popped
/// messages as a single string. Empty batch collapses to "(empty)"
/// so claude doesn't go hunting for content. Each message is rendered
/// in the same `from: <name>\n\n<body>` shape as `format_recv` —
/// per-message redelivery banner included — separated by a thin rule
/// so the model can tell where one body ends and the next begins.
pub fn format_recv_batch(resp: Result<SocketReply, anyhow::Error>) -> String {
use std::fmt::Write as _;
let messages = match resp {
Ok(SocketReply::Batch(m)) => m,
Ok(SocketReply::Err(m)) => return format!("recv_batch failed: {m}"),
Ok(other) => return format!("recv_batch unexpected response: {other:?}"),
Err(e) => return format!("recv_batch transport error: {e:#}"),
Ok(SocketReply::Messages(m)) => m,
Ok(SocketReply::Err(m)) => return format!("recv failed: {m}"),
Ok(other) => return format!("recv unexpected response: {other:?}"),
Err(e) => return format!("recv transport error: {e:#}"),
};
if messages.is_empty() {
return "(empty)".to_owned();
}
if messages.len() == 1 {
let m = &messages[0];
let banner = if m.redelivered { REDELIVERY_HINT } else { "" };
return format!("{banner}from: {}\n\n{}", m.from, m.body);
}
let n = messages.len();
let mut out = format!("popped {n} message(s):\n\n");
for (i, m) in messages.iter().enumerate() {
@ -214,6 +156,14 @@ pub fn format_recv_batch(resp: Result<SocketReply, anyhow::Error>) -> String {
out
}
/// Header prepended to message bodies that were popped by a prior
/// harness session, never acked (turn crash / OOM / restart), and
/// resurfaced by `RequeueInflight` on this session's boot. Same
/// string surfaces in the wake prompt (see the bin loops) and the
/// in-turn `recv` tool result so claude sees the warning either way.
pub const REDELIVERY_HINT: &str =
"[redelivered after harness restart — may already be handled]\n";
/// Format helper for `get_loose_ends`: renders a short bulleted list
/// of pending approvals + questions + reminders. Empty list collapses
/// to a clear marker so claude doesn't go hunting for a payload that
@ -365,23 +315,22 @@ pub struct SendArgs {
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct RecvArgs {
/// How long to long-poll for a new message before returning the
/// empty marker. Capped at 60s server-side. Default (None) is
/// 30s. Useful when an agent wants to throttle wakes without
/// actually napping — pick a longer wait to coalesce bursts.
/// 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<u64>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct RecvBatchArgs {
/// Maximum number of messages to pop in this round-trip. Capped
/// at 32 server-side; values above the cap clamp silently.
/// Returns whatever is currently pending (possibly zero) without
/// long-polling — call when you've been told the inbox is busy
/// (e.g. the wake prompt mentioned N pending) and want to drain
/// them in one tool call instead of N separate `recv` calls.
pub max: u32,
/// 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 32 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<u32>,
}
/// MCP tool args for `remind`. Exactly one of `delay_seconds` or
@ -535,14 +484,21 @@ impl AgentServer {
}
#[tool(
description = "Pop one message from this agent's inbox. Returns the sender and body, \
or an empty marker if nothing is waiting. Without `wait_seconds` (or with 0) the \
call returns immediately a cheap 'anything pending?' peek. Pass a positive \
description = "Pop messages from this agent's inbox. Returns one or more messages, or \
an empty marker if nothing is waiting. \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. Pass a positive \
`wait_seconds` (capped at 180) to park the turn waiting for new work incoming \
messages wake you instantly, otherwise the call returns empty at the timeout. \
That's strictly better than a fixed shell `sleep`. Typical pattern: when you have \
nothing else useful to do, call `recv(wait_seconds: 180)` to park until \
something arrives."
That's strictly better than a fixed shell `sleep`. \n\n\
**Batch drain**: pass `max: N` (capped at 32) 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\
Typical pattern: when you have nothing else useful to do, call \
`recv(wait_seconds: 180)` to park until something arrives."
)]
async fn recv(&self, Parameters(args): Parameters<RecvArgs>) -> String {
let log = format!("{args:?}");
@ -550,6 +506,7 @@ impl AgentServer {
let (resp, retries) = self
.dispatch(hive_sh4re::AgentRequest::Recv {
wait_seconds: args.wait_seconds,
max: args.max,
})
.await;
annotate_retries(format_recv(resp), retries)
@ -557,26 +514,6 @@ impl AgentServer {
.await
}
#[tool(
description = "Pop up to `max` messages from this agent's inbox in a single round-trip \
(no long-poll returns whatever's pending immediately, possibly zero). Use this \
when the wake prompt tells you the inbox has more messages queued, or any time you \
know you'll be draining several at once: one tool call beats N consecutive `recv`s. \
Per-message redelivery banners + ack bookkeeping work the same as `recv`. `max` \
caps at 32 server-side; pass whatever you reasonably expect to handle this turn."
)]
async fn recv_batch(&self, Parameters(args): Parameters<RecvBatchArgs>) -> String {
let log = format!("{args:?}");
let max = args.max;
run_tool_envelope("recv_batch", log, async move {
let (resp, retries) = self
.dispatch(hive_sh4re::AgentRequest::RecvBatch { max })
.await;
annotate_retries(format_recv_batch(resp), retries)
})
.await
}
#[tool(
description = "List loose ends pending against this agent: unanswered questions \
where you are the asker (waiting on someone) or the target (someone's waiting on \
@ -866,11 +803,14 @@ impl ManagerServer {
}
#[tool(
description = "Pop one message from the manager inbox. Returns sender + body, or \
empty. Without `wait_seconds` (or 0) returns immediately a cheap inbox peek. \
Pass a positive value (capped at 180) to park until either a message arrives \
or the timeout fires; prefer a long wait (120 or 180) over ending a turn \
early when you have nothing else to do."
description = "Pop messages from the manager inbox. Default returns one (sender + \
body) or empty. Without `wait_seconds` (or 0) returns immediately a cheap inbox \
peek. Pass a positive value (capped at 180) to park until either a message arrives \
or the timeout fires; prefer a long wait (120 or 180) over ending a turn early \
when you have nothing else to do. \n\n\
Pass `max: N` (capped at 32) to drain up to N messages in one round-trip useful \
when the wake prompt tells you the inbox has more queued. `wait_seconds` still \
applies to the FIRST message; once one lands the call drains up to `max` in total."
)]
async fn recv(&self, Parameters(args): Parameters<RecvArgs>) -> String {
let log = format!("{args:?}");
@ -878,6 +818,7 @@ impl ManagerServer {
let (resp, retries) = self
.dispatch(hive_sh4re::ManagerRequest::Recv {
wait_seconds: args.wait_seconds,
max: args.max,
})
.await;
annotate_retries(format_recv(resp), retries)
@ -885,26 +826,6 @@ impl ManagerServer {
.await
}
#[tool(
description = "Pop up to `max` messages from the manager inbox in a single round-trip \
(no long-poll returns whatever's pending immediately, possibly zero). Use this \
when the wake prompt tells you the inbox has more messages queued, or any time you \
know you'll be draining several at once: one tool call beats N consecutive `recv`s. \
Per-message redelivery banners + ack bookkeeping work the same as `recv`. `max` \
caps at 32 server-side; pass whatever you reasonably expect to handle this turn."
)]
async fn recv_batch(&self, Parameters(args): Parameters<RecvBatchArgs>) -> String {
let log = format!("{args:?}");
let max = args.max;
run_tool_envelope("recv_batch", log, async move {
let (resp, retries) = self
.dispatch(hive_sh4re::ManagerRequest::RecvBatch { max })
.await;
annotate_retries(format_recv_batch(resp), retries)
})
.await
}
#[tool(
description = "Queue a Spawn approval for a brand-new sub-agent. The operator \
approves on the dashboard before the container is actually created."