Synced from monorepo

Changes:
- Persist submitter identity for /feedback
- Let custom models use rotating tokens from named auth providers
- Template stale tool/param name literals in server-native descriptions
- Minimal mode commits thinking in full, lookups as one-liners
- Tighten durable append internals
- Nudge model to end turn on no-op bash commands
- Per-fetch signing nonce in the managed-config envelope, with a server-side replay probe
- Include working tree in startup status
This commit is contained in:
grokkybara[bot] 2026-07-20 18:06:59 +01:00
commit a881e6703f
140 changed files with 6746 additions and 2377 deletions

View file

@ -23,7 +23,7 @@ use super::{apply, errors::ApplyPatchError};
// ─── Description ─────────────────────────────────────────────────────
/// Tool description derived from the codex `apply_patch_tool_instructions.md`.
const DESCRIPTION: &str = r#"Use the `apply_patch` tool to edit files.
const DESCRIPTION: &str = r#"Use this tool to edit files.
Your patch language is a strippeddown, fileoriented diff format designed to be easy to parse and safe to apply. You can think of it as a highlevel envelope:
*** Begin Patch

View file

@ -277,9 +277,11 @@ pub struct BashToolInput {
pub description: String,
/// Set to true for long-running commands that should run in the background (e.g., dev servers, long builds).
/// Returns a task_id immediately while the command keeps running in the background; you are notified on completion, so do not poll or sleep-wait for it.
/// Returns a task id immediately while the command keeps running in the background; you are notified on completion, so do not poll or sleep-wait for it.
// "task id" stays plain English: the kill/get-output input params are
// renameable, so naming a literal key here goes stale after randomization.
#[schemars(
description = "Set to true for long-running commands that should run in the background (e.g., dev servers, long builds). Returns a task_id immediately while the command keeps running in the background; you are notified on completion, so do not poll or sleep-wait for it."
description = "Set to true for long-running commands that should run in the background (e.g., dev servers, long builds). Returns a task id immediately while the command keeps running in the background; you are notified on completion, so do not poll or sleep-wait for it."
)]
#[serde(
default,
@ -403,6 +405,43 @@ fn annotations(bash: &BashOutput) -> String {
s
}
const NOOP_END_TURN_REMINDER: &str = "<system-reminder>\n\
You appear to be running empty commands to stay active while waiting for background work. \
End your turn you will be woken automatically when there is something to do.\n\
</system-reminder>";
fn is_noop_command(command: &str) -> bool {
let trimmed = command.trim();
trimmed.is_empty() || trimmed == "true" || trimmed == ":" || is_pure_status_print(trimmed)
}
fn is_pure_status_print(trimmed: &str) -> bool {
if !(matches!(trimmed, "echo" | "printf")
|| trimmed.starts_with("echo ")
|| trimmed.starts_with("printf "))
{
return false;
}
let mut in_single = false;
let mut in_double = false;
let mut chars = trimmed.chars();
while let Some(c) = chars.next() {
match c {
'\\' if !in_single => {
chars.next();
}
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
'$' | '`' if !in_single => return false,
';' | '&' | '|' | '<' | '>' | '(' | ')' | '\n' if !in_single && !in_double => {
return false;
}
_ => {}
}
}
true
}
/// Build the full DEFAULT prompt text from a `BashOutput`.
///
/// - Normal: `exit: N [annotations]\n<stripped_output>`
@ -438,7 +477,12 @@ pub(crate) fn format_default_prompt(bash: &BashOutput) -> String {
Some(reason) => format!("exit: killed ({}){}", reason, annotations(bash)),
None => format!("exit: {}{}", bash.exit_code, annotations(bash)),
};
format!("{}\n{}", header, output_str)
let prompt = format!("{}\n{}", header, output_str);
if bash.signal.is_none() && is_noop_command(&bash.command) {
format!("{}\n\n{}", prompt.trim_end(), NOOP_END_TURN_REMINDER)
} else {
prompt
}
}
}
@ -1336,9 +1380,33 @@ impl BashTool {
Some(default_ms.min(budget_ms).max(1))
}
/// Background retrieval hint naming the get-output tool and its task-ids
/// param. Kind-wide resolution is correct here: this names *another*
/// tool's param (the get-output tool), not this bash tool's own schema
/// key — do not switch it to invoking-tool param names.
async fn background_retrieval_hint(
resources: &SharedResources,
task_id: &str,
) -> Result<String, xai_tool_runtime::ToolError> {
let res = resources.lock().await;
let renderer = res.require::<TemplateRenderer>()?;
let get_task_name = renderer
.render("${{ tools.by_kind.background_task_action }}")
.unwrap_or_else(|_| "get_command_or_subagent_output".to_string());
let task_ids_param = renderer
.param_for_kind(ToolKind::BackgroundTaskAction, "task_ids")
.unwrap_or("task_ids");
Ok(format!(
"Use {get_task_name} tool with {task_ids_param}=[\"{task_id}\"] to retrieve the output."
))
}
/// Model-facing input schema. `timeout_param_name` is the client-facing
/// timeout field (canonical or alias) — must match the remapped key.
fn exported_input_schema(
input_schema: &serde_json::Value,
params: &BashParams,
timeout_param_name: &str,
) -> serde_json::Value {
let background_enabled = Self::background_enabled(params);
let auto_bg = Self::auto_background_on_timeout_enabled(params);
@ -1353,19 +1421,21 @@ impl BashTool {
let max_ms = Self::effective_max_timeout_ms(params);
let default_ms = Self::effective_default_timeout_ms(params);
// `max_timeout_secs` is a foreground-only ceiling; background
// `timeout: 0` is always unbounded, so this note is
// `{name}: 0` is always unbounded, so this note is
// unconditional.
let bg_zero = "`timeout: 0` in background mode disables the wrapper timeout entirely; the task runs until it exits or is killed via the kill task tool.";
let bg_zero = format!(
"`{timeout_param_name}: 0` in background mode disables the wrapper timeout entirely; the task runs until it exits or is killed via the kill task tool."
);
// Keep main-style auto-bg wording (no FG-budget ms advertised).
// Follow-up: surface effective_auto_bg_wait_ms / FG budget here
// once we deliberately change model-facing copy.
let desc = if auto_bg {
format!(
"Optional timeout in milliseconds (max {max_ms}). Default: {default_ms}. If not specified, commands exceeding the default timeout will be automatically backgrounded. {bg_zero}"
"Optional {timeout_param_name} in milliseconds (max {max_ms}). Default: {default_ms}. If not specified, commands exceeding the default timeout will be automatically backgrounded. {bg_zero}"
)
} else {
format!(
"Optional timeout in milliseconds (max {max_ms}). Default: {default_ms}. {bg_zero}"
"Optional {timeout_param_name} in milliseconds (max {max_ms}). Default: {default_ms}. {bg_zero}"
)
};
timeout_prop.insert("description".to_string(), serde_json::json!(desc));
@ -1425,10 +1495,10 @@ impl BashTool {
r#"Run a ${%- if is_windows %} shell command${%- else %} bash command${%- endif %} and return its output.
Usage notes:
- You can specify an optional ${{ params.execute.timeout }} in milliseconds (up to ${{ max_timeout_ms | default(300000) }}ms). ${%- if auto_background_on_timeout %} If not specified, commands exceeding the default timeout will be automatically backgrounded instead of killed. You will receive a task_id to check output later.${%- else %} If not specified, commands will timeout after ${{ default_timeout_ms | default(120000) }}ms.${%- endif %}
- You can specify an optional ${{ params.execute.timeout }} in milliseconds (up to ${{ max_timeout_ms | default(300000) }}ms). ${%- if auto_background_on_timeout %} If not specified, commands exceeding the default timeout will be automatically backgrounded instead of killed. You will receive a task id to check output later.${%- else %} If not specified, commands will timeout after ${{ default_timeout_ms | default(120000) }}ms.${%- endif %}
- Timeout enforcement: when the timeout fires, the wrapper${%- if is_windows %} terminates the child's Job Object, killing every descendant process immediately (no graceful-termination grace period).${%- else %} kills the child process group (SIGTERM, escalated to SIGKILL after a ~1s grace period). Descendants that did not detach via `setsid` / `nohup` will also be killed.${%- endif %} `${{ params.execute.timeout }}: 0` in `${%- if params is defined and params.execute is defined and params.execute.is_background %}${{ params.execute.is_background }}${%- else %}background${%- endif %}: true` mode disables the wrapper timeout entirely; the child's lifetime is owned by the model via ${{ tools.by_kind.kill_task_action }}.
- If the output exceeds {max_output_bytes} characters, output will be truncated before being returned to you.
- You can use the ${{ params.execute.is_background }} parameter to run the command in the background (e.g., dev servers, long builds): it returns a task_id immediately and keeps running in the background. You are notified on completion, so do not poll or sleep-wait for it.${%- if has_unix_utilities %} You do not need to use '&' at the end of the command when using this parameter.${%- endif %}
- You can use the ${{ params.execute.is_background }} parameter to run the command in the background (e.g., dev servers, long builds): it returns a task id immediately and keeps running in the background. You are notified on completion, so do not poll or sleep-wait for it.${%- if has_unix_utilities %} You do not need to use '&' at the end of the command when using this parameter.${%- endif %}
${%- if shell_uses_semicolon %}
- '&&' is not supported in this shell; chain sequential commands with ';'.
${%- endif %}
@ -1545,7 +1615,16 @@ impl crate::types::tool_metadata::ToolMetadata for BashTool {
let params: BashParams =
serde_json::from_value(effective_params.clone()).unwrap_or_default();
let description = Self::rendered_description(description_override, renderer, &params);
let exported_schema = Self::exported_input_schema(input_schema, &params);
// Only this tool's param_map renames schema property keys — do not
// fall back to kind-wide renderer aliases (another Execute tool's
// override could advertise e.g. max_wait while this schema still
// exposes timeout).
let timeout_param_name = param_map
.get("timeout")
.map(String::as_str)
.unwrap_or("timeout");
let exported_schema =
Self::exported_input_schema(input_schema, &params, timeout_param_name);
let remapped_schema = if param_map.is_empty() {
exported_schema
} else {
@ -1987,13 +2066,7 @@ impl xai_tool_runtime::Tool for BashTool {
description: Some(input.description.clone()),
});
// Build the retrieval hint with the resolved tool name; the task id
// is passed as a single-element `task_ids` array (the only arg).
let __res = resources.lock().await;
let renderer = __res.require::<TemplateRenderer>()?;
let get_task_name = renderer
.render("${{ tools.by_kind.background_task_action }}")
.unwrap_or_else(|_| "get_command_or_subagent_output".to_string());
let retrieval_hint = Self::background_retrieval_hint(&resources, &task_id).await?;
Ok(BashToolOutput::Background(BackgroundTaskStarted {
task_id: task_id.clone(),
@ -2002,10 +2075,7 @@ impl xai_tool_runtime::Tool for BashTool {
status: "running".to_string(),
command: input.command,
summary: format!("Background task {} started", task_id),
retrieval_hint: format!(
"Use {} tool with task_ids=[\"{}\"] to retrieve the output.",
get_task_name, task_id
),
retrieval_hint,
pre_formatted: None,
pid: bg_pid,
}))
@ -2090,13 +2160,8 @@ impl xai_tool_runtime::Tool for BashTool {
description: Some(input.description.clone()),
});
// Build the retrieval hint with the resolved tool name; the task
// id is passed as a single-element `task_ids` array (the only arg).
let __res = resources.lock().await;
let renderer = __res.require::<TemplateRenderer>()?;
let get_task_name = renderer
.render("${{ tools.by_kind.background_task_action }}")
.unwrap_or_else(|_| "get_command_or_subagent_output".to_string());
let retrieval_hint =
Self::background_retrieval_hint(&resources, tool_call_id.as_str()).await?;
let summary = if auto_backgrounded {
format!(
@ -2118,11 +2183,7 @@ impl xai_tool_runtime::Tool for BashTool {
status: "running".to_string(),
command: input.command,
summary,
retrieval_hint: format!(
"Use {} tool with task_ids=[\"{}\"] to retrieve the output.",
get_task_name,
tool_call_id.as_str()
),
retrieval_hint,
pre_formatted: None,
// Real PID from the foreground spawn surfaced via
// `TerminalRunResult::pid`. Adapters rely on this
@ -3260,12 +3321,15 @@ mod tests {
#[tokio::test]
async fn tool_name_mapping_in_background_hint() {
let mut resources = make_resources(MockTerminal::background_ok("t1"));
// Custom model-facing tool name. The task id is always passed via the
// canonical single-element `task_ids` array (the param name is not
// overridable in the hint, matching the subagent-started footers).
// Custom model-facing tool AND param names — the hint must track both
// (a hardcoded `task_ids` goes stale after randomization renames).
resources.insert(TemplateRenderer::new(
[(ToolKind::BackgroundTaskAction, "GetOutput".to_string())].into(),
HashMap::new(),
[(
ToolKind::BackgroundTaskAction,
HashMap::from([("task_ids".to_string(), "jobs".to_string())]),
)]
.into(),
));
let tool = BashTool;
@ -3285,8 +3349,8 @@ mod tests {
bg.retrieval_hint
);
assert!(
bg.retrieval_hint.contains("task_ids=[\"t1\"]"),
"Hint should pass the task id via a single-element task_ids array: {}",
bg.retrieval_hint.contains("jobs=[\"t1\"]"),
"Hint should pass the task id via the renamed task_ids param: {}",
bg.retrieval_hint
);
}
@ -3303,7 +3367,7 @@ mod tests {
output: output.as_bytes().to_vec(),
output_for_prompt: BashOutput::make_output_for_prompt(output),
exit_code,
command: "echo test".to_string(),
command: "cat test".to_string(),
truncated: false,
signal: None,
timed_out: false,
@ -3453,6 +3517,68 @@ mod tests {
);
}
fn bash_output_with_command(command: &str, output: &str) -> BashOutput {
BashOutput {
output: output.as_bytes().to_vec(),
output_for_prompt: BashOutput::make_output_for_prompt(output),
exit_code: 0,
command: command.to_string(),
truncated: false,
signal: None,
timed_out: false,
description: None,
current_dir: "/tmp".to_string(),
output_file: String::new(),
total_bytes: output.len(),
output_delta: None,
was_bare_echo: false,
}
}
#[test]
fn default_prompt_noop_command_appends_end_turn_reminder() {
for cmd in [
"true",
":",
"",
" ",
"\t\n",
"echo ok",
"echo \"Healthy.\"",
"echo \"s14=198; s11 full. Healthy.\"",
"printf hi",
"printf 'done\\n'",
] {
let prompt = format_default_prompt(&bash_output_with_command(cmd, ""));
assert!(
prompt.contains(NOOP_END_TURN_REMINDER),
"no-op command {cmd:?} should append the end-turn reminder, got: {prompt:?}"
);
}
}
#[test]
fn default_prompt_normal_command_has_no_end_turn_reminder() {
for cmd in [
"true && echo hi",
"run-true",
"grep : file",
"cat file",
"echo $VAR",
"echo x > f",
"echo a | cat",
"echo $(date)",
"echo hi; ls",
"printf '%s' \"$x\"",
] {
let prompt = format_default_prompt(&bash_output_with_command(cmd, "hi\n"));
assert!(
!prompt.contains("<system-reminder>"),
"normal command {cmd:?} must not append the end-turn reminder, got: {prompt:?}"
);
}
}
// ─── contains_background_operator unit tests ───
mod background_operator_tests {
@ -4002,7 +4128,7 @@ mod tests {
}
fn timeout_desc(params: &BashParams) -> String {
let schema = BashTool::exported_input_schema(&base_schema(), params);
let schema = BashTool::exported_input_schema(&base_schema(), params, "timeout");
schema["properties"]["timeout"]["description"]
.as_str()
.expect("timeout description")
@ -4110,7 +4236,7 @@ mod tests {
desc.contains("Default: 30000") || desc.contains("30000"),
"default must track config: {desc}"
);
let schema = BashTool::exported_input_schema(&base_schema(), &params);
let schema = BashTool::exported_input_schema(&base_schema(), &params, "timeout");
assert_eq!(
schema["properties"]["timeout"]["maximum"].as_u64(),
Some(60_000)
@ -4133,6 +4259,78 @@ mod tests {
);
}
/// Property description must track rename after `remap_schema_properties`
/// (regression: stale `` `timeout: 0` `` under `properties.<alias>`).
#[test]
fn schema_property_description_tracks_renamed_timeout() {
let param_map =
std::collections::HashMap::from([("timeout".to_string(), "max_wait".to_string())]);
let exported =
BashTool::exported_input_schema(&base_schema(), &BashParams::default(), "max_wait");
let remapped = crate::util::remap::remap_schema_properties(&exported, &param_map);
let desc = remapped["properties"]["max_wait"]["description"]
.as_str()
.expect("max_wait description");
assert!(
desc.contains("Optional max_wait in milliseconds")
&& desc.contains("`max_wait: 0`"),
"renamed timeout must appear in property description:\n{desc}"
);
assert!(
!desc.contains("`timeout: 0`")
&& !desc.contains("Optional timeout in milliseconds"),
"canonical timeout must not remain in property description:\n{desc}"
);
}
/// Kind-wide renderer aliases must not rewrite this tool's property
/// description when this tool's own param_map did not rename timeout —
/// schema keys only follow param_map.
#[test]
fn schema_property_description_ignores_kind_wide_timeout_alias() {
use crate::types::tool_metadata::ToolMetadata;
let renderer = TemplateRenderer::new(
HashMap::from([(ToolKind::Execute, "run_terminal_cmd".to_string())]),
HashMap::from([(
ToolKind::Execute,
// Another Execute tool (or identity-seed collision) renamed
// timeout kind-wide; this bash tool's param_map is empty.
HashMap::from([("timeout".to_string(), "max_wait".to_string())]),
)]),
);
let def = ToolMetadata::versioned_definition(
&BashTool,
None,
"run_terminal_cmd",
None,
&renderer,
&HashMap::new(),
&base_schema(),
&serde_json::json!({}),
);
let props = def
.function
.parameters
.get("properties")
.expect("properties");
assert!(
props.get("timeout").is_some() && props.get("max_wait").is_none(),
"empty param_map must keep schema key timeout, got: {props}"
);
let desc = props["timeout"]["description"]
.as_str()
.expect("timeout description");
assert!(
desc.contains("Optional timeout in milliseconds") && desc.contains("`timeout: 0`"),
"property description must match schema key, not kind-wide alias:\n{desc}"
);
assert!(
!desc.contains("max_wait"),
"kind-wide alias must not leak into property description:\n{desc}"
);
}
#[test]
fn tool_description_timeout_numbers_track_config() {
let params = BashParams {

View file

@ -299,6 +299,7 @@ pub(crate) async fn run_read_file(
contract_version: Option<&str>,
resources: SharedResources,
streamable_out: Option<&mut bool>,
invoking_param_names: &crate::types::resources::InvokingToolParamNames,
) -> Result<ReadFileOutput, xai_tool_runtime::ToolError> {
let (cwd, display_cwd, fs, hints_enabled);
{
@ -473,11 +474,13 @@ pub(crate) async fn run_read_file(
.render("${{ tools.by_kind.execute }}")
.map_err(|e| xai_tool_runtime::ToolError::invalid_arguments(e.to_string()))?;
}
let offset_param = invoking_param_names.resolve("offset");
let limit_param = invoking_param_names.resolve("limit");
let single_content_line = extracted.raw_output.lines().count() <= 1;
let single_line_hint = if single_content_line && !execute_name.is_empty() {
format!(
"\nNote: the requested read is a single very long line, so \
line-based offset/limit cannot narrow it further. Use the \
line-based {offset_param}/{limit_param} cannot narrow it further. Use the \
'{execute_name}' tool to extract the parts you need (e.g. \
`jq`, `python3`, or `cut -c`)."
)
@ -493,18 +496,16 @@ pub(crate) async fn run_read_file(
.limit
.map_or_else(|| "to end".to_string(), |v| v.to_string());
format!(
"The requested line range (offset={}, limit={}) contains {} tokens, \
which exceeds the maximum allowed tokens ({} tokens).\n\
Try a smaller `limit`, a different starting `offset`, \
or use the '{}' tool to search for specific content.{}",
off, lim, token_count, MAX_NUM_TOKENS, grep_name, single_line_hint
"The requested line range ({offset_param}={off}, {limit_param}={lim}) contains {token_count} tokens, \
which exceeds the maximum allowed tokens ({MAX_NUM_TOKENS} tokens).\n\
Try a smaller `{limit_param}`, a different starting `{offset_param}`, \
or use the '{grep_name}' tool to search for specific content.{single_line_hint}"
)
} else {
format!(
"File content ({} tokens) exceeds maximum allowed tokens ({} tokens).\n\
Please use offset and limit parameters to read a shorter range, \
or use the '{}' to search for specific content.{}",
token_count, MAX_NUM_TOKENS, grep_name, single_line_hint
"File content ({token_count} tokens) exceeds maximum allowed tokens ({MAX_NUM_TOKENS} tokens).\n\
Please use {offset_param} and {limit_param} parameters to read a shorter range, \
or use the '{grep_name}' to search for specific content.{single_line_hint}"
)
};
return Ok(ReadFileOutput::FileTooLarge(msg));
@ -601,21 +602,22 @@ impl xai_tool_runtime::Tool for ReadFileTool {
});
};
Box::pin(async_stream::stream! {
match ReadFileTool::read_with_streamability(& ctx, input). await {
Ok((output, streamable)) => { if streamable && let
ReadFileOutput::FileContent(fc) = & output && ! fc.content.is_empty() {
let content = fc.content.as_bytes(); let mut last_total : u64 = 0; let
mut window_start = 0usize; while window_start < content.len() { let mut
window_end = (window_start + STREAM_DELTA_TARGET_BYTES).min(content
.len()); while window_end > window_start && ! fc.content
.is_char_boundary(window_end) { window_end -= 1; } if let Some(p) =
xai_tool_runtime::stream_chunk(spec, & content[..window_end], window_end
as u64, & mut last_total, false,) { yield
xai_tool_runtime::ToolStreamItem::Progress(p); } window_start =
window_end; } } yield
xai_tool_runtime::ToolStreamItem::Terminal(Ok(output)); } Err(e) => yield
xai_tool_runtime::ToolStreamItem::Terminal(Err(e)), }
})
match ReadFileTool::read_with_streamability(& ctx, input). await {
Ok((output, streamable)) => { if streamable && let
ReadFileOutput::FileContent(fc) = & output && ! fc.content.is_empty() {
let content = fc.content.as_bytes(); let mut last_total : u64 = 0; let
mut window_start = 0usize; while window_start < content.len() { let mut
window_end = (window_start + STREAM_DELTA_TARGET_BYTES).min(content
.len()); while window_end > window_start && ! fc.content
.is_char_boundary(window_end) { window_end -= 1; }
if let Some(p) =
xai_tool_runtime::stream_chunk(spec, & content[..window_end], window_end
as u64, & mut last_total, false,) { yield
xai_tool_runtime::ToolStreamItem::Progress(p); } window_start =
window_end; } } yield
xai_tool_runtime::ToolStreamItem::Terminal(Ok(output)); } Err(e) => yield
xai_tool_runtime::ToolStreamItem::Terminal(Err(e)), }
})
}
#[tracing::instrument(name = "tool.read_file", skip_all, fields(path = %input.path))]
async fn run(
@ -643,12 +645,14 @@ impl ReadFileTool {
.map(|c| c.0.clone());
let bv = crate::types::tool_metadata::behavior_version(ctx);
let mut streamable_text = false;
let invoking = crate::types::tool_metadata::invoking_param_names(ctx);
let output = run_read_file(
input,
cwd_override.clone(),
bv.as_deref(),
resources.clone(),
Some(&mut streamable_text),
&invoking,
)
.await?;
Ok((output, streamable_text))
@ -1007,6 +1011,63 @@ mod tests {
other => panic!("Expected FileTooLarge, got {:?}", other),
}
}
/// Regression: FileTooLarge must name *this* tool's schema keys, not
/// whatever a sibling Read tool last wrote into the kind-wide param map.
#[tokio::test]
async fn token_limit_error_uses_invoking_tool_param_names_not_kind_wide() {
let tmp = TempDir::new().unwrap();
let line = "x".repeat(200);
let big_content = std::iter::repeat_n(line.as_str(), 1100)
.collect::<Vec<_>>()
.join("\n");
std::fs::write(tmp.path().join("big.txt"), &big_content).unwrap();
let tool = ReadFileTool;
let mut resources = test_resources(tmp.path());
resources.insert(TemplateRenderer::new(
[(ToolKind::Search, "Grep".to_string())].into(),
[(
ToolKind::Read,
[
("offset".to_string(), "poisoned_offset".to_string()),
("limit".to_string(), "poisoned_limit".to_string()),
]
.into(),
)]
.into(),
));
let input = ReadFileInput {
path: "big.txt".to_string(),
offset: Some(1),
limit: Some(800),
pages: None,
format: None,
};
let mut ctx = test_ctx(resources.into_shared());
ctx.extensions
.insert(crate::types::resources::InvokingToolParamNames(
[
("offset".to_string(), "start_line".to_string()),
("limit".to_string(), "max_lines".to_string()),
]
.into(),
));
let result = xai_tool_runtime::Tool::run(&tool, ctx, input)
.await
.unwrap();
match result {
ReadFileOutput::FileTooLarge(msg) => {
assert!(
msg.contains("start_line=1") && msg.contains("max_lines=800"),
"expected invoking-tool names, got: {msg}"
);
assert!(
!msg.contains("poisoned_offset") && !msg.contains("poisoned_limit"),
"must not use kind-wide sibling renames: {msg}"
);
}
other => panic!("Expected FileTooLarge, got {:?}", other),
}
}
#[test]
fn test_extract_file_content_lines_basic() {
let extracted = extract_file_content_lines("1\n2\r\n3\n", None, None, 4);

View file

@ -6,7 +6,7 @@ const DESCRIPTION_CONCISE: &str = r#"Reads a file from the computer's filesystem
It is okay to read a file that does not exist; an error will be returned.
Usage:
- You can optionally specify a line offset and limit (especially handy for long files).
- You can optionally specify ${{ params.read.offset }} and ${{ params.read.limit }} (especially handy for long files).
- Lines in the output are numbered starting at 1, using following format: LINE_NUMBERLINE_CONTENT.
- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful."#;
use crate::types::output::ReadFileOutput;
@ -84,7 +84,8 @@ impl xai_tool_runtime::Tool for ReadFileConciseTool {
.map(|c| c.0.clone());
// `None`: the concise tool does not stream, so it needs no
// text-path streamability signal (see `run_read_file`).
let result = run_read_file(input, cwd_override, None, resources, None).await?;
let invoking = crate::types::tool_metadata::invoking_param_names(&ctx);
let result = run_read_file(input, cwd_override, None, resources, None, &invoking).await?;
match result {
ReadFileOutput::FileContent(mut fc) => {
@ -133,6 +134,33 @@ mod tests {
);
}
#[test]
fn description_template_tracks_renamed_offset_limit() {
use crate::types::template_renderer::TemplateRenderer;
use crate::types::tool_metadata::ToolMetadata;
use std::collections::HashMap;
let tools = HashMap::from([(ToolKind::Read, "read_file".to_string())]);
let params = HashMap::from([(
ToolKind::Read,
HashMap::from([
("offset".to_string(), "start_line".to_string()),
("limit".to_string(), "num_lines".to_string()),
]),
)]);
let rendered = TemplateRenderer::new(tools, params)
.render(ToolMetadata::description_template(&ReadFileConciseTool))
.unwrap();
assert!(
rendered.contains("start_line and num_lines"),
"renamed offset/limit must appear:\n{rendered}"
);
assert!(
!rendered.contains("a line offset and limit"),
"canonical offset/limit must not remain after rename:\n{rendered}"
);
}
#[tokio::test]
async fn concise_mode_uses_concise_content() {
let tmp = TempDir::new().unwrap();

View file

@ -47,7 +47,7 @@ Operations (use the "op" field):
"write" Replace entire file content (no anchors needed).
{ "op": "write", "content": "full file content here" }
Batch edits: pass multiple operations in "edits". They are validated against the
Batch edits: pass multiple operations in "${{ params.edit.edits }}". They are validated against the
pre-edit snapshot and applied atomically bottom-up if any anchor fails
validation, ALL edits in the batch are rejected (none are applied).
Overlapping ranges are also rejected.
@ -461,6 +461,35 @@ mod tests {
resources
}
#[test]
fn description_template_tracks_renamed_edits() {
use crate::types::template_renderer::TemplateRenderer;
use crate::types::tool::ToolKind;
use crate::types::tool_metadata::ToolMetadata;
use std::collections::HashMap;
let tools = HashMap::from([
(ToolKind::Edit, "hashline_edit".to_string()),
(ToolKind::Read, "hashline_read".to_string()),
(ToolKind::Search, "hashline_grep".to_string()),
]);
let params = HashMap::from([(
ToolKind::Edit,
HashMap::from([("edits".to_string(), "changes".to_string())]),
)]);
let rendered = TemplateRenderer::new(tools, params)
.render(ToolMetadata::description_template(&HashlineEditTool))
.unwrap();
assert!(
rendered.contains("pass multiple operations in \"changes\""),
"renamed edits param must appear:\n{rendered}"
);
assert!(
!rendered.contains("in \"edits\""),
"canonical edits must not remain after rename:\n{rendered}"
);
}
fn anchors_for(content: &str) -> Vec<String> {
use crate::implementations::grok_build_hashline::anchor::split_lines;
use crate::implementations::grok_build_hashline::edit::apply::anchor_suffix;

View file

@ -86,8 +86,8 @@ use the fresh anchors returned by ${{ tools.by_kind.edit }} or re-read the file.
Usage:
- The ${{ params.read.target_file }} parameter must be an absolute path, not a relative path
- By default reads up to {max_lines_read} lines from the beginning
- Optionally specify offset and limit for large files
- Can read images (PNG, JPG, etc.) and PDF files (each page rendered as an image; use `pages` parameter for PDFs with more than 10 pages, max 20 per call)
- Optionally specify ${{ params.read.offset }} and ${{ params.read.limit }} for large files
- Can read images (PNG, JPG, etc.) and PDF files (each page rendered as an image; use ${{ params.read.pages }} for PDFs with more than 10 pages, max 20 per call)
- You can call multiple tools in a single response
- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents."#;
@ -188,7 +188,16 @@ impl xai_tool_runtime::Tool for HashlineReadTool {
.map(|c| c.0.clone());
// `None`: the hashline tool does not stream, so it needs no
// text-path streamability signal (see `run_read_file`).
let result = run_read_file(input, cwd_override, None, resources.clone(), None).await?;
let invoking = crate::types::tool_metadata::invoking_param_names(&ctx);
let result = run_read_file(
input,
cwd_override,
None,
resources.clone(),
None,
&invoking,
)
.await?;
match result {
ReadFileOutput::FileContent(mut fc) => {
@ -374,6 +383,38 @@ mod tests {
assert!(ToolMetadata::description_template(&hashline).contains("tools.by_kind.edit"));
}
#[test]
fn description_template_tracks_renamed_offset_limit() {
use crate::types::template_renderer::TemplateRenderer;
use crate::types::tool::ToolKind;
use crate::types::tool_metadata::ToolMetadata;
use std::collections::HashMap;
let tools = HashMap::from([
(ToolKind::Read, "hashline_read".to_string()),
(ToolKind::Edit, "hashline_edit".to_string()),
]);
let params = HashMap::from([(
ToolKind::Read,
HashMap::from([
("target_file".to_string(), "target_file".to_string()),
("offset".to_string(), "start_line".to_string()),
("limit".to_string(), "max_lines".to_string()),
]),
)]);
let rendered = TemplateRenderer::new(tools, params)
.render(ToolMetadata::description_template(&HashlineReadTool))
.unwrap();
assert!(
rendered.contains("start_line and max_lines for large files"),
"renamed offset/limit must appear:\n{rendered}"
);
assert!(
!rendered.contains("offset and limit for large files"),
"canonical offset/limit must not remain after rename:\n{rendered}"
);
}
#[tokio::test]
async fn read_basic_file() {
let tmp = TempDir::new().unwrap();

View file

@ -16,7 +16,8 @@ pub enum LspConfig {
impl LspConfig {
pub fn is_enabled(&self) -> bool {
matches!(self, Self::Enabled { servers, .. } if !servers.is_empty())
matches!(self, Self::Enabled { servers, .. }
if !servers.is_empty())
}
}

View file

@ -64,7 +64,7 @@ Before executing the command, please follow these steps:
- Capture the output of the command.
Usage notes:
- The command argument is required.
- The ${{ params.execute.command }} argument is required.
- You can specify an optional ${{ params.execute.timeout }} in milliseconds. If not specified, commands will use the default timeout.
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- If the output exceeds {max_output_bytes} characters, output will be truncated before being returned to you.
@ -111,7 +111,7 @@ Git Safety Protocol:
- CRITICAL: If you already pushed to remote, NEVER amend unless user explicitly requests it (requires force push)
- NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel, each using the Bash tool:
1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel, each using this tool:
- Run a git status command to see all untracked files.
- Run a git diff command to see both staged and unstaged changes that will be committed.
- Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.
@ -134,11 +134,11 @@ Important notes:
- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit
# Creating pull requests
Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed.
Use the gh command via this tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed.
IMPORTANT: When the user asks you to create a pull request, follow these steps carefully:
1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel using the Bash tool, in order to understand the current state of the branch since it diverged from the main branch:
1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel using this tool, in order to understand the current state of the branch since it diverged from the main branch:
- Run a git status command to see all untracked files
- Run a git diff command to see both staged and unstaged changes that will be committed
- Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote
@ -597,6 +597,35 @@ mod tests {
);
}
#[test]
fn description_template_tracks_renamed_command() {
use crate::types::template_renderer::TemplateRenderer;
use crate::types::tool::ToolKind;
use crate::types::tool_metadata::ToolMetadata;
use std::collections::HashMap;
let tools = HashMap::from([(ToolKind::Execute, "run_command".to_string())]);
let params = HashMap::from([(
ToolKind::Execute,
HashMap::from([
("command".to_string(), "script".to_string()),
("timeout".to_string(), "timeout".to_string()),
]),
)]);
let rendered = TemplateRenderer::new(tools, params)
.render(ToolMetadata::description_template(&BashTool))
.unwrap();
assert!(
rendered.contains("The script argument is required."),
"renamed command must appear:\n{rendered}"
);
assert!(
!rendered.contains("The command argument is required.")
&& !rendered.contains("Bash tool"),
"stale command/tool-name literals must not remain:\n{rendered}"
);
}
fn make_input(command: &str) -> BashInput {
BashInput {
command: command.to_string(),

View file

@ -40,13 +40,13 @@ Assume this tool is able to read all files on the machine. If the User provides
Usage:
- The ${{ params.read.filePath }} parameter must be an absolute path, not a relative path
- By default, it reads up to {max_lines_read} lines starting from the beginning of the file
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
- You can optionally specify ${{ params.read.offset }} and ${{ params.read.limit }} (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
- Any lines longer than {max_chars_per_line} characters will be truncated
- Results are returned using cat -n format, with line numbers starting at 1. The format is: LINE_NUMBERLINE_CONTENT, where LINE_NUMBER is right-aligned and padded with spaces
- This tool can read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as this tool uses multimodal LLMs.
- This tool can read PDF files (.pdf). PDFs are processed page by page, extracting both text and visual content for analysis.
- This tool can read Jupyter notebooks (.ipynb files) and returns all cells with their outputs, combining code, text, and visualizations.
- This tool can only read files, not directories. To read a directory, use an ls command via the Bash tool.
- This tool can only read files, not directories.${%- if tools.by_kind.execute %} To read a directory, use an ls command via the ${{ tools.by_kind.execute }} tool.${%- endif %}
- You can call multiple tools in a single response. It is always better to speculatively read multiple potentially useful files in parallel.
- You will regularly be asked to read screenshots. If the user provides a path to a screenshot, ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths.
- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents."#;
@ -147,16 +147,20 @@ impl xai_tool_runtime::Tool for ReadTool {
ctx: xai_tool_runtime::ToolCallContext,
input: ReadInput,
) -> Result<ReadFileOutput, xai_tool_runtime::ToolError> {
use crate::types::tool_metadata::{resolve_cwd, shared_resources};
use crate::types::tool_metadata::{invoking_param_names, resolve_cwd, shared_resources};
let resources = shared_resources(&ctx)?;
// Client-facing `offset` name for runtime "read beyond…" hints; a
// rename must not tell the model to pass a key its schema lacks.
let invoking = invoking_param_names(&ctx);
let offset_param = invoking.resolve("offset");
// ── Validate offset ─────────────────────────────────────────
if let Some(offset) = input.offset
&& offset < 1
{
return Ok(ReadFileOutput::FileReadError(
"offset must be >= 1".to_string(),
));
return Ok(ReadFileOutput::FileReadError(format!(
"{offset_param} must be >= 1"
)));
}
// ── Resolve path (single lock acquisition) ─────────────────
@ -188,7 +192,7 @@ impl xai_tool_runtime::Tool for ReadTool {
// BRANCH A: DIRECTORY
// ═══════════════════════════════════════════════════════════
if metadata.is_dir() {
return Ok(read_directory(&path, input.offset, input.limit).await);
return Ok(read_directory(&path, input.offset, input.limit, offset_param).await);
}
// ═══════════════════════════════════════════════════════════
@ -265,8 +269,7 @@ impl xai_tool_runtime::Tool for ReadTool {
// Validate offset against file size.
if total_lines > 0 && start >= total_lines {
return Ok(ReadFileOutput::FileReadError(format!(
"Offset {} is out of range for this file ({} lines)",
offset, total_lines,
"{offset_param} {offset} is out of range for this file ({total_lines} lines)"
)));
}
@ -329,16 +332,14 @@ impl xai_tool_runtime::Tool for ReadTool {
let footer = if truncated_by_bytes {
format!(
"\n\n(Output capped at 50 KB. Showing lines {}-{}. Use offset={} to continue.)",
offset, last_read_line, next_offset,
"\n\n(Output capped at 50 KB. Showing lines {offset}-{last_read_line}. Use {offset_param}={next_offset} to continue.)"
)
} else if has_more_lines {
format!(
"\n\n(Showing lines {}-{} of {}. Use offset={} to continue.)",
offset, last_read_line, total_lines, next_offset,
"\n\n(Showing lines {offset}-{last_read_line} of {total_lines}. Use {offset_param}={next_offset} to continue.)"
)
} else {
format!("\n\n(End of file - total {} lines)", total_lines)
format!("\n\n(End of file - total {total_lines} lines)")
};
let formatted = format!(
@ -370,6 +371,8 @@ async fn read_directory(
path: &std::path::Path,
offset: Option<u32>,
limit: Option<u32>,
// Client-facing `offset` param name for the "read beyond…" hint.
offset_param: &str,
) -> ReadFileOutput {
let mut entries = Vec::new();
@ -423,11 +426,9 @@ async fn read_directory(
let truncated = (start + shown) < total;
let entries_footer = if truncated {
let beyond = offset_val + shown;
format!(
"\n(Showing {} of {} entries. Use 'offset' parameter to read beyond entry {})",
shown,
total,
offset_val + shown,
"\n(Showing {shown} of {total} entries. Use the {offset_param} parameter to read beyond entry {beyond})"
)
} else {
format!("\n({} entries)", total)
@ -513,6 +514,115 @@ mod tests {
resources
}
#[test]
fn description_template_tracks_renamed_offset_limit_and_execute() {
use crate::types::template_renderer::TemplateRenderer;
use crate::types::tool::ToolKind;
use crate::types::tool_metadata::ToolMetadata;
use std::collections::HashMap;
let tools = HashMap::from([
(ToolKind::Read, "read".to_string()),
(ToolKind::Execute, "run_command".to_string()),
]);
let params = HashMap::from([(
ToolKind::Read,
HashMap::from([
("filePath".to_string(), "filePath".to_string()),
("offset".to_string(), "start_line".to_string()),
("limit".to_string(), "max_lines".to_string()),
]),
)]);
let rendered = TemplateRenderer::new(tools, params)
.render(ToolMetadata::description_template(&ReadTool))
.unwrap();
assert!(
rendered.contains("start_line and max_lines"),
"renamed offset/limit must appear:\n{rendered}"
);
assert!(
rendered.contains("via the run_command tool"),
"resolved execute tool name must appear:\n{rendered}"
);
assert!(
!rendered.contains("a line offset and limit") && !rendered.contains("Bash tool"),
"stale offset/limit/Bash-tool literals must not remain:\n{rendered}"
);
}
/// Runtime "read beyond…" footer must name this tool's client-facing
/// offset param, not the canonical `offset`, after a rename.
#[tokio::test]
async fn runtime_footer_tracks_renamed_offset() {
let tmp = TempDir::new().unwrap();
let canonical_tmp = dunce::canonicalize(tmp.path()).unwrap();
let file_path = canonical_tmp.join("big.txt");
let content = (1..=100)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(&file_path, &content).unwrap();
let resources = test_resources(&canonical_tmp);
let mut ctx = test_ctx(resources.into_shared());
ctx.extensions
.insert(crate::types::resources::InvokingToolParamNames(
[("offset".to_string(), "start_line".to_string())].into(),
));
let input = ReadInput {
file_path: file_path.to_string_lossy().to_string(),
offset: None,
limit: Some(5),
};
let result = xai_tool_runtime::Tool::run(&ReadTool, ctx, input)
.await
.unwrap();
match result {
ReadFileOutput::FileContent(fc) => {
assert!(
fc.content.contains("Use start_line=6 to continue")
&& !fc.content.contains("Use offset="),
"footer must use renamed offset param: {}",
fc.content
);
}
other => panic!("Expected FileContent, got {other:?}"),
}
}
/// The invalid-offset validation error must name this tool's client-facing
/// offset param, not the canonical `offset`, after a rename. (Fires before
/// path resolution, so no file is needed.)
#[tokio::test]
async fn validation_error_tracks_renamed_offset() {
let tmp = TempDir::new().unwrap();
let resources = test_resources(tmp.path());
let mut ctx = test_ctx(resources.into_shared());
ctx.extensions
.insert(crate::types::resources::InvokingToolParamNames(
[("offset".to_string(), "start_line".to_string())].into(),
));
let input = ReadInput {
file_path: "whatever.txt".to_string(),
offset: Some(0),
limit: None,
};
let result = xai_tool_runtime::Tool::run(&ReadTool, ctx, input)
.await
.unwrap();
match result {
ReadFileOutput::FileReadError(msg) => {
assert!(
msg.contains("start_line must be >= 1") && !msg.contains("offset must"),
"validation error must use renamed offset param: {msg}"
);
}
other => panic!("Expected FileReadError, got {other:?}"),
}
}
#[tokio::test]
async fn read_text_file_basic() {
let tmp = TempDir::new().unwrap();

View file

@ -140,9 +140,10 @@ mod tests {
assert_eq!(err.tool_id, "GrokBuild:bash");
assert_eq!(err.field_path(), "tools[3].params_json");
assert!(matches!(
&err.kind,
ToolConfigEntryErrorKind::ParamsJsonParse { raw, .. } if raw == "{not json"
));
&err.kind,
ToolConfigEntryErrorKind::ParamsJsonParse { raw, .. }
if raw == "{not json"
));
}
#[test]
@ -180,9 +181,10 @@ mod tests {
assert_eq!(err.field_path(), "tools[2].name_override");
assert!(
matches!(
&err.kind,
ToolConfigEntryErrorKind::NameOverrideInvalid { name: n, .. } if n == name
),
&err.kind,
ToolConfigEntryErrorKind::NameOverrideInvalid { name: n, .. }
if n == name
),
"name={name:?} kind={:?}",
err.kind
);

View file

@ -1414,6 +1414,9 @@ impl FinalizedToolset {
let mut ctx = xai_tool_runtime::ToolCallContext::new(parent_ctx.call_id.clone());
ctx.extensions.insert(self.resources.clone());
ctx.extensions.insert_arc(Arc::clone(&self.renderer));
ctx.extensions.insert(
crate::types::resources::InvokingToolParamNames::from_reverse_params(&reverse_params),
);
if let Some(cwd) = parent_ctx.extensions.get::<xai_tool_runtime::Cwd>() {
ctx.extensions.insert((*cwd).clone());
}
@ -1542,6 +1545,9 @@ impl FinalizedToolset {
let mut ctx = xai_tool_runtime::ToolCallContext::new(rt_call_id);
ctx.extensions.insert(self.resources.clone());
ctx.extensions.insert_arc(Arc::clone(&self.renderer));
ctx.extensions.insert(
crate::types::resources::InvokingToolParamNames::from_reverse_params(&reverse_params),
);
if let Some(cwd) = cwd_override {
ctx.extensions.insert(xai_tool_runtime::Cwd(cwd));
}

View file

@ -712,6 +712,35 @@ impl ParamNameMapping {
.unwrap_or(canonical)
}
}
/// Canonical → client-facing param names for the tool currently executing.
///
/// Stamped onto [`xai_tool_runtime::ToolCallContext::extensions`] by
/// `prepare_dispatch` / `call_raw` from that tool's own
/// `params_name_overrides`. Prefer this over kind-wide
/// [`crate::types::template_renderer::TemplateRenderer::param_for_kind`] when
/// naming params in that tool's own errors — multiple tools can share a
/// `ToolKind` with different renames, and the kind map is first/last-wins.
#[derive(Debug, Clone, Default)]
pub struct InvokingToolParamNames(pub HashMap<String, String>);
impl InvokingToolParamNames {
/// Build from a client→canonical reverse map (the dispatch remap direction).
pub fn from_reverse_params(reverse_params: &HashMap<String, String>) -> Self {
Self(
reverse_params
.iter()
.map(|(client, canonical)| (canonical.clone(), client.clone()))
.collect(),
)
}
/// Resolve a canonical parameter name for the invoking tool.
/// Falls back to the canonical name if not in the map.
pub fn resolve<'a>(&'a self, canonical: &'a str) -> &'a str {
self.0
.get(canonical)
.map(String::as_str)
.unwrap_or(canonical)
}
}
/// Map of `ToolKind` → client-facing tool name.
///
/// Built at finalize time from the enabled tools and client name overrides.
@ -1203,6 +1232,17 @@ mod tests {
assert_eq!(mapping.resolve("other_tool", "old_string"), "old_string");
}
#[test]
fn invoking_tool_param_names_from_reverse_and_resolve() {
let reverse = HashMap::from([
("start_line".to_string(), "offset".to_string()),
("max_lines".to_string(), "limit".to_string()),
]);
let names = InvokingToolParamNames::from_reverse_params(&reverse);
assert_eq!(names.resolve("offset"), "start_line");
assert_eq!(names.resolve("limit"), "max_lines");
assert_eq!(names.resolve("path"), "path");
}
#[test]
fn params_deref() {
let p = Params(EditConfig {
skip_read_before_edit: true,

View file

@ -180,3 +180,19 @@ pub fn behavior_version(ctx: &xai_tool_runtime::ToolCallContext) -> Option<Strin
.get::<xai_tool_runtime::BehaviorVersion>()
.map(|v| v.0.clone())
}
/// This tool's own canonical→client param-name map, stamped on the dispatch
/// context by `prepare_dispatch` / `call_raw`. Returns an empty (identity)
/// map when absent — e.g. unit tests that call `Tool::run` directly — so
/// callers resolve to canonical names. Prefer this over kind-wide
/// [`crate::types::template_renderer::TemplateRenderer::param_for_kind`] when
/// naming *this* tool's own params (a sibling tool sharing the `ToolKind`
/// can rename the same field differently).
pub fn invoking_param_names(
ctx: &xai_tool_runtime::ToolCallContext,
) -> crate::types::resources::InvokingToolParamNames {
ctx.extensions
.get::<crate::types::resources::InvokingToolParamNames>()
.map(|arc| (*arc).clone())
.unwrap_or_default()
}