Skip to content

Commit 6bdb85f

Browse files
committed
improvement(executor): run the Agent block on the shared model-fallback walk
1 parent 90f5b00 commit 6bdb85f

5 files changed

Lines changed: 533 additions & 334 deletions

File tree

‎apps/sim/executor/handlers/agent/handler.test.ts‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { AgentBlockHandler } from '@/executor/handlers/agent/handler'
4343
import * as agentMemory from '@/executor/handlers/agent/memory'
4444
import type { AgentInputs, Message } from '@/executor/handlers/agent/types'
4545
import type { ExecutionContext, StreamingExecution } from '@/executor/types'
46+
import { projectResolvedSecretDiagnosticError } from '@/executor/utils/resolved-secret-content-projection'
4647
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
4748
import {
4849
getEncryptedConversationMessage,
@@ -1303,6 +1304,111 @@ describe('AgentBlockHandler', () => {
13031304
expect(blockLog).toMatchObject({ modelFallbacks: ['gpt-4o'] })
13041305
})
13051306

1307+
describe('a fallback whose provider cannot take the attached file', () => {
1308+
const attachment = {
1309+
id: 'file-1',
1310+
name: 'example.png',
1311+
key: 'execution/test-workspace/test-workflow/exec-1/example.png',
1312+
url: 'https://storage.example.com/expired',
1313+
size: 8,
1314+
type: 'image/png',
1315+
context: 'execution',
1316+
base64: 'iVBORw0KGgo=',
1317+
}
1318+
1319+
/** A secret only the primary's tool call activates, so only its failed attempt's registries know it. */
1320+
const contextWithToolSecretCatalog = (blockLog: ReturnType<typeof openLog>) => ({
1321+
...mockContext,
1322+
executionId: 'exec-1',
1323+
blockLogs: [blockLog],
1324+
resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry([
1325+
{ name: 'TOKEN', plaintext: 'tool-secret', encryptedValue: 'encrypted-tool-secret' },
1326+
]),
1327+
})
1328+
1329+
const inputsWithAttachment = (fallbackModels: Array<{ model: string }>) => ({
1330+
...baseInputs,
1331+
files: [attachment],
1332+
fallbackModels,
1333+
})
1334+
1335+
let hydrate: ReturnType<typeof vi.spyOn>
1336+
const primaryError = new Error('overloaded after reading tool-secret')
1337+
1338+
beforeEach(() => {
1339+
mockGetProviderFromModel.mockImplementation((model: string) =>
1340+
model.startsWith('deepseek') ? 'deepseek' : providerFor(model)
1341+
)
1342+
hydrate = vi
1343+
.spyOn(userFileBase64, 'hydrateUserFilesWithBase64')
1344+
.mockImplementation(async (files) => files)
1345+
mockExecuteProviderRequest.mockImplementationOnce(async (_provider, _request, options) => {
1346+
const runtimeRegistry = options.resolvedSecretTraceRegistry as ResolvedSecretTraceRegistry
1347+
runtimeRegistry.recordResolved('TOKEN', 'tool-secret', { propagated: true })
1348+
throw primaryError
1349+
})
1350+
})
1351+
1352+
afterEach(() => {
1353+
hydrate.mockRestore()
1354+
})
1355+
1356+
it("is skipped, and the primary's error rethrows against its failed attempt's registries", async () => {
1357+
const blockLog = openLog()
1358+
blockLog.modelFallbacks = ['stale-from-earlier-run']
1359+
const ctx = contextWithToolSecretCatalog(blockLog)
1360+
1361+
await expect(
1362+
handler.execute(ctx, mockBlock, inputsWithAttachment([{ model: 'deepseek-chat' }]))
1363+
).rejects.toBe(primaryError)
1364+
1365+
expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(1)
1366+
/** Only the final error failed, and a skip is not a failed try, so the log records nothing. */
1367+
expect(blockLog.modelFallbacks).toBeUndefined()
1368+
for (const registry of [
1369+
ctx.errorResolvedSecretTraceRegistry,
1370+
ctx.resolvedSecretTraceRegistry,
1371+
]) {
1372+
expect(projectResolvedSecretDiagnosticError(primaryError, registry)).toMatchObject({
1373+
error: 'overloaded after reading {{TOKEN}}',
1374+
})
1375+
}
1376+
})
1377+
1378+
it('is skipped for a later fallback that starts from the settled registries and answers', async () => {
1379+
const blockLog = openLog()
1380+
const ctx = contextWithToolSecretCatalog(blockLog)
1381+
const fallbackRegistries: Array<ResolvedSecretTraceRegistry | undefined> = []
1382+
mockExecuteProviderRequest.mockImplementationOnce(async (_provider, _request, options) => {
1383+
const executionContext = options.executionContext as ExecutionContext
1384+
fallbackRegistries.push(
1385+
executionContext.errorResolvedSecretTraceRegistry,
1386+
executionContext.resolvedSecretTraceRegistry
1387+
)
1388+
return providerResponse('claude-sonnet-5', 'from fallback')
1389+
})
1390+
1391+
const result = await handler.execute(
1392+
ctx,
1393+
mockBlock,
1394+
inputsWithAttachment([{ model: 'deepseek-chat' }, { model: 'claude-sonnet-5' }])
1395+
)
1396+
1397+
expect(mockExecuteProviderRequest.mock.calls.map(([provider]) => provider)).toEqual([
1398+
'openai',
1399+
'anthropic',
1400+
])
1401+
expect((result as { model: string }).model).toBe('claude-sonnet-5')
1402+
/** Only the primary's tool call activated the secret, so registries from the settled inputs leave it unredacted. */
1403+
expect(fallbackRegistries).toHaveLength(2)
1404+
for (const registry of fallbackRegistries) {
1405+
expect(projectResolvedSecretDiagnosticError(primaryError, registry)).toMatchObject({
1406+
error: 'overloaded after reading tool-secret',
1407+
})
1408+
}
1409+
})
1410+
})
1411+
13061412
it('skips sim-auto, duplicates, the primary itself, and unusable providers', async () => {
13071413
mockExecuteProviderRequest
13081414
.mockRejectedValueOnce(new Error('down'))

0 commit comments

Comments
 (0)