Synced from monorepo

Synced from monorepo

Changes:
- Release a shell session's resources in one drop
- Make the tools blocking-wait cap client-configurable and self-describing
- Recognize API "exceeds budget" errors as context overflow
- Retry /btw on model overload
- Carry running background tasks and subagents across compaction
- Require round-trip time for SDK liveness checks
- Background-subagent completion reminders with a selectable delivery surface
- Make a PTY shell reap itself until it reaches the registry
- Recover the OS error code from a TLS-phase connection reset
- Consume the attached-client signal and report why idle is withheld
- Treat `.grok/sandbox.toml` edits as protected so auto mode prompts before writing
- Surface history/search in the Ctrl+. cheatsheet and keep it working in history view
- Delete sessions from the dashboard and welcome list
- Release a session's activity record when the session ends
- Stop charging auth-retry budget for fail-closed 401s; reset it across suspends
- Scope skills watches on project vendor roots
- Make [stop] cancel in-flight compaction
- Make the leader soak measure the leader, not its harness

Source-Revision: 8d69c91f02bcacf01e98d5aebbf2f92547c45738
This commit is contained in:
grokkybara[bot] 2026-07-31 18:08:03 +00:00
commit a422116582
165 changed files with 15161 additions and 1969 deletions

File diff suppressed because it is too large Load diff

View file

