Compare commits

...

3 Commits

Author SHA1 Message Date
centdix
337b3b1eb6 refactor: extract flow delete helpers
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-07 15:55:38 +02:00
centdix
fc62a19e8e fix: reduce ai tool delete tree walks
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-07 14:31:06 +02:00
centdix
adfc73765d fix: restore ai agent tool deletion
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-07 12:52:27 +02:00
5 changed files with 309 additions and 49 deletions

View File

@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('../aiProviderStorage', () => ({
loadStoredConfig: () => undefined
}))
vi.mock('./flowInfers', () => ({
AI_AGENT_SCHEMA: { properties: {} }
}))
import type { FlowModule } from '$lib/gen'
import { removeAgentToolByIdDeep } from './agentToolUtils'
function makeRawModule(id: string): FlowModule {
return {
id,
summary: id,
value: { type: 'rawscript', content: '', language: 'python3' } as any
} as FlowModule
}
function makeAiAgent(id: string, tools: any[]): FlowModule {
return {
id,
summary: id,
value: {
type: 'aiagent',
tools,
input_transforms: {}
} as any
} as FlowModule
}
function makeFlowModuleTool(module: FlowModule) {
return {
id: module.id,
summary: module.summary,
value: {
tool_type: 'flowmodule',
...module.value
}
}
}
describe('removeAgentToolByIdDeep', () => {
it('removes a direct tool from an ai agent', () => {
const tool = makeFlowModuleTool(makeRawModule('lookup_user'))
const agent = makeAiAgent('agent', [tool])
const removed: string[] = []
expect(removeAgentToolByIdDeep([agent], 'lookup_user', (x) => removed.push(x.id))).toBe(true)
expect((agent.value as any).tools).toEqual([])
expect(removed).toEqual(['lookup_user'])
})
it('removes a nested tool from a nested ai agent tool', () => {
const nestedTool = makeFlowModuleTool(makeRawModule('create_ticket'))
const nestedAgent = makeAiAgent('support_agent', [nestedTool])
const rootAgent = makeAiAgent('root_agent', [makeFlowModuleTool(nestedAgent)])
const removed: string[] = []
expect(removeAgentToolByIdDeep([rootAgent], 'create_ticket', (x) => removed.push(x.id))).toBe(
true
)
expect((((rootAgent.value as any).tools as any[])[0].value as any).tools).toEqual([])
expect(removed).toEqual(['create_ticket'])
})
it('returns false when the tool does not exist', () => {
const agent = makeAiAgent('agent', [makeFlowModuleTool(makeRawModule('lookup_user'))])
const removed: string[] = []
expect(removeAgentToolByIdDeep([agent], 'missing_tool', (x) => removed.push(x.id))).toBe(false)
expect((agent.value as any).tools).toHaveLength(1)
expect(removed).toEqual([])
})
})

View File

