Skip to content

Commit e05ac7e

Browse files
committed
fix(executor): guard loop and agent logs against memory growth
Long-running loops with agent blocks can accumulate large in-memory structures (tool call payloads, loop iteration aggregates, and block logs) until the process runs out of memory. This adds bounded retention + compaction for agent tool calls, caps loop iteration result retention with explicit truncation metadata, and limits block log growth while preserving recent execution details. Fixes #2525
1 parent 0d86ea0 commit e05ac7e

File tree

9 files changed

+334
-9
lines changed

9 files changed

+334
-9
lines changed

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,15 @@ import {
3737
import { streamingResponseFormatProcessor } from '@/executor/utils'
3838
import { buildBlockExecutionError, normalizeError } from '@/executor/utils/errors'
3939
import { isJSONString } from '@/executor/utils/json'
40+
import { retainTailInPlace } from '@/executor/utils/memory'
4041
import { filterOutputForLog } from '@/executor/utils/output-filter'
4142
import type { VariableResolver } from '@/executor/variables/resolver'
4243
import type { SerializedBlock } from '@/serializer/types'
4344
import type { SubflowType } from '@/stores/workflows/workflow/types'
4445
import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants'
4546

4647
const logger = createLogger('BlockExecutor')
48+
const BLOCK_LOGS_RETAINED_LIMIT = 5000
4749

4850
export class BlockExecutor {
4951
constructor(
@@ -74,6 +76,14 @@ export class BlockExecutor {
7476
if (!isSentinel) {
7577
blockLog = this.createBlockLog(ctx, node.id, block, node)
7678
ctx.blockLogs.push(blockLog)
79+
const { dropped } = retainTailInPlace(ctx.blockLogs, BLOCK_LOGS_RETAINED_LIMIT)
80+
if (dropped > 0) {
81+
const prevDropped = ctx.metadata.logTruncation?.dropped ?? 0
82+
ctx.metadata.logTruncation = {
83+
dropped: prevDropped + dropped,
84+
limit: BLOCK_LOGS_RETAINED_LIMIT,
85+
}
86+
}
7787
this.callOnBlockStart(ctx, node, block, blockLog.executionOrder)
7888
}
7989

apps/sim/executor/execution/state.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ export interface LoopScope {
88
iteration: number
99
currentIterationOutputs: Map<string, NormalizedBlockOutput>
1010
allIterationOutputs: NormalizedBlockOutput[][]
11+
/**
12+
* Number of iteration result entries dropped from the head of `allIterationOutputs`
13+
* due to retention limits.
14+
*/
15+
allIterationOutputsDroppedCount?: number
16+
/**
17+
* Max number of iterations retained in `allIterationOutputs` (tail retention).
18+
*/
19+
allIterationOutputsLimit?: number
1120
maxIterations?: number
1221
item?: any
1322
items?: any[]

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1428,6 +1428,43 @@ describe('AgentBlockHandler', () => {
14281428
expect((result as any).toolCalls.list[1].result.success).toBe(true)
14291429
})
14301430

1431+
it('should truncate toolCalls list and compact payloads when many tool calls are returned', async () => {
1432+
const toolCalls = Array.from({ length: 60 }, (_, i) => ({
1433+
name: `tool_${i}`,
1434+
startTime: new Date().toISOString(),
1435+
endTime: new Date().toISOString(),
1436+
duration: 1,
1437+
arguments: { index: i, payload: 'a'.repeat(5000) },
1438+
result: 'b'.repeat(5000),
1439+
}))
1440+
1441+
mockExecuteProviderRequest.mockResolvedValueOnce({
1442+
content: 'Tool call heavy response',
1443+
model: 'gpt-4o',
1444+
tokens: { input: 10, output: 15, total: 25 },
1445+
toolCalls,
1446+
timing: { total: 100 },
1447+
})
1448+
1449+
const result = await handler.execute(mockContext, mockBlock, {
1450+
model: 'gpt-4o',
1451+
userPrompt: 'Do lots of tool calls',
1452+
apiKey: 'test-api-key',
1453+
})
1454+
1455+
expect((result as any).toolCalls.count).toBe(60)
1456+
expect((result as any).toolCalls.list).toHaveLength(50)
1457+
expect((result as any).toolCalls.truncated).toBe(true)
1458+
expect((result as any).toolCalls.omitted).toBe(10)
1459+
expect((result as any).toolCalls.limit).toBe(50)
1460+
1461+
expect((result as any).toolCalls.list[0].name).toBe('tool_10')
1462+
expect((result as any).toolCalls.list[49].name).toBe('tool_59')
1463+
1464+
expect((result as any).toolCalls.list[0].result).toContain('[truncated')
1465+
expect((result as any).toolCalls.list[0].arguments.payload).toContain('[truncated')
1466+
})
1467+
14311468
it('should handle MCP tool execution errors', async () => {
14321469
mockExecuteTool.mockImplementation((toolId, params) => {
14331470
if (toolId === 'mcp-server1-failing_tool') {

apps/sim/executor/handlers/agent/agent-handler.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import type { BlockHandler, ExecutionContext, StreamingExecution } from '@/execu
3030
import { collectBlockData } from '@/executor/utils/block-data'
3131
import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http'
3232
import { stringifyJSON } from '@/executor/utils/json'
33+
import { compactValue } from '@/executor/utils/memory'
3334
import { executeProviderRequest } from '@/providers'
3435
import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
3536
import type { SerializedBlock } from '@/serializer/types'
@@ -38,6 +39,8 @@ import { getTool, getToolAsync } from '@/tools/utils'
3839

3940
const logger = createLogger('AgentBlockHandler')
4041

42+
const AGENT_TOOL_CALLS_LIST_LIMIT = 50
43+
4144
/**
4245
* Handler for Agent blocks that process LLM requests with optional tools.
4346
*/
@@ -1294,32 +1297,53 @@ export class AgentBlockHandler implements BlockHandler {
12941297
timing?: any
12951298
cost?: any
12961299
}) {
1300+
const toolCalls = Array.isArray(result.toolCalls) ? result.toolCalls : []
1301+
const totalToolCalls = toolCalls.length
1302+
const slicedToolCalls =
1303+
totalToolCalls > AGENT_TOOL_CALLS_LIST_LIMIT
1304+
? toolCalls.slice(-AGENT_TOOL_CALLS_LIST_LIMIT)
1305+
: toolCalls
1306+
const omittedToolCalls = totalToolCalls - slicedToolCalls.length
1307+
12971308
return {
12981309
tokens: result.tokens || {
12991310
input: DEFAULTS.TOKENS.PROMPT,
13001311
output: DEFAULTS.TOKENS.COMPLETION,
13011312
total: DEFAULTS.TOKENS.TOTAL,
13021313
},
13031314
toolCalls: {
1304-
list: result.toolCalls?.map(this.formatToolCall.bind(this)) || [],
1305-
count: result.toolCalls?.length || DEFAULTS.EXECUTION_TIME,
1315+
list: slicedToolCalls.map(this.formatToolCall.bind(this)),
1316+
count: totalToolCalls,
1317+
...(omittedToolCalls > 0
1318+
? {
1319+
omitted: omittedToolCalls,
1320+
truncated: true,
1321+
limit: AGENT_TOOL_CALLS_LIST_LIMIT,
1322+
}
1323+
: {}),
13061324
},
13071325
providerTiming: result.timing,
13081326
cost: result.cost,
13091327
}
13101328
}
13111329

13121330
private formatToolCall(tc: any) {
1313-
const toolName = stripCustomToolPrefix(tc.name)
1331+
const toolName = stripCustomToolPrefix(typeof tc?.name === 'string' ? tc.name : '')
1332+
const compactOptions = {
1333+
maxDepth: 6,
1334+
maxStringLength: 2000,
1335+
maxArrayLength: 30,
1336+
maxObjectKeys: 80,
1337+
} as const
13141338

13151339
return {
1316-
...tc,
13171340
name: toolName,
13181341
startTime: tc.startTime,
13191342
endTime: tc.endTime,
13201343
duration: tc.duration,
1321-
arguments: tc.arguments || tc.input || {},
1322-
result: tc.result || tc.output,
1344+
arguments: compactValue(tc.arguments || tc.input || {}, compactOptions),
1345+
result: compactValue(tc.result || tc.output, compactOptions),
1346+
...(tc?.error ? { error: compactValue(tc.error, compactOptions) } : {}),
13231347
}
13241348
}
13251349
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { loggerMock } from '@sim/testing'
2+
import { describe, expect, it, vi } from 'vitest'
3+
import { LoopOrchestrator } from '@/executor/orchestrators/loop'
4+
5+
vi.mock('@sim/logger', () => loggerMock)
6+
7+
describe('LoopOrchestrator retention', () => {
8+
it('retains only the tail of allIterationOutputs and tracks dropped count', async () => {
9+
const state = { setBlockOutput: vi.fn() } as any
10+
const orchestrator = new LoopOrchestrator({} as any, state, {} as any)
11+
12+
vi.spyOn(orchestrator as any, 'evaluateCondition').mockResolvedValue(true)
13+
14+
const scope: any = {
15+
iteration: 0,
16+
currentIterationOutputs: new Map(),
17+
allIterationOutputs: [],
18+
allIterationOutputsDroppedCount: 0,
19+
allIterationOutputsLimit: 3,
20+
}
21+
22+
const ctx: any = {
23+
loopExecutions: new Map([['loop-1', scope]]),
24+
metadata: { duration: 0 },
25+
}
26+
27+
for (let i = 0; i < 5; i++) {
28+
scope.currentIterationOutputs.set('block', { i })
29+
const result = await orchestrator.evaluateLoopContinuation(ctx, 'loop-1')
30+
expect(result.shouldContinue).toBe(true)
31+
}
32+
33+
expect(scope.allIterationOutputs).toHaveLength(3)
34+
expect(scope.allIterationOutputsDroppedCount).toBe(2)
35+
expect(scope.allIterationOutputs[0][0]).toEqual({ i: 2 })
36+
expect(scope.allIterationOutputs[2][0]).toEqual({ i: 4 })
37+
})
38+
39+
it('includes truncation metadata in exit output', () => {
40+
const state = { setBlockOutput: vi.fn() } as any
41+
const orchestrator = new LoopOrchestrator({} as any, state, {} as any)
42+
43+
const scope: any = {
44+
iteration: 0,
45+
currentIterationOutputs: new Map(),
46+
allIterationOutputs: [[{ a: 1 }]],
47+
allIterationOutputsDroppedCount: 5,
48+
allIterationOutputsLimit: 1,
49+
}
50+
51+
const ctx: any = {
52+
metadata: { duration: 0 },
53+
}
54+
55+
const result = (orchestrator as any).createExitResult(ctx, 'loop-1', scope)
56+
57+
expect(result.resultsTruncated).toBe(true)
58+
expect(result.totalIterations).toBe(6)
59+
expect(result.droppedIterations).toBe(5)
60+
expect(result.retainedIterations).toBe(1)
61+
expect(result.retentionLimit).toBe(1)
62+
63+
expect(state.setBlockOutput).toHaveBeenCalledWith(
64+
'loop-1',
65+
expect.objectContaining({
66+
results: scope.allIterationOutputs,
67+
resultsTruncated: true,
68+
totalIterations: 6,
69+
droppedIterations: 5,
70+
retainedIterations: 1,
71+
retentionLimit: 1,
72+
}),
73+
expect.any(Number)
74+
)
75+
})
76+
})

apps/sim/executor/orchestrators/loop.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
type NormalizedBlockOutput,
1414
} from '@/executor/types'
1515
import type { LoopConfigWithNodes } from '@/executor/types/loop'
16+
import { retainTailInPlace } from '@/executor/utils/memory'
1617
import { replaceValidReferences } from '@/executor/utils/reference-validation'
1718
import {
1819
addSubflowErrorLog,
@@ -28,6 +29,7 @@ import type { SerializedLoop } from '@/serializer/types'
2829
const logger = createLogger('LoopOrchestrator')
2930

3031
const LOOP_CONDITION_TIMEOUT_MS = 5000
32+
const LOOP_RESULTS_RETAINED_LIMIT = 100
3133

3234
export type LoopRoute = typeof EDGE.LOOP_CONTINUE | typeof EDGE.LOOP_EXIT
3335

@@ -36,6 +38,11 @@ export interface LoopContinuationResult {
3638
shouldExit: boolean
3739
selectedRoute: LoopRoute
3840
aggregatedResults?: NormalizedBlockOutput[][]
41+
totalIterations?: number
42+
droppedIterations?: number
43+
retainedIterations?: number
44+
retentionLimit?: number
45+
resultsTruncated?: boolean
3946
}
4047

4148
export class LoopOrchestrator {
@@ -65,6 +72,8 @@ export class LoopOrchestrator {
6572
iteration: 0,
6673
currentIterationOutputs: new Map(),
6774
allIterationOutputs: [],
75+
allIterationOutputsDroppedCount: 0,
76+
allIterationOutputsLimit: LOOP_RESULTS_RETAINED_LIMIT,
6877
}
6978

7079
const loopType = loopConfig.loopType
@@ -253,6 +262,12 @@ export class LoopOrchestrator {
253262

254263
if (iterationResults.length > 0) {
255264
scope.allIterationOutputs.push(iterationResults)
265+
const limit = scope.allIterationOutputsLimit ?? LOOP_RESULTS_RETAINED_LIMIT
266+
const { dropped } = retainTailInPlace(scope.allIterationOutputs, limit)
267+
if (dropped > 0) {
268+
scope.allIterationOutputsDroppedCount =
269+
(scope.allIterationOutputsDroppedCount ?? 0) + dropped
270+
}
256271
}
257272

258273
scope.currentIterationOutputs.clear()
@@ -280,7 +295,22 @@ export class LoopOrchestrator {
280295
scope: LoopScope
281296
): LoopContinuationResult {
282297
const results = scope.allIterationOutputs
283-
const output = { results }
298+
const droppedIterations = scope.allIterationOutputsDroppedCount ?? 0
299+
const retentionLimit = scope.allIterationOutputsLimit ?? LOOP_RESULTS_RETAINED_LIMIT
300+
const totalIterations = droppedIterations + results.length
301+
const resultsTruncated = droppedIterations > 0
302+
const output = {
303+
results,
304+
totalIterations,
305+
...(resultsTruncated
306+
? {
307+
resultsTruncated: true,
308+
droppedIterations,
309+
retainedIterations: results.length,
310+
retentionLimit,
311+
}
312+
: {}),
313+
}
284314
this.state.setBlockOutput(loopId, output, DEFAULTS.EXECUTION_TIME)
285315

286316
// Emit onBlockComplete for the loop container so the UI can track it
@@ -300,6 +330,11 @@ export class LoopOrchestrator {
300330
shouldExit: true,
301331
selectedRoute: EDGE.LOOP_EXIT,
302332
aggregatedResults: results,
333+
totalIterations,
334+
droppedIterations: resultsTruncated ? droppedIterations : 0,
335+
retainedIterations: results.length,
336+
retentionLimit,
337+
resultsTruncated,
303338
}
304339
}
305340

apps/sim/executor/orchestrators/node.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import type { BlockStateController } from '@/executor/execution/types'
66
import type { LoopOrchestrator } from '@/executor/orchestrators/loop'
77
import type { ParallelOrchestrator } from '@/executor/orchestrators/parallel'
88
import type { ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
9+
import { compactValue } from '@/executor/utils/memory'
10+
import { filterOutputForLog } from '@/executor/utils/output-filter'
911
import { extractBaseBlockId } from '@/executor/utils/subflow-utils'
1012

1113
const logger = createLogger('NodeExecutionOrchestrator')
@@ -140,7 +142,16 @@ export class NodeExecutionOrchestrator {
140142
shouldContinue: false,
141143
shouldExit: true,
142144
selectedRoute: continuationResult.selectedRoute,
143-
totalIterations: continuationResult.aggregatedResults?.length || 0,
145+
totalIterations:
146+
continuationResult.totalIterations ?? continuationResult.aggregatedResults?.length ?? 0,
147+
...(continuationResult.resultsTruncated
148+
? {
149+
resultsTruncated: true,
150+
droppedIterations: continuationResult.droppedIterations,
151+
retainedIterations: continuationResult.retainedIterations,
152+
retentionLimit: continuationResult.retentionLimit,
153+
}
154+
: {}),
144155
}
145156
}
146157

@@ -233,7 +244,16 @@ export class NodeExecutionOrchestrator {
233244
output: NormalizedBlockOutput,
234245
loopId: string
235246
): void {
236-
this.loopOrchestrator.storeLoopNodeOutput(ctx, loopId, node.id, output)
247+
const blockType = node.block.metadata?.id || ''
248+
const filtered = filterOutputForLog(blockType, output, { block: node.block })
249+
const compacted = compactValue(filtered, {
250+
maxDepth: 6,
251+
maxStringLength: 4000,
252+
maxArrayLength: 50,
253+
maxObjectKeys: 120,
254+
}) as NormalizedBlockOutput
255+
256+
this.loopOrchestrator.storeLoopNodeOutput(ctx, loopId, node.id, compacted)
237257
this.state.setBlockOutput(node.id, output)
238258
}
239259

0 commit comments

Comments
 (0)