feat: hint to do other work when recv/bash_status waits time out

When an agent parks on a long-poll that expires with nothing to show,
nudge it to spend the idle time on other useful work instead of
immediately re-blocking on the same call.

- recv: when wait_seconds > 0 and the inbox is empty at timeout, the
  '(empty)' result now carries IDLE_WAIT_HINT. Immediate peeks
  (no/zero wait) are unchanged.
- bash_status: when wait_seconds > 0 and the task is still
  pending/running at timeout, append BASH_IDLE_WAIT_HINT. Finished
  tasks and no-wait calls are unchanged.

Both thread a 'waited' flag into the formatter so the hint only fires
on an actual wait-timeout. Unit tests cover both. Implements #1411.
This commit is contained in:
iris 2026-06-05 19:58:22 +02:00 committed by mara
commit d34c594f2b
2 changed files with 94 additions and 13 deletions

View file

@ -160,14 +160,16 @@ pub fn format_ack(resp: Result<SocketReply, anyhow::Error>, tool: &str, ok_msg:
/// 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.
/// 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.
#[must_use]
pub fn format_recv(resp: Result<SocketReply, anyhow::Error>) -> String {
pub fn format_recv(resp: Result<SocketReply, anyhow::Error>, waited: bool) -> String {
use std::fmt::Write as _;
let messages = match resp {
Ok(SocketReply::Messages(m)) => m,
@ -176,7 +178,11 @@ pub fn format_recv(resp: Result<SocketReply, anyhow::Error>) -> String {
Err(e) => return format!("recv transport error: {e:#}"),
};
if messages.is_empty() {
return "(empty)".to_owned();
return if waited {
format!("(empty){IDLE_WAIT_HINT}")
} else {
"(empty)".to_owned()
};
}
if messages.len() == 1 {
let m = &messages[0];
@ -202,6 +208,14 @@ pub fn format_recv(resp: Result<SocketReply, anyhow::Error>) -> String {
/// 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";
/// 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<LooseEnd>` already extracted from the
/// socket reply. Called by both `format_loose_ends` (which handles the
/// `Result<SocketReply>` wrapper) and the augmented `get_loose_ends`
@ -723,13 +737,14 @@ impl AgentServer {
async fn recv(&self, Parameters(args): Parameters<RecvArgs>) -> 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_sh4re::Request::Recv {
wait_seconds: args.wait_seconds,
max: args.max,
})
.await;
annotate_retries(format_recv(resp), retries)
annotate_retries(format_recv(resp, waited), retries)
})
.await
}
@ -2043,3 +2058,21 @@ pub fn render_claude_config(agent_binary: &str, socket: &std::path::Path) -> Str
let config = serde_json::json!({ "mcpServers": servers });
serde_json::to_string_pretty(&config).unwrap_or_else(|_| "{}".into())
}
#[cfg(test)]
mod recv_hint_tests {
use super::{IDLE_WAIT_HINT, SocketReply, format_recv};
#[test]
fn empty_recv_after_wait_appends_idle_hint() {
let out = format_recv(Ok(SocketReply::Messages(vec![])), 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(SocketReply::Messages(vec![])), false);
assert_eq!(out, "(empty)");
}
}