Skip to content

Commit f8e5df5

Browse files
committed
Let edit_workflow configure block retries
1 parent 0cae6e3 commit f8e5df5

7 files changed

Lines changed: 187 additions & 2 deletions

File tree

apps/sim/lib/copilot/generated/tool-catalog-v1.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2259,7 +2259,7 @@ export const EditWorkflow: ToolCatalogEntry = {
22592259
params: {
22602260
type: 'object',
22612261
description:
2262-
'Parameters for the operation (optional).\nFor edit: {"inputs": {"temperature": 0.5}} NOT {"subBlocks": {"temperature": {"value": 0.5}}}\nFor add: {"type": "agent", "name": "My Agent", "inputs": {"model": "<model-id from agent.json>"}}\nFor delete: omit params entirely (none needed)',
2262+
'Parameters for the operation (optional).\nFor edit: {"inputs": {"temperature": 0.5}} NOT {"subBlocks": {"temperature": {"value": 0.5}}}\nFor add: {"type": "agent", "name": "My Agent", "inputs": {"model": "<model-id from agent.json>"}}\nFor delete: omit params entirely (none needed)\nBlock-level settings (retry, triggerMode, advancedMode) go beside "inputs", never inside it.',
22632263
},
22642264
},
22652265
required: ['operation_type', 'block_id'],

apps/sim/lib/copilot/generated/tool-schemas-v1.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2224,7 +2224,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
22242224
params: {
22252225
type: 'object',
22262226
description:
2227-
'Parameters for the operation (optional).\nFor edit: {"inputs": {"temperature": 0.5}} NOT {"subBlocks": {"temperature": {"value": 0.5}}}\nFor add: {"type": "agent", "name": "My Agent", "inputs": {"model": "<model-id from agent.json>"}}\nFor delete: omit params entirely (none needed)',
2227+
'Parameters for the operation (optional).\nFor edit: {"inputs": {"temperature": 0.5}} NOT {"subBlocks": {"temperature": {"value": 0.5}}}\nFor add: {"type": "agent", "name": "My Agent", "inputs": {"model": "<model-id from agent.json>"}}\nFor delete: omit params entirely (none needed)\nBlock-level settings (retry, triggerMode, advancedMode) go beside "inputs", never inside it.',
22282228
},
22292229
},
22302230
required: ['operation_type', 'block_id'],

apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@
33
*/
44
import { describe, expect, it, vi } from 'vitest'
55
import {
6+
applyBlockRetry,
67
applyTriggerConfigToBlockSubblocks,
78
createBlockFromParams,
89
filterDisallowedTools,
910
normalizeSubblockValue,
11+
resolveBlockRetryUpdate,
1012
} from '@/lib/copilot/tools/server/workflow/edit-workflow/builders'
13+
import type { SkippedItem } from '@/lib/copilot/tools/server/workflow/edit-workflow/types'
1114

