fix: reject invalid AI agent tool names when the chat writes a flow (#10756)

* fix: reject invalid AI agent tool names when the chat writes a flow

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: address review nits on agent tool name validation

Share one AI-agent walk between the providerless-agent and invalid-tool-name
collectors, drop the unused validateToolName, and list every reserved id in the
tool naming rules.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: describe an agent tool's summary as the name the agent calls it by

The OpenFlow schema described `AgentTool.summary` as a short description of
the tool, which is the same schema the flow write tools hand the model, so it
pulled against the naming rules. Narrow those rules to flowmodule tools, since
websearch and mcp tool names are never regex-checked, and let `kind` take
either vocabulary its callers resolve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: name-check only the agent tools whose summary the agent calls

An mcp tool exposes the MCP server's own tool names and a websearch tool's
summary is a plain label, so neither reaches the worker's name check. Both
default to an empty summary in the editor, which the chat then refused to
write back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-08-20 10:49:01 +02:00
committed by GitHub
parent f6645af77e
commit 2b4369d7cb
16 changed files with 496 additions and 85 deletions
+66 -1
View File
File diff suppressed because one or more lines are too long
@@ -12,7 +12,7 @@
import { yamlStringifyExceptKeys } from './utils'
import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs'
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
import { getToolNameError } from '$lib/components/graph/renderers/nodes/AIToolNode.svelte'
import { getToolNameError } from '$lib/components/flows/agentToolUtils'
import {
inputBaseClass,
inputBorderClass,
@@ -1148,45 +1148,6 @@ Example: Before writing TypeScript/Bun code, call \`get_instructions_for_code_ge
3. **After making code changes, ALWAYS use \`get_lint_errors\` to check for issues.** Fix any errors before proceeding with testing.
### AI Agent Modules
AI agents can use tools to accomplish tasks. When creating an AI agent module:
\`\`\`javascript
{
id: "support_agent",
summary: "AI agent for customer support",
value: {
type: "aiagent",
input_transforms: {
provider: { type: "static", value: "$res:f/ai_providers/openai" },
output_type: { type: "static", value: "text" },
user_message: { type: "javascript", expr: "flow_input.query" },
system_prompt: { type: "static", value: "You are a helpful assistant." }
},
tools: [
{
id: "search_docs",
summary: "Search_documentation",
description: "Search the product documentation. Use it whenever the user asks how a feature works.",
value: {
tool_type: "flowmodule",
type: "rawscript",
language: "bun",
content: "export async function main(query: string) { return ['doc1', 'doc2']; }",
input_transforms: { query: { type: "static", value: "" } }
}
}
]
}
}
\`\`\`
- **Tool IDs**: Cannot contain spaces - use underscores
- **Tool summaries**: Cannot contain spaces - use underscores. This is the tool *name* the agent sees
- **Tool descriptions**: Optional free text telling the agent when and how to call the tool. Set it whenever the name alone does not make that obvious - it overrides the description derived from the underlying script
- **Tool types**: \`flowmodule\` for scripts/flows, \`mcp\` for MCP server tools
### Contexts
You have access to the following contexts:
@@ -100,3 +100,58 @@ describe('flow settings in the compact editable view', () => {
).toThrow(/chat_input_enabled/)
})
})
describe('AI agent tool names', () => {
function makeAgentFlow(toolSummary: string, extraTools: unknown[] = []) {
return {
modules: [
{
id: 'support_agent',
value: {
type: 'aiagent',
input_transforms: {
provider: {
type: 'static',
value: { kind: 'openai', resource: '$res:f/ai/openai', model: 'gpt-4o' }
},
output_type: { type: 'static', value: 'text' },
user_message: { type: 'static', value: 'hi' }
},
tools: [
{
id: 'search_docs',
summary: toolSummary,
value: {
tool_type: 'flowmodule',
type: 'rawscript',
language: 'bun',
content: 'export async function main() { return 1 }',
input_transforms: {}
}
},
...extraTools
]
}
}
]
}
}
it('rejects a tool name the worker would refuse at run time', () => {
expect(() => validateEditableFlowJson(makeAgentFlow('Search documentation'))).toThrow(
/Invalid AI agent tool name\(s\).*letters, numbers and underscores/s
)
})
it('accepts an underscored tool name', () => {
expect(() => validateEditableFlowJson(makeAgentFlow('search_documentation'))).not.toThrow()
})
it('leaves mcp and websearch summaries alone - the worker never reads them as names', () => {
const flow = makeAgentFlow('search_documentation', [
{ id: 'mcp_tool', summary: '', value: { tool_type: 'mcp', resource_path: 'f/mcp/server' } },
{ id: 'websearch_tool', summary: 'Web Search', value: { tool_type: 'websearch' } }
])
expect(() => validateEditableFlowJson(flow)).not.toThrow()
})
})
@@ -1,7 +1,10 @@
import { z } from 'zod'
import type { FlowModule, FlowValue } from '$lib/gen'
import { collectAllFlowModuleIdsFromModules } from '$lib/components/flows/flowTree'
import { collectProviderlessAgentIds } from '$lib/components/flows/agentToolTree'
import {
collectInvalidAgentToolNames,
collectProviderlessAgentIds
} from '$lib/components/flows/agentToolTree'
import { SPECIAL_MODULE_IDS } from '../shared'
import { findUnresolvedInlineScriptRefs, type InlineScriptSession } from './inlineScriptsUtils'
import {
@@ -268,6 +271,22 @@ export function validateFlowModules(
)
}
// An agent tool's `summary` is the name the LLM sees; the worker rejects anything outside
// `^[a-zA-Z0-9_]+$`, so a flow written with a spaced name saves but fails on every run.
const invalidToolNames = collectInvalidAgentToolNames(parsedModules)
if (invalidToolNames.length > 0) {
throw new Error(
`Invalid AI agent tool name(s): ${invalidToolNames
.map(
(t) =>
`agent "${t.agentId}" tool "${t.toolId}" is named ${JSON.stringify(t.name)} - ${t.error}`
)
.join(
'; '
)}. The tool's "summary" is the name the agent calls it by: use underscores instead of spaces (e.g. "search_docs").`
)
}
return parsedModules
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,5 +1,10 @@
import type { FlowModule } from '$lib/gen'
import { isFlowModuleTool, type AgentTool, type FlowModuleTool } from './agentToolUtils'
import {
getToolNameError,
isFlowModuleTool,
type AgentTool,
type FlowModuleTool
} from './agentToolUtils'
type FlowNodeLike = Pick<FlowModule, 'id' | 'value'>
@@ -163,19 +168,19 @@ function collectFlowNodeIdsFromNode(node: FlowNodeLike): string[] {
return ids
}
/** Ids of AI agent modules that neither link to a saved agent nor set a provider — they pass schema
* validation (a linked step legitimately has no provider of its own) but fail on every run. */
export function collectProviderlessAgentIds(modules: unknown): string[] {
const ids: string[] = []
/** Walks every AI agent module of a flow, including agents nested in loops, branches and in another
* agent's tools. */
function visitAgentModules(
modules: unknown,
cb: (mod: FlowModule, value: Record<string, any>) => void
) {
const visit = (mods: unknown) => {
if (!Array.isArray(mods)) return
for (const mod of mods) {
const v = (mod as FlowModule | undefined)?.value as Record<string, any> | undefined
if (!v) continue
if (v.type === 'aiagent') {
if (!v.agent && !v.input_transforms?.provider) {
ids.push((mod as FlowModule).id)
}
cb(mod as FlowModule, v)
visit(v.tools)
} else if (v.type === 'forloopflow' || v.type === 'whileloopflow') {
visit(v.modules)
@@ -188,5 +193,46 @@ export function collectProviderlessAgentIds(modules: unknown): string[] {
}
}
visit(modules)
}
/** Ids of AI agent modules that neither link to a saved agent nor set a provider — they pass schema
* validation (a linked step legitimately has no provider of its own) but fail on every run. */
export function collectProviderlessAgentIds(modules: unknown): string[] {
const ids: string[] = []
visitAgentModules(modules, (mod, v) => {
if (!v.agent && !v.input_transforms?.provider) {
ids.push(mod.id)
}
})
return ids
}
export type InvalidAgentToolName = {
agentId: string
toolId: string
name: string
error: string
}
/** Agent tools whose name (their `summary`) the worker rejects — the flow saves fine but every run
* of the agent step fails, so writers must catch this before the flow is stored. */
export function collectInvalidAgentToolNames(modules: unknown): InvalidAgentToolName[] {
const invalid: InvalidAgentToolName[] = []
visitAgentModules(modules, (mod, v) => {
const tools: AgentTool[] = Array.isArray(v.tools) ? v.tools : []
// Only a flowmodule tool's summary is a callable name: the worker never reads an mcp or
// websearch summary, so a blank one there must stay writable (both default to '').
const named = tools.filter(
(tool) => tool.value?.tool_type !== 'mcp' && tool.value?.tool_type !== 'websearch'
)
const siblingNames = named.map((tool) => tool.summary ?? '')
for (const tool of named) {
const name = tool.summary ?? ''
const error = getToolNameError(name, tool.value?.tool_type, siblingNames)
if (error) {
invalid.push({ agentId: mod.id, toolId: tool.id, name, error })
}
}
})
return invalid
}
@@ -1,6 +1,37 @@
import type { AiAgent, FlowModule, FlowModuleValue, InputTransform } from '$lib/gen'
import { loadStoredConfig } from '../aiProviderStorage'
import { AI_AGENT_SCHEMA } from './flowInfers'
import { forbiddenIds } from './idUtils'
/**
* 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.
*
* `kind` only has to tell the three tool kinds apart, so callers may pass either the raw
* `value.tool_type` or the module type they resolved it to: anything other than `'mcp'` and
* `'websearch'` — including `undefined` on a legacy tool — is checked as a flow module tool, which
* is what the worker does too.
*/
export function getToolNameError(
name: string,
kind?: 'mcp' | 'websearch' | (string & {}),
siblingNames?: string[]
): string | undefined {
if (kind === 'websearch') return undefined
if (kind === 'mcp') {
return name.length > 0 ? undefined : 'Tool name must not be empty'
}
if (!/^[a-zA-Z0-9_]+$/.test(name)) {
return 'Tool name must only contain letters, numbers and underscores'
}
if (forbiddenIds.includes(name)) {
return `'${name}' is a reserved name`
}
if (siblingNames && siblingNames.filter((n) => n === name).length > 1) {
return 'Duplicate tool name'
}
return undefined
}
export const SPECIAL_TOOL_KINDS = ['mcpTool', 'websearchTool', 'aiAgentTool'] as const
export type SpecialToolKind = (typeof SPECIAL_TOOL_KINDS)[number]
@@ -24,7 +24,7 @@
import { getLatestHashForScript } from '$lib/scripts'
import { sendUserToast, type Item } from '$lib/utils'
import { twMerge } from 'tailwind-merge'
import { getToolNameError } from '$lib/components/graph/renderers/nodes/AIToolNode.svelte'
import { getToolNameError } from '$lib/components/flows/agentToolUtils'
import autosize from '$lib/autosize'
interface Props {
@@ -1,31 +1,5 @@
<script module lang="ts">
import { forbiddenIds } from '$lib/components/flows/idUtils'
import type { AgentTool } from '$lib/components/flows/agentToolUtils'
export function getToolNameError(
name: string,
type?: string,
siblingNames?: string[]
): string | undefined {
if (type === 'websearch') return undefined
if (type === 'mcp') {
return name.length > 0 ? undefined : 'Tool name must not be empty'
}
if (!/^[a-zA-Z0-9_]+$/.test(name)) {
return 'Tool name must only contain letters, numbers and underscores'
}
if (forbiddenIds.includes(name)) {
return `'${name}' is a reserved name`
}
if (siblingNames && siblingNames.filter((n) => n === name).length > 1) {
return 'Duplicate tool name'
}
return undefined
}
export function validateToolName(name: string, type?: string) {
return getToolNameError(name, type) === undefined
}
import { getToolNameError, type AgentTool } from '$lib/components/flows/agentToolUtils'
export const AI_TOOL_BASE_OFFSET = 5
export const AI_TOOL_ROW_OFFSET = 30
+1 -1
View File
@@ -942,7 +942,7 @@ components:
description: Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')
summary:
type: string
description: Short description of what this tool does (shown to the AI)
description: "The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."
description:
type: string
description: Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script.
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
+65
View File
@@ -63,11 +63,75 @@ value:
- Use underscores, not spaces (e.g., `fetch_data` not `fetch data`)
- Use descriptive names that reflect the step's purpose
## AI Agent Modules
An `aiagent` module runs an LLM that can call tools. Each entry of `value.tools` is a module-shaped
object with an extra `value.tool_type`: `flowmodule` for a script/flow tool, `mcp` for an MCP server
tool, `websearch` for web search.
```json
{
"id": "support_agent",
"summary": "AI agent for customer support",
"value": {
"type": "aiagent",
"input_transforms": {
"provider": {
"type": "static",
"value": { "kind": "openai", "resource": "$res:f/ai_providers/openai", "model": "gpt-4o" }
},
"output_type": { "type": "static", "value": "text" },
"user_message": { "type": "javascript", "expr": "flow_input.query" },
"system_prompt": { "type": "static", "value": "You are a helpful assistant." }
},
"tools": [
{
"id": "search_docs",
"summary": "search_documentation",
"description": "Search the product documentation. Use it whenever the user asks how a feature works.",
"value": {
"tool_type": "flowmodule",
"type": "rawscript",
"language": "bun",
"content": "export async function main(query: string) { return ['doc1', 'doc2']; }",
"input_transforms": { "query": { "type": "static", "value": "" } }
}
}
]
}
}
```
- `provider` is a static object, not a bare resource string: `{ "kind": <provider kind>,
"resource": "$res:<path>", "model": <model id> }`. Required unless the module links to a saved
agent through `value.agent`
### Tool Naming Rules
These rules cover `flowmodule` tools, the ones the agent calls by name. A `websearch` tool's
`summary` is a plain label (`Web Search`), and an `mcp` tool exposes the MCP server's own tool
names, so neither is name-checked at all — leave those summaries as they are.
- A flowmodule tool's `summary` is the **name the agent calls it by**, not a human label. Put the
human-readable explanation in `description`
- `summary` must match `^[a-zA-Z0-9_]+$`: letters, numbers and underscores only. No spaces, dashes,
dots or accents — `search_documentation`, never `Search documentation`
- Always set `summary`. It must be unique among that agent's tools, and must not be one of the
reserved ids (`do`, `bg`, `ctx`, `state`, `if`, `else`, `for`, `delete`, `while`, `new`, `in`,
`failure`, `preprocessor`, `as`, `Input`, `Result`, `Trigger`)
- A tool name outside that character set is rejected: flow write tools refuse it, and a flow that
reaches the worker with one fails every run with `Invalid tool name`
- Tool `id` follows the same rules as any module ID — unique across the flow, underscores not spaces
- `description` is optional free text telling the agent when and how to call the tool. Set it
whenever the name alone does not make that obvious; it overrides the description derived from the
underlying script
## Common Mistakes to Avoid
- Missing `input_transforms` - Rawscript parameters won't receive values without them
- Referencing future steps - `results.step_id` only works for steps that execute before the current one
- Duplicate module IDs - Each module ID must be unique in the flow
- AI agent flowmodule tool names with spaces - `summary` is the tool name and only accepts letters, numbers and underscores
## Data Flow Between Steps
@@ -286,6 +350,7 @@ Before finalizing a flow, verify:
- any failure handler is in `value.failure_module`
- any approval step has module-level `suspend`
- no downstream step references inner branch step ids from outside the branch
- every AI agent flowmodule tool has a unique `summary` made only of letters, numbers and underscores
## S3 Object Operations