@ -3076,7 +3076,9 @@ mod tests {
"supported_protocol_versions": ["1.0.0"],
});
let _ = socket.send(Message::Text(ack.to_string().into())).await;
while let Some(Ok(Message::Text(text))) = socket.recv().await {
// Ignore WS Ping/Pong/Close: keepalive fires immediately after hello.
while let Some(Ok(msg)) = socket.recv().await {
let Message::Text(text) = msg else { continue };
let Ok(value) = serde_json::from_str::<Value>(text.as_ref()) else {
continue;
};

View file

@ -433,6 +433,11 @@ mod inner {
HEARTBEAT_PONG_DROPPED_TOTAL.inc();
}
#[cfg(test)]
pub(crate) fn heartbeat_pong_dropped_count() -> u64 {
HEARTBEAT_PONG_DROPPED_TOTAL.get()
}
pub(crate) fn cancel_applied() {
CANCEL_APPLIED_TOTAL.inc();
}
@ -561,6 +566,10 @@ mod inner {
#[cfg(not(feature = "metrics"))]
mod inner {
#[cfg(test)]
static TEST_HEARTBEAT_PONG_DROPPED: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
pub(crate) fn pool_connections_inc() {}
pub(crate) fn pool_connections_dec() {}
pub(crate) fn pool_evictions_inc() {}
@ -584,7 +593,15 @@ mod inner {
pub(crate) fn writer_sink_send_error() {}
pub(crate) fn reconnect_writer_resume() {}
pub(crate) fn liveness_deadline_expired() {}
pub(crate) fn heartbeat_pong_dropped() {}
pub(crate) fn heartbeat_pong_dropped() {
#[cfg(test)]
TEST_HEARTBEAT_PONG_DROPPED.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
#[cfg(test)]
pub(crate) fn heartbeat_pong_dropped_count() -> u64 {
TEST_HEARTBEAT_PONG_DROPPED.load(std::sync::atomic::Ordering::Relaxed)
}
pub(crate) fn cancel_applied() {}
pub(crate) fn cancel_pending_tombstoned() {}
pub(crate) fn cancel_no_target() {}
@ -619,6 +636,8 @@ pub(crate) use inner::demux_inbox_depth_set;
pub(crate) use inner::disconnect_detail_class;
pub(crate) use inner::early_notif_buffered;
pub(crate) use inner::heartbeat_pong_dropped;
#[cfg(test)]
pub(crate) use inner::heartbeat_pong_dropped_count;
pub(crate) use inner::hook_send;
pub(crate) use inner::inbox_full_notification_dropped;
pub(crate) use inner::inbox_full_reject_send_failed;

View file

@ -294,18 +294,15 @@ impl ToolServerBuilder {
}
/// Override the inbound-liveness deadline on a freshly-opened
/// connection: if no inbound WebSocket frame of any kind arrives within
/// this window, the connection is declared dead and reconnected. This
/// catches silently dead transports (e.g. a VM snapshot restore or
/// NAT/LB flow expiry) that a send-only keepalive never notices.
/// connection: if no RTT proof (WS/app pong) arrives within this
/// window, the connection is declared dead and reconnected.
/// Hub→client pings and one-way data do not re-arm.
///
/// Default (also used for a zero value): 2.5× the effective ping
/// interval — 75s at the default 30s ping — which guarantees at least
/// two keepalive pings fit in every window, so a healthy-but-idle
/// connection (one pong per ping) can never trip it. Explicit values
/// are honored verbatim; keep them comfortably above the ping interval
/// for the same reason (a value at or below the ping interval churns
/// healthy idle connections and is logged as a warning at connect).
/// Default (also used for a zero value): `min(4× ping, 120s)` — 120s
/// at the default 30s ping, still under the hub's ~150s idle. Explicit
/// values are honored verbatim; keep them comfortably above the ping
/// interval (a value at or below the ping interval churns healthy idle
/// connections and is logged as a warning at connect).
pub fn with_ws_liveness_deadline(mut self, deadline: std::time::Duration) -> Self {
self.ws_liveness_deadline = Some(deadline);
self
@ -398,8 +395,9 @@ impl ToolServerBuilder {
self
}
/// Optional callback fired once on the initial successful connect, before
/// the actor starts (so it happens-before any disconnect/reconnect).
/// Optional callback fired once on the initial successful connect, after
/// the writer task enters its loop and before the reader actor starts.
/// The first keepalive may still be in flight.
pub fn on_connect<F>(mut self, cb: F) -> Self
where
F: Fn() + Send + Sync + 'static,

View file

@ -33,6 +33,7 @@ pub fn is_context_length_error(message: &str) -> bool {
|| m.contains("maximum prompt length")
|| m.contains("maximum context length")
|| m.contains("context_length_exceeded")
|| (m.contains("current message") && m.contains("exceeds budget"))
}
/// Classify an HTTP API failure (status + message) for the compaction retry
@ -183,6 +184,9 @@ mod tests {
"exceeds the maximum prompt length",
"This model's maximum context length is 128000 tokens",
"error code: context_length_exceeded",
"Failed to start sampling: [conversation] Current message (1000000 tokens) exceeds budget (500000 tokens)",
"compact failed: API error (status 400 Bad Request): invalid-argument: Failed to start sampling: [conversation] Current message (1000000 tokens) exceeds budget (500000 tokens)",
"Current message (600000) exceeds budget (500000)",
] {
assert!(is_context_length_error(msg), "should match: {msg}");
}
@ -190,6 +194,8 @@ mod tests {
"internal server error",
"rate limited",
"connection reset by peer",
"Attached file content (300000 tokens) causes message to exceed budget",
"compact index estimate 2.0 GB exceeds budget 1.0 GB",
] {
assert!(!is_context_length_error(msg), "should not match: {msg}");
}

View file

@ -319,6 +319,26 @@ mod tests {
assert_eq!(sampler.call_count(), 1, "overflow must not retry");
}
#[tokio::test]
async fn conversation_exceeds_budget_is_context_overflow() {
let sampler =
MockSampler::scripted(vec![Err(CompactionSampleError::Other(anyhow::anyhow!(
"API error (status 400 Bad Request): invalid-argument: \
Failed to start sampling: [conversation] Current message \
(1000000 tokens) exceeds budget (500000 tokens)"
)))]);
let err = run(&sampler, 3).await.expect_err("should fail");
assert!(matches!(
err,
SampleRetryError::Failure {
deterministic: true,
context_overflow: true,
..
}
));
assert_eq!(sampler.call_count(), 1, "overflow must not retry");
}
#[tokio::test]
async fn transient_exhausted_is_non_deterministic_failure() {
let sampler = MockSampler::scripted(vec![

View file

@ -16,6 +16,12 @@
//! sections (files, AGENTS.md, skills, MCP, memory). Callers pass **borrowed
//! views** (`&str` over live state) so long fields (commands, todo content,
//! descriptions, ids) are not cloned just to format.
//!
//! KEEP IN SYNC: the exact wording of these sections is a compatibility
//! surface — downstream mirrors reproduce it verbatim (grep for
//! `format_section_running_subagents` / `format_section_background_tasks`
//! and `section_todo_list` mirrors). Update them when changing any wording
//! here.
// ---------------------------------------------------------------------------
// Borrowed views over harness live state (no long-string clones)

View file

@ -905,6 +905,66 @@ pub struct ToolServerStatusPayload {
/// tasks.
#[serde(default)]
pub idle_ignores_background: bool,
/// Why `idle_since_ms` is being withheld, when it is. `None` ⇒ not withheld
/// (or the tool server is genuinely busy, which `active_tool_calls`
/// reports).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub withhold_reason: Option<IdleWithholdReason>,
/// Epoch ms the current hold is measured from. Never `None` while
/// `withhold_reason` is `Some`.
///
/// NOT the instant the withhold began, for the preview reasons. It is the
/// **real-use anchor**: the last routed request or tool call, floored at
/// process start. A status poll never moves it, which is the point — a
/// ceiling has to be measured from genuine use, or the pane could hold a
/// sandbox open forever by resetting the clock it is judged against. So
/// for a poll-pinned session this is *older* than the poll-only period,
/// and `now - withhold_since_ms` is time-since-real-use, not
/// time-spent-withholding. `Durability` is the exception: there it is the
/// true busy-since stamp.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub withhold_since_ms: Option<u64>,
/// `true` once the current hold has crossed its effective ceiling. The
/// verdict is published rather than re-derived because the ceiling is
/// per-session config only the sender can see. Always `false` today: no
/// ceilings are configured yet.
#[serde(default)]
pub withhold_capped: bool,
/// Open WebSocket (HMR) tunnels through the in-sandbox preview proxy.
/// Nonzero ⇒ a client is attached even if the preview is otherwise silent.
#[serde(default)]
pub preview_ws_tunnels_open: u32,
}
/// Why a tool server is withholding `idle_since_ms`, ordered by strength of
/// evidence that someone is really using the sandbox.
///
/// Carries an [`Unknown`](Self::Unknown) escape so adding a variant cannot
/// break older readers: without it, one unrecognised string would fail
/// deserialization of the **entire** status frame, taking the idle verdict down
/// with it. Sandbox binaries are pinned per session, so old and new report side
/// by side for at least a full session TTL.
///
/// Deliberately not `#[non_exhaustive]`: in-workspace matches should stay
/// exhaustive so a new variant is a compile error at every decision site, which
/// is a different problem from wire tolerance.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IdleWithholdReason {
/// Artifact producers or queued uploads outstanding. Already bounded by the
/// durability idle-hold cap.
Durability,
/// An open WebSocket tunnel or a routed request in flight. Never a status
/// poll, however long it is held.
PreviewAttached,
/// Recent `Routed` preview traffic — a human loading the app.
PreviewRouted,
/// Only the preview pane's own `/__grok-preview/status` liveness poll.
PreviewStatusOnly,
/// A reason this build does not recognise — a newer sender. Never
/// constructed locally; only produced by deserialization.
#[serde(other)]
Unknown,
}
impl ToolServerStatusPayload {
@ -1336,6 +1396,10 @@ mod tests {
drain_started_ms: Some(1721234599999),
turn_active: true,
idle_ignores_background: false,
withhold_reason: None,
withhold_since_ms: None,
withhold_capped: false,
preview_ws_tunnels_open: 0,
};
let json = serde_json::to_value(&payload).expect("serialize");
assert_eq!(json["upload_queue_pending"], 7);
@ -1350,6 +1414,81 @@ mod tests {
assert_eq!(back, payload);
}
/// `withhold_reason` is snake_case on the wire so it can be used directly
/// as a metric label.
#[test]
fn tool_server_status_payload_carries_withhold_fields() {
let payload = super::ToolServerStatusPayload {
status: super::ToolServerLifecycleStatus::Ready,
session_id: Some(sid()),
idle_since_ms: None,
withhold_reason: Some(super::IdleWithholdReason::PreviewStatusOnly),
withhold_since_ms: Some(1721234560000),
withhold_capped: true,
preview_ws_tunnels_open: 2,
..Default::default()
};
let json = serde_json::to_value(&payload).expect("serialize");
assert_eq!(json["withhold_reason"], "preview_status_only");
assert_eq!(json["withhold_since_ms"], 1721234560000u64);
assert_eq!(json["withhold_capped"], true);
assert_eq!(json["preview_ws_tunnels_open"], 2);
let back: super::ToolServerStatusPayload =
serde_json::from_value(json).expect("deserialize");
assert_eq!(back, payload);
}
/// An unrecognised reason from a newer sender must degrade to `Unknown`,
/// not fail the whole frame and take the idle verdict with it.
#[test]
fn unknown_withhold_reason_does_not_fail_the_frame() {
let json = serde_json::json!({
"status": "ready",
"active_tool_calls": 0,
"background_tasks": 0,
"pending_tool_calls": 0,
"last_tool_call_started_ms": 0,
"last_tool_call_completed_ms": 0,
"uptime_ms": 1000,
"withhold_reason": "some_future_reason",
"withhold_since_ms": 1721234560000u64,
});
let back: super::ToolServerStatusPayload =
serde_json::from_value(json).expect("a newer reason must not break the frame");
assert_eq!(
back.withhold_reason,
Some(super::IdleWithholdReason::Unknown)
);
assert_eq!(
back.withhold_since_ms,
Some(1721234560000),
"the rest of the frame must survive intact"
);
}
/// The fleet is version-pinned per session, so old and new binaries report
/// side by side for at least a full session TTL.
#[test]
fn tool_server_status_payload_withhold_fields_are_optional_on_the_wire() {
let json = serde_json::json!({
"status": "ready",
"active_tool_calls": 0,
"background_tasks": 0,
"pending_tool_calls": 0,
"last_tool_call_started_ms": 0,
"last_tool_call_completed_ms": 0,
"uptime_ms": 1000,
});
let back: super::ToolServerStatusPayload =
serde_json::from_value(json).expect("old payload must still deserialize");
assert_eq!(back.withhold_reason, None);
assert_eq!(back.withhold_since_ms, None);
assert!(!back.withhold_capped);
assert_eq!(back.preview_ws_tunnels_open, 0);
// An absent reason is indistinguishable from "nothing is withheld" —
// what the field-coverage ratio exists to measure.
}
/// A legacy payload without the new fields deserializes with defaults.
#[test]
fn tool_server_status_payload_legacy_without_pr9_fields_defaults() {

View file

@ -38,21 +38,21 @@ pub use error_codes::{
};
pub use error_wire::ToolErrorWire;
pub use frames::{
AttachRoute, HookFrame, HookReplyFrame, LastSeq, LogsDonateParams, MAX_DONATION_BYTES,
MAX_LOG_RECORDS_PER_DONATION, MAX_METRICS_PER_DONATION, MAX_SPANS_PER_DONATION,
MAX_SYSTEM_NOTIFY_PAYLOAD_BYTES, MetricsDonateParams, NotificationFilter, PingFrame, PongFrame,
ServeParams, ServeResult, ServerBindAck, ServerBindOutcome, ServerBindParams, ServerInfo,
ServerUnbindAck, ServerUnbindOutcome, ServerUnbindParams, ServersListParams, ServersListResult,
SessionAttachServerParams, SessionAttachServerResult, SessionBindParams, SessionBindResult,
SessionBindServerParams, SessionBindServerResult, SessionCloseParams, SessionOpenParams,
SessionOpenResult, SessionUnbindParams, SessionUnbindServerParams, SubscribeAck,
SubscribeNotificationsParams, SubscribeOutcome, SystemNotifyParams, ToolCallParams,
ToolCallProgressFrame, ToolCallResult, ToolNotificationFrame, ToolSearchResult,
ToolServerConnectionStatus, ToolServerDisconnectReason, ToolServerEvictParams,
ToolServerGetStatusParams, ToolServerGetStatusResult, ToolServerLifecycleStatus,
ToolServerStatusPayload, ToolsChanged, ToolsListParams, ToolsListResult, ToolsSearchParams,
ToolsSearchResultBody, TracesDonateParams, UnsubscribeAck, UnsubscribeNotificationsParams,
UnsubscribeOutcome,
AttachRoute, HookFrame, HookReplyFrame, IdleWithholdReason, LastSeq, LogsDonateParams,
MAX_DONATION_BYTES, MAX_LOG_RECORDS_PER_DONATION, MAX_METRICS_PER_DONATION,
MAX_SPANS_PER_DONATION, MAX_SYSTEM_NOTIFY_PAYLOAD_BYTES, MetricsDonateParams,
NotificationFilter, PingFrame, PongFrame, ServeParams, ServeResult, ServerBindAck,
ServerBindOutcome, ServerBindParams, ServerInfo, ServerUnbindAck, ServerUnbindOutcome,
ServerUnbindParams, ServersListParams, ServersListResult, SessionAttachServerParams,
SessionAttachServerResult, SessionBindParams, SessionBindResult, SessionBindServerParams,
SessionBindServerResult, SessionCloseParams, SessionOpenParams, SessionOpenResult,
SessionUnbindParams, SessionUnbindServerParams, SubscribeAck, SubscribeNotificationsParams,
SubscribeOutcome, SystemNotifyParams, ToolCallParams, ToolCallProgressFrame, ToolCallResult,
ToolNotificationFrame, ToolSearchResult, ToolServerConnectionStatus,
ToolServerDisconnectReason, ToolServerEvictParams, ToolServerGetStatusParams,
ToolServerGetStatusResult, ToolServerLifecycleStatus, ToolServerStatusPayload, ToolsChanged,
ToolsListParams, ToolsListResult, ToolsSearchParams, ToolsSearchResultBody, TracesDonateParams,
UnsubscribeAck, UnsubscribeNotificationsParams, UnsubscribeOutcome,
};
pub use handshake::{HelloAckMsg, HelloMsg, PROTOCOL_VERSION};
pub use hook::HookEvent;

View file

@ -13,15 +13,16 @@ pub use serde_lenient::{
pub use task::{
BUILTIN_SUBAGENTS, BuiltinSubagent, EXPLORE_PROMPT, EXPLORE_SUBAGENT, GENERAL_PURPOSE_PROMPT,
GENERAL_PURPOSE_SUBAGENT, KillTaskOutput, KillTaskResult, KillTaskToolInput,
KillTaskToolNaming, MAX_MULTI_WAIT_IDS, MultiTaskOutputResult, PLAN_PROMPT, PLAN_SUBAGENT,
SubagentCapabilityMode, SubagentCompletedOutput, SubagentDescriptor, SubagentIsolationMode,
SubagentToolNaming, TaskOutputOutput, TaskOutputResult, TaskOutputToolInput,
TaskOutputToolNaming, TaskToolInput, TaskToolNaming, WaitMode, WaitTasksToolInput,
WaitTasksToolNaming, build_kill_task_description, build_task_description,
build_task_output_description, build_wait_tasks_description, builtin_subagent_by_name,
default_subagent_type, format_resume_footer, format_subagent_completed,
format_subagent_started_background, is_not_sentinel, resolve_task_ids, sanitize_optional_arg,
task_output_waits, task_output_waits_from_json,
KillTaskToolNaming, MAX_MULTI_WAIT_IDS, MAX_WAIT_BLOCK_MS_DEFAULT, MAX_WAIT_MS_PLACEHOLDER,
MultiTaskOutputResult, PLAN_PROMPT, PLAN_SUBAGENT, SubagentCapabilityMode,
SubagentCompletedOutput, SubagentDescriptor, SubagentIsolationMode, SubagentToolNaming,
TaskOutputOutput, TaskOutputResult, TaskOutputToolInput, TaskOutputToolNaming, TaskToolInput,
TaskToolNaming, WaitMode, WaitTasksToolInput, WaitTasksToolNaming, build_kill_task_description,
build_task_description, build_task_output_description, build_wait_tasks_description,
builtin_subagent_by_name, default_subagent_type, format_resume_footer,
format_subagent_completed, format_subagent_started_background, format_wait_cap_ms,
is_not_sentinel, max_wait_block_ms, resolve_task_ids, sanitize_optional_arg, task_output_waits,
task_output_waits_from_json,
};
pub use types::{
ArgumentType, SchemaType, ToolArgument, ToolDescription, ValidationError, ValidationErrors,

View file

@ -340,8 +340,12 @@ pub struct TaskOutputToolInput {
pub task_ids: Vec<String>,
/// When set and positive, wait up to this many milliseconds; omit or `0` polls.
///
/// `{max_wait_ms}` is resolved at finalize from the session's wait ceiling,
/// which also pins it as the schema `maximum` — the tool description cannot
/// carry the bound alone, since randomization may replace it wholesale.
#[schemars(
description = "Max wait time in milliseconds. A positive value waits for completion; omit or pass 0 for a non-blocking status poll."
description = "Max wait time in milliseconds, up to {max_wait_ms}. A positive value waits for completion; omit or pass 0 for a non-blocking status poll."
)]
#[serde(default)]
pub timeout_ms: Option<u64>,
@ -381,6 +385,44 @@ pub fn task_output_waits(timeout_ms: Option<u64>) -> bool {
timeout_ms.is_some_and(|ms| ms > 0)
}
/// Default ceiling on a single blocking wait (`get_task_output` with a positive
/// `timeout_ms`, `wait_tasks`). Capping is safe because a completed task pings
/// the model, so a truncated wait costs one more poll, not the result.
pub const MAX_WAIT_BLOCK_MS_DEFAULT: u64 = 600_000;
/// The blocking-wait ceiling in effect, honoring `GROK_MAX_WAIT_BLOCK_MS`.
///
/// A host whose transport deadline is shorter than the default sets the env var
/// so the server enforces — and the tool descriptions advertise — the same
/// number the caller will actually wait for. Without that, a model believing the
/// default asks for a wait its own client will abandon first.
pub fn max_wait_block_ms() -> u64 {
std::env::var("GROK_MAX_WAIT_BLOCK_MS")
.ok()
.and_then(|raw| raw.parse::<u64>().ok())
.unwrap_or(MAX_WAIT_BLOCK_MS_DEFAULT)
}
/// Render a wait ceiling for tool descriptions, e.g. `600000 (~10 min)`.
///
/// The unit is derived from the value, so it cannot drift from the millisecond
/// figure beside it. Both branches round *down*: a cap must never read as
/// longer than it is.
pub fn format_wait_cap_ms(ms: u64) -> String {
if ms < 60_000 {
format!("{ms} (~{} s)", ms / 1_000)
} else {
format!("{ms} (~{} min)", ms / 60_000)
}
}
/// Placeholder the description builders emit for the wait ceiling.
///
/// Resolved per session by `TruncationConfig::interpolate_description` in the
/// finalize loop, the same way `{max_lines_read}` is: the cap is client
/// configurable, so it cannot be baked in when the description is built.
pub const MAX_WAIT_MS_PLACEHOLDER: &str = "{max_wait_ms}";
/// Same as [`task_output_waits`], from raw tool-arg JSON (fingerprint / doom-loop).
pub fn task_output_waits_from_json(args: &serde_json::Value) -> bool {
let timeout_ms = args.get("timeout_ms").and_then(|v| {
@ -504,7 +546,9 @@ pub struct WaitTasksToolInput {
)]
pub mode: WaitMode,
#[schemars(description = "Max wait time in milliseconds")]
/// Carries the same `{max_wait_ms}` marker as `TaskOutputToolInput`: this
/// tool blocks on the same ceiling, so it needs the same resolved bound.
#[schemars(description = "Max wait time in milliseconds, up to {max_wait_ms}")]
#[serde(default)]
pub timeout_ms: Option<u64>,
}
@ -1023,12 +1067,13 @@ pub fn build_task_output_description(naming: &TaskOutputToolNaming) -> String {
Some(r) => format!("\n- If output is large, use {r} on the output_file path"),
None => String::new(),
};
let wait_cap = MAX_WAIT_MS_PLACEHOLDER;
format!(
"Get output and status from a background task{target_suffix}.\n\n\
Usage notes:\n\
- Pass {task_ids_param} with one or more ids from {sources}{monitor_note}; for a single task use a one-element array. Multiple ids with a positive {timeout_ms_param} wait until all complete\n\
- Omit {timeout_ms_param} or pass 0 for a non-blocking status snapshot; set a positive {timeout_ms_param} to wait up to that many milliseconds, capped at ~10 min\n\
- Omit {timeout_ms_param} or pass 0 for a non-blocking status snapshot; set a positive {timeout_ms_param} to wait up to that many milliseconds, capped at {wait_cap}\n\
- Returns current output, status, and exit code if completed{read_note}"
)
}
@ -1061,13 +1106,15 @@ pub fn build_wait_tasks_description(naming: &WaitTasksToolNaming) -> String {
(None, None) => "background tasks".to_string(),
};
let wait_cap = MAX_WAIT_MS_PLACEHOLDER;
format!(
"Wait for multiple background tasks or subagents to complete.\n\n\
Prefer {background_retrieval_tool} with task_ids and a positive timeout_ms. This tool is kept for compatibility.\n\n\
Usage notes:\n\
- task_ids: list of task IDs from {sources}\n\
- mode: 'wait_all' or 'wait_any'\n\
- timeout_ms: optional max wait, default 30s, capped at ~10 min"
- timeout_ms: optional max wait, default 30s, capped at {wait_cap}"
)
}
@ -1528,6 +1575,19 @@ mod tests {
);
}
#[test]
fn format_wait_cap_ms_derives_its_unit_and_rounds_down() {
assert_eq!(
format_wait_cap_ms(MAX_WAIT_BLOCK_MS_DEFAULT),
"600000 (~10 min)"
);
assert_eq!(format_wait_cap_ms(300_000), "300000 (~5 min)");
// Rounds down: 1.5 min must not read as 2.
assert_eq!(format_wait_cap_ms(90_000), "90000 (~1 min)");
// Sub-minute caps switch unit rather than rendering "~0 min".
assert_eq!(format_wait_cap_ms(30_000), "30000 (~30 s)");
}
#[test]
fn task_output_description_tracks_renamed_params() {
let desc = build_task_output_description(&TaskOutputToolNaming {
@ -1573,7 +1633,7 @@ mod tests {
"Get output and status from a background task, monitor, or subagent.\n\n\
Usage notes:\n\
- Pass task_ids with one or more ids from background=true commands or subagents (a monitor's task_id is returned by monitor); for a single task use a one-element array. Multiple ids with a positive timeout_ms wait until all complete\n\
- Omit timeout_ms or pass 0 for a non-blocking status snapshot; set a positive timeout_ms to wait up to that many milliseconds, capped at ~10 min\n\
- Omit timeout_ms or pass 0 for a non-blocking status snapshot; set a positive timeout_ms to wait up to that many milliseconds, capped at {max_wait_ms}\n\
- Returns current output, status, and exit code if completed\n\
- If output is large, use read_file on the output_file path"
);
@ -1595,7 +1655,7 @@ mod tests {
"Get output and status from a background task or subagent.\n\n\
Usage notes:\n\
- Pass task_ids with one or more ids from run_in_background=true subagents; for a single task use a one-element array. Multiple ids with a positive timeout_ms wait until all complete\n\
- Omit timeout_ms or pass 0 for a non-blocking status snapshot; set a positive timeout_ms to wait up to that many milliseconds, capped at ~10 min\n\
- Omit timeout_ms or pass 0 for a non-blocking status snapshot; set a positive timeout_ms to wait up to that many milliseconds, capped at {max_wait_ms}\n\
- Returns current output, status, and exit code if completed\n\
- If output is large, use read_file on the output_file path"
);
@ -1615,7 +1675,7 @@ mod tests {
Usage notes:\n\
- task_ids: list of task IDs from background=true commands or subagents\n\
- mode: 'wait_all' or 'wait_any'\n\
- timeout_ms: optional max wait, default 30s, capped at ~10 min"
- timeout_ms: optional max wait, default 30s, capped at {max_wait_ms}"
);
}