fix(frontend): prevent duplicate and reserved agent tool names (#8367)

* fix(frontend): prevent duplicate and reserved agent tool names

Extend tool name validation to detect duplicates within an agent step
and reserved names (like 'preprocessor', 'failure'). Show specific error
messages in the editor panel and red styling in the graph view.

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

* fix(frontend): remove duplicate banner for agent tool name errors

The inline per-tool error messages are sufficient — the panel-level
banner was redundant and showed a double error.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-03-13 23:30:53 +01:00
committed by GitHub
parent a079dd500f
commit c431053a1e
9 changed files with 81 additions and 37 deletions
@@ -12,7 +12,7 @@
import { yamlStringifyExceptKeys } from './utils'
import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs'
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
import { validateToolName } from '$lib/components/graph/renderers/nodes/AIToolNode.svelte'
import { getToolNameError } from '$lib/components/graph/renderers/nodes/AIToolNode.svelte'
import {
inputBaseClass,
inputBorderClass,
@@ -117,6 +117,7 @@ Generate a tool name for the script below:
elementProps?: Record<string, any>
class?: string
onChange?: (content: string) => void
siblingToolNames?: string[]
}
let {
@@ -130,9 +131,16 @@ Generate a tool name for the script below:
elementType = 'input',
elementProps = {},
class: clazz = '',
onChange = undefined
onChange = undefined,
siblingToolNames = undefined
}: Props = $props()
let toolNameError = $derived(
promptConfigName === 'agentToolFunctionName'
? getToolNameError(content ?? '', undefined, siblingToolNames)
: undefined
)
let el: HTMLElement | undefined = $state()
let generatedContent = $state('')
let active = $state(false)
@@ -347,16 +355,16 @@ Generate a tool name for the script below:
inputBaseClass,
inputSizeClasses.md,
inputBorderClass({
error: promptConfigName === 'agentToolFunctionName' && !validateToolName(content ?? '')
error: !!toolNameError
}),
'w-full'
)}
onfocus={() => (focused = true)}
onblur={() => (focused = false)}
/>
{#if promptConfigName === 'agentToolFunctionName' && !validateToolName(content ?? '')}
{#if toolNameError}
<p class="text-3xs text-red-400 leading-tight mt-0.5">
Invalid tool name, should only contain letters, numbers and underscores
{toolNameError}
</p>
{/if}
{/if}
@@ -12,6 +12,7 @@
action?: import('svelte').Snippet
children?: import('svelte').Snippet
isAgentTool?: boolean
siblingToolNames?: string[]
}
let {
@@ -23,7 +24,8 @@
header,
action,
children,
isAgentTool = false
isAgentTool = false,
siblingToolNames = undefined
}: Props = $props()
</script>
@@ -38,6 +40,7 @@
{flowModuleValue}
{action}
{isAgentTool}
{siblingToolNames}
>
{@render header?.()}
</FlowCardHeader>
@@ -18,7 +18,7 @@
import { Flag, Lock, RefreshCw, Unlock } from 'lucide-svelte'
import { createEventDispatcher, untrack } from 'svelte'
import { twMerge } from 'tailwind-merge'
import { validateToolName } from '$lib/components/graph/renderers/nodes/AIToolNode.svelte'
import { getToolNameError } from '$lib/components/graph/renderers/nodes/AIToolNode.svelte'
import { DEFAULT_HUB_BASE_URL, PRIVATE_HUB_MIN_VERSION } from '$lib/hub'
interface Props {
@@ -28,6 +28,7 @@
children?: import('svelte').Snippet
action?: import('svelte').Snippet
isAgentTool?: boolean
siblingToolNames?: string[]
}
let {
@@ -36,9 +37,14 @@
summary = $bindable(undefined),
children,
action,
isAgentTool = false
isAgentTool = false,
siblingToolNames = undefined
}: Props = $props()
let toolNameError = $derived(
isAgentTool ? getToolNameError(summary ?? '', undefined, siblingToolNames) : undefined
)
let latestHash: string | undefined = $state(undefined)
// Extract version_id from hub path (format: hub/{version_id}/{app}/{summary})
@@ -103,6 +109,7 @@
elementProps={{
placeholder: isAgentTool ? 'Tool name' : 'Summary'
}}
{siblingToolNames}
/>
{:else if flowModuleValue.type === 'script' && 'path' in flowModuleValue && flowModuleValue.path}
<IconedPath path={flowModuleValue.path} hash={flowModuleValue.hash} class="grow" />
@@ -173,14 +180,16 @@
/>
</div>
{/if}
<input
bind:value={summary}
placeholder={isAgentTool ? 'Tool name' : 'Summary'}
class={twMerge(
'w-full grow',
isAgentTool && !validateToolName(summary ?? '') && '!border-red-400'
)}
/>
<div class="flex flex-col w-full grow">
<input
bind:value={summary}
placeholder={isAgentTool ? 'Tool name' : 'Summary'}
class={twMerge('w-full grow', toolNameError && '!border-red-400')}
/>
{#if toolNameError}
<p class="text-3xs text-red-400 leading-tight mt-0.5">{toolNameError}</p>
{/if}
</div>
{:else if flowModuleValue.type === 'flow'}
<Badge color="indigo" capitalize>flow</Badge>
<input bind:value={summary} placeholder="Summary" class="w-full grow" />
@@ -14,6 +14,7 @@
previousModule?: FlowModule | undefined
forceTestTab?: Record<string, boolean>
highlightArg?: Record<string, string | undefined>
siblingToolNames?: string[]
}
let {
@@ -23,7 +24,8 @@
parentModule = undefined,
previousModule = undefined,
forceTestTab,
highlightArg
highlightArg,
siblingToolNames = undefined
}: Props = $props()
</script>
@@ -43,6 +45,7 @@
forceTestTab={forceTestTab?.[tool.id]}
highlightArg={highlightArg?.[tool.id]}
isAgentTool={true}
{siblingToolNames}
/>
{:else if isMcpTool(tool)}
<!-- MCP tool - use McpToolEditor -->
@@ -101,9 +101,7 @@
)
let canMoveSelected = $derived(
resolvedModuleIds.length > 0 &&
areContiguousSiblings(
locateModules(resolvedModuleIds, flowStore.val.value.modules ?? [])
)
areContiguousSiblings(locateModules(resolvedModuleIds, flowStore.val.value.modules ?? []))
)
</script>
@@ -110,6 +110,7 @@
forceTestTab?: boolean
highlightArg?: string
isAgentTool?: boolean
siblingToolNames?: string[]
}
let {
@@ -125,7 +126,8 @@
savedModule = undefined,
forceTestTab = false,
highlightArg = undefined,
isAgentTool = false
isAgentTool = false,
siblingToolNames = undefined
}: Props = $props()
let workspaceScriptTag: string | undefined = $state(undefined)
@@ -237,7 +239,9 @@
}
let forceReload = $state(0)
let editorPanelSize = $state(untrack(() => noEditor) ? 0 : flowModule.value.type == 'script' ? 30 : 50)
let editorPanelSize = $state(
untrack(() => noEditor) ? 0 : flowModule.value.type == 'script' ? 30 : 50
)
let editorSettingsPanelSize = $state(100 - untrack(() => editorPanelSize))
let stepHistoryLoader = getStepHistoryLoaderContext()
@@ -726,6 +730,7 @@
}}
bind:summary={flowModule.summary}
{isAgentTool}
{siblingToolNames}
>
{#snippet header()}
<FlowModuleHeader
@@ -1062,8 +1067,8 @@
{enableAi}
{isAgentTool}
allowedAiTransforms={isAgentTool && flowModule.value.type === 'aiagent'
? ['user_message']
: undefined}
? ['user_message']
: undefined}
helperScript={retrieveDynCodeAndLang(flowModule.value)}
chatInputEnabled={flowStore.val.value?.chat_input_enabled ?? false}
/>
@@ -305,6 +305,7 @@
{enableAi}
{forceTestTab}
{highlightArg}
siblingToolNames={flowModule.value.tools.map((t) => t.summary ?? '')}
/>
{/if}
{/each}
@@ -300,6 +300,7 @@ export type AiToolN = {
data: {
tool: string
type?: string
nameError?: string
eventHandlers: GraphEventHandlers
moduleId: string
insertable: boolean
@@ -1,10 +1,29 @@
<script module lang="ts">
export function validateToolName(name: string, type?: string) {
if (type === 'websearch') return true
import { forbiddenIds } from '$lib/components/flows/idUtils'
export function getToolNameError(
name: string,
type?: string,
siblingNames?: string[]
): string | undefined {
if (type === 'websearch') return undefined
if (type === 'mcp') {
return name.length > 0
return name.length > 0 ? undefined : 'Tool name must not be empty'
}
return /^[a-zA-Z0-9_]+$/.test(name)
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
}
export const AI_TOOL_BASE_OFFSET = 5
@@ -147,6 +166,7 @@
}
}
const siblingNames = tools.map((t) => t.name)
const toolNodes: (Node & AiToolN)[] = tools.map((tool, i) => {
let inputToolXGap = 12
let inputToolWidth = (ROW_WIDTH - inputToolXGap) / 2
@@ -160,6 +180,7 @@
data: {
tool: tool.name,
type: tool.type,
nameError: getToolNameError(tool.name, tool.type, siblingNames),
eventHandlers,
moduleId: tool.id,
insertable,
@@ -169,13 +190,13 @@
width: inputToolWidth,
position: {
x:
(tools.length === 1
tools.length === 1
? (ROW_WIDTH - inputToolWidth) / 2
: (i + 1) % 2 === 0
? inputToolWidth + inputToolXGap
: isLastRow && tools.length % 2 === 1
? (ROW_WIDTH - inputToolWidth) / 2
: 0),
: 0,
y:
baseOffset +
rowOffset *
@@ -287,7 +308,7 @@
const flowModuleState = $derived(data.flowModuleStates?.[data.moduleId])
let colorClasses = $derived(
getNodeColorClasses(
!validateToolName(data.tool, data.type) ? 'Failure' : flowModuleState?.type,
data.nameError ? 'Failure' : flowModuleState?.type,
selectionManager?.getSelectedId() === data.moduleId
)
)
@@ -324,12 +345,7 @@
<Wrench size={16} class="ml-1 shrink-0" />
{/if}
<span
class={twMerge(
'text-3xs truncate flex-1',
!validateToolName(data.tool, data.type) && 'text-red-400'
)}
>
<span class={twMerge('text-3xs truncate flex-1', data.nameError && 'text-red-400')}>
{data.tool || 'Missing name'}
</span>
</button>