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:
parent
dd04f397b1
commit
a422116582
165 changed files with 15161 additions and 1969 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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![
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue