Skip to content

Commit 93abc4c

Browse files
committed
fix(agent): address the pre-landing review of retry-then-fallback
Review fixes: the executor judges the final try once per iteration; the skip-warn is one helper; the fallback warn names the candidate position rather than reusing `attempt`; the BlockLog.modelFallbacks doc matches the final-try semantics; the viability check resolves a provider once through the new providerRequiresFamilyCredentials; editor handlers read rows via a ref so a keystroke in one row no longer re-renders every row, and tuning options keep their identity across renders. Two consistency fixes from the red-team pass: a sim-auto fallback takes the projected system prompt rather than the raw input, and a row key is honoured at runtime only when the block stored it as a whole {{NAME}} reference, the one form the editor, the validator, and an export agree on. Tests now cover the composed executor-plus-handler sequence (three tries on the selected model, then each fallback once), the node-taking handler signature, a fallback whose provider cannot take the attachments, the per-provider hydration cache, a stop during a skipped candidate, the un-primed stream when no candidate follows, a non-retryable failure on a non-final try, a numeric tuning value, and an unresolved temperature.
1 parent dbf585c commit 93abc4c

11 files changed

Lines changed: 425 additions & 68 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/model-fallback-list/model-fallback-list.tsx‎

Lines changed: 49 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { memo, useCallback, useMemo } from 'react'
3+
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
44
import { Button, Combobox, type ComboboxOption, Label, Tooltip } from '@sim/emcn'
55
import { ChevronDown, ChevronUp, Plus, Trash } from '@sim/emcn/icons'
66
import { generateShortId } from '@sim/utils/id'
@@ -51,7 +51,10 @@ interface ViableModelOption {
5151
interface FallbackRowProps {
5252
row: FallbackModelEntry
5353
index: number
54-
count: number
54+
/** The row can move down only while another follows it. */
55+
isLast: boolean
56+
/** Move controls render only once a second row exists. */
57+
canMove: boolean
5558
primaryModel: string
5659
primaryTuning: Partial<Record<FallbackTuningKnob, unknown>>
5760
viableOptions: ViableModelOption[]
@@ -69,7 +72,8 @@ interface FallbackRowProps {
6972
const FallbackRow = memo(function FallbackRow({
7073
row,
7174
index,
72-
count,
75+
isLast,
76+
canMove,
7377
primaryModel,
7478
primaryTuning,
7579
viableOptions,
@@ -96,7 +100,12 @@ const FallbackRow = memo(function FallbackRow({
96100
return {
97101
needsApiKey: fallbackRowNeedsApiKey(row.model, primaryModel),
98102
tuningFields: getFallbackTuningKnobsToShow(row.model, primaryModel, primaryTuning).map(
99-
(knob) => ({ knob, options: getTuningOptionsForModel(row.model, knob) ?? [] })
103+
(knob) => ({
104+
knob,
105+
options: (getTuningOptionsForModel(row.model, knob) ?? []).map(
106+
(value): ComboboxOption => ({ label: value, value })
107+
),
108+
})
100109
),
101110
}
102111
}, [row.model, primaryModel, primaryTuning])
@@ -112,7 +121,7 @@ const FallbackRow = memo(function FallbackRow({
112121
<div className='flex items-center justify-between rounded-t-[4px] border-[var(--border-1)] border-b bg-[var(--surface-4)] px-2.5 py-[5px]'>
113122
<span className='text-[var(--text-tertiary)] text-sm'>{ordinalChoiceLabel(index)}</span>
114123
<div className='flex items-center gap-2'>
115-
{count > 1 && (
124+
{canMove && (
116125
<>
117126
<Tooltip.Root>
118127
<Tooltip.Trigger asChild>
@@ -133,7 +142,7 @@ const FallbackRow = memo(function FallbackRow({
133142
<Button
134143
variant='ghost'
135144
onClick={() => onMove(row.id, 1)}
136-
disabled={readOnly || index === count - 1}
145+
disabled={readOnly || isLast}
137146
className='h-auto p-0'
138147
aria-label='Move down'
139148
>
@@ -196,8 +205,8 @@ const FallbackRow = memo(function FallbackRow({
196205
<div key={knob} className='flex flex-col gap-1.5'>
197206
<Label>{FALLBACK_TUNING_LABELS[knob]}</Label>
198207
<Combobox
199-
options={options.map((value) => ({ label: value, value }))}
200-
value={row[knob] ?? options[0] ?? ''}
208+
options={options}
209+
value={row[knob] ?? options[0]?.value ?? ''}
201210
onChange={(value) => onChangeTuning(row.id, knob, value)}
202211
placeholder={`Select ${FALLBACK_TUNING_LABELS[knob].toLowerCase()}`}
203212
disabled={readOnly}
@@ -306,38 +315,53 @@ export function ModelFallbackList({
306315
return options
307316
}, [workspaceId, workspaceEnv, personalEnv, navigateToSettings])
308317

318+
/**
319+
* Handlers read the latest rows through a ref so their identity survives an
320+
* edit; otherwise every keystroke in one row would re-render all of them.
321+
*/
322+
const rowsRef = useRef(rows)
323+
useEffect(() => {
324+
rowsRef.current = rows
325+
}, [rows])
326+
309327
const write = useCallback(
310-
(next: FallbackModelEntry[]) => {
311-
if (readOnly || next === rows) return
312-
setStoreValue(next)
328+
(transform: (current: FallbackModelEntry[]) => FallbackModelEntry[]) => {
329+
if (readOnly) return
330+
const current = rowsRef.current
331+
const next = transform(current)
332+
if (next !== current) setStoreValue(next)
313333
},
314-
[readOnly, rows, setStoreValue]
334+
[readOnly, setStoreValue]
315335
)
316336

317-
const handleAdd = useCallback(() => write(addFallbackRow(rows, generateShortId())), [rows, write])
337+
const handleAdd = useCallback(
338+
() => write((current) => addFallbackRow(current, generateShortId())),
339+
[write]
340+
)
318341
const handleRemove = useCallback(
319-
(id: string) => write(removeFallbackRow(rows, id)),
320-
[rows, write]
342+
(id: string) => write((current) => removeFallbackRow(current, id)),
343+
[write]
321344
)
322345
const handleMove = useCallback(
323-
(id: string, direction: -1 | 1) => write(moveFallbackRow(rows, id, direction)),
324-
[rows, write]
346+
(id: string, direction: -1 | 1) => write((current) => moveFallbackRow(current, id, direction)),
347+
[write]
325348
)
326349
const handleChangeModel = useCallback(
327-
(id: string, model: string) => write(changeFallbackRowModel(rows, id, model, primaryModel)),
328-
[rows, primaryModel, write]
350+
(id: string, model: string) =>
351+
write((current) => changeFallbackRowModel(current, id, model, primaryModel)),
352+
[primaryModel, write]
329353
)
330354
const handleChangeTuning = useCallback(
331355
(id: string, knob: FallbackTuningKnob, value: string) =>
332-
write(changeFallbackRowTuning(rows, id, knob, value)),
333-
[rows, write]
356+
write((current) => changeFallbackRowTuning(current, id, knob, value)),
357+
[write]
334358
)
335359
const handleChangeApiKey = useCallback(
336360
(id: string, apiKey: string) => {
337361
if (apiKey === CREATE_SECRET_VALUE) return
338-
write(changeFallbackRowApiKey(rows, id, apiKey))
362+
write((current) => changeFallbackRowApiKey(current, id, apiKey))
339363
},
340-
[rows, write]
364+
[write]
341365
)
342366

343367
return (
@@ -347,7 +371,8 @@ export function ModelFallbackList({
347371
key={row.id}
348372
row={row}
349373
index={index}
350-
count={rows.length}
374+
isLast={index === rows.length - 1}
375+
canMove={rows.length > 1}
351376
primaryModel={primaryModel}
352377
primaryTuning={primaryTuning}
353378
viableOptions={viableOptions}

‎apps/sim/blocks/utils.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import {
7575
parseOptionalBooleanInput,
7676
parseOptionalJsonInput,
7777
parseOptionalNumberInput,
78+
providerRequiresFamilyCredentials,
7879
requiresProviderFamilyCredentials,
7980
} from '@/blocks/utils'
8081
import { getProviderFromModel } from '@/providers/utils'
@@ -98,6 +99,15 @@ const BASE_CLOUD_MODELS: Record<string, string> = {
9899
'mistral-large-latest': 'mistral',
99100
}
100101

102+
describe('providerRequiresFamilyCredentials', () => {
103+
it('answers for a provider the caller already resolved', () => {
104+
expect(providerRequiresFamilyCredentials('vertex')).toBe(true)
105+
expect(providerRequiresFamilyCredentials('openai')).toBe(false)
106+
expect(providerRequiresFamilyCredentials(null)).toBe(false)
107+
expect(providerRequiresFamilyCredentials(undefined)).toBe(false)
108+
})
109+
})
110+
101111
describe('requiresProviderFamilyCredentials', () => {
102112
beforeEach(() => {
103113
setEnvFlags({ isHosted: false, isAzureConfigured: false, isOllamaConfigured: false })

‎apps/sim/blocks/utils.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,15 @@ export function getCohereRerankerApiKeyCondition() {
292292
* that provider family, so nothing outside the family can inherit them.
293293
*/
294294
export function requiresProviderFamilyCredentials(model: string): boolean {
295-
const provider = findProviderFromModel(model.trim())
295+
return providerRequiresFamilyCredentials(findProviderFromModel(model.trim()))
296+
}
297+
298+
/**
299+
* The provider-keyed half of {@link requiresProviderFamilyCredentials}, for a
300+
* caller that has already resolved the provider and must not pay for a second
301+
* catalog scan.
302+
*/
303+
export function providerRequiresFamilyCredentials(provider: string | null | undefined): boolean {
296304
if (provider === 'vertex') return true
297305
if (provider === 'bedrock') return !isTruthy(getEnv('NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS'))
298306
if (provider === 'azure-openai' || provider === 'azure-anthropic') {

‎apps/sim/executor/execution/block-executor.retry.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ describe('BlockExecutor retry', () => {
119119

120120
await expect(executor.execute(createContext(state), createNode(block), block)).rejects.toThrow()
121121
expect(execute).toHaveBeenCalledTimes(1)
122+
expect(execute.mock.calls[0][3]).not.toHaveProperty('retry')
122123
})
123124

124125
it('tells each try where it sits in the policy, and a block without one nothing', async () => {
@@ -151,6 +152,29 @@ describe('BlockExecutor retry', () => {
151152
expect(executePlain.mock.calls[0][3]).not.toHaveProperty('retry')
152153
})
153154

155+
it('hands the same try position to a handler that takes the node', async () => {
156+
const block = createBlock({ enabled: true, maxTries: 2, waitBetweenTriesMs: 0 })
157+
const executeWithNode = vi
158+
.fn()
159+
.mockRejectedValueOnce(new Error('one'))
160+
.mockResolvedValueOnce({ ok: true })
161+
const execute = vi.fn()
162+
const state = new ExecutionState()
163+
const executor = buildExecutor(
164+
block,
165+
{ canHandle: () => true, execute, executeWithNode },
166+
state
167+
)
168+
169+
await executor.execute(createContext(state), createNode(block), block)
170+
171+
expect(execute).not.toHaveBeenCalled()
172+
expect(executeWithNode.mock.calls.map(([, , , metadata]) => metadata.retry)).toEqual([
173+
{ attempt: 1, maxTries: 2, isFinalTry: false },
174+
{ attempt: 2, maxTries: 2, isFinalTry: true },
175+
])
176+
})
177+
154178
it('replays any failure and succeeds on a later try', async () => {
155179
const block = createBlock(enabled)
156180
const execute = vi

‎apps/sim/executor/execution/block-executor.ts‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -569,12 +569,9 @@ export class BlockExecutor {
569569
try {
570570
for (;;) {
571571
tries++
572+
const isFinalTry = tries >= policy.maxTries
572573
try {
573-
const output = await invoke({
574-
attempt: tries,
575-
maxTries: policy.maxTries,
576-
isFinalTry: tries >= policy.maxTries,
577-
})
574+
const output = await invoke({ attempt: tries, maxTries: policy.maxTries, isFinalTry })
578575
if (!shouldAccumulateFunctionCost || !accumulatedFunctionCost || !isRecordLike(output)) {
579576
return output
580577
}
@@ -596,7 +593,6 @@ export class BlockExecutor {
596593
)
597594
}
598595

599-
const isFinalTry = tries >= policy.maxTries
600596
if (isFinalTry || ctx.abortSignal?.aborted || !isRetryableBlockError(error)) {
601597
attachTrustedExecutionCost(error, accumulatedFunctionCost)
602598
throw error

0 commit comments

Comments
 (0)