Compare commits
3 Commits
v1.681.0
...
ai-agent-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
337b3b1eb6 | ||
|
|
fc62a19e8e | ||
|
|
adfc73765d |
77
frontend/src/lib/components/flows/agentToolUtils.test.ts
Normal file
77
frontend/src/lib/components/flows/agentToolUtils.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
81
frontend/src/lib/components/flows/flowDeleteUtils.test.ts
Normal file
81
frontend/src/lib/components/flows/flowDeleteUtils.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
51
frontend/src/lib/components/flows/flowDeleteUtils.ts
Normal file
51
frontend/src/lib/components/flows/flowDeleteUtils.ts
Normal 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]
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user