feat: edit a nested AI agent's own tools from the flow editor

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-09-17 14:42:08 +02:00
co-authored by Claude Opus 5
parent c297ed0052
commit e20cd87ef9
11 changed files with 129 additions and 41 deletions
@@ -59,20 +59,26 @@ describe('findAgentToolOwner', () => {
})
it('finds a nested tool owner inside a nested ai agent tool', () => {
const nestedAgent = makeAiAgent('support_agent', [makeFlowModuleTool(makeRawModule('create_ticket'))])
const nestedAgent = makeAiAgent('support_agent', [
makeFlowModuleTool(makeRawModule('create_ticket'))
])
const rootAgent = makeAiAgent('root_agent', [makeFlowModuleTool(nestedAgent)])
expect(findAgentToolOwner([rootAgent], 'create_ticket')).toMatchObject({
const owner = findAgentToolOwner([rootAgent], 'create_ticket')
expect(owner).toMatchObject({
agentId: 'support_agent',
toolIndex: 0,
depth: 2
})
expect(owner?.agents.map((agent) => agent.id)).toEqual(['root_agent', 'support_agent'])
})
})
describe('removeAgentToolOwner', () => {
it('removes the matched tool and returns its subtree ids', () => {
const nestedAgent = makeAiAgent('support_agent', [makeFlowModuleTool(makeRawModule('create_ticket'))])
const nestedAgent = makeAiAgent('support_agent', [
makeFlowModuleTool(makeRawModule('create_ticket'))
])
const rootAgent = makeAiAgent('root_agent', [
makeFlowModuleTool(makeRawModule('lookup_user')),
makeFlowModuleTool(nestedAgent)
@@ -85,7 +91,9 @@ describe('removeAgentToolOwner', () => {
removedIds: ['support_agent', 'create_ticket']
})
expect((rootAgent.value as any).tools).toHaveLength(1)
expect(((rootAgent.value as any).tools as any[]).map((tool) => tool.id)).toEqual(['lookup_user'])
expect(((rootAgent.value as any).tools as any[]).map((tool) => tool.id)).toEqual([
'lookup_user'
])
})
})
@@ -93,7 +101,9 @@ describe('collectFlowNodeIds', () => {
it('includes ai agent tool ids when deleting an ai agent flow module', () => {
const agent = makeAiAgent('root_agent', [
makeFlowModuleTool(makeRawModule('lookup_user')),
makeFlowModuleTool(makeAiAgent('support_agent', [makeFlowModuleTool(makeRawModule('create_ticket'))]))
makeFlowModuleTool(
makeAiAgent('support_agent', [makeFlowModuleTool(makeRawModule('create_ticket'))])
)
])
expect(collectFlowNodeIds(agent)).toEqual([
@@ -7,7 +7,7 @@ import {
} from './agentToolUtils'
import { forEachAiAgentModule } from './aiAgentModules'
type FlowNodeLike = Pick<FlowModule, 'id' | 'value'>
type FlowNodeLike = Pick<FlowModule, 'id' | 'value' | 'summary'>
export type AgentToolOwner = {
agentId: string
@@ -15,6 +15,8 @@ export type AgentToolOwner = {
toolIndex: number
tool: AgentTool
depth: number
/** Every agent the tool sits under, the step's own first and `agentId`'s last. */
agents: FlowNodeLike[]
}
export type RemovedAgentTool = {
@@ -26,7 +28,7 @@ export function findAgentToolOwner(
modules: FlowModule[],
toolId: string
): AgentToolOwner | undefined {
return findAgentToolOwnerInModules(modules, toolId, 0)
return findAgentToolOwnerInModules(modules, toolId, [])
}
export function removeAgentToolOwner(owner: AgentToolOwner): RemovedAgentTool | undefined {
@@ -53,10 +55,10 @@ export function collectAgentToolIds(tool: AgentTool): string[] {
function findAgentToolOwnerInModules(
modules: FlowModule[],
toolId: string,
depth: number
agents: FlowNodeLike[]
): AgentToolOwner | undefined {
for (const module of modules) {
const owner = findAgentToolOwnerInNode(module, toolId, depth)
const owner = findAgentToolOwnerInNode(module, toolId, agents)
if (owner) {
return owner
}
@@ -68,15 +70,15 @@ function findAgentToolOwnerInModules(
function findAgentToolOwnerInNode(
node: FlowNodeLike,
toolId: string,
depth: number
agents: FlowNodeLike[]
): AgentToolOwner | undefined {
if (node.value.type === 'forloopflow' || node.value.type === 'whileloopflow') {
return findAgentToolOwnerInModules(node.value.modules, toolId, depth)
return findAgentToolOwnerInModules(node.value.modules, toolId, agents)
}
if (node.value.type === 'branchall') {
for (const branch of node.value.branches) {
const owner = findAgentToolOwnerInModules(branch.modules, toolId, depth)
const owner = findAgentToolOwnerInModules(branch.modules, toolId, agents)
if (owner) {
return owner
}
@@ -85,12 +87,12 @@ function findAgentToolOwnerInNode(
}
if (node.value.type === 'branchone') {
const defaultOwner = findAgentToolOwnerInModules(node.value.default, toolId, depth)
const defaultOwner = findAgentToolOwnerInModules(node.value.default, toolId, agents)
if (defaultOwner) {
return defaultOwner
}
for (const branch of node.value.branches) {
const owner = findAgentToolOwnerInModules(branch.modules, toolId, depth)
const owner = findAgentToolOwnerInModules(branch.modules, toolId, agents)
if (owner) {
return owner
}
@@ -102,6 +104,7 @@ function findAgentToolOwnerInNode(
return undefined
}
const withNode = [...agents, node]
// Absent for a linked agent, whose tools live in the resource rather than on the module.
const tools = node.value.tools ?? []
const toolIndex = tools.findIndex((tool) => tool.id === toolId)
@@ -111,7 +114,8 @@ function findAgentToolOwnerInNode(
tools,
toolIndex,
tool: tools[toolIndex],
depth: depth + 1
depth: withNode.length,
agents: withNode
}
}
@@ -120,7 +124,7 @@ function findAgentToolOwnerInNode(
continue
}
const owner = findAgentToolOwnerInNode(tool as FlowNodeLike, toolId, depth + 1)
const owner = findAgentToolOwnerInNode(tool as FlowNodeLike, toolId, withNode)
if (owner) {
return owner
}
@@ -14,6 +14,8 @@
header?: import('svelte').Snippet
action?: import('svelte').Snippet
children?: import('svelte').Snippet
/** See `FlowCardHeader`. */
trail?: import('svelte').Snippet
isAgentTool?: boolean
siblingToolNames?: string[]
}
@@ -30,6 +32,7 @@
header,
action,
children,
trail,
isAgentTool = false,
siblingToolNames = undefined
}: Props = $props()
@@ -49,6 +52,7 @@
{subtitleDocLink}
{flowModuleValue}
{action}
{trail}
{isAgentTool}
{siblingToolNames}
>
@@ -39,6 +39,8 @@
subtitleDocLink?: string | undefined
children?: import('svelte').Snippet
action?: import('svelte').Snippet
/** A line above the header naming where the step sits. */
trail?: import('svelte').Snippet
isAgentTool?: boolean
siblingToolNames?: string[]
}
@@ -52,6 +54,7 @@
subtitleDocLink = undefined,
children,
action,
trail,
isAgentTool = false,
siblingToolNames = undefined
}: Props = $props()
@@ -192,6 +195,7 @@
</script>
<div class="flex flex-col gap-1 px-4 py-2">
{@render trail?.()}
<div
class="overflow-x-auto scrollbar-hidden flex items-center justify-between flex-nowrap w-full"
>
@@ -20,6 +20,8 @@
emptyMessage?: string
/** Where the picker's popover belongs, when the roster is not inside the flow editor. */
pickerPortal?: string
/** See `InsertModuleInner`. */
allowAiAgentTool?: boolean
}
let {
@@ -28,7 +30,8 @@
onAddTool = undefined,
onDeleteTool = undefined,
emptyMessage = 'No tools yet. Add one from the agent on the flow graph.',
pickerPortal = '#flow-editor'
pickerPortal = '#flow-editor',
allowAiAgentTool = true
}: Props = $props()
let funcDesc = $state('')
@@ -81,6 +84,7 @@
<InsertModuleInner
bind:funcDesc
toolMode
{allowAiAgentTool}
on:close={close}
on:new={(e) => (onAddTool?.(e.detail), close())}
on:insert={(e) => (onAddTool?.(e.detail), close())}
@@ -20,6 +20,9 @@
staticOnly?: boolean
/** See `FlowModuleComponent`: set where there is no graph to select a nested tool on. */
noToolNavigation?: boolean
/** See `FlowModuleComponent`. */
agentTrail?: Pick<FlowModule, 'id' | 'summary'>[]
flowModuleSchemaMap?: import('../map/FlowModuleSchemaMap.svelte').default
}
let {
@@ -32,7 +35,9 @@
highlightArg,
siblingToolNames = undefined,
staticOnly = false,
noToolNavigation = false
noToolNavigation = false,
agentTrail = undefined,
flowModuleSchemaMap = undefined
}: Props = $props()
</script>
@@ -65,6 +70,8 @@
isAgentTool={true}
{staticOnly}
{noToolNavigation}
{agentTrail}
{flowModuleSchemaMap}
bind:toolDescription={tool.description}
{siblingToolNames}
/>
@@ -558,6 +558,7 @@
{onAddTool}
{onDeleteTool}
pickerPortal={toolPickerPortal}
allowAiAgentTool={!isAgentTool}
/>
{:else}
<!-- Inert rather than merely button-less: every control below writes into
@@ -70,7 +70,7 @@
signDebugRequest,
getDebugErrorMessage
} from '$lib/components/debug'
import { Bug, Terminal } from 'lucide-svelte'
import { Bug, ChevronRight, Terminal } from 'lucide-svelte'
import { sendUserToast } from '$lib/utils'
const {
@@ -130,6 +130,9 @@
* surface without a graph — the agent editor, which addresses one tool at a time — would
* offer a row whose click lands nowhere. */
noToolNavigation?: boolean
/** The agents an agent tool sits under, the step's own first. The header lists them to go
* back up, since a nested agent's tools have no graph node to reach them from. */
agentTrail?: Pick<FlowModule, 'id' | 'summary'>[]
toolDescription?: string | undefined
siblingToolNames?: string[]
}
@@ -151,6 +154,7 @@
staticOnly = false,
flowModuleSchemaMap = undefined,
noToolNavigation = false,
agentTrail = undefined,
toolDescription = $bindable(undefined),
siblingToolNames = undefined
}: Props = $props()
@@ -828,6 +832,26 @@
<svelte:window onkeydown={onKeyDown} />
{#snippet agentTrailNav()}
<nav
aria-label="Breadcrumb"
class="flex flex-row flex-wrap items-center gap-0.5 min-w-0 text-xs text-secondary"
>
{#each agentTrail ?? [] as agent, i (i)}
<Button
variant="subtle"
unifiedSize="2xs"
onClick={() => selectionManager.selectId(agent.id, { openPanel: true })}
wrapperClasses="min-w-0 shrink"
btnClasses="!px-0 !font-normal !text-xs text-secondary hover:text-emphasis hover:underline hover:!bg-transparent min-w-0"
>
<span class="truncate">{agent.summary || 'AI Agent'}</span>
</Button>
<ChevronRight size={12} class="text-tertiary shrink-0" />
{/each}
</nav>
{/snippet}
{#if flowModule.value}
<div class="h-full bg-surface" bind:clientWidth={width}>
<FlowCard
@@ -846,6 +870,7 @@
on:reload={reloadModule}
bind:summary={flowModule.summary}
bind:description={toolDescription}
trail={agentTrail?.length && !noToolNavigation ? agentTrailNav : undefined}
{isAgentTool}
{siblingToolNames}
>
@@ -1253,6 +1278,10 @@
? (detail) =>
flowModuleSchemaMap?.addToolToAgent(flowModule.id, detail)
: undefined}
onDeleteTool={flowModuleSchemaMap && !agentLinked
? (toolId) =>
flowModuleSchemaMap?.deleteAgentTool(flowModule.id, toolId)
: undefined}
/>
{:else}
<InputTransformSchemaForm
@@ -26,6 +26,7 @@
import type { TriggerContext } from '$lib/components/triggers'
import { formatCron } from '$lib/utils'
import AgentToolWrapper from './AgentToolWrapper.svelte'
import { findAgentToolOwner } from '../agentToolTree'
const { selectionManager, flowStateStore, opWorkspace } =
getContext<FlowEditorContext>('FlowEditorContext')
const selectedId = $derived(selectionManager.getSelectedId())
@@ -66,6 +67,13 @@
flowModuleSchemaMap = undefined
}: Props = $props()
// Searched at any depth: a nested agent's tools have no wrapper of their own to render them.
const selectedToolOwner = $derived(
flowModule.value.type === 'aiagent' && selectedId
? findAgentToolOwner([flowModule], selectedId)
: undefined
)
function initializePrimaryScheduleForTriggerScript(module: FlowModule) {
const primaryIndex = triggersState.triggers.findIndex((t) => t.isPrimary)
if (primaryIndex === -1) {
@@ -326,18 +334,19 @@
{/if}
{/each}
{:else if flowModule.value.type === 'aiagent'}
{#each flowModule.value.tools ?? [] as tool, toolIndex (toolIndex)}
{#if selectedId === tool.id}
<AgentToolWrapper
{noEditor}
bind:tool={flowModule.value.tools![toolIndex]}
parentModule={flowModule}
{previousModule}
{enableAi}
{forceTestTab}
{highlightArg}
siblingToolNames={flowModule.value.tools!.map((t) => t.summary ?? '')}
/>
{/if}
{/each}
{#if selectedToolOwner}
{@const owner = selectedToolOwner}
<AgentToolWrapper
{noEditor}
bind:tool={() => owner.tools[owner.toolIndex], (v) => (owner.tools[owner.toolIndex] = v)}
parentModule={owner.agents[owner.agents.length - 1] as FlowModule}
{previousModule}
{enableAi}
{forceTestTab}
{highlightArg}
siblingToolNames={owner.tools.map((t) => t.summary ?? '')}
agentTrail={owner.agents}
{flowModuleSchemaMap}
/>
{/if}
{/if}
@@ -293,7 +293,7 @@
})
}
function requestDelete(ids: string[]) {
function requestDelete(ids: string[], selectAfter?: string) {
const request = prepareDeleteRequest({
ids,
flow: flowStore.val,
@@ -304,6 +304,9 @@
if (!request) {
return
}
if (selectAfter) {
request.plan.selection = { kind: 'select', id: selectAfter }
}
const affectedGroups = request.plan.structureDelete?.affectedGroups ?? []
@@ -325,6 +328,13 @@
requestDelete(ids)
}
/** Delete a tool from its agent's Tools section, the only place a nested agent's tools can be
* deleted from. The agent stays selected: a plain delete selects whatever precedes the tool,
* often a sibling tool, which would take the panel away from the list being edited. */
export function deleteAgentTool(agentId: string, toolId: string) {
requestDelete([toolId], agentId)
}
// Operates directly on the flat module array (not the structure tree).
// Cloned modules are inserted after the originals, intentionally outside any group.
export function duplicateMultiple(ids: string[]) {
@@ -25,6 +25,9 @@
kind?: 'script' | 'trigger' | 'preprocessor' | 'failure'
allowTrigger?: boolean
toolMode?: boolean
/** Off for a tool of a nested agent: the worker runs an agent tool only under a step's own
* agent, and leaves one nested any deeper out of what the model is offered. */
allowAiAgentTool?: boolean
/** Narrow layout (450px instead of 650px). Defaults on for the preprocessor
* and failure pickers; set it when the container cannot fit the wide one. */
small?: boolean
@@ -37,6 +40,7 @@
kind = 'script',
allowTrigger = true,
toolMode = false,
allowAiAgentTool = true,
small: smallProp = undefined
}: Props = $props()
@@ -230,13 +234,15 @@
dispatch('close')
}}
/>
<TopLevelNode
label="AI Agent"
onSelect={() => {
dispatch('pickAiAgentTool')
dispatch('close')
}}
/>
{#if allowAiAgentTool}
<TopLevelNode
label="AI Agent"
onSelect={() => {
dispatch('pickAiAgentTool')
dispatch('close')
}}
/>
{/if}
{:else}
{#if customUi?.triggers != false && allowTrigger}
<TopLevelNode