1215
const { mockIsIntegrationDeploymentAvailable } = vi.hoisted(() => ({
1316
mockIsIntegrationDeploymentAvailable: vi.fn(() => true),
@@ -245,3 +248,79 @@ describe('applyTriggerConfigToBlockSubblocks', () => {
245248
})
246249
})
247250
})
251+
252+
describe('block retry policy', () => {
253+
it('defaults the numbers when only enabling', () => {
254+
expect(resolveBlockRetryUpdate({ enabled: true }, undefined)).toEqual({
255+
enabled: true,
256+
maxTries: 3,
257+
waitBetweenTriesMs: 1000,
258+
})
259+
})
260+
261+
it('treats numbers alone as an intent to retry', () => {
262+
expect(resolveBlockRetryUpdate({ maxTries: 4 }, undefined)).toMatchObject({
263+
enabled: true,
264+
maxTries: 4,
265+
})
266+
})
267+
268+
it('keeps configured numbers when retry is switched off', () => {
269+
const existing = { enabled: true, maxTries: 5, waitBetweenTriesMs: 250 }
270+
expect(resolveBlockRetryUpdate({ enabled: false }, existing)).toEqual({
271+
enabled: false,
272+
maxTries: 5,
273+
waitBetweenTriesMs: 250,
274+
})
275+
})
276+
277+
it('clamps out-of-range values instead of rejecting them', () => {
278+
expect(resolveBlockRetryUpdate({ enabled: true, maxTries: 99 }, undefined).maxTries).toBe(5)
279+
expect(resolveBlockRetryUpdate({ enabled: true, maxTries: 1 }, undefined).maxTries).toBe(2)
280+
expect(
281+
resolveBlockRetryUpdate({ enabled: true, waitBetweenTriesMs: 999999 }, undefined)
282+
.waitBetweenTriesMs
283+
).toBe(5000)
284+
})
285+
286+
it('applies a policy to an eligible block', () => {
287+
const block: Record<string, unknown> = { type: 'agent' }
288+
applyBlockRetry(
289+
block,
290+
{ enabled: true, maxTries: 4 },
291+
{
292+
operationType: 'edit',
293+
blockId: 'b1',
294+
}
295+
)
296+
expect(block.retry).toMatchObject({ enabled: true, maxTries: 4 })
297+
})
298+
299+
it('reports why an ineligible block cannot retry instead of storing dead config', () => {
300+
const skippedItems: SkippedItem[] = []
301+
const block: Record<string, unknown> = { type: 'agent', triggerMode: true }
302+
303+
applyBlockRetry(
304+
block,
305+
{ enabled: true },
306+
{
307+
operationType: 'edit',
308+
blockId: 'b1',
309+
skippedItems,
310+
}
311+
)
312+
313+
expect(block.retry).toBeUndefined()
314+
expect(skippedItems).toHaveLength(1)
315+
expect(skippedItems[0]).toMatchObject({ type: 'retry_not_supported', blockId: 'b1' })
316+
})
317+
318+
it('clears the policy when null is sent', () => {
319+
const block: Record<string, unknown> = {
320+
type: 'agent',
321+
retry: { enabled: true, maxTries: 3, waitBetweenTriesMs: 1000 },
322+
}
323+
applyBlockRetry(block, null, { operationType: 'edit', blockId: 'b1' })
324+
expect(block.retry).toBeUndefined()
325+
})
326+
})

apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
import { createLogger } from '@sim/logger'
22
import { generateId, isValidUuid } from '@sim/utils/id'
33
import { sortObjectKeysDeep } from '@sim/utils/object'
4+
import {
5+
type BlockRetryConfig,
6+
normalizeBlockRetryTries,
7+
normalizeBlockRetryWaitMs,
8+
} from '@sim/workflow-types/workflow'
49
import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server'
510
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
611
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
12+
import { isRetryEligibleBlock } from '@/lib/workflows/blocks/retry-eligibility'
713
import {
814
buildCanonicalIndex,
915
buildDefaultCanonicalModes,
@@ -22,6 +28,73 @@ import {
2228
validateTargetHandle,
2329
} from './validation'
2430

31+
/**
32+
* Merges a requested retry policy onto whatever the block already had, clamped
33+
* to the executor's bounds.
34+
*
35+
* `enabled` is optional and falls back to the block's current state (or `true`
36+
* for a block with no policy yet), so `{maxTries: 4}` reads as "retry four
37+
* times" rather than silently storing a disabled policy. Numbers are kept when
38+
* only `enabled` changes, matching the editor: toggling retry off and back on
39+
* restores what was configured instead of resetting to the defaults.
40+
*
41+
* Clamped through the shared normalizers rather than rejected, so a value that
42+
* drifts outside the bounds still yields a runnable policy — same contract the
43+
* editor and executor already follow.
44+
*/
45+
export function resolveBlockRetryUpdate(
46+
requested: Partial<BlockRetryConfig>,
47+
existing: BlockRetryConfig | undefined
48+
): BlockRetryConfig {
49+
return {
50+
enabled:
51+
typeof requested.enabled === 'boolean' ? requested.enabled : (existing?.enabled ?? true),
52+
maxTries: normalizeBlockRetryTries(requested.maxTries ?? existing?.maxTries),
53+
waitBetweenTriesMs: normalizeBlockRetryWaitMs(
54+
requested.waitBetweenTriesMs ?? existing?.waitBetweenTriesMs
55+
),
56+
}
57+
}
58+
59+
/**
60+
* Applies a requested retry policy to a block, or records why it could not be.
61+
*
62+
* Eligibility is checked with the same predicate the executor uses, so a policy
63+
* the runtime would ignore (triggers, human-in-the-loop, sentinels) is reported
64+
* back instead of being written as dead configuration.
65+
*/
66+
export function applyBlockRetry(
67+
block: any,
68+
requested: unknown,
69+
context: { operationType: string; blockId: string; skippedItems?: SkippedItem[] }
70+
): void {
71+
if (requested === null) {
72+
block.retry = undefined
73+
return
74+
}
75+
if (typeof requested !== 'object' || Array.isArray(requested)) return
76+
77+
if (
78+
!isRetryEligibleBlock({
79+
blockType: block.type,
80+
category: getBlock(block.type)?.category,
81+
triggerMode: block.triggerMode,
82+
})
83+
) {
84+
if (context.skippedItems) {
85+
logSkippedItem(context.skippedItems, {
86+
type: 'retry_not_supported',
87+
operationType: context.operationType,
88+
blockId: context.blockId,
89+
reason: `Block "${context.blockId}" (${block.type}) cannot retry - triggers, human-in-the-loop, and container blocks always run once`,
90+
})
91+
}
92+
return
93+
}
94+
95+
block.retry = resolveBlockRetryUpdate(requested as Partial<BlockRetryConfig>, block.retry)
96+
}
97+
2598
/**
2699
* Helper to create a block state from operation params
27100
*/
@@ -88,6 +161,16 @@ export function createBlockFromParams(
88161
locked: false,
89162
}
90163

164+
// Block-level setting like `enabled`, not a subBlock input — the executor
165+
// reads it from block state when wrapping the run, never from the tool params.
166+
if (params.retry !== undefined) {
167+
applyBlockRetry(blockState, params.retry, {
168+
operationType: 'add',
169+
blockId,
170+
skippedItems,
171+
})
172+
}
173+
91174
// Add validated inputs as subBlocks
92175
if (validatedInputs) {
93176
Object.entries(validatedInputs).forEach(([key, value]) => {

apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getBlock } from '@/blocks/registry'
55
import { normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants'
66
import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
77
import {
8+
applyBlockRetry,
89
applyTriggerConfigToBlockSubblocks,
910
createBlockFromParams,
1011
filterDisallowedTools,
@@ -631,6 +632,17 @@ export function handleEditOperation(op: EditWorkflowOperation, ctx: OperationCon
631632
block.advancedMode = params.advancedMode
632633
}
633634

635+
// Handle retry policy. Runs after the trigger-mode branch above so eligibility
636+
// sees the block's post-update mode: turning a block into a trigger in the
637+
// same operation makes it ineligible, exactly as the editor treats it.
638+
if (params?.retry !== undefined) {
639+
applyBlockRetry(block, params.retry, {
640+
operationType: 'edit',
641+
blockId: block_id,
642+
skippedItems,
643+
})
644+
}
645+
634646
// Handle nested nodes update (for loops/parallels) using merge strategy.
635647
// Existing children that match an incoming node by name are updated in place
636648
// (preserving their block ID). New children are created. Children not present

apps/sim/lib/copilot/tools/server/workflow/edit-workflow/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export type SkippedItemType =
4848
| 'nested_subflow_not_allowed'
4949
| 'duplicate_block_name'
5050
| 'reserved_block_name'
51+
| 'retry_not_supported'
5152
| 'duplicate_trigger'
5253
| 'duplicate_single_instance_block'
5354

apps/sim/lib/copilot/vfs/serializers.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types'
1515
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
1616
import { getServiceAccountProviderForProviderId } from '@/lib/oauth/utils'
17+
import { isRetryEligibleBlock } from '@/lib/workflows/blocks/retry-eligibility'
1718
import { isSubBlockHidden } from '@/lib/workflows/subblocks/visibility'
1819
import { getBlock } from '@/blocks'
1920
import { isCustomBlockType } from '@/blocks/custom/build-config'
@@ -692,6 +693,15 @@ export function serializeBlockSchema(
692693
longDescription: block.longDescription || undefined,
693694
bestPractices: block.bestPractices || undefined,
694695
triggerAllowed: block.triggerAllowed || undefined,
696+
// Retry is block STATE (like `enabled`), not a subBlock input — set it via
697+
// edit_workflow's `retry` param, never through `inputs`. Emitted only when
698+
// eligible so the agent never proposes a policy the executor would ignore.
699+
retryAllowed:
700+
isRetryEligibleBlock({
701+
blockType: block.type,
702+
category: block.category,
703+
triggerMode: undefined,
704+
}) || undefined,
695705
singleInstance: block.singleInstance || undefined,
696706
authMode: block.authMode || undefined,
697707
// Custom (deploy-as-block) blocks execute via a baked `workflow_executor`

0 commit comments

Comments
 (0)