refactor: reserve __wm_web_search as the name web search is enabled by

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-09-14 17:46:28 +02:00
co-authored by Claude Opus 5
parent b24707dc1b
commit b2f332f4c3
12 changed files with 55 additions and 41 deletions
+31 -21
View File
@@ -248,10 +248,23 @@ fn overlay_tool_inputs(
/// editor's label is not one: a flow module tool could carry the same one, and enabling that tool
/// would then silently turn web search on with it.
///
/// The hyphen is what makes the name unshareable. A flow module tool's name must match
/// `TOOL_NAME_REGEX`, which allows only letters, digits and underscores, so nothing else in a
/// roster can answer to this.
const WEBSEARCH_ENABLED_NAME: &str = "web-search";
/// Reserved, on the `__wm_` prefix this codebase uses for names it keeps for itself, and held that
/// way by `flow_module_tool_name` refusing to advertise a tool that takes it.
const WEBSEARCH_ENABLED_NAME: &str = "__wm_web_search";
/// The name a flow module tool is advertised to the model under.
///
/// Rejected rather than skipped: a tool the model is never shown is a tool the agent silently does
/// not have, and a run that quietly drops one is harder to explain than a run that will not start.
fn flow_module_tool_name(summary: Option<&str>) -> Result<&str, Error> {
match summary {
Some(name) if name == WEBSEARCH_ENABLED_NAME => Err(Error::internal_err(format!(
"Invalid tool name: {name:?} is reserved for enabling web search"
))),
Some(name) if TOOL_NAME_REGEX.is_match(name) => Ok(name),
other => Err(Error::internal_err(format!("Invalid tool name: {other:?}"))),
}
}
/// The name a run enables a roster entry by: the name the model is shown, except for an entry the
/// model is shown nothing of, which cannot be named by a label others may share. An MCP server is
@@ -625,12 +638,7 @@ pub async fn handle_ai_agent_job(
let job = job;
let user_description = tool_descriptions.get(&t.id).cloned();
async move {
let Some(summary) = t.summary.as_ref().filter(|s| TOOL_NAME_REGEX.is_match(s)) else {
return Err(Error::internal_err(format!(
"Invalid tool name: {:?}",
t.summary
)));
};
let summary = flow_module_tool_name(t.summary.as_deref())?;
// Extract schema, input_transforms, and an auto-derived description from the module value
let module_value = t.get_value()?;
@@ -741,7 +749,7 @@ pub async fn handle_ai_agent_job(
def: ToolDef {
r#type: "function".to_string(),
function: ToolDefFunction {
name: summary.clone(),
name: summary.to_string(),
description: Some(description),
parameters: schema.unwrap_or_else(|| {
to_raw_value(&serde_json::json!({
@@ -2047,17 +2055,19 @@ mod tests {
["w"]
);
}
}
// The reserved name is one nothing else in a roster can answer to, so enabling a tool
// cannot switch web search on beside it: a tool named after it would be rejected by
// `TOOL_NAME_REGEX`, which is what the hyphen is there to stay outside of.
assert!(!TOOL_NAME_REGEX.is_match(WEBSEARCH_ENABLED_NAME));
let mut collision = vec![named("t", "web_search")];
collision.push(websearch("w", None));
assert_eq!(
ids(&narrow_roster(collision, Some(&["web_search".to_string()]))),
["t"]
);
#[test]
fn a_tool_cannot_take_the_name_web_search_is_enabled_by() {
// Nothing else in a roster may answer to the reserved name, or enabling that tool would
// switch web search on beside it. Held here rather than by the shape of the name, which is
// an ordinary identifier: the run refuses to start instead.
assert!(TOOL_NAME_REGEX.is_match(WEBSEARCH_ENABLED_NAME));
assert!(flow_module_tool_name(Some(WEBSEARCH_ENABLED_NAME)).is_err());
assert_eq!(flow_module_tool_name(Some("get_user")).unwrap(), "get_user");
assert!(flow_module_tool_name(Some("get user")).is_err());
assert!(flow_module_tool_name(None).is_err());
}
#[test]
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -121,7 +121,7 @@ export const AGENT_FIELDS: AgentFieldSpec[] = [
group: 'tools',
label: 'Enabled tools',
tooltip:
'Which of the agent tools a run carries, so it costs no more than it needs. Selecting none leaves the agent with no tools, and unsetting the field gives it all of them. Set it to an expression to decide per run, naming each one the way this list does: a tool by its own name, an MCP server by its resource path, and web search by "web-search". An MCP server carries every tool it exposes, which its own include and exclude lists decide.',
'Which of the agent tools a run carries, so it costs no more than it needs. Selecting none leaves the agent with no tools, and unsetting the field gives it all of them. Set it to an expression to decide per run, naming each one the way this list does: a tool by its own name, an MCP server by its resource path, and web search by "__wm_web_search". An MCP server carries every tool it exposes, which its own include and exclude lists decide.',
seed: [],
defaultHint: 'Default: all of them'
},
@@ -43,7 +43,10 @@ describe('toolEnabledName', () => {
it('reserves that name against every other kind', () => {
// A flow module tool cannot be called it, so enabling a tool never enables web search beside
// it. `getToolNameError` is the rule that holds, and the hyphen is what stays outside it.
expect(getToolNameError(WEBSEARCH_ENABLED_NAME)).toBeDefined()
// it. Nothing about the name itself stops that — it is an ordinary identifier — so the rule
// is `getToolNameError` refusing it, as `flow_module_tool_name` does on the worker.
expect(getToolNameError(WEBSEARCH_ENABLED_NAME)).toBe(
`'${WEBSEARCH_ENABLED_NAME}' is a reserved name`
)
})
})
@@ -3,6 +3,12 @@ import { loadStoredConfig } from '../aiProviderStorage'
import { AI_AGENT_SCHEMA } from './flowInfers'
import { forbiddenIds } from './idUtils'
/** What every websearch entry is named by, mirroring `WEBSEARCH_ENABLED_NAME` in `ai_executor.rs`.
* Reserved rather than merely conventional: `getToolNameError` refuses it to a flow module tool,
* as the worker does, or that tool would answer to the same name and be switched on with web
* search. */
export const WEBSEARCH_ENABLED_NAME = '__wm_web_search'
/**
* A tool's `summary` is the name the LLM sees, and the worker rejects any name that does not match
* `^[a-zA-Z0-9_]+$` (`ai_executor.rs`), so an unvalidated name fails on every run of the flow.
@@ -29,7 +35,7 @@ export function getToolNameError(
if (!/^[a-zA-Z0-9_]+$/.test(name)) {
return 'Tool name must only contain letters, numbers and underscores'
}
if (forbiddenIds.includes(name)) {
if (forbiddenIds.includes(name) || name === WEBSEARCH_ENABLED_NAME) {
return `'${name}' is a reserved name`
}
if (siblingNames && siblingNames.filter((n) => n === name).length > 1) {
@@ -99,12 +105,6 @@ export function toolDisplayName(tool: AgentTool): string | undefined {
return tool?.summary || value?.path || value?.resource_path || undefined
}
/** What every websearch entry is named by, mirroring `WEBSEARCH_ENABLED_NAME` in `ai_executor.rs`.
* The hyphen is load-bearing: it is what stops a flow module tool, whose name `getToolNameError`
* holds to letters, digits and underscores, from answering to the same name and being switched on
* with web search. */
export const WEBSEARCH_ENABLED_NAME = 'web-search'
/** The name `enabled_tools` holds a tool by: the name the model is shown, except for an entry the
* model is shown nothing of, which is named by whatever identifies it instead. An MCP server is
* named by the resource it points at, and web search by `WEBSEARCH_ENABLED_NAME`, since either
@@ -158,7 +158,7 @@ export const AI_AGENT_SCHEMA: Schema = {
enabled_tools: {
type: 'array',
description:
'Which of the agent tools a run may call: a tool by the name the model is shown, an MCP server by its resource path, which carries every tool it exposes, and web search by "web-search". Unset carries every tool.',
'Which of the agent tools a run may call: a tool by the name the model is shown, an MCP server by its resource path, which carries every tool it exposes, and web search by "__wm_web_search". Unset carries every tool.',
items: {
type: 'string'
}
+3 -2
View File
@@ -1077,8 +1077,9 @@ components:
A tool is named as the model is shown it. An entry the model is shown nothing of is
named by what identifies it instead: an MCP server by its resource path, carrying
every tool it exposes (which of them stays that entry's include_tools/exclude_tools),
and a websearch entry by the reserved name 'web-search', whatever summary it carries.
Example: ['get_user', 'u/admin/github_mcp', 'web-search']
and a websearch entry by the reserved name '__wm_web_search', whatever summary it carries
(no tool may take that name).
Example: ['get_user', 'u/admin/github_mcp', '__wm_web_search']
max_completion_tokens:
allOf:
- $ref: '#/components/schemas/InputTransform'
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long