blob: 7c9eb79781a2d40545a8499b4a525d4edae33d6f [file]
// packages/runtime/src/tool-output.ts
//
// Shared, model-facing truncation for tool output (Bash stdout/stderr today).
//
// WHY: an unbounded tool result either floods the model's context (a chatty
// command's full output) or, worse, gets discarded outright when a hard byte
// cap is hit (the old Bash behavior threw away *all* output past 10MB, failing
// otherwise-finished work). Both hurt completion reliability. This bounds what
// the model sees to a line/byte budget, keeps the most useful slice, and tells
// the model the output was cut and how to recover the rest.
//
// This helper does not own a spill-file lifecycle. Instead the truncation
// marker points the model at portable recovery — re-run the command (only when
// it is safe to repeat) redirecting to a file, then Read/Grep that file;
// otherwise work from the kept window.
//
// ATTRIBUTION: truncateToolOutput below is adapted from opencode's
// truncate.output() (packages/opencode/src/tool/truncate.ts): same byte+line
// budget and head/tail windowing, minus the file spill + retention machinery.
// Maka adds byte-safe single-line slicing, trailing-newline handling, and the
// recovery hint above.
//
// Source: https://github.com/anomalyco/opencode
// Revision: fc80874f45a595ff6874a4d36b1090f6a64424d2
// License: MIT
// Copyright: Copyright (c) 2025 opencode
//
// Scope: the adapted material only; the rest is Maka source under the
// repository Apache-2.0 license, so there is no whole-file SPDX identifier.
// See LICENSE, THIRD-PARTY COMPONENTS for the notice and the upstream chain.
export interface TruncateToolOutputOptions {
/** Max retained lines before truncation kicks in. Default 2000. */
maxLines?: number;
/** Max retained UTF-8 bytes before truncation kicks in. Default 50KB. */
maxBytes?: number;
/**
* Which end to KEEP. 'head' keeps the start (default, good for generic
* output); 'tail' keeps the end (good for shell logs, where the failing
* summary is usually last).
*/
direction?: 'head' | 'tail';
/** Recovery address when the retained source can be read without re-executing. */
recoveryHint?: string;
}
export interface TruncatedToolOutput {
/** The bounded text, including an inline truncation marker when cut. */
content: string;
/** Whether any content was removed. */
truncated: boolean;
/** How much was removed (lines or bytes — see `unit`). 0 when not truncated. */
removed: number;
/** What `removed` counts. */
unit: 'lines' | 'bytes';
}
const DEFAULT_MAX_LINES = 2000;
const DEFAULT_MAX_BYTES = 50 * 1024;
// Single recovery instruction shared by every "output was omitted" marker (the
// byte/line truncation marker here and the oversized-line drop marker in
// shell-exec). Conditioned on safety so it never encourages repeating a
// side-effecting command. Keep both "safe to re-run" and "side effects" phrasing
// — markers and their tests rely on it.
export const OUTPUT_RECOVERY_HINT =
'If the command is safe to re-run, redirect its output to a file ' +
'(e.g. `cmd > out.txt 2>&1`) then Read or Grep that file for the omitted portion. ' +
'If re-running could repeat side effects, do not.';
function utf8Len(text: string): number {
return Buffer.byteLength(text, 'utf8');
}
/**
* Keep at most `maxBytes` UTF-8 bytes of a single line, from the head or tail.
* Cutting mid-character is avoided: Buffer.toString replaces the partial
* multi-byte sequence at the boundary with U+FFFD, which we strip.
*/
function sliceLineByBytes(line: string, maxBytes: number, keep: 'head' | 'tail'): string {
if (utf8Len(line) <= maxBytes) return line;
// One UTF-16 unit needs at least one UTF-8 byte. The extra unit keeps a
// surrogate pair crossing the window boundary outside the retained bytes.
// Preserve Buffer.subarray's original behavior for unusual numeric budgets.
const window =
Number.isInteger(maxBytes) && maxBytes >= 0
? keep === 'head'
? line.slice(0, maxBytes + 1)
: line.slice(-(maxBytes + 1))
: line;
const buf = Buffer.from(window, 'utf8');
const slice = keep === 'head' ? buf.subarray(0, maxBytes) : buf.subarray(buf.length - maxBytes);
const decoded = slice.toString('utf8');
return keep === 'head' ? decoded.replace(/�+$/, '') : decoded.replace(/^�+/, '');
}
/**
* Bound `text` to a line/byte budget for inclusion in a tool result the model
* reads. Returns the text unchanged when it already fits. When it does not, the
* kept window (head or tail) is returned with an inline marker naming how much
* was dropped and how to recover the omitted portion.
*/
export function truncateToolOutput(
text: string,
options: TruncateToolOutputOptions = {},
): TruncatedToolOutput {
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
const direction = options.direction ?? 'head';
const totalBytes = utf8Len(text);
// A single trailing newline terminates the last line; it is not an extra
// empty line, so it must not count against the line budget.
const body = text.endsWith('\n') ? text.slice(0, -1) : text;
let lineCount = 1;
for (let index = body.indexOf('\n'); index !== -1; index = body.indexOf('\n', index + 1)) {
lineCount++;
}
if (lineCount <= maxLines && totalBytes <= maxBytes) {
return { content: text, truncated: false, removed: 0, unit: 'lines' };
}
const out: string[] = [];
let bytes = 0;
let hitBytes = false;
if (direction === 'head') {
let start = 0;
for (let i = 0; i < lineCount && i < maxLines; i++) {
const newline = body.indexOf('\n', start);
const end = newline === -1 ? body.length : newline;
const line = body.slice(start, end);
const size = utf8Len(line) + (i > 0 ? 1 : 0);
if (bytes + size > maxBytes) {
hitBytes = true;
break;
}
out.push(line);
bytes += size;
start = end + 1;
}
} else {
let end = body.length;
for (let i = lineCount - 1; i >= 0 && out.length < maxLines; i--) {
const start = end > 0 ? body.lastIndexOf('\n', end - 1) + 1 : 0;
const line = body.slice(start, end);
const size = utf8Len(line) + (out.length > 0 ? 1 : 0);
if (bytes + size > maxBytes) {
hitBytes = true;
break;
}
out.unshift(line);
bytes += size;
end = start - 1;
}
}
// The boundary line alone exceeds the byte budget. Rather than show only a
// marker (the common single-huge-line case: minified file, base64, one-line
// JSON/stack trace), keep a byte-safe slice of that line.
let preview: string;
if (out.length === 0) {
const firstNewline = body.indexOf('\n');
const line =
direction === 'head'
? body.slice(0, firstNewline === -1 ? body.length : firstNewline)
: body.slice(body.lastIndexOf('\n') + 1);
preview = sliceLineByBytes(line, maxBytes, direction);
bytes = utf8Len(preview);
hitBytes = true;
} else {
preview = out.join('\n');
}
const removed = hitBytes ? Math.max(0, totalBytes - bytes) : lineCount - out.length;
if (removed <= 0) {
// Nothing was actually dropped — e.g. content fits but a lone trailing
// newline pushed totalBytes one over the byte budget. Don't emit a
// misleading "0 ... truncated" marker.
return { content: text, truncated: false, removed: 0, unit: 'lines' };
}
const unit: 'lines' | 'bytes' = hitBytes ? 'bytes' : 'lines';
const marker =
`...${removed} ${unit} truncated. ${options.recoveryHint ?? OUTPUT_RECOVERY_HINT} ` +
'Otherwise work from the kept output above.';
const content = direction === 'head' ? `${preview}\n\n${marker}` : `${marker}\n\n${preview}`;
return { content, truncated: true, removed, unit };
}