Repository Public
1/**2 * Step bodies sent to the browser are bounded (CONTRACT §8: 20 000 chars each, `truncated`3 * flag). The JSON STRUCTURE is preserved so the console can still render a tool call or a model4 * turn: long strings are cut, and once the budget is spent the remaining array items / object5 * keys are dropped and replaced by a marker.6 */7export const STEP_BODY_MAX_CHARS = 20_000;8const MAX_DEPTH = 40;910/** `scrub` runs on every string BEFORE it is cut, so a secret straddling the cut cannot survive as a prefix. */11export function truncateBody(value: unknown, maxChars = STEP_BODY_MAX_CHARS, scrub: (s: string) => string = (s) => s): { value: unknown; truncated: boolean } {12 let left = maxChars;13 let truncated = false;14 const walk = (raw: unknown, depth: number): unknown => {15 const v = typeof raw === 'string' ? scrub(raw) : raw;16 if (typeof v === 'string') {17 if (v.length <= left) {18 left -= v.length;19 return v;20 }21 truncated = true;22 const kept = v.slice(0, Math.max(left, 0));23 left = 0;24 return `${kept}…[truncated ${v.length - kept.length} chars]`;25 }26 if (v === null || typeof v !== 'object') {27 left -= 8;28 return v;29 }30 if (depth >= MAX_DEPTH) {31 truncated = true;32 return '…[truncated: too deep]';33 }34 if (Array.isArray(v)) {35 const out: unknown[] = [];36 for (let i = 0; i < v.length; i++) {37 if (left <= 0) {38 truncated = true;39 out.push(`…[truncated ${v.length - i} more items]`);40 break;41 }42 left -= 2;43 out.push(walk(v[i], depth + 1));44 }45 return out;46 }47 const out: Record<string, unknown> = {};48 const keys = Object.keys(v);49 for (let i = 0; i < keys.length; i++) {50 const k = keys[i] as string;51 if (left <= 0) {52 truncated = true;53 out.__truncated = `${keys.length - i} more keys`;54 break;55 }56 left -= k.length + 4;57 out[k.length > 200 ? `${k.slice(0, 200)}…` : k] = walk((v as Record<string, unknown>)[k], depth + 1);58 }59 return out;60 };61 const out = walk(value, 0);62 return { value: out, truncated };63}64