broker: recv_batch(max) — drain a bursty inbox in one round-trip

This commit is contained in:
damocles 2026-05-19 00:40:31 +02:00
parent 96ffb0e39a
commit 77b89bf2c6
9 changed files with 354 additions and 11 deletions

View file

@ -183,6 +183,29 @@ read them à la carte.
In-flight or recent context that hasn't earned a section yet. In-flight or recent context that hasn't earned a section yet.
Prune freely. Prune freely.
- **Just landed:** inbox batching via `recv_batch(max)`. New
MCP tool on both agent + manager surfaces pops up to `max`
pending messages in a single round-trip (server-side cap
32, `max = 0` short-circuits). Same delivery + ack
bookkeeping as `recv`: every popped row is marked
`delivered_at = NOW`, tracked on the per-recipient
`unacked_ids` list (so the next `AckTurn` closes them out),
and tagged `redelivered: true` if it was resurfaced by
`RequeueInflight`. Wake prompt's pending-inbox hint
rewritten to recommend `recv_batch(max: N)` instead of N
consecutive `recv`s. Wire: new
`AgentRequest::RecvBatch { max }` /
`AgentResponse::Batch { messages: Vec<DeliveredMessage> }`
(+ manager mirror) and a new `DeliveredMessage` struct in
`hive-sh4re` shared by both. `format_recv_batch` on the
mcp side renders the popped list with per-message
redelivery banners + `---` separators; empty batch
collapses to "(empty)" like single `recv`. 4 new broker
tests cover FIFO + cap, empty-when-idle, zero-max
short-circuit, and redelivered-flag propagation. Closes
the "inbox batching hint" item from the ergonomics
wishlist — the hint that was already in `format_wake_prompt`
is now actionable in one tool call instead of N.
- **Just landed:** lease-style message delivery / no-drop - **Just landed:** lease-style message delivery / no-drop
on turn fail. The `messages` table gained an `acked_at` on turn fail. The `messages` table gained an `acked_at`
column (idempotent ALTER + backfill = `delivered_at` so column (idempotent ALTER + backfill = `delivered_at` so

View file

@ -35,15 +35,6 @@
Filed by damocles, who actually lives in this thing. Loosely ranked by Filed by damocles, who actually lives in this thing. Loosely ranked by
how often the friction bites in normal use. how often the friction bites in normal use.
- **Inbox batching hint in the wake prompt** — when the harness pops a
message and there are N more waiting, the wake prompt should say so
(e.g. `"(+3 more queued; consider draining before acting)"`) so claude
knows to call `recv()` again in the same turn instead of doing the
expensive Read/Edit dance once per message over N turns. The data's
already in the broker (`Broker::pending_count(agent)`); just thread it
into the prompt builder in `hive-ag3nt::turn.rs`. Even better: add a
one-shot `recv_batch(max: u32)` MCP tool that returns up to `max`
pending messages in a single round-trip.
- **Optional `in_reply_to: <msg_id>` on send** — pure wire addition; no - **Optional `in_reply_to: <msg_id>` on send** — pure wire addition; no
behavioural change. The dashboard could render conversation threads behavioural change. The dashboard could render conversation threads
(already wants this for the agent-to-agent question UI in the (already wants this for the agent-to-agent question UI in the

View file

@ -265,6 +265,7 @@ async fn serve(
Ok( Ok(
AgentResponse::Ok AgentResponse::Ok
| AgentResponse::Status { .. } | AgentResponse::Status { .. }
| AgentResponse::Batch { .. }
| AgentResponse::Recent { .. } | AgentResponse::Recent { .. }
| AgentResponse::QuestionQueued { .. } | AgentResponse::QuestionQueued { .. }
| AgentResponse::LooseEnds { .. } | AgentResponse::LooseEnds { .. }
@ -301,7 +302,8 @@ fn format_wake_prompt(from: &str, body: &str, unread: u64, redelivered: bool) ->
String::new() String::new()
} else { } else {
format!( format!(
"\n\n({unread} more message(s) pending in your inbox — drain via `mcp__hyperhive__recv` if relevant.)" "\n\n({unread} more message(s) pending in your inbox — call `mcp__hyperhive__recv_batch` \
with `max: {unread}` to drain them all in one round-trip before acting.)"
) )
}; };
format!("{banner}Incoming message from `{from}`:\n---\n{body}\n---{pending}") format!("{banner}Incoming message from `{from}`:\n---\n{body}\n---{pending}")

View file

@ -221,6 +221,7 @@ async fn serve(
Ok( Ok(
ManagerResponse::Ok ManagerResponse::Ok
| ManagerResponse::Status { .. } | ManagerResponse::Status { .. }
| ManagerResponse::Batch { .. }
| ManagerResponse::QuestionQueued { .. } | ManagerResponse::QuestionQueued { .. }
| ManagerResponse::Recent { .. } | ManagerResponse::Recent { .. }
| ManagerResponse::Logs { .. } | ManagerResponse::Logs { .. }
@ -256,7 +257,8 @@ fn format_wake_prompt(from: &str, body: &str, unread: u64, redelivered: bool) ->
String::new() String::new()
} else { } else {
format!( format!(
"\n\n({unread} more message(s) pending in your inbox — drain via `mcp__hyperhive__recv` if relevant.)" "\n\n({unread} more message(s) pending in your inbox — call `mcp__hyperhive__recv_batch` \
with `max: {unread}` to drain them all in one round-trip before acting.)"
) )
}; };
format!("{banner}Incoming message from `{from}`:\n---\n{body}\n---{pending}") format!("{banner}Incoming message from `{from}`:\n---\n{body}\n---{pending}")

View file

@ -46,6 +46,10 @@ pub enum SocketReply {
id: i64, id: i64,
redelivered: bool, 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, Empty,
Status(u64), Status(u64),
QuestionQueued(i64), QuestionQueued(i64),
@ -77,6 +81,7 @@ impl From<hive_sh4re::AgentResponse> for SocketReply {
redelivered, redelivered,
}, },
hive_sh4re::AgentResponse::Empty => Self::Empty, hive_sh4re::AgentResponse::Empty => Self::Empty,
hive_sh4re::AgentResponse::Batch { messages } => Self::Batch(messages),
hive_sh4re::AgentResponse::Status { unread } => Self::Status(unread), hive_sh4re::AgentResponse::Status { unread } => Self::Status(unread),
hive_sh4re::AgentResponse::Recent { rows } => Self::Recent(rows), hive_sh4re::AgentResponse::Recent { rows } => Self::Recent(rows),
hive_sh4re::AgentResponse::QuestionQueued { id } => Self::QuestionQueued(id), hive_sh4re::AgentResponse::QuestionQueued { id } => Self::QuestionQueued(id),
@ -114,6 +119,7 @@ impl From<hive_sh4re::ManagerResponse> for SocketReply {
redelivered, redelivered,
}, },
hive_sh4re::ManagerResponse::Empty => Self::Empty, hive_sh4re::ManagerResponse::Empty => Self::Empty,
hive_sh4re::ManagerResponse::Batch { messages } => Self::Batch(messages),
hive_sh4re::ManagerResponse::Status { unread } => Self::Status(unread), hive_sh4re::ManagerResponse::Status { unread } => Self::Status(unread),
hive_sh4re::ManagerResponse::QuestionQueued { id } => Self::QuestionQueued(id), hive_sh4re::ManagerResponse::QuestionQueued { id } => Self::QuestionQueued(id),
hive_sh4re::ManagerResponse::Recent { rows } => Self::Recent(rows), hive_sh4re::ManagerResponse::Recent { rows } => Self::Recent(rows),
@ -179,6 +185,35 @@ pub fn format_recv(resp: Result<SocketReply, anyhow::Error>) -> String {
pub const REDELIVERY_HINT: &str = pub const REDELIVERY_HINT: &str =
"[redelivered after harness restart — may already be handled]\n"; "[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:#}"),
};
if messages.is_empty() {
return "(empty)".to_owned();
}
let n = messages.len();
let mut out = format!("popped {n} message(s):\n\n");
for (i, m) in messages.iter().enumerate() {
if i > 0 {
out.push_str("\n---\n\n");
}
let banner = if m.redelivered { REDELIVERY_HINT } else { "" };
let _ = write!(out, "{banner}from: {}\n\n{}", m.from, m.body);
}
out
}
/// Format helper for `get_loose_ends`: renders a short bulleted list /// Format helper for `get_loose_ends`: renders a short bulleted list
/// of pending approvals + questions + reminders. Empty list collapses /// of pending approvals + questions + reminders. Empty list collapses
/// to a clear marker so claude doesn't go hunting for a payload that /// to a clear marker so claude doesn't go hunting for a payload that
@ -338,6 +373,17 @@ pub struct RecvArgs {
pub wait_seconds: Option<u64>, 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,
}
/// MCP tool args for `remind`. Exactly one of `delay_seconds` or /// MCP tool args for `remind`. Exactly one of `delay_seconds` or
/// `at_unix_timestamp` must be set; both / neither is a tool-side error. /// `at_unix_timestamp` must be set; both / neither is a tool-side error.
/// Hides the tagged `ReminderTiming` enum behind a flatter schema so the /// Hides the tagged `ReminderTiming` enum behind a flatter schema so the
@ -511,6 +557,26 @@ impl AgentServer {
.await .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( #[tool(
description = "List loose ends pending against this agent: unanswered questions \ 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 \ where you are the asker (waiting on someone) or the target (someone's waiting on \
@ -819,6 +885,26 @@ impl ManagerServer {
.await .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( #[tool(
description = "Queue a Spawn approval for a brand-new sub-agent. The operator \ description = "Queue a Spawn approval for a brand-new sub-agent. The operator \
approves on the dashboard before the container is actually created." approves on the dashboard before the container is actually created."

View file

@ -87,6 +87,15 @@ async fn serve(stream: UnixStream, agent: String, coord: Arc<Coordinator>) -> Re
/// positive `wait_seconds`. /// positive `wait_seconds`.
const RECV_LONG_POLL_MAX: std::time::Duration = std::time::Duration::from_secs(180); const RECV_LONG_POLL_MAX: std::time::Duration = std::time::Duration::from_secs(180);
/// Server-side hard cap on `RecvBatch.max`. Bounds the size of a
/// single round-trip so a confused caller can't drain the entire
/// inbox in one go and blow past wire-buffer sizes; everything above
/// the cap silently clamps. 32 is comfortably above the burst sizes
/// we've seen in practice (post-rebuild rescue, multi-agent reply
/// storms) and well under the per-message `MESSAGE_MAX_BYTES` * N
/// envelope budget.
const RECV_BATCH_MAX: u32 = 32;
fn recv_timeout(wait_seconds: Option<u64>) -> std::time::Duration { fn recv_timeout(wait_seconds: Option<u64>) -> std::time::Duration {
match wait_seconds { match wait_seconds {
Some(s) => std::time::Duration::from_secs(s).min(RECV_LONG_POLL_MAX), Some(s) => std::time::Duration::from_secs(s).min(RECV_LONG_POLL_MAX),
@ -114,6 +123,25 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
message: format!("{e:#}"), message: format!("{e:#}"),
}, },
}, },
AgentRequest::RecvBatch { max } => {
let cap = (*max).min(RECV_BATCH_MAX) as usize;
match broker.recv_batch(agent, cap) {
Ok(deliveries) => AgentResponse::Batch {
messages: deliveries
.into_iter()
.map(|d| hive_sh4re::DeliveredMessage {
from: d.message.from,
body: d.message.body,
id: d.id,
redelivered: d.redelivered,
})
.collect(),
},
Err(e) => AgentResponse::Err {
message: format!("{e:#}"),
},
}
}
AgentRequest::Status => match broker.count_pending(agent) { AgentRequest::Status => match broker.count_pending(agent) {
Ok(unread) => AgentResponse::Status { unread }, Ok(unread) => AgentResponse::Status { unread },
Err(e) => AgentResponse::Err { Err(e) => AgentResponse::Err {

View file

@ -362,6 +362,85 @@ impl Broker {
})) }))
} }
/// Pop up to `max` pending messages for `recipient` in one
/// round-trip. Same per-row semantics as `recv`: every popped row
/// is marked `delivered_at = NOW`, pushed onto the per-recipient
/// `unacked_ids` list (so the next `ack_turn` closes them out),
/// and tagged with `redelivered = true` if it was resurfaced by
/// the most recent `requeue_inflight`. Emits one
/// `MessageEvent::Delivered` per popped row so the dashboard
/// forwarder stream stays consistent with the single-row path.
///
/// `max == 0` short-circuits to an empty vec (no DB hit); any
/// positive value caps the batch at `max`. FIFO order matches
/// `recv`.
pub fn recv_batch(&self, recipient: &str, max: usize) -> Result<Vec<Delivery>> {
if max == 0 {
return Ok(Vec::new());
}
// Same lock order as `recv` / `ack_turn` / `requeue_inflight`.
let mut inflight = self.inflight.lock().unwrap();
let conn = self.conn.lock().unwrap();
let max_i = i64::try_from(max).unwrap_or(i64::MAX);
let mut stmt = conn.prepare(
"SELECT id, sender, recipient, body
FROM messages
WHERE recipient = ?1 AND delivered_at IS NULL
ORDER BY id ASC
LIMIT ?2",
)?;
let rows: Vec<(i64, String, String, String)> = stmt
.query_map(params![recipient, max_i], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
})?
.collect::<rusqlite::Result<_>>()?;
drop(stmt);
if rows.is_empty() {
return Ok(Vec::new());
}
// Stamp all popped rows in a single UPDATE — under the broker
// mutex, well within sqlite's 999-param default.
let now = now_unix();
let ids: Vec<i64> = rows.iter().map(|(id, _, _, _)| *id).collect();
let placeholders = std::iter::repeat_n("?", ids.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!("UPDATE messages SET delivered_at = ? WHERE id IN ({placeholders})");
let mut params_vec: Vec<&dyn rusqlite::ToSql> = Vec::with_capacity(ids.len() + 1);
params_vec.push(&now);
for id in &ids {
params_vec.push(id);
}
conn.execute(&sql, params_vec.as_slice())?;
drop(conn);
// Bookkeeping + assemble the Delivery list. Per-row
// `requeued_ids` lookup runs once per pop, same as `recv`.
let slot = inflight.entry(recipient.to_owned()).or_default();
let mut deliveries = Vec::with_capacity(rows.len());
for (id, from, to, body) in rows {
slot.unacked_ids.push(id);
let redelivered = slot.requeued_ids.remove(&id);
deliveries.push(Delivery {
id,
redelivered,
message: Message { from, to, body },
});
}
drop(inflight);
// Mirror the per-row Delivered emit `recv` does so the
// dashboard forwarder sees one event per message regardless of
// which surface the harness used.
for d in &deliveries {
let _ = self.events.send(MessageEvent::Delivered {
from: d.message.from.clone(),
to: d.message.to.clone(),
body: d.message.body.clone(),
at: now,
});
}
Ok(deliveries)
}
/// Drain the per-recipient unacked-id list and mark every row /// Drain the per-recipient unacked-id list and mark every row
/// `acked_at = NOW`. Fired by the harness after `TurnOutcome::Ok`. /// `acked_at = NOW`. Fired by the harness after `TurnOutcome::Ok`.
/// Returns the number of rows acked (zero is normal — claude /// Returns the number of rows acked (zero is normal — claude
@ -867,6 +946,70 @@ mod tests {
); );
} }
/// Happy path for `recv_batch`: pops in FIFO order, respects
/// `max`, leaves the rest pending for the next call.
#[test]
fn recv_batch_pops_fifo_capped_at_max() {
let h = open_broker();
let broker = &h.broker;
for i in 0..5 {
broker.send(&msg("a", "b", &format!("m{i}"))).unwrap();
}
let batch = broker.recv_batch("b", 3).unwrap();
let bodies: Vec<_> = batch.iter().map(|d| d.message.body.as_str()).collect();
assert_eq!(bodies, vec!["m0", "m1", "m2"]);
// Remaining two stay pending; a second batch drains them.
let next = broker.recv_batch("b", 10).unwrap();
let bodies: Vec<_> = next.iter().map(|d| d.message.body.as_str()).collect();
assert_eq!(bodies, vec!["m3", "m4"]);
// ack_turn closes out all five popped rows in one go.
assert_eq!(broker.ack_turn("b").unwrap(), 5);
}
/// `recv_batch` with no pending traffic returns an empty vec
/// (the "(empty)" path), not an error.
#[test]
fn recv_batch_returns_empty_when_idle() {
let h = open_broker();
let batch = h.broker.recv_batch("ghost", 5).unwrap();
assert!(batch.is_empty());
}
/// `max = 0` short-circuits without touching the DB (covered by
/// asserting we don't accidentally pop a pending row).
#[test]
fn recv_batch_zero_max_pops_nothing() {
let h = open_broker();
let broker = &h.broker;
broker.send(&msg("a", "b", "stay")).unwrap();
assert!(broker.recv_batch("b", 0).unwrap().is_empty());
// The pending row is still in flight for the next real recv.
let d = broker.recv("b").unwrap().expect("still pending");
assert_eq!(d.message.body, "stay");
}
/// `recv_batch` tags requeued rows with `redelivered: true` and
/// drains them from the per-recipient `requeued_ids` set so a
/// fresh follow-up recv after the batch doesn't double-tag.
#[test]
fn recv_batch_propagates_redelivered_flag() {
let h = open_broker();
let broker = &h.broker;
broker.send(&msg("a", "b", "one")).unwrap();
broker.send(&msg("a", "b", "two")).unwrap();
broker.recv("b").unwrap().expect("popped 1");
broker.recv("b").unwrap().expect("popped 2");
broker.requeue_inflight("b").unwrap();
let batch = broker.recv_batch("b", 5).unwrap();
assert_eq!(batch.len(), 2);
assert!(batch.iter().all(|d| d.redelivered));
// Fresh send after the batch is NOT tagged redelivered.
broker.send(&msg("a", "b", "three")).unwrap();
let d = broker.recv("b").unwrap().expect("re-pop 3");
assert_eq!(d.message.body, "three");
assert!(!d.redelivered);
}
/// Per-recipient isolation: `requeue_inflight("a")` doesn't touch /// Per-recipient isolation: `requeue_inflight("a")` doesn't touch
/// b's inflight rows. /// b's inflight rows.
#[test] #[test]