@@ -108,6 +108,66 @@ export function createWebsearchTool(id: string): WebsearchTool {
}
}
/**
* Remove an AI agent tool by id, recursively traversing nested modules and nested AI agents.
* Returns true when a matching tool was found and removed.
*/
export function removeAgentToolByIdDeep(
modules: FlowModule[],
id: string,
onRemove?: (tool: AgentTool) => void
): boolean {
for (const module of modules) {
if (module.value.type === 'forloopflow' || module.value.type === 'whileloopflow') {
if (removeAgentToolByIdDeep(module.value.modules, id, onRemove)) {
return true
}
continue
}
if (module.value.type === 'branchall') {
for (const branch of module.value.branches) {
if (removeAgentToolByIdDeep(branch.modules, id, onRemove)) {
return true
}
}
continue
}
if (module.value.type === 'branchone') {
if (removeAgentToolByIdDeep(module.value.default, id, onRemove)) {
return true
}
for (const branch of module.value.branches) {
if (removeAgentToolByIdDeep(branch.modules, id, onRemove)) {
return true
}
}
continue
}
if (module.value.type !== 'aiagent') {
continue
}
const toolIndex = module.value.tools.findIndex((tool) => tool.id === id)
if (toolIndex !== -1) {
const [removed] = module.value.tools.splice(toolIndex, 1)
onRemove?.(removed)
return true
}
const nestedToolModules = module.value.tools
.filter(isFlowModuleTool)
.map((tool) => agentToolToFlowModule(tool))
if (removeAgentToolByIdDeep(nestedToolModules, id, onRemove)) {
return true
}
}
return false
}
/**
* Convert a FlowModuleTool to a FlowModule for use with loadFlowModuleState etc.
* Strips the extra `tool_type` field and maps AgentTool fields to FlowModule fields.

View File

@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('../aiProviderStorage', () => ({
loadStoredConfig: () => undefined
}))
vi.mock('./flowInfers', () => ({
AI_AGENT_SCHEMA: { properties: {} }
}))
import type { FlowModule } from '$lib/gen'
import type { FlowStructureNode } from '$lib/components/graph/flowStructure'
import { partitionDeleteTargets, removeToolIds } from './flowDeleteUtils'
function makeRawModule(id: string): FlowModule {
return {
id,
summary: id,
value: { type: 'rawscript', content: '', language: 'python3' } as any
} as FlowModule
}
function makeAiAgent(id: string, tools: any[]): FlowModule {
return {
id,
summary: id,
value: {
type: 'aiagent',
tools,
input_transforms: {}
} as any
} as FlowModule
}
function makeFlowModuleTool(module: FlowModule) {
return {
id: module.id,
summary: module.summary,
value: {
tool_type: 'flowmodule',
...module.value
}
}
}
describe('partitionDeleteTargets', () => {
it('splits structure nodes from AI tool ids in one pass', () => {
const tree: FlowStructureNode[] = [
{ id: 'step_a', kind: 'leaf', branches: [] },
{
id: 'loop',
kind: 'forloopflow',
branches: [{ children: [{ id: 'nested_step', kind: 'leaf', branches: [] }] }]
}
]
expect(partitionDeleteTargets(tree, ['step_a', 'tool_x', 'nested_step'])).toEqual({
structureIds: ['step_a', 'nested_step'],
toolIds: ['tool_x']
})
})
})
describe('removeToolIds', () => {
it('returns only the ids that were actually removed', () => {
const rootAgent = makeAiAgent('root_agent', [
makeFlowModuleTool(makeRawModule('lookup_user')),
makeFlowModuleTool(makeAiAgent('nested_agent', [makeFlowModuleTool(makeRawModule('create_ticket'))]))
])
const removed: string[] = []
expect(
removeToolIds([rootAgent], ['lookup_user', 'missing_tool', 'create_ticket'], (tool) => {
removed.push(tool.id)
})
).toEqual(['lookup_user', 'create_ticket'])
expect((rootAgent.value as any).tools).toHaveLength(1)
expect((((rootAgent.value as any).tools as any[])[0].value as any).tools).toEqual([])
expect(removed).toEqual(['lookup_user', 'create_ticket'])
})
})

View File

@@ -0,0 +1,51 @@
import type { FlowModule } from '$lib/gen'
import { findInStructure, type FlowStructureNode } from '$lib/components/graph/flowStructure'
import type { AgentTool } from './agentToolUtils'
import { removeAgentToolByIdDeep } from './agentToolUtils'
export type DeleteTargetPartition = {
structureIds: string[]
toolIds: string[]
}
/**
* Split delete targets between structure-tree nodes and AI agent tool nodes.
* AI tools are rendered in the graph but are not represented in the grouped structure tree.
*/
export function partitionDeleteTargets(
tree: FlowStructureNode[],
ids: string[]
): DeleteTargetPartition {
const structureIds: string[] = []
const toolIds: string[] = []
for (const id of ids) {
if (findInStructure(tree, id)) {
structureIds.push(id)
} else {
toolIds.push(id)
}
}
return { structureIds, toolIds }
}
/**
* Remove AI agent tools by id and return the ids that were actually removed.
*/
export function removeToolIds(
modules: FlowModule[],
ids: string[],
onRemove?: (tool: AgentTool) => void
): string[] {
const removedIds = new Set<string>()
for (const id of ids) {
removeAgentToolByIdDeep(modules, id, (tool) => {
removedIds.add(tool.id)
onRemove?.(tool)
})
}
return [...removedIds]
}

View File

