Skip to content

Commit 8da1dcb

Browse files
j15zclaude
andcommitted
fix(agent): keep permission modes with reordered tools across API edits
Apply each agent tool's Permission Mode after the edit batch reindexes tool canonical modes, so a mode chosen for a tool's final position is not moved again as if it were keyed by the original tool list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b3530db commit 8da1dcb

6 files changed

Lines changed: 143 additions & 71 deletions

File tree

apps/sim/lib/workflows/editing/builders.test.ts

Lines changed: 7 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,8 @@ import {
1111
normalizeSubblockValue,
1212
normalizeTools,
1313
resolveBlockRetryUpdate,
14-
updateCanonicalModesForInputs,
1514
} from '@/lib/workflows/editing/builders'
1615
import type { SkippedItem } from '@/lib/workflows/editing/types'
17-
import { getBlock } from '@/blocks/registry'
1816

1917
const { mockIsIntegrationDeploymentAvailable } = vi.hoisted(() => ({
2018
mockIsIntegrationDeploymentAvailable: vi.fn(() => true),
@@ -202,48 +200,6 @@ describe('createBlockFromParams', () => {
202200
})
203201
})
204202

205-
describe('agent permission selections from API inputs', () => {
206-
it('preserves distinct active modes when an unchanged round trip contains both values', () => {
207-
const repeated = Array.from({ length: 2 }, () => ({
208-
type: 'custom-tool',
209-
customToolId: 'repeated',
210-
usageControl: 'force',
211-
usageControlExpression: 'none',
212-
}))
213-
const block = createBlockFromParams('agent', {
214-
type: 'agent',
215-
name: 'Agent',
216-
inputs: { tools: repeated },
217-
})
218-
const modes = { '1:agentToolUsageControl': 'advanced' as const, model: 'advanced' as const }
219-
block.data.canonicalModes = modes
220-
block.subBlocks.tools.value = normalizeTools(structuredClone(repeated))
221-
222-
updateCanonicalModesForInputs(block, ['tools'], getBlock('agent')!)
223-
224-
expect(block.data.canonicalModes).toEqual(modes)
225-
})
226-
227-
it('returns to the Auto default when an API tool omits both permission fields', () => {
228-
const block = createBlockFromParams('agent', {
229-
type: 'agent',
230-
name: 'Agent',
231-
inputs: {
232-
tools: [{ type: 'custom-tool', customToolId: 'custom-1', usageControlExpression: 'none' }],
233-
},
234-
})
235-
block.data.canonicalModes.model = 'advanced'
236-
block.subBlocks.tools.value = normalizeTools([
237-
{ type: 'custom-tool', customToolId: 'custom-1' },
238-
])
239-
240-
updateCanonicalModesForInputs(block, ['tools'], getBlock('agent')!)
241-
242-
expect(block.data.canonicalModes).toEqual({ model: 'advanced' })
243-
expect(block.subBlocks.tools.value[0].usageControl).toBe('auto')
244-
})
245-
})
246-
247203
describe('filterDisallowedTools', () => {
248204
it('assigns canonical tool modes after removing disallowed tools', () => {
249205
const block = createBlockFromParams(
@@ -314,6 +270,13 @@ describe('normalizeTools', () => {
314270
},
315271
])
316272
})
273+
274+
it('defaults a custom tool reference without either permission field to Auto', () => {
275+
expect(normalizeTools([{ type: 'custom-tool', customToolId: 'custom-1' }])[0]).toMatchObject({
276+
usageControl: 'auto',
277+
usageControlExpression: undefined,
278+
})
279+
})
317280
})
318281

319282
describe('normalizeSubblockValue', () => {

apps/sim/lib/workflows/editing/builders.ts

Lines changed: 10 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { generateId, isValidUuid } from '@sim/utils/id'
3-
import { isRecordLike, sortObjectKeysDeep } from '@sim/utils/object'
3+
import { sortObjectKeysDeep } from '@sim/utils/object'
44
import {
55
type BlockRetryConfig,
66
normalizeBlockRetryTries,
@@ -24,7 +24,7 @@ import {
2424
buildDefaultCanonicalModes,
2525
isCanonicalPair,
2626
} from '@/lib/workflows/subblocks/visibility'
27-
import { buildAgentToolUsageControlCanonicalKey } from '@/lib/workflows/tool-input/usage-control'
27+
import { applyAgentToolUsageControlModes } from '@/lib/workflows/tool-input/usage-control'
2828
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
2929
import { getBlock } from '@/blocks/registry'
3030
import type { BlockConfig } from '@/blocks/types'
@@ -252,6 +252,13 @@ export function createBlockFromParams(
252252

253253
if (validatedInputs) {
254254
updateCanonicalModesForInputs(blockState, Object.keys(validatedInputs), blockConfig)
255+
const tools = blockState.subBlocks.tools?.value
256+
if (params.type === 'agent' && Array.isArray(tools)) {
257+
blockState.data = {
258+
...blockState.data,
259+
canonicalModes: applyAgentToolUsageControlModes(tools, blockState.data?.canonicalModes),
260+
}
261+
}
255262
}
256263
}
257264

@@ -278,10 +285,7 @@ export function createBlockFromParams(
278285
}
279286

280287
export function updateCanonicalModesForInputs(
281-
block: {
282-
data?: { canonicalModes?: Record<string, 'basic' | 'advanced'> }
283-
subBlocks?: Record<string, { value?: unknown }>
284-
},
288+
block: { data?: { canonicalModes?: Record<string, 'basic' | 'advanced'> } },
285289
inputKeys: string[],
286290
blockConfig: BlockConfig
287291
): void {
@@ -312,27 +316,6 @@ export function updateCanonicalModesForInputs(
312316
if (!block.data.canonicalModes) block.data.canonicalModes = {}
313317
Object.assign(block.data.canonicalModes, canonicalModeUpdates)
314318
}
315-
if (blockConfig.type === 'agent' && inputKeys.includes('tools')) {
316-
const tools = block.subBlocks?.tools?.value
317-
if (Array.isArray(tools)) {
318-
const canonicalModes = { ...block.data?.canonicalModes }
319-
tools.forEach((tool, index) => {
320-
if (!isRecordLike(tool)) return
321-
const hasFixedPermission = tool.usageControl !== undefined
322-
const hasPermissionExpression = tool.usageControlExpression !== undefined
323-
/** A round trip can include both the active value and the dormant alternative. */
324-
if (hasFixedPermission && hasPermissionExpression) return
325-
326-
const key = buildAgentToolUsageControlCanonicalKey(index)
327-
if (hasPermissionExpression) {
328-
canonicalModes[key] = 'advanced'
329-
} else {
330-
delete canonicalModes[key]
331-
}
332-
})
333-
block.data = { ...block.data, canonicalModes }
334-
}
335-
}
336319
}
337320

338321
/**

apps/sim/lib/workflows/editing/engine.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { PermissionGroupConfig } from '@/lib/permission-groups/fields'
55
import { coerceObjectArray } from '@/lib/workflows/persistence/remap-internal-ids'
66
import { isValidKey } from '@/lib/workflows/sanitization/key-validation'
77
import { reindexRewrittenToolCanonicalModes } from '@/lib/workflows/subblocks/visibility'
8+
import { applyAgentToolUsageControlModes } from '@/lib/workflows/tool-input/usage-control'
89
import { getBlock } from '@/blocks/registry'
910
import { validateEdges } from '@/stores/workflows/workflow/edge-validation'
1011
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
@@ -265,6 +266,10 @@ export function applyOperationsToWorkflowState(
265266
workflowState.blocks as Record<string, BlockState> | undefined,
266267
modifiedState.blocks as Record<string, BlockState> | undefined
267268
)
269+
applyAgentToolUsageControlModesAfterEdits(
270+
workflowState.blocks as Record<string, BlockState> | undefined,
271+
modifiedState.blocks as Record<string, BlockState> | undefined
272+
)
268273

269274
// Regenerate loops and parallels after modifications
270275
;(modifiedState as any).loops = generateLoopBlocks((modifiedState as any).blocks)
@@ -336,6 +341,31 @@ function reindexToolCanonicalModesAfterEdits(
336341
}
337342
}
338343

344+
/**
345+
* An agent tool's Permission Mode follows the fields its rewritten entry supplies. Runs after
346+
* {@link reindexToolCanonicalModesAfterEdits} so each choice lands on its tool's final position
347+
* rather than being moved again as if it were keyed by the original list.
348+
*/
349+
function applyAgentToolUsageControlModesAfterEdits(
350+
originalBlocks: Record<string, BlockState> | undefined,
351+
blocks: Record<string, BlockState> | undefined
352+
): void {
353+
for (const [blockId, block] of Object.entries(blocks ?? {})) {
354+
if (block.type !== 'agent') continue
355+
const tools = coerceObjectArray(block.subBlocks?.tools?.value).array
356+
if (!tools) continue
357+
const originalTools = coerceObjectArray(
358+
originalBlocks?.[blockId]?.subBlocks?.tools?.value
359+
).array
360+
if (originalTools && isEqual(originalTools, tools)) continue
361+
362+
block.data = {
363+
...block.data,
364+
canonicalModes: applyAgentToolUsageControlModes(tools, block.data?.canonicalModes),
365+
}
366+
}
367+
}
368+
339369
/**
340370
* Resolves pending forward-reference connections recorded on block.data.
341371
*

apps/sim/lib/workflows/editing/operations.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1344,4 +1344,42 @@ describe('tool canonical-mode reindexing', () => {
13441344
expect(editTools(workflow, [selectorTool])).toEqual({ '0:projectId': 'basic' })
13451345
expect(editTools(workflow, [])).toEqual({})
13461346
})
1347+
1348+
it('selects a Permission Mode at the final position of a tool the edit also moves', () => {
1349+
const fixedTool = { ...selectorTool, usageControl: 'force' }
1350+
const expressionTool = {
1351+
type: 'jira',
1352+
operation: 'jira_get_issue',
1353+
title: 'Variable',
1354+
params: { manualProjectId: '{{PROJECT}}' },
1355+
usageControlExpression: '<start.toolMode>',
1356+
isExpanded: false,
1357+
}
1358+
const workflow = agentWithTools([expressionTool, fixedTool], {
1359+
'0:agentToolUsageControl': 'advanced',
1360+
})
1361+
1362+
expect(editTools(workflow, [fixedTool, expressionTool])).toEqual({
1363+
'1:agentToolUsageControl': 'advanced',
1364+
})
1365+
})
1366+
1367+
it('keeps a round-tripped Permission Mode with its tool when an explicit choice moves past it', () => {
1368+
const roundTripTool = { ...selectorTool, usageControlExpression: '<start.dormant>' }
1369+
const expressionTool = {
1370+
type: 'jira',
1371+
operation: 'jira_get_issue',
1372+
title: 'Variable',
1373+
params: { manualProjectId: '{{PROJECT}}' },
1374+
usageControlExpression: '<start.toolMode>',
1375+
isExpanded: false,
1376+
}
1377+
const workflow = agentWithTools([roundTripTool, expressionTool], {
1378+
'1:agentToolUsageControl': 'advanced',
1379+
})
1380+
1381+
expect(editTools(workflow, [expressionTool, roundTripTool])).toEqual({
1382+
'0:agentToolUsageControl': 'advanced',
1383+
})
1384+
})
13471385
})

apps/sim/lib/workflows/tool-input/usage-control.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,38 @@
44
import { describe, expect, it } from 'vitest'
55
import { parseStoredToolInputValue } from '@/lib/workflows/tool-input/types'
66
import {
7+
applyAgentToolUsageControlModes,
78
buildAgentToolUsageControlCanonicalKey,
89
getAgentToolUsageControlMode,
910
resolveAgentToolUsageControl,
1011
} from '@/lib/workflows/tool-input/usage-control'
1112

13+
describe('applyAgentToolUsageControlModes', () => {
14+
it('selects Variable for an expression alone and Selector for a fixed value alone', () => {
15+
expect(
16+
applyAgentToolUsageControlModes(
17+
[{ usageControl: 'force' }, { usageControlExpression: '<start.toolMode>' }],
18+
{ '0:agentToolUsageControl': 'advanced', model: 'advanced' }
19+
)
20+
).toEqual({ '1:agentToolUsageControl': 'advanced', model: 'advanced' })
21+
})
22+
23+
it('keeps the current mode of a tool that carries both values', () => {
24+
const modes = { '1:agentToolUsageControl': 'advanced', model: 'advanced' } as const
25+
const repeated = { usageControl: 'force', usageControlExpression: 'none' }
26+
27+
expect(applyAgentToolUsageControlModes([repeated, repeated], modes)).toEqual(modes)
28+
})
29+
30+
it('returns to Selector when a tool omits both values', () => {
31+
expect(
32+
applyAgentToolUsageControlModes([{ type: 'custom-tool' }], {
33+
'0:agentToolUsageControl': 'advanced',
34+
})
35+
).toEqual({})
36+
})
37+
})
38+
1239
describe('agent tool usage control', () => {
1340
it('defaults legacy tools to Auto in basic mode', () => {
1441
expect(resolveAgentToolUsageControl({}, 0)).toBe('auto')

apps/sim/lib/workflows/tool-input/usage-control.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isRecordLike } from '@sim/utils/object'
12
import type { CanonicalMode, CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility'
23
import type { ToolUsageControl } from '@/providers/types'
34

@@ -22,6 +23,36 @@ export function getAgentToolUsageControlMode(
2223
: 'basic'
2324
}
2425

26+
/**
27+
* Selects each agent tool's Permission Mode from the fields a serialized tool array supplies: an
28+
* expression alone selects Variable, and a fixed value alone (or neither) selects Selector. A tool
29+
* carrying both keeps its current mode, since a round trip includes the dormant alternative.
30+
* `canonicalModes` must already be keyed by the tools' final positions.
31+
*/
32+
export function applyAgentToolUsageControlModes(
33+
tools: readonly unknown[],
34+
canonicalModes: CanonicalModeOverrides | undefined
35+
): Record<string, CanonicalMode> {
36+
const result: Record<string, CanonicalMode> = {}
37+
for (const [key, mode] of Object.entries(canonicalModes ?? {})) {
38+
if (mode) result[key] = mode
39+
}
40+
tools.forEach((tool, index) => {
41+
if (!isRecordLike(tool)) return
42+
const hasFixedValue = tool.usageControl !== undefined
43+
const hasExpression = tool.usageControlExpression !== undefined
44+
if (hasFixedValue && hasExpression) return
45+
46+
const key = buildAgentToolUsageControlCanonicalKey(index)
47+
if (hasExpression) {
48+
result[key] = 'advanced'
49+
} else {
50+
delete result[key]
51+
}
52+
})
53+
return result
54+
}
55+
2556
export function resolveAgentToolUsageControl(
2657
tool: AgentToolUsageControlInput,
2758
toolIndex: number,

0 commit comments

Comments
 (0)