View file

@ -75,6 +75,11 @@ async fn serve(stream: UnixStream, coord: Arc<Coordinator>) -> Result<()> {
/// seconds (clamped at MAX). /// seconds (clamped at MAX).
const MANAGER_RECV_LONG_POLL_MAX: std::time::Duration = std::time::Duration::from_secs(180); const MANAGER_RECV_LONG_POLL_MAX: std::time::Duration = std::time::Duration::from_secs(180);
/// Same shape + rationale as `agent_server::RECV_BATCH_MAX`. Kept
/// numerically aligned across surfaces so a tool description that
/// quotes the cap stays accurate either way.
const MANAGER_RECV_BATCH_MAX: u32 = 32;
fn manager_recv_timeout(wait_seconds: Option<u64>) -> std::time::Duration { fn manager_recv_timeout(wait_seconds: Option<u64>) -> std::time::Duration {
match wait_seconds { match wait_seconds {
Some(s) => std::time::Duration::from_secs(s).min(MANAGER_RECV_LONG_POLL_MAX), Some(s) => std::time::Duration::from_secs(s).min(MANAGER_RECV_LONG_POLL_MAX),
@ -149,6 +154,25 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc<Coordinator>) -> ManagerResp
message: format!("{e:#}"), message: format!("{e:#}"),
}, },
}, },
ManagerRequest::RecvBatch { max } => {
let cap = (*max).min(MANAGER_RECV_BATCH_MAX) as usize;
match coord.broker.recv_batch(MANAGER_AGENT, cap) {
Ok(deliveries) => ManagerResponse::Batch {
messages: deliveries
.into_iter()
.map(|d| hive_sh4re::DeliveredMessage {
from: d.message.from,
body: d.message.body,
id: d.id,
redelivered: d.redelivered,
})
.collect(),
},
Err(e) => ManagerResponse::Err {
message: format!("{e:#}"),
},
}
}
ManagerRequest::RequestSpawn { name, description } => { ManagerRequest::RequestSpawn { name, description } => {
tracing::info!(%name, "manager: request_spawn"); tracing::info!(%name, "manager: request_spawn");
match coord.approvals.submit_kind( match coord.approvals.submit_kind(

View file

@ -174,6 +174,28 @@ pub struct InboxRow {
pub at: i64, pub at: i64,
} }
/// One delivered message in a `RecvBatch` response. Same fields as
/// `AgentResponse::Message` / `ManagerResponse::Message` without the
/// variant wrapper — a batch returns a `Vec<DeliveredMessage>` so the
/// harness can iterate without unpicking N separate top-level frames.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeliveredMessage {
pub from: String,
pub body: String,
/// Broker row id, mirrored from the `Delivery` struct. Opaque to
/// claude but tracked by the harness so the broker's in-memory
/// unacked list can be drained on `AckTurn`. Marked `default` for
/// wire backwards-compat — pre-feature peers parse to 0.
#[serde(default)]
pub id: i64,
/// `true` when this row was previously popped, never acked, and
/// resurfaced by `RequeueInflight`. The format helper prepends the
/// "may already be handled" hint to the rendered body so claude
/// sees the warning per-message in the batch.
#[serde(default)]
pub redelivered: bool,
}
/// Reminder timing: either relative (wait N seconds) or absolute (at unix /// Reminder timing: either relative (wait N seconds) or absolute (at unix
/// timestamp). /// timestamp).
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -271,6 +293,15 @@ pub enum AgentRequest {
#[serde(default)] #[serde(default)]
wait_seconds: Option<u64>, wait_seconds: Option<u64>,
}, },
/// Pop up to `max` pending messages in one round-trip. No
/// long-poll — returns whatever's currently queued (possibly
/// zero) immediately. Same delivery + ack bookkeeping as `Recv`:
/// every popped row is marked `delivered_at = NOW`, tracked in
/// the broker's per-recipient `unacked_ids` list (so the next
/// `AckTurn` closes them out), and tagged `redelivered = true` if
/// it was resurfaced by `RequeueInflight`. Used by the harness to
/// drain a bursty inbox without N socket round-trips.
RecvBatch { max: u32 },
/// Non-mutating: how many pending messages are addressed to me? /// Non-mutating: how many pending messages are addressed to me?
/// Used by the harness to render a status line after each tool call. /// Used by the harness to render a status line after each tool call.
Status, Status,
@ -408,6 +439,10 @@ pub enum AgentResponse {
}, },
/// `Recv` found nothing pending. /// `Recv` found nothing pending.
Empty, Empty,
/// `RecvBatch` result. `messages` is in FIFO order and may be
/// empty (treated like `Empty` for `Recv`); never longer than the
/// `max` the caller passed.
Batch { messages: Vec<DeliveredMessage> },
/// `Status` result: how many pending messages are in this agent's inbox. /// `Status` result: how many pending messages are in this agent's inbox.
Status { unread: u64 }, Status { unread: u64 },
/// `Recent` result: newest-first inbox rows. /// `Recent` result: newest-first inbox rows.
@ -583,6 +618,10 @@ pub enum ManagerRequest {
#[serde(default)] #[serde(default)]
wait_seconds: Option<u64>, wait_seconds: Option<u64>,
}, },
/// Mirror of `AgentRequest::RecvBatch` on the manager surface —
/// pop up to `max` pending messages in one round-trip, no
/// long-poll. Same ack + redelivery bookkeeping.
RecvBatch { max: u32 },
/// Non-mutating: pending message count, used to render a status line /// Non-mutating: pending message count, used to render a status line
/// after each MCP tool call (mirrors `AgentRequest::Status`). /// after each MCP tool call (mirrors `AgentRequest::Status`).
Status, Status,
@ -734,6 +773,11 @@ pub enum ManagerResponse {
redelivered: bool, redelivered: bool,
}, },
Empty, Empty,
/// Mirror of `AgentResponse::Batch` on the manager surface.
/// `messages` is in FIFO order and may be empty.
Batch {
messages: Vec<DeliveredMessage>,
},
Status { Status {
unread: u64, unread: u64,
}, },