mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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.
This commit is contained in:
@@ -34,6 +34,61 @@ describe('terminal console utils', () => {
|
||||
expect(result).toContain('"name": "root"')
|
||||
})
|
||||
|
||||
it('preserves small objects nested at the agent tool-call depth', () => {
|
||||
const output = normalizeConsoleOutput({
|
||||
toolCalls: {
|
||||
list: [
|
||||
{
|
||||
name: 'table_query_rows',
|
||||
result: {
|
||||
rows: [{ data: { deal_id: 'DEAL-001', client_name: 'Jennifer Martinez' } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}) as {
|
||||
toolCalls: { list: Array<{ result: { rows: Array<{ data: Record<string, unknown> }> } }> }
|
||||
}
|
||||
|
||||
const row = output.toolCalls.list[0].result.rows[0]
|
||||
expect(row).not.toBe('[Truncated object]')
|
||||
expect(row.data.deal_id).toBe('DEAL-001')
|
||||
expect(row.data.client_name).toBe('Jennifer Martinez')
|
||||
})
|
||||
|
||||
it('resolves true circular references without infinite recursion', () => {
|
||||
const circular: { name: string; self?: unknown } = { name: 'root' }
|
||||
circular.self = circular
|
||||
|
||||
const output = normalizeConsoleOutput(circular) as { name: string; self: unknown }
|
||||
|
||||
expect(output.name).toBe('root')
|
||||
expect(output.self).toBe('[Circular]')
|
||||
})
|
||||
|
||||
it('renders a value shared across sibling positions fully (not circular)', () => {
|
||||
const shared = { x: 1 }
|
||||
const output = normalizeConsoleOutput({ a: shared, b: shared }) as {
|
||||
a: { x: number }
|
||||
b: { x: number }
|
||||
}
|
||||
|
||||
expect(output.a).toEqual({ x: 1 })
|
||||
expect(output.b).toEqual({ x: 1 })
|
||||
})
|
||||
|
||||
it('truncates structures nested beyond MAX_DEPTH as a backstop', () => {
|
||||
let deep: Record<string, unknown> = { value: 'leaf' }
|
||||
for (let i = 0; i < TERMINAL_CONSOLE_LIMITS.MAX_DEPTH + 2; i++) {
|
||||
deep = { nested: deep }
|
||||
}
|
||||
|
||||
const serialized = safeConsoleStringify(normalizeConsoleOutput(deep))
|
||||
|
||||
expect(serialized).toContain('[Truncated object]')
|
||||
expect(serialized).not.toContain('leaf')
|
||||
})
|
||||
|
||||
it('truncates oversized nested strings in console output', () => {
|
||||
const output = normalizeConsoleOutput({
|
||||
stdout: 'x'.repeat(TERMINAL_CONSOLE_LIMITS.MAX_STRING_LENGTH + 100),
|
||||
|
||||
@@ -9,7 +9,7 @@ export const TERMINAL_CONSOLE_LIMITS = {
|
||||
MAX_STRING_LENGTH: 50_000,
|
||||
MAX_OBJECT_KEYS: 100,
|
||||
MAX_ARRAY_ITEMS: 100,
|
||||
MAX_DEPTH: 6,
|
||||
MAX_DEPTH: 12,
|
||||
MAX_SERIALIZED_BYTES: 256 * 1024,
|
||||
MAX_SERIALIZED_PREVIEW_LENGTH: 10_000,
|
||||
} as const
|
||||
@@ -92,8 +92,18 @@ export function safeConsoleStringify(value: unknown): string {
|
||||
|
||||
/**
|
||||
* Produces a terminal-safe representation of any value.
|
||||
*
|
||||
* Recursion is bounded by two independent guards: `seen` tracks the current
|
||||
* ancestor chain so true circular references resolve to `[Circular]` (a value
|
||||
* reused across sibling positions is not a cycle and renders fully), and
|
||||
* `MAX_DEPTH` is a pathological-nesting backstop. Actual payload size is bounded
|
||||
* downstream by `truncateString` and `capNormalizedValue`, not by depth.
|
||||
*/
|
||||
export function normalizeConsoleValue(value: unknown, depth = 0): unknown {
|
||||
export function normalizeConsoleValue(
|
||||
value: unknown,
|
||||
depth = 0,
|
||||
seen: WeakSet<object> = new WeakSet()
|
||||
): unknown {
|
||||
if (value === null || value === undefined) {
|
||||
return value
|
||||
}
|
||||
@@ -130,33 +140,48 @@ export function normalizeConsoleValue(value: unknown, depth = 0): unknown {
|
||||
return `[Truncated ${Array.isArray(value) ? 'array' : 'object'}]`
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const normalizedItems = value
|
||||
.slice(0, TERMINAL_CONSOLE_LIMITS.MAX_ARRAY_ITEMS)
|
||||
.map((item) => normalizeConsoleValue(item, depth + 1))
|
||||
const objectValue = value as object
|
||||
|
||||
if (value.length > TERMINAL_CONSOLE_LIMITS.MAX_ARRAY_ITEMS) {
|
||||
normalizedItems.push(
|
||||
`[... truncated ${value.length - TERMINAL_CONSOLE_LIMITS.MAX_ARRAY_ITEMS} items]`
|
||||
)
|
||||
if (seen.has(objectValue)) {
|
||||
return '[Circular]'
|
||||
}
|
||||
|
||||
seen.add(objectValue)
|
||||
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
const normalizedItems = value
|
||||
.slice(0, TERMINAL_CONSOLE_LIMITS.MAX_ARRAY_ITEMS)
|
||||
.map((item) => normalizeConsoleValue(item, depth + 1, seen))
|
||||
|
||||
if (value.length > TERMINAL_CONSOLE_LIMITS.MAX_ARRAY_ITEMS) {
|
||||
normalizedItems.push(
|
||||
`[... truncated ${value.length - TERMINAL_CONSOLE_LIMITS.MAX_ARRAY_ITEMS} items]`
|
||||
)
|
||||
}
|
||||
|
||||
return normalizedItems
|
||||
}
|
||||
|
||||
return normalizedItems
|
||||
const objectEntries = Object.entries(value as Record<string, unknown>)
|
||||
const normalizedObject: Record<string, unknown> = {}
|
||||
|
||||
for (const [key, entryValue] of objectEntries.slice(
|
||||
0,
|
||||
TERMINAL_CONSOLE_LIMITS.MAX_OBJECT_KEYS
|
||||
)) {
|
||||
normalizedObject[key] = normalizeConsoleValue(entryValue, depth + 1, seen)
|
||||
}
|
||||
|
||||
if (objectEntries.length > TERMINAL_CONSOLE_LIMITS.MAX_OBJECT_KEYS) {
|
||||
normalizedObject.__simTruncatedKeys =
|
||||
objectEntries.length - TERMINAL_CONSOLE_LIMITS.MAX_OBJECT_KEYS
|
||||
}
|
||||
|
||||
return normalizedObject
|
||||
} finally {
|
||||
seen.delete(objectValue)
|
||||
}
|
||||
|
||||
const objectEntries = Object.entries(value as Record<string, unknown>)
|
||||
const normalizedObject: Record<string, unknown> = {}
|
||||
|
||||
for (const [key, entryValue] of objectEntries.slice(0, TERMINAL_CONSOLE_LIMITS.MAX_OBJECT_KEYS)) {
|
||||
normalizedObject[key] = normalizeConsoleValue(entryValue, depth + 1)
|
||||
}
|
||||
|
||||
if (objectEntries.length > TERMINAL_CONSOLE_LIMITS.MAX_OBJECT_KEYS) {
|
||||
normalizedObject.__simTruncatedKeys =
|
||||
objectEntries.length - TERMINAL_CONSOLE_LIMITS.MAX_OBJECT_KEYS
|
||||
}
|
||||
|
||||
return normalizedObject
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user