Skip to content

Commit aae7c40

Browse files
aadamsxclaude
andcommitted
fix: prevent memory exhaustion in loops with bounded iteration outputs
This change addresses memory exhaustion issues that occur when running workflows with loops containing agent blocks that make many tool calls. ## Problem Memory accumulated unbounded in two key areas: 1. `allIterationOutputs` in LoopScope - every iteration pushed results 2. `blockLogs` in ExecutionContext - every block execution added logs This caused OOM crashes on systems with 64GB+ RAM during long-running workflow executions. ## Solution Added memory management with configurable limits: ### Loop Orchestrator (`loop.ts`) - New `addIterationOutputsWithMemoryLimit()` method - Limits stored iterations to MAX_STORED_ITERATION_OUTPUTS (100) - Monitors memory size with MAX_ITERATION_OUTPUTS_SIZE_BYTES (50MB) - Discards oldest iterations when limits exceeded ### Block Executor (`block-executor.ts`) - New `addBlockLogWithMemoryLimit()` method - Limits stored logs to MAX_BLOCK_LOGS (500) - Monitors memory size with MAX_BLOCK_LOGS_SIZE_BYTES (100MB) - Periodic size checks (every 50 logs) to avoid serialization overhead ### Constants (`constants.ts`) - Added new configurable defaults for all limits ## Trade-offs - Final aggregated results contain only recent iterations - Logs show warning when truncation occurs for debugging Fixes #2525 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 3d9d9cb commit aae7c40

File tree

3 files changed

+137
-2
lines changed

3 files changed

+137
-2
lines changed

apps/sim/executor/constants.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,26 @@ export const DEFAULTS = {
130130
MAX_LOOP_ITERATIONS: 1000,
131131
MAX_WORKFLOW_DEPTH: 10,
132132
EXECUTION_TIME: 0,
133+
/**
134+
* Maximum number of iteration outputs to retain in memory during loop execution.
135+
* Older iterations are discarded to prevent memory exhaustion in long-running loops.
136+
* The final aggregated results will contain only the most recent iterations.
137+
*/
138+
MAX_STORED_ITERATION_OUTPUTS: 100,
139+
/**
140+
* Maximum size in bytes for iteration outputs before triggering truncation.
141+
* This is an approximate estimate based on JSON serialization.
142+
*/
143+
MAX_ITERATION_OUTPUTS_SIZE_BYTES: 50 * 1024 * 1024, // 50MB
144+
/**
145+
* Maximum number of block logs to retain in memory during execution.
146+
* Older logs are discarded to prevent memory exhaustion in long-running workflows.
147+
*/
148+
MAX_BLOCK_LOGS: 500,
149+
/**
150+
* Maximum size in bytes for block logs before triggering truncation.
151+
*/
152+
MAX_BLOCK_LOGS_SIZE_BYTES: 100 * 1024 * 1024, // 100MB
133153
TOKENS: {
134154
PROMPT: 0,
135155
COMPLETION: 0,

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

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export class BlockExecutor {
5959
let blockLog: BlockLog | undefined
6060
if (!isSentinel) {
6161
blockLog = this.createBlockLog(ctx, node.id, block, node)
62-
ctx.blockLogs.push(blockLog)
62+
this.addBlockLogWithMemoryLimit(ctx, blockLog)
6363
this.callOnBlockStart(ctx, node, block)
6464
}
6565

@@ -658,4 +658,54 @@ export class BlockExecutor {
658658

659659
executionOutput.content = fullContent
660660
}
661+
662+
/**
663+
* Adds a block log to the execution context with memory management.
664+
* Prevents unbounded memory growth by:
665+
* 1. Limiting the number of stored logs (MAX_BLOCK_LOGS)
666+
* 2. Checking estimated memory size (MAX_BLOCK_LOGS_SIZE_BYTES)
667+
*
668+
* When limits are exceeded, older logs are discarded to make room for newer ones.
669+
*/
670+
private addBlockLogWithMemoryLimit(ctx: ExecutionContext, blockLog: BlockLog): void {
671+
ctx.blockLogs.push(blockLog)
672+
673+
// Check log count limit
674+
if (ctx.blockLogs.length > DEFAULTS.MAX_BLOCK_LOGS) {
675+
const discardCount = ctx.blockLogs.length - DEFAULTS.MAX_BLOCK_LOGS
676+
ctx.blockLogs = ctx.blockLogs.slice(discardCount)
677+
logger.warn('Block logs exceeded count limit, discarding older logs', {
678+
discardedCount: discardCount,
679+
retainedCount: ctx.blockLogs.length,
680+
maxAllowed: DEFAULTS.MAX_BLOCK_LOGS,
681+
})
682+
}
683+
684+
// Periodically check memory size (every 50 logs to avoid frequent serialization)
685+
if (ctx.blockLogs.length % 50 === 0) {
686+
const estimatedSize = this.estimateBlockLogsSize(ctx.blockLogs)
687+
if (estimatedSize > DEFAULTS.MAX_BLOCK_LOGS_SIZE_BYTES) {
688+
const halfLength = Math.floor(ctx.blockLogs.length / 2)
689+
const discardCount = Math.max(halfLength, 1)
690+
ctx.blockLogs = ctx.blockLogs.slice(discardCount)
691+
logger.warn('Block logs exceeded memory limit, discarding older logs', {
692+
estimatedSizeBytes: estimatedSize,
693+
maxSizeBytes: DEFAULTS.MAX_BLOCK_LOGS_SIZE_BYTES,
694+
discardedCount: discardCount,
695+
retainedCount: ctx.blockLogs.length,
696+
})
697+
}
698+
}
699+
}
700+
701+
/**
702+
* Estimates the memory size of block logs in bytes.
703+
*/
704+
private estimateBlockLogsSize(logs: BlockLog[]): number {
705+
try {
706+
return JSON.stringify(logs).length * 2
707+
} catch {
708+
return DEFAULTS.MAX_BLOCK_LOGS_SIZE_BYTES
709+
}
710+
}
661711
}

apps/sim/executor/orchestrators/loop.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ export class LoopOrchestrator {
141141
}
142142

143143
if (iterationResults.length > 0) {
144-
scope.allIterationOutputs.push(iterationResults)
144+
this.addIterationOutputsWithMemoryLimit(scope, iterationResults, loopId)
145145
}
146146

147147
scope.currentIterationOutputs.clear()
@@ -462,4 +462,69 @@ export class LoopOrchestrator {
462462
return []
463463
}
464464
}
465+
466+
/**
467+
* Adds iteration outputs to the loop scope with memory management.
468+
* Prevents unbounded memory growth by:
469+
* 1. Limiting the number of stored iterations (MAX_STORED_ITERATION_OUTPUTS)
470+
* 2. Checking estimated memory size (MAX_ITERATION_OUTPUTS_SIZE_BYTES)
471+
*
472+
* When limits are exceeded, older iterations are discarded to make room for newer ones.
473+
* This ensures long-running loops don't cause memory exhaustion while still providing
474+
* access to recent iteration results.
475+
*/
476+
private addIterationOutputsWithMemoryLimit(
477+
scope: LoopScope,
478+
iterationResults: NormalizedBlockOutput[],
479+
loopId: string
480+
): void {
481+
scope.allIterationOutputs.push(iterationResults)
482+
483+
// Check iteration count limit
484+
if (scope.allIterationOutputs.length > DEFAULTS.MAX_STORED_ITERATION_OUTPUTS) {
485+
const discardCount = scope.allIterationOutputs.length - DEFAULTS.MAX_STORED_ITERATION_OUTPUTS
486+
scope.allIterationOutputs = scope.allIterationOutputs.slice(discardCount)
487+
logger.warn('Loop iteration outputs exceeded count limit, discarding older iterations', {
488+
loopId,
489+
iteration: scope.iteration,
490+
discardedCount: discardCount,
491+
retainedCount: scope.allIterationOutputs.length,
492+
maxAllowed: DEFAULTS.MAX_STORED_ITERATION_OUTPUTS,
493+
})
494+
}
495+
496+
// Check memory size limit (approximate)
497+
const estimatedSize = this.estimateObjectSize(scope.allIterationOutputs)
498+
if (estimatedSize > DEFAULTS.MAX_ITERATION_OUTPUTS_SIZE_BYTES) {
499+
// Discard oldest half of iterations when memory limit exceeded
500+
const halfLength = Math.floor(scope.allIterationOutputs.length / 2)
501+
const discardCount = Math.max(halfLength, 1)
502+
scope.allIterationOutputs = scope.allIterationOutputs.slice(discardCount)
503+
logger.warn('Loop iteration outputs exceeded memory limit, discarding older iterations', {
504+
loopId,
505+
iteration: scope.iteration,
506+
estimatedSizeBytes: estimatedSize,
507+
maxSizeBytes: DEFAULTS.MAX_ITERATION_OUTPUTS_SIZE_BYTES,
508+
discardedCount: discardCount,
509+
retainedCount: scope.allIterationOutputs.length,
510+
})
511+
}
512+
}
513+
514+
/**
515+
* Estimates the memory size of an object in bytes.
516+
* This is an approximation based on JSON serialization size.
517+
* Actual memory usage may vary due to object overhead and references.
518+
*/
519+
private estimateObjectSize(obj: unknown): number {
520+
try {
521+
// Use JSON.stringify length as a rough estimate
522+
// Multiply by 2 for UTF-16 encoding overhead in JS strings
523+
return JSON.stringify(obj).length * 2
524+
} catch {
525+
// If serialization fails (circular refs, etc.), return a large estimate
526+
// to trigger cleanup as a safety measure
527+
return DEFAULTS.MAX_ITERATION_OUTPUTS_SIZE_BYTES
528+
}
529+
}
465530
}

0 commit comments

Comments
 (0)