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
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 %}"#
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue