From d34c594f2b22af9b6cda781381d205c35716166e Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 5 Jun 2026 19:58:22 +0200 Subject: [PATCH] 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. --- hive-ag3nt/src/mcp.rs | 51 ++++++++++++++++++++++++++------ hive-bash-mcp/src/bin/mcp.rs | 56 +++++++++++++++++++++++++++++++++--- 2 files changed, 94 insertions(+), 13 deletions(-) diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index 018e9298..e613aa92 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -160,14 +160,16 @@ pub fn format_ack(resp: Result, 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) -> String { +pub fn format_recv(resp: Result, 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) -> 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) -> 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` already extracted from the /// socket reply. Called by both `format_loose_ends` (which handles the /// `Result` wrapper) and the augmented `get_loose_ends` @@ -723,13 +737,14 @@ impl AgentServer { async fn recv(&self, Parameters(args): Parameters) -> 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)"); + } +} diff --git a/hive-bash-mcp/src/bin/mcp.rs b/hive-bash-mcp/src/bin/mcp.rs index 308f9afc..c7c5b23e 100644 --- a/hive-bash-mcp/src/bin/mcp.rs +++ b/hive-bash-mcp/src/bin/mcp.rs @@ -119,11 +119,27 @@ fn render_bash_run(id: &str, resp: Result) -> String { } } +/// Appended to a `status` result when the caller parked on a +/// `wait_seconds` poll that expired while the task was still running — +/// nudges the model to spend the idle time on other work instead of +/// immediately re-waiting on the same task. +const BASH_IDLE_WAIT_HINT: &str = "\n\nThe task is still running — your wait timed out before it \ +finished. If you have other useful work, do that and check back later (the task keeps running, and \ +a wake fires when it completes) rather than immediately re-waiting."; + /// Turn a `DaemonResponse` from a `BashStatus` call into the string -/// claude sees as the tool result. -fn render_bash_status(id: &str, resp: Result) -> String { +/// claude sees as the tool result. When `waited` is set (the call +/// parked on a `wait_seconds` poll) and the task is still non-terminal, +/// [`BASH_IDLE_WAIT_HINT`] is appended. +fn render_bash_status(id: &str, resp: Result, waited: bool) -> String { match resp { - Ok(DaemonResponse::Ok { payload }) => format_task(id, &payload), + Ok(DaemonResponse::Ok { payload }) => { + let mut out = format_task(id, &payload); + if waited && matches!(payload["status"].as_str(), Some("pending" | "running")) { + out.push_str(BASH_IDLE_WAIT_HINT); + } + out + } Ok(DaemonResponse::Error { message }) => message, Err(e) => format!("bash bridge error: {e:#}"), } @@ -215,11 +231,12 @@ impl BashMcp { )] async fn status(&self, Parameters(args): Parameters) -> String { let id = args.id.clone(); + let waited = args.wait_seconds.is_some_and(|w| w > 0); let req = DaemonRequest::BashStatus { id: args.id, wait_seconds: args.wait_seconds, }; - render_bash_status(&id, round_trip(req).await) + render_bash_status(&id, round_trip(req).await, waited) } } @@ -240,3 +257,34 @@ async fn main() -> Result<()> { service.waiting().await?; Ok(()) } + +#[cfg(test)] +mod status_hint_tests { + use super::{BASH_IDLE_WAIT_HINT, render_bash_status}; + use anyhow::Result; + use hive_bash_mcp::protocol::DaemonResponse; + + fn ok(status: &str) -> Result { + Ok(DaemonResponse::Ok { + payload: serde_json::json!({ "status": status, "started_at": 1 }), + }) + } + + #[test] + fn running_task_after_wait_appends_idle_hint() { + let out = render_bash_status("t1", ok("running"), true); + assert!(out.contains(BASH_IDLE_WAIT_HINT)); + } + + #[test] + fn running_task_without_wait_has_no_hint() { + let out = render_bash_status("t1", ok("running"), false); + assert!(!out.contains(BASH_IDLE_WAIT_HINT)); + } + + #[test] + fn finished_task_after_wait_has_no_hint() { + let out = render_bash_status("t1", ok("done"), true); + assert!(!out.contains(BASH_IDLE_WAIT_HINT)); + } +}