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

View file

@ -11,6 +11,7 @@ arc-swap = { workspace = true }
dirs = { workspace = true }
derive_more = { workspace = true, features = ["from", "try_into"] }
dunce = { workspace = true }
encoding_rs = { workspace = true, optional = true }
fs2 = { workspace = true }
educe = { workspace = true, features = ["Debug"] }
async-openai = { workspace = true }
@ -31,6 +32,7 @@ ignore = { workspace = true }
infer = { workspace = true }
globset = { workspace = true }
image = { workspace = true, features = ["png", "jpeg", "gif", "webp", "bmp", "tiff", "ico"] }
kamadak-exif = { workspace = true, optional = true }
pdf_oxide = { workspace = true }
# PPTX text extraction (implementations/read_file/pptx.rs). Deliberately NOT
# `workspace = true`: the workspace `zip` pin has default features on, which
@ -73,6 +75,7 @@ tokio = { workspace = true, features = [
tokio-util = { workspace = true, features = ["compat"] }
tonic = { workspace = true }
tracing = { workspace = true }
unicode-normalization = { workspace = true, optional = true }
url = { workspace = true }
uuid = { workspace = true, features = ["v7"] }
wildmatch = { workspace = true }
@ -113,6 +116,7 @@ xai-test-utils = { workspace = true }
[build-dependencies]
reqwest = { workspace = true, features = ["blocking", "rustls-tls"] }
flate2 = { workspace = true }
sha2 = { workspace = true }
tar = { workspace = true }
[lints]

View file

@ -10,15 +10,185 @@ use std::path::PathBuf;
const RG_VER: &str = "15.0.0";
const BFS_VER: &str = "4.1";
const UGREP_VER: &str = "7.7.0";
const FD_VER: &str = "10.4.2";
// fd stopped publishing x86_64-apple-darwin assets after 10.3.0.
const FD_VER_MACOS_X64: &str = "10.3.0";
/// Pinned SHA-256 of each `(version, triple)` fd release tarball we embed.
const FD_TARBALL_SHA256: &[(&str, &str, &str)] = &[
(
"10.4.2",
"x86_64-unknown-linux-musl",
"e3257d48e29a6be965187dbd24ce9af564e0fe67b3e73c9bdcd180f4ec11bdde",
),
(
"10.4.2",
"aarch64-unknown-linux-musl",
"f32d3657473fba74e2600babc8db0b93420d51169223b7e8143b2ed55d8fd9e8",
),
(
"10.4.2",
"aarch64-apple-darwin",
"623dc0afc81b92e4d4606b380d7bc91916ba7b97814263e554d50923a39e480a",
),
(
"10.3.0",
"x86_64-apple-darwin",
"50d30f13fe3d5914b14c4fff5abcbd4d0cdab4b855970a6956f4f006c17117a3",
),
];
fn main() -> Result<(), Box<dyn std::error::Error>> {
bundle_rg()?;
// fd is an optional vendored file-search binary backing a feature-gated
// toolset; skip the download/embed entirely when that feature is off
// (shipped TUI binaries).
if env::var_os("CARGO_FEATURE_PI").is_some() {
bundle_fd()?;
}
// bfs/ugrep back the bash-harness find/grep shadows (embedded_search_tools).
bundle_search_tool("bfs", "BFS", BFS_VER)?;
bundle_search_tool("ugrep", "UGREP", UGREP_VER)?;
Ok(())
}
/// Download + embed fd as an optional vendored file-search binary, mirroring
/// the ripgrep bundling
/// (release-only or `GROK_TOOLS_BUNDLE_FD_PATH` override), plus pinned
/// per-asset SHA-256 verification of the downloaded tarball.
fn bundle_fd() -> Result<(), Box<dyn std::error::Error>> {
println!("cargo:rerun-if-env-changed=GROK_TOOLS_BUNDLE_FD_PATH");
println!("cargo:rustc-check-cfg=cfg(bundle_fd)");
let gen_dir = PathBuf::from(env::var("OUT_DIR")?).join("bundle-fd");
fs::create_dir_all(&gen_dir)?;
// The consuming vendor extraction is unix-only — never bundle on
// Windows targets, mirroring the bfs/ugrep skip.
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if target_os == "windows" {
return Ok(());
}
let path_override = env::var("GROK_TOOLS_BUNDLE_FD_PATH").ok();
let is_release = env::var("PROFILE").as_deref() == Ok("release");
if path_override.is_none() && !is_release {
return Ok(());
}
// Per-target version: macOS x86_64 pins the last release with that asset.
let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
let (ver, asset_triple) = match (target_os.as_str(), target_arch.as_str()) {
("macos", "aarch64") => (FD_VER, "aarch64-apple-darwin"),
("macos", "x86_64") => (FD_VER_MACOS_X64, "x86_64-apple-darwin"),
("linux", "x86_64") => (FD_VER, "x86_64-unknown-linux-musl"),
("linux", "aarch64") => (FD_VER, "aarch64-unknown-linux-musl"),
_ => {
if path_override.is_none() {
return Err(format!(
"Unsupported target for fd bundling: {target_os}-{target_arch}. Set GROK_TOOLS_BUNDLE_FD_PATH to a local fd binary for offline or unsupported builds.",
)
.into());
}
(FD_VER, "override")
}
};
println!("cargo:rustc-cfg=bundle_fd");
println!("cargo:rustc-env=GROK_TOOLS_FD_VER={ver}");
if let Some(path) = path_override {
let dest = gen_dir.join(format!("fd-{ver}-override.bin"));
println!("cargo:rustc-env=GROK_TOOLS_FD_TARGET=override");
let _ = fs::remove_file(&dest);
fs::copy(PathBuf::from(path.clone()), &dest).map_err(|e| {
format!(
"Failed copying GROK_TOOLS_BUNDLE_FD_PATH: {e} from path {path} to dest {}",
dest.display()
)
})?;
return Ok(());
}
println!("cargo:rustc-env=GROK_TOOLS_FD_TARGET={asset_triple}");
let dest = gen_dir.join(format!("fd-{ver}-{asset_triple}.bin"));
let _ = fs::remove_file(&dest);
let url = format!(
"https://github.com/sharkdp/fd/releases/download/v{ver}/fd-v{ver}-{asset_triple}.tar.gz"
);
let bytes: Vec<u8> = {
let resp = reqwest::blocking::get(&url).map_err(|e| {
format!(
"Failed to download fd: {e}\nSet GROK_TOOLS_BUNDLE_FD_PATH to a local fd for offline builds."
)
})?;
if !resp.status().is_success() {
return Err(format!(
"HTTP {} downloading fd. Set GROK_TOOLS_BUNDLE_FD_PATH for offline builds.",
resp.status()
)
.into());
}
resp.bytes()?.to_vec()
};
// Verify the tarball against the pinned per-asset hash before unpacking.
let expected_sha = FD_TARBALL_SHA256
.iter()
.find(|(v, t, _)| *v == ver && *t == asset_triple)
.map(|(_, _, sha)| *sha)
.ok_or_else(|| format!("No pinned SHA-256 for fd {ver} {asset_triple}"))?;
let actual_sha = {
use sha2::Digest as _;
let mut hasher = sha2::Sha256::new();
hasher.update(&bytes);
hex_encode(&hasher.finalize())
};
if actual_sha != expected_sha {
return Err(format!(
"SHA-256 mismatch for {url}:\n expected {expected_sha}\n actual {actual_sha}"
)
.into());
}
let gz = flate2::read::GzDecoder::new(&bytes[..]);
let mut ar = tar::Archive::new(gz);
let mut found = false;
for entry in ar.entries()? {
let mut e = entry?;
let p = e.path()?;
if p.file_name().is_some_and(|n| n == "fd") {
let data: Vec<u8> = {
let mut v = Vec::new();
io::copy(&mut e, &mut v)?;
v
};
fs::write(&dest, &data)?;
found = true;
break;
}
}
if !found {
return Err(format!(
"Could not find 'fd' in fd archive {url}. Set GROK_TOOLS_BUNDLE_FD_PATH for offline builds."
)
.into());
}
Ok(())
}
fn hex_encode(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push_str(&format!("{byte:02x}"));
}
out
}
/// Bundle a prebuilt **static** search-tool binary (`bfs`/`ugrep`) when
/// `GROK_TOOLS_BUNDLE_<NAME>_PATH` points at one (supplied by the release
/// pipeline). Emits

View file

@ -31,29 +31,22 @@ use xai_tool_types::{
/// constant is not applied unless a wait is active.
pub(crate) const DEFAULT_WAIT_TIMEOUT: Duration = Duration::from_secs(30);
/// Max time a blocking wait (`get_command_or_subagent_output` with positive
/// `timeout_ms` / `wait_commands_or_subagents`) may hold the turn, regardless of
/// the requested `timeout_ms`. Safe to cap because completed tasks ping the
/// model (`send_task_complete` → auto-wake). 10m matches the external
/// `TaskOutput` cap. Env override: `GROK_MAX_WAIT_BLOCK_MS`.
const MAX_WAIT_BLOCK: Duration = Duration::from_secs(600);
fn max_wait_block() -> Duration {
std::env::var("GROK_MAX_WAIT_BLOCK_MS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.map(Duration::from_millis)
.unwrap_or(MAX_WAIT_BLOCK)
/// The blocking-wait ceiling: `GROK_MAX_WAIT_BLOCK_MS`, else 10 min.
///
/// The same value fills `{max_wait_ms}` in the descriptions, so a wait can
/// never exceed what the model was told it may ask for.
pub(crate) fn max_wait_block() -> Duration {
Duration::from_millis(xai_tool_types::max_wait_block_ms())
}
/// Resolve a model-supplied `timeout_ms` into the effective blocking-wait
/// duration: default when omitted, then clamped to [`max_wait_block`] so a
/// single wait call can never wedge the turn for longer than the cap.
pub(crate) fn capped_wait_timeout(timeout_ms: Option<u64>) -> Duration {
/// duration: default when omitted, then clamped to `cap` so a single wait call
/// can never wedge the turn for longer than the ceiling.
pub(crate) fn capped_wait_timeout(timeout_ms: Option<u64>, cap: Duration) -> Duration {
let base = timeout_ms
.map(Duration::from_millis)
.unwrap_or(DEFAULT_WAIT_TIMEOUT);
base.min(max_wait_block())
base.min(cap)
}
/// The caller's requested wait before capping, or the default when omitted.
@ -201,10 +194,11 @@ impl TaskOutputTool {
}
let waits = xai_tool_types::task_output_waits(timeout_ms);
let wait_cap = max_wait_block();
let wait_hint = if waits {
WaitHint::Elapsed {
requested: requested_wait_timeout(timeout_ms),
waited: capped_wait_timeout(timeout_ms),
waited: capped_wait_timeout(timeout_ms, wait_cap),
}
} else {
WaitHint::NotRequested
@ -212,7 +206,7 @@ impl TaskOutputTool {
let snapshot = if waits {
// Cap the blocking wait so a large `timeout_ms` can't wedge the turn;
// the model is pinged on completion regardless (see `capped_wait_timeout`).
let timeout = capped_wait_timeout(timeout_ms);
let timeout = capped_wait_timeout(timeout_ms, wait_cap);
terminal.wait_for_completion(task_id, Some(timeout)).await
} else {
terminal.get_task(task_id).await
@ -255,7 +249,7 @@ impl TaskOutputTool {
// Same cap as the bash path: a blocking subagent query can't wedge the
// turn beyond the wait cap (the parent is pinged when the child finishes).
let query_timeout_ms = if waits {
Some(capped_wait_timeout(timeout_ms).as_millis() as u64)
Some(capped_wait_timeout(timeout_ms, wait_cap).as_millis() as u64)
} else {
timeout_ms
};
@ -298,7 +292,7 @@ impl TaskOutputTool {
) -> Result<TaskOutputOutput, xai_tool_runtime::ToolError> {
let waits = xai_tool_types::task_output_waits(timeout_ms);
let requested = requested_wait_timeout(timeout_ms);
let timeout = capped_wait_timeout(timeout_ms);
let timeout = capped_wait_timeout(timeout_ms, max_wait_block());
let (terminal, backend, read_file_name, max_output_bytes) = {
let res = resources.lock().await;
@ -1087,13 +1081,26 @@ mod tests {
// unbounded blocking wait wedged the turn for hours).
#[test]
fn capped_wait_timeout_clamps_and_defaults() {
assert_eq!(capped_wait_timeout(None), DEFAULT_WAIT_TIMEOUT);
let cap = Duration::from_millis(xai_tool_types::MAX_WAIT_BLOCK_MS_DEFAULT);
assert_eq!(capped_wait_timeout(None, cap), DEFAULT_WAIT_TIMEOUT);
assert_eq!(
capped_wait_timeout(Some(5_000)),
capped_wait_timeout(Some(5_000), cap),
Duration::from_millis(5_000)
);
assert_eq!(capped_wait_timeout(Some(36_000_000)), MAX_WAIT_BLOCK);
assert_eq!(capped_wait_timeout(Some(600_000)), MAX_WAIT_BLOCK);
assert_eq!(capped_wait_timeout(Some(36_000_000), cap), cap);
assert_eq!(capped_wait_timeout(Some(600_000), cap), cap);
}
/// A client that shortens the cap at finalize must also shorten the wait —
/// otherwise the server outlasts the deadline the client will honor.
#[test]
fn capped_wait_timeout_honors_a_shortened_cap() {
let cap = Duration::from_millis(300_000);
assert_eq!(capped_wait_timeout(Some(600_000), cap), cap);
assert_eq!(
capped_wait_timeout(Some(120_000), cap),
Duration::from_millis(120_000)
);
}
#[test]

View file

@ -22,11 +22,14 @@ impl crate::types::tool_metadata::ToolMetadata for GetTerminalCommandOutputTool
}
fn description_template(&self) -> &str {
// `{max_wait_ms}` is resolved per session by the finalize loop's
// `TruncationConfig::interpolate_description`, like `{max_lines_read}`:
// the cap is client-configurable, so it cannot be baked in here.
r#"Get output and status from a background terminal command${%- if tools.by_kind.monitor %} or monitor${%- endif %}.
Usage notes:
- Pass ${{ params.background_task_action.task_ids }} with one or more ids from ${%- if params is defined and params.execute is defined and params.execute.is_background %} ${{ params.execute.is_background }}=true commands${%- else %} background commands${%- endif %}${%- if tools.by_kind.monitor %} (a monitor's ${{ params.kill_task_action.task_id }} is returned by ${{ tools.by_kind.monitor }})${%- endif %}; for a single task use a one-element array. Multiple ids with a positive ${{ params.background_task_action.timeout_ms }} wait until all complete
- Omit ${{ params.background_task_action.timeout_ms }} or pass 0 for a non-blocking status snapshot; set a positive ${{ params.background_task_action.timeout_ms }} to wait up to that many milliseconds, capped at ~10 min
- Omit ${{ params.background_task_action.timeout_ms }} or pass 0 for a non-blocking status snapshot; set a positive ${{ params.background_task_action.timeout_ms }} to wait up to that many milliseconds, capped at {max_wait_ms}
- Returns current output, status, and exit code if completed${%- if tools.by_kind.read %}
- If output is large, use ${{ tools.by_kind.read }} on the output_file path${%- endif %}"#
}

View file

@ -173,8 +173,10 @@ impl xai_tool_runtime::Tool for WaitTasksTool {
.timeout_ms
.map(std::time::Duration::from_millis)
.unwrap_or(super::DEFAULT_WAIT_TIMEOUT);
let timeout =
crate::implementations::grok_build::task_output::capped_wait_timeout(input.timeout_ms);
let timeout = crate::implementations::grok_build::task_output::capped_wait_timeout(
input.timeout_ms,
crate::implementations::grok_build::task_output::max_wait_block(),
);
let (terminal, backend, read_file_name, max_output_bytes) = {
let res = resources.lock().await;

View file

@ -1162,9 +1162,16 @@ impl ToolRegistryBuilder {
desc,
&client_name,
crate::DEFAULT_TOOL_OUTPUT_BYTES,
xai_tool_types::max_wait_block_ms(),
));
}
renderer.render_schema_descriptions(&mut definition.function.parameters);
truncation_config.apply_to_schema(
&mut definition.function.parameters,
&client_name,
crate::DEFAULT_TOOL_OUTPUT_BYTES,
xai_tool_types::max_wait_block_ms(),
);
(entry.apply_params)(&effective_params, &mut resources);
tools.push(FinalizedTool {
namespace: entry.namespace,
@ -1323,6 +1330,13 @@ impl FinalizedToolset {
pub fn local_registry(&self) -> &xai_computer_hub_sdk::LocalRegistry {
&self.local_registry
}
/// Whether the server must await this tool's in-process cancellation cleanup.
pub fn cooperative_cancellation(&self, tool_name: &str) -> bool {
{
let _ = tool_name;
false
}
}
/// Get all tool definitions to send to the client.
pub fn tool_definitions(&self) -> Vec<ToolDefinition> {
self.tools
@ -2336,6 +2350,7 @@ mod tests {
.into_iter()
.map(|id| ToolConfig::from_id(format!("GrokBuild:{id}")))
.chain(std::iter::empty::<ToolConfig>())
.chain(std::iter::empty::<ToolConfig>())
.collect(),
behavior_preset: None,
};
@ -2389,7 +2404,8 @@ mod tests {
_ => {}
}
}
for def in toolset.tool_definitions() {
let definitions = toolset.tool_definitions();
for def in definitions {
let name = &def.function.name;
let desc = def.function.description.as_deref().unwrap_or_default();
assert!(
@ -2422,6 +2438,10 @@ mod tests {
collect_descriptions(&def.function.parameters, &mut field_descs);
for field_desc in &field_descs {
assert_no_render_whitespace_artifacts(name, field_desc);
assert!(
!field_desc.contains("{max_"),
"{name}: unresolved {{max_*}} placeholder in a field description"
);
}
}
}
@ -3428,6 +3448,119 @@ mod tests {
);
}
#[tokio::test]
async fn non_pi_finalized_contract_snapshot_is_unchanged() {
let tmp = TempDir::new().unwrap();
let toolset = ToolRegistryBuilder::new()
.finalize(
ToolServerConfig {
tools: vec![
ToolConfig::for_tool::<grok_build::TodoWriteTool>(),
ToolConfig::for_tool::<opencode::OpenCodeWriteTool>(),
],
behavior_preset: None,
},
test_session_context(&tmp),
)
.unwrap();
let mut contracts: Vec<serde_json::Value> = toolset
.tool_definitions()
.into_iter()
.map(|definition| {
serde_json::json!({
"name": definition.function.name,
"description": definition.function.description,
"parameters": definition.function.parameters,
})
})
.collect();
contracts.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
let expected: serde_json::Value = serde_json::from_str(
r##"
[
{
"name": "todo_write",
"description": "Create and manage a structured task list. The user sees this list live — it is your primary way to show progress.\n\nUse for any task with 3+ steps. Skip for trivial single-step work.",
"parameters": {
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"todos"
],
"type": "object",
"properties": {
"merge": {
"description": "Optional. When true (default), merges the provided todos into the existing list by id — send only the items you are changing, and to flip status without changing content send just id + status. When false, the provided todos replace the existing list.",
"type": "boolean",
"default": true
},
"todos": {
"description": "Array of todo items to write to the workspace",
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"description": "Unique identifier for the todo item",
"type": "string"
},
"content": {
"description": "The description/content of the todo item",
"type": [
"string",
"null"
]
},
"status": {
"description": "The status of the todo item: pending, in_progress, completed, or cancelled",
"type": [
"string",
"null"
],
"enum": [
"pending",
"in_progress",
"completed",
"cancelled",
null
]
}
},
"required": [
"id"
]
}
}
}
}
},
{
"name": "write",
"description": "Create or overwrite a file.\n\n- Writing to an existing path replaces the file.\n- Parent directories are created for you.",
"parameters": {
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"file_path",
"content"
],
"properties": {
"file_path": {
"description": "The absolute path to the file to write.",
"type": "string"
},
"content": {
"description": "The full file content to write.",
"type": "string"
}
},
"type": "object"
}
}
]
"##,
)
.expect("checked-in snapshot parses");
assert_eq!(expected, serde_json::Value::Array(contracts));
}
#[tokio::test]
async fn tool_definitions_builtins_only_hides_mcp_tools() {
let tmp = TempDir::new().unwrap();
let builder = ToolRegistryBuilder::new();
@ -3805,6 +3938,22 @@ mod tests {
desc.contains("run_in_background"),
"`{name}` description must resolve params.task.run_in_background"
);
let timeout = defs
.iter()
.find(|d| d.function.name == name)
.map(|d| &d.function.parameters["properties"]["timeout_ms"])
.unwrap_or_else(|| panic!("`{name}` should expose timeout_ms"));
assert!(
timeout.get("maximum").is_some(),
"`{name}`.timeout_ms must carry the resolved wait ceiling: {timeout}"
);
assert!(
!timeout["description"]
.as_str()
.unwrap_or("")
.contains("{max_"),
"`{name}`.timeout_ms has an unresolved placeholder: {timeout}"
);
}
}
#[test]

View file

@ -402,6 +402,10 @@ pub async fn resolve_read_tool_name(bridge: &ToolBridge) -> Option<String> {
/// in the current agent's toolset. When `None`, the subagent's full
/// `output` is inlined verbatim -- this notification is the only place
/// the model will see it (no disk-backed output file exists for subagents).
///
/// KEEP IN SYNC: the exact wording of this message is a compatibility
/// surface — downstream mirrors reproduce it verbatim (grep for
/// `format_subagent_completion_reminder`). Update them when changing it.
pub fn format_subagent_completion(
c: &SubagentCompletionSummary,
task_output_name: Option<&str>,

View file

@ -66,6 +66,7 @@ impl TruncationConfig {
///
/// Recognized placeholders:
/// - `{max_lines_read}` — from `max_lines_read` (default 1000)
/// - `{max_wait_ms}` — the blocking-wait ceiling, as `600000 (~10 min)`
/// - `{max_output_bytes}` — resolved via `max_output_bytes_for(tool_name, builtin_default)`
/// - `{max_chars_per_line}` — fixed display value for opencode-compat
/// descriptions only; the opencode `read` tool clips at its own
@ -78,9 +79,14 @@ impl TruncationConfig {
description: &str,
tool_name: &str,
builtin_output_default: usize,
max_wait_ms: u64,
) -> String {
description
.replace("{max_lines_read}", &self.max_lines_read().to_string())
.replace(
"{max_wait_ms}",
&xai_tool_types::format_wait_cap_ms(max_wait_ms),
)
.replace("{max_chars_per_line}", "2000")
.replace(
"{max_output_bytes}",
@ -89,6 +95,50 @@ impl TruncationConfig {
.to_string(),
)
}
/// Resolve placeholders in each schema property description, and pin the
/// blocking-wait ceiling as a `maximum` on whichever property documents it.
///
/// The tool description alone cannot carry the cap: `description_override`
/// replaces that string outright under toolchain randomization, so on most
/// draws the interpolated copy never reaches the model. Properties are only
/// ever renamed, so a bound placed here survives every draw — and a
/// `maximum` reaches a model that skips the prose.
///
/// `{max_wait_ms}` in a property description is the marker for which
/// property is the wait, so no tool or parameter name is hardcoded and a
/// renamed parameter is handled for free (keys are remapped by the time
/// this runs).
pub fn apply_to_schema(
&self,
schema: &mut serde_json::Value,
tool_name: &str,
builtin_output_default: usize,
max_wait_ms: u64,
) {
let Some(properties) = schema.get_mut("properties").and_then(|p| p.as_object_mut()) else {
return;
};
for property in properties.values_mut() {
let Some(object) = property.as_object_mut() else {
continue;
};
let Some(description) = object.get("description").and_then(|d| d.as_str()) else {
continue;
};
let documents_wait = description.contains("{max_wait_ms}");
let resolved = self.interpolate_description(
description,
tool_name,
builtin_output_default,
max_wait_ms,
);
object.insert("description".to_string(), serde_json::json!(resolved));
if documents_wait {
object.insert("maximum".to_string(), serde_json::json!(max_wait_ms));
}
}
}
}
#[cfg(test)]
@ -108,6 +158,114 @@ mod tests {
assert_eq!(cfg.max_lines_read(), 50);
}
#[test]
fn interpolate_description_resolves_max_wait_ms() {
let cfg = TruncationConfig::default();
let cap = 300_000;
assert_eq!(
cfg.interpolate_description("capped at {max_wait_ms}", "get_task_output", 40_000, cap),
"capped at 300000 (~5 min)"
);
let default_cap =
xai_tool_types::format_wait_cap_ms(xai_tool_types::MAX_WAIT_BLOCK_MS_DEFAULT);
assert_eq!(
TruncationConfig::default().interpolate_description(
"capped at {max_wait_ms}",
"get_task_output",
40_000,
xai_tool_types::MAX_WAIT_BLOCK_MS_DEFAULT,
),
format!("capped at {default_cap}")
);
}
#[test]
fn apply_to_schema_resolves_and_pins_the_wait_property() {
let cfg = TruncationConfig::default();
let cap = 300_000;
let mut schema = serde_json::json!({
"properties": {
"timeout_ms": {"type": "integer", "description": "Wait up to {max_wait_ms}."},
"task_ids": {"type": "array", "description": "Task IDs."},
}
});
cfg.apply_to_schema(&mut schema, "get_task_output", 40_000, cap);
let timeout = &schema["properties"]["timeout_ms"];
assert_eq!(timeout["description"], "Wait up to 300000 (~5 min).");
assert_eq!(timeout["maximum"], serde_json::json!(300_000u64));
// Only the property documenting the wait gets a ceiling.
assert_eq!(schema["properties"]["task_ids"]["description"], "Task IDs.");
assert!(schema["properties"]["task_ids"].get("maximum").is_none());
}
#[test]
fn apply_to_schema_tracks_a_raised_ceiling_and_a_renamed_property() {
// A 900s actor must not be handed the 300s default, and the marker —
// not the property name — is what identifies the wait.
let cfg = TruncationConfig::default();
let cap = 900_000;
let mut schema = serde_json::json!({
"properties": {
"max_wait": {"type": "integer", "description": "Up to {max_wait_ms}."},
}
});
cfg.apply_to_schema(&mut schema, "get_task_output", 40_000, cap);
assert_eq!(
schema["properties"]["max_wait"]["description"],
"Up to 900000 (~15 min)."
);
assert_eq!(
schema["properties"]["max_wait"]["maximum"],
serde_json::json!(900_000u64)
);
}
/// The bound has to land somewhere that actually constrains the value.
/// `Option<u64>` could plausibly be emitted as `anyOf: [integer, null]`, in
/// which case a root `maximum` would be inert — so assert against the real
/// generated schema rather than a hand-written one, and pin the shape it
/// relies on. schemars puts `minimum` at the root for the same field, which
/// is the precedent this follows.
#[test]
fn apply_to_schema_bounds_the_real_optional_u64_property() {
let generated =
serde_json::to_value(schemars::schema_for!(xai_tool_types::TaskOutputToolInput))
.unwrap();
let timeout = &generated["properties"]["timeout_ms"];
assert!(
timeout.get("anyOf").is_none(),
"shape changed to anyOf — a root `maximum` no longer constrains the \
integer arm, so apply_to_schema must walk the branches: {timeout}"
);
assert_eq!(timeout["type"], serde_json::json!(["integer", "null"]));
let cfg = TruncationConfig::default();
let cap = 300_000;
let mut schema = generated.clone();
cfg.apply_to_schema(&mut schema, "get_task_output", 40_000, cap);
let bounded = &schema["properties"]["timeout_ms"];
assert_eq!(bounded["maximum"], serde_json::json!(300_000u64));
assert!(
!bounded["description"].as_str().unwrap().contains("{max_"),
"placeholder survived: {bounded}"
);
}
#[test]
fn apply_to_schema_tolerates_schemas_without_properties() {
let mut schema = serde_json::json!({"type": "object"});
TruncationConfig::default().apply_to_schema(
&mut schema,
"get_task_output",
40_000,
xai_tool_types::MAX_WAIT_BLOCK_MS_DEFAULT,
);
assert_eq!(schema, serde_json::json!({"type": "object"}));
}
#[test]
fn mcp_max_output_bytes_for_lookup_order() {
// per-tool > mcp-specific > default > builtin