@@ -53,6 +53,7 @@
agentToolToFlowModule
} from '../agentToolUtils'
import { loadFlowModuleState } from '../flowStateUtils.svelte'
import { partitionDeleteTargets, removeToolIds } from '../flowDeleteUtils'
import { getNoteEditorContext } from '$lib/components/graph/noteEditor.svelte'
import {
GroupedModulesProxy,
@@ -252,48 +253,6 @@
}
}
/**
* Helper function to remove an AgentTool by id from the tools array
* Tools are always leaf nodes, so we just need to delete their state directly
*/
function removeAgentToolById(tools: AgentTool[], id: string): AgentTool[] {
const index = tools.findIndex((tool) => tool.id == id)
if (index != -1) {
const [removed] = tools.splice(index, 1)
deleteFlowStateById(removed.id, flowStateStore)
}
return tools
}
export function removeAtId(modules: FlowModule[], id: string): FlowModule[] {
const index = modules.findIndex((mod) => mod.id == id)
if (index != -1) {
const [removed] = modules.splice(index, 1)
const leaves = dfs([removed], (mod) => mod.id)
leaves.forEach((leafId: string) => deleteFlowStateById(leafId, flowStateStore))
return modules
}
return modules.map((mod) => {
if (mod.value.type == 'forloopflow' || mod.value.type == 'whileloopflow') {
mod.value.modules = removeAtId(mod.value.modules, id)
} else if (mod.value.type == 'branchall') {
mod.value.branches = mod.value.branches.map((branch) => {
branch.modules = removeAtId(branch.modules, id)
return branch
})
} else if (mod.value.type == 'branchone') {
mod.value.branches = mod.value.branches.map((branch) => {
branch.modules = removeAtId(branch.modules, id)
return branch
})
mod.value.default = removeAtId(mod.value.default, id)
} else if (mod.value.type == 'aiagent') {
mod.value.tools = removeAgentToolById(mod.value.tools, id)
}
return mod
})
}
let sidebarMode: 'list' | 'graph' = 'graph'
let minHeight = $state(0)
@@ -383,6 +342,7 @@
}
export function deleteMultiple(ids: string[]) {
const { structureIds, toolIds } = partitionDeleteTargets(proxy.items, ids)
const deletingSet = new Set(ids)
const allDeps: Record<string, string[]> = {}
for (const id of ids) {
@@ -395,20 +355,32 @@
}
const opts = { displayState: groupDisplayState }
const { emptiedGroups, duplicateGroups, commit } = proxy.prepareMutation((tree) => {
for (const id of ids) {
const found = findInStructure(tree, id)
if (found) found.parentChildren.splice(found.index, 1)
}
}, opts)
const { emptiedGroups, duplicateGroups, commit } =
structureIds.length > 0
? proxy.prepareMutation((tree) => {
for (const id of structureIds) {
const found = findInStructure(tree, id)
if (found) found.parentChildren.splice(found.index, 1)
}
}, opts)
: {
emptiedGroups: [],
duplicateGroups: [],
commit: () => {}
}
const affectedGroups = [...emptiedGroups, ...duplicateGroups]
const cb = () => {
push(history, flowStore.val)
commit({ removeDuplicates: duplicateGroups.length > 0 })
const removedToolIds = removeToolIds(flowStore.val.value.modules, toolIds, (tool) => {
deleteFlowStateById(tool.id, flowStateStore)
})
for (const id of ids) {
delete flowStateStore.val[id]
if (structureIds.includes(id) || removedToolIds.includes(id)) {
delete flowStateStore.val[id]
}
}
selectionManager.clearSelection()
refreshStateStore(flowStore)
@@ -678,6 +650,25 @@
return
}
if (!findInStructure(proxy.items, id)) {
const cb = () => {
push(history, flowStore.val)
selectNextId(id)
const removedIds = removeToolIds(flowStore.val.value.modules, [id], (tool) => {
deleteFlowStateById(tool.id, flowStateStore)
})
if (removedIds.length === 0) return
refreshStateStore(flowStore)
onDelete?.(id)
}
if (Object.keys(dependents).length > 0) {
deleteCallback = cb
} else {
cb()
}
return
}
const dsOpts = { displayState: groupDisplayState }
const { emptiedGroups, duplicateGroups, commit } = proxy.prepareMutation((tree) => {
const found = findInStructure(tree, id)