recv: fold batch drain into recv(max) — one tool, uniform list response
This commit is contained in:
parent
77b89bf2c6
commit
5d27ae3048
8 changed files with 271 additions and 417 deletions
|
|
@ -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.)"
|
||||
)
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.)"
|
||||
)
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue