mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
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:
co-authored by
Claude Opus 5
parent
c297ed0052
commit
e20cd87ef9
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user