Skip to content

Commit 37f1141

Browse files
authored
fix(terminal): truncate console values by size and cycles, not nesting depth (#4924)
normalizeConsoleValue replaced any value at depth >= 6 with [Truncated object], discarding tiny payloads solely for their position in the tree (agent tool-call rows sit at exactly depth 6). The depth cap was also the only guard against infinite recursion on circular structures. Add a path-tracked WeakSet so true ancestor cycles resolve to [Circular] while values shared across sibling positions still render fully, and raise MAX_DEPTH to 12 as a pathological-nesting backstop. Actual size stays bounded by the existing 50KB string cap and 256KB byte cap.
1 parent 24f0416 commit 37f1141

2 files changed

Lines changed: 102 additions & 22 deletions

File tree

apps/sim/stores/terminal/console/utils.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,61 @@ describe('terminal console utils', () => {
3434
expect(result).toContain('"name": "root"')
3535
})
3636

37+
it('preserves small objects nested at the agent tool-call depth', () => {
38+
const output = normalizeConsoleOutput({
39+
toolCalls: {
40+
list: [
41+
{
42+
name: 'table_query_rows',
43+
result: {
44+
rows: [{ data: { deal_id: 'DEAL-001', client_name: 'Jennifer Martinez' } }],
45+
},
46+
},
47+
],
48+
},
49+
}) as {
50+
toolCalls: { list: Array<{ result: { rows: Array<{ data: Record<string, unknown> }> } }> }
51+
}
52+
53+
const row = output.toolCalls.list[0].result.rows[0]
54+
expect(row).not.toBe('[Truncated object]')
55+
expect(row.data.deal_id).toBe('DEAL-001')
56+
expect(row.data.client_name).toBe('Jennifer Martinez')
57+
})
58+
59+
it('resolves true circular references without infinite recursion', () => {
60+
const circular: { name: string; self?: unknown } = { name: 'root' }
61+
circular.self = circular
62+
63+
const output = normalizeConsoleOutput(circular) as { name: string; self: unknown }
64+
65+
expect(output.name).toBe('root')
66+
expect(output.self).toBe('[Circular]')
67+
})
68+
69+
it('renders a value shared across sibling positions fully (not circular)', () => {
70+
const shared = { x: 1 }
71+
const output = normalizeConsoleOutput({ a: shared, b: shared }) as {
72+
a: { x: number }
73+
b: { x: number }
74+
}
75+
76+
expect(output.a).toEqual({ x: 1 })
77+
expect(output.b).toEqual({ x: 1 })
78+
})
79+
80+
it('truncates structures nested beyond MAX_DEPTH as a backstop', () => {
81+
let deep: Record<string, unknown> = { value: 'leaf' }
82+
for (let i = 0; i < TERMINAL_CONSOLE_LIMITS.MAX_DEPTH + 2; i++) {
83+
deep = { nested: deep }
84+
}
85+
86+
const serialized = safeConsoleStringify(normalizeConsoleOutput(deep))
87+
88+
expect(serialized).toContain('[Truncated object]')
89+
expect(serialized).not.toContain('leaf')
90+
})
91+
3792
it('truncates oversized nested strings in console output', () => {
3893
const output = normalizeConsoleOutput({
3994
stdout: 'x'.repeat(TERMINAL_CONSOLE_LIMITS.MAX_STRING_LENGTH + 100),

apps/sim/stores/terminal/console/utils.ts

Lines changed: 47 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export const TERMINAL_CONSOLE_LIMITS = {
99
MAX_STRING_LENGTH: 50_000,
1010
MAX_OBJECT_KEYS: 100,
1111
MAX_ARRAY_ITEMS: 100,
12-
MAX_DEPTH: 6,
12+
MAX_DEPTH: 12,
1313
MAX_SERIALIZED_BYTES: 256 * 1024,
1414
MAX_SERIALIZED_PREVIEW_LENGTH: 10_000,
1515
} as const
@@ -92,8 +92,18 @@ export function safeConsoleStringify(value: unknown): string {
9292

9393
/**
9494
* Produces a terminal-safe representation of any value.
95+
*
96+
* Recursion is bounded by two independent guards: `seen` tracks the current
97+
* ancestor chain so true circular references resolve to `[Circular]` (a value
98+
* reused across sibling positions is not a cycle and renders fully), and
99+
* `MAX_DEPTH` is a pathological-nesting backstop. Actual payload size is bounded
100+
* downstream by `truncateString` and `capNormalizedValue`, not by depth.
95101
*/
96-
export function normalizeConsoleValue(value: unknown, depth = 0): unknown {
102+
export function normalizeConsoleValue(
103+
value: unknown,
104+
depth = 0,
105+
seen: WeakSet<object> = new WeakSet()
106+
): unknown {
97107
if (value === null || value === undefined) {
98108
return value
99109
}
@@ -130,33 +140,48 @@ export function normalizeConsoleValue(value: unknown, depth = 0): unknown {
130140
return `[Truncated ${Array.isArray(value) ? 'array' : 'object'}]`
131141
}
132142

133-
if (Array.isArray(value)) {
134-
const normalizedItems = value
135-
.slice(0, TERMINAL_CONSOLE_LIMITS.MAX_ARRAY_ITEMS)
136-
.map((item) => normalizeConsoleValue(item, depth + 1))
143+
const objectValue = value as object
137144

138-
if (value.length > TERMINAL_CONSOLE_LIMITS.MAX_ARRAY_ITEMS) {
139-
normalizedItems.push(
140-
`[... truncated ${value.length - TERMINAL_CONSOLE_LIMITS.MAX_ARRAY_ITEMS} items]`
141-
)
145+
if (seen.has(objectValue)) {
146+
return '[Circular]'
147+
}
148+
149+
seen.add(objectValue)
150+
151+
try {
152+
if (Array.isArray(value)) {
153+
const normalizedItems = value
154+
.slice(0, TERMINAL_CONSOLE_LIMITS.MAX_ARRAY_ITEMS)
155+
.map((item) => normalizeConsoleValue(item, depth + 1, seen))
156+
157+
if (value.length > TERMINAL_CONSOLE_LIMITS.MAX_ARRAY_ITEMS) {
158+
normalizedItems.push(
159+
`[... truncated ${value.length - TERMINAL_CONSOLE_LIMITS.MAX_ARRAY_ITEMS} items]`
160+
)
161+
}
162+
163+
return normalizedItems
142164
}
143165

144-
return normalizedItems
145-
}
166+
const objectEntries = Object.entries(value as Record<string, unknown>)
167+
const normalizedObject: Record<string, unknown> = {}
146168

147-
const objectEntries = Object.entries(value as Record<string, unknown>)
148-
const normalizedObject: Record<string, unknown> = {}
169+
for (const [key, entryValue] of objectEntries.slice(
170+
0,
171+
TERMINAL_CONSOLE_LIMITS.MAX_OBJECT_KEYS
172+
)) {
173+
normalizedObject[key] = normalizeConsoleValue(entryValue, depth + 1, seen)
174+
}
149175

150-
for (const [key, entryValue] of objectEntries.slice(0, TERMINAL_CONSOLE_LIMITS.MAX_OBJECT_KEYS)) {
151-
normalizedObject[key] = normalizeConsoleValue(entryValue, depth + 1)
152-
}
176+
if (objectEntries.length > TERMINAL_CONSOLE_LIMITS.MAX_OBJECT_KEYS) {
177+
normalizedObject.__simTruncatedKeys =
178+
objectEntries.length - TERMINAL_CONSOLE_LIMITS.MAX_OBJECT_KEYS
179+
}
153180

154-
if (objectEntries.length > TERMINAL_CONSOLE_LIMITS.MAX_OBJECT_KEYS) {
155-
normalizedObject.__simTruncatedKeys =
156-
objectEntries.length - TERMINAL_CONSOLE_LIMITS.MAX_OBJECT_KEYS
181+
return normalizedObject
182+
} finally {
183+
seen.delete(objectValue)
157184
}
158-
159-
return normalizedObject
160185
}
161186

162187
/**

0 commit comments

Comments
 (0)