add ack_until: bulk-ack inbox messages by id + surface msg ids in wake prompts and recv (closes #2125)
This commit is contained in:
parent
b191858366
commit
34374fd10a
8 changed files with 221 additions and 16 deletions
|
|
@ -588,6 +588,7 @@ async fn handle_turn<S: Surface>(
|
|||
let from = first.from;
|
||||
let body = first.body;
|
||||
let redelivered = first.redelivered;
|
||||
let msg_id = first.id;
|
||||
log_system_event(bus, &from, &body);
|
||||
tracing::info!(%from, %body, %redelivered, "inbox");
|
||||
let unread = S::inbox_unread(socket).await;
|
||||
|
|
@ -600,7 +601,7 @@ async fn handle_turn<S: Surface>(
|
|||
let started_at = serve_common::now_unix();
|
||||
let started_instant = std::time::Instant::now();
|
||||
let model_at_start = bus.model();
|
||||
let prompt = serve_common::format_wake_prompt(&from, &body, unread, redelivered);
|
||||
let prompt = serve_common::format_wake_prompt(msg_id, &from, &body, unread, redelivered);
|
||||
let outcome = {
|
||||
let _guard = turn_lock.lock().await;
|
||||
turn::drive_turn(&prompt, files, bus).await
|
||||
|
|
|
|||
|
|
@ -33,12 +33,14 @@ pub enum SocketReply {
|
|||
/// 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.
|
||||
/// separators. Per-row `id` is rendered as a `[msg #<id>]` marker
|
||||
/// so claude can bulk-triage via `ack_until` (turn-level ack still
|
||||
/// rides `AckTurn`); `redelivered` triggers the "may already be
|
||||
/// handled" banner in `format_recv` for that specific row.
|
||||
Messages(Vec<hive_sh4re::DeliveredMessage>),
|
||||
Status(u64),
|
||||
/// `ack_until` result: rows newly marked handled.
|
||||
Acked(u64),
|
||||
QuestionQueued(i64),
|
||||
Recent(Vec<hive_sh4re::InboxRow>),
|
||||
Logs(String),
|
||||
|
|
@ -74,6 +76,7 @@ impl From<hive_sh4re::Response> for SocketReply {
|
|||
hive_sh4re::Response::Err { message } => Self::Err(message),
|
||||
hive_sh4re::Response::Messages { messages } => Self::Messages(messages),
|
||||
hive_sh4re::Response::Status { unread } => Self::Status(unread),
|
||||
hive_sh4re::Response::Acked { count } => Self::Acked(count),
|
||||
hive_sh4re::Response::Recent { rows } => Self::Recent(rows),
|
||||
hive_sh4re::Response::QuestionQueued { id } => Self::QuestionQueued(id),
|
||||
hive_sh4re::Response::LooseEnds { loose_ends } => Self::LooseEnds(loose_ends),
|
||||
|
|
@ -219,7 +222,7 @@ pub fn format_recv(resp: Result<SocketReply, anyhow::Error>, waited: bool) -> St
|
|||
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);
|
||||
return format!("{banner}{}from: {}\n\n{}", msg_id_tag(m.id), m.from, m.body);
|
||||
}
|
||||
let n = messages.len();
|
||||
let mut out = format!("popped {n} message(s):\n\n");
|
||||
|
|
@ -228,11 +231,29 @@ pub fn format_recv(resp: Result<SocketReply, anyhow::Error>, waited: bool) -> St
|
|||
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);
|
||||
let _ = write!(
|
||||
out,
|
||||
"{banner}{}from: {}\n\n{}",
|
||||
msg_id_tag(m.id),
|
||||
m.from,
|
||||
m.body
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `[msg #<id>] ` marker prefixed to each recv row so the agent knows
|
||||
/// what to pass to `ack_until` when bulk-triaging a backlog. Transient
|
||||
/// pings carry the sentinel id 0 (in-memory only, nothing in the
|
||||
/// broker to ack) and render without the marker.
|
||||
fn msg_id_tag(id: i64) -> String {
|
||||
if id > 0 {
|
||||
format!("[msg #{id}] ")
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
|
@ -601,6 +622,16 @@ pub struct RecvArgs {
|
|||
pub max: Option<u32>,
|
||||
}
|
||||
|
||||
/// MCP tool args for `ack_until`.
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct AckUntilArgs {
|
||||
/// Highest broker message id to mark handled: every inbox message
|
||||
/// with `id <= up_to` (ids show as `[msg #<id>]` in recv output)
|
||||
/// is acked in one sweep. Pass the highest id you've actually
|
||||
/// seen/triaged — anything above it stays queued for later turns.
|
||||
pub up_to: i64,
|
||||
}
|
||||
|
||||
/// MCP tool args for `remind`. Exactly one of `delay_seconds` or
|
||||
/// `at_unix_timestamp` must be set; both / neither is a tool-side error.
|
||||
/// Hides the tagged `ReminderTiming` enum behind a flatter schema so the
|
||||
|
|
@ -798,6 +829,36 @@ impl AgentServer {
|
|||
.await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Bulk-mark inbox messages handled: every message with broker id \
|
||||
<= `up_to` (ids show as `[msg #<id>]` in recv output and wake prompts) is \
|
||||
acked in one call — pending and already-delivered rows alike. Use this to \
|
||||
clear a backlog you've already triaged (e.g. a redelivered flood after a \
|
||||
container restart, or a pile of stale notifications) instead of draining it \
|
||||
one recv at a time: note the highest `[msg #N]` you've seen, then \
|
||||
`ack_until(up_to: N)`. Acked messages never redeliver. Only affects YOUR \
|
||||
inbox rows; messages newer than `up_to` stay queued. Returns how many rows \
|
||||
were newly acked."
|
||||
)]
|
||||
async fn ack_until(&self, Parameters(args): Parameters<AckUntilArgs>) -> String {
|
||||
let log = format!("{args:?}");
|
||||
run_tool_envelope("ack_until", log, async move {
|
||||
let (resp, retries) = self
|
||||
.dispatch(hive_sh4re::Request::AckUntil { up_to: args.up_to })
|
||||
.await;
|
||||
let rendered = match resp {
|
||||
Ok(SocketReply::Acked(count)) => {
|
||||
format!("acked {count} message(s) up to id {}", args.up_to)
|
||||
}
|
||||
Ok(SocketReply::Err(m)) => format!("ack_until failed: {m}"),
|
||||
Ok(other) => format!("ack_until unexpected response: {other:?}"),
|
||||
Err(e) => format!("ack_until transport error: {e:#}"),
|
||||
};
|
||||
annotate_retries(rendered, 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 \
|
||||
|
|
|
|||
|
|
@ -9,21 +9,36 @@ use crate::turn::TurnOutcome;
|
|||
use crate::turn_stats::TurnStatRow;
|
||||
|
||||
/// Assemble the per-turn wake prompt string. The role/tools/etc. live in the
|
||||
/// system prompt; this is just the wake signal body. `unread` is the inbox
|
||||
/// depth after this message was popped. `redelivered` prepends a "may already
|
||||
/// be handled" banner.
|
||||
/// system prompt; this is just the wake signal body. `id` is the broker row
|
||||
/// id, rendered as a `[msg #<id>]` marker so the agent can reference it in
|
||||
/// `ack_until` (transient pings carry the sentinel 0 and render without it).
|
||||
/// `unread` is the inbox depth after this message was popped. `redelivered`
|
||||
/// prepends a "may already be handled" banner.
|
||||
#[must_use]
|
||||
pub fn format_wake_prompt(from: &str, body: &str, unread: u64, redelivered: bool) -> String {
|
||||
pub fn format_wake_prompt(
|
||||
id: i64,
|
||||
from: &str,
|
||||
body: &str,
|
||||
unread: u64,
|
||||
redelivered: bool,
|
||||
) -> String {
|
||||
let banner = if redelivered { REDELIVERY_HINT } else { "" };
|
||||
let tag = if id > 0 {
|
||||
format!("[msg #{id}] ")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let pending = if unread == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
"\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.)"
|
||||
with `max: {unread}` to drain them all in one round-trip before acting. If the \
|
||||
backlog is stale/already handled, `ack_until(up_to: <highest [msg #N] seen>)` \
|
||||
clears everything up to that id in one call instead.)"
|
||||
)
|
||||
};
|
||||
format!("{banner}Incoming message from `{from}`:\n---\n{body}\n---{pending}")
|
||||
format!("{banner}{tag}Incoming message from `{from}`:\n---\n{body}\n---{pending}")
|
||||
}
|
||||
|
||||
/// Current time as a Unix timestamp (seconds). Returns 0 on any error.
|
||||
|
|
|
|||
Loading…
Reference in a new issue