@juspay/neurolink
Version:
The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002
166 lines (165 loc) • 7.24 kB
JavaScript
/**
* Tool output preview generation.
* Generates head/tail previews of large tool outputs for context-efficient LLM calls.
* @module
*/
/** Default maximum preview size in bytes (50KB) */
export const DEFAULT_MAX_PREVIEW_BYTES = 50 * 1024;
/** Default maximum preview lines */
export const DEFAULT_MAX_PREVIEW_LINES = 2_000;
/** Default head ratio (25% of preview budget) */
export const DEFAULT_HEAD_RATIO = 0.25;
/** Tool name referenced in truncation notices for on-demand full-output access */
export const RETRIEVE_CONTEXT_TOOL_NAME = "retrieve_context";
/** Default tail ratio (75% of preview budget) */
export const DEFAULT_TAIL_RATIO = 0.75;
/**
* True when `generateToolOutputPreview` would actually shrink this output.
*
* Callers gate on this before paying for a preview. It exists so the gate and
* the generator can never disagree: the budget is expressed in BYTES, and
* testing it against `String.length` (UTF-16 code units) under-reports every
* multibyte payload — CJK or emoji tool output would be judged small enough to
* leave alone while the generator would have truncated it. Line count matters
* for the same reason: a short-but-tall output exceeds `maxLines` alone.
*/
export function exceedsToolOutputPreviewBudget(output, options) {
const maxBytes = options?.maxBytes ?? DEFAULT_MAX_PREVIEW_BYTES;
const maxLines = options?.maxLines ?? DEFAULT_MAX_PREVIEW_LINES;
return (Buffer.byteLength(output, "utf-8") > maxBytes ||
output.split("\n").length > maxLines);
}
/**
* First `limit` bytes of `value`, cut on a code-point boundary.
*
* `Buffer.subarray().toString()` mid-sequence emits U+FFFD, which is itself
* three bytes and can push the result back over the very budget the cut was
* made to satisfy — so back off to the start of the split character instead.
*/
function takeBytes(value, limit) {
if (limit <= 0) {
return "";
}
const buf = Buffer.from(value, "utf-8");
if (buf.length <= limit) {
return value;
}
let end = limit;
while (end > 0 && (buf[end] & 0xc0) === 0x80) {
end--;
}
return buf.subarray(0, end).toString("utf-8");
}
/**
* Generate a head/tail preview of a tool output string.
* If the output is within limits, returns it unchanged with truncated: false.
* If over limits, keeps the first 25% and last 75% with an omission notice.
*
* Industry pattern: 25/75 head/tail split. Head captures schema/headers/structure,
* tail captures the most recent and typically most relevant data.
*/
export function generateToolOutputPreview(output, options) {
const maxBytes = options?.maxBytes ?? DEFAULT_MAX_PREVIEW_BYTES;
const maxLines = options?.maxLines ?? DEFAULT_MAX_PREVIEW_LINES;
const rawHeadRatio = options?.headRatio ?? DEFAULT_HEAD_RATIO;
const rawTailRatio = options?.tailRatio ?? DEFAULT_TAIL_RATIO;
// Clamp ratios to valid range to avoid negative omittedBytes
const headRatio = Math.max(0, Math.min(1, rawHeadRatio));
const tailRatio = Math.max(0, Math.min(1, rawTailRatio));
const originalSize = Buffer.byteLength(output, "utf-8");
const lines = output.split("\n");
const exceedsBytes = originalSize > maxBytes;
const exceedsLines = lines.length > maxLines;
if (!exceedsBytes && !exceedsLines) {
return { preview: output, truncated: false, originalSize };
}
// Line-based split
const headLineCount = Math.max(1, Math.floor(maxLines * headRatio));
const tailLineCount = Math.max(1, maxLines - headLineCount);
let head;
let tail;
if (exceedsLines) {
head = lines.slice(0, headLineCount).join("\n");
tail = lines.slice(-tailLineCount).join("\n");
}
else {
head = lines
.slice(0, Math.max(1, Math.floor(lines.length * headRatio)))
.join("\n");
tail = lines
.slice(-Math.max(1, Math.ceil(lines.length * tailRatio)))
.join("\n");
}
// Byte-based cap on each portion
const headMaxBytes = Math.floor(maxBytes * headRatio);
const tailMaxBytes = maxBytes - headMaxBytes;
if (Buffer.byteLength(head, "utf-8") > headMaxBytes) {
head = Buffer.from(head, "utf-8")
.subarray(0, headMaxBytes)
.toString("utf-8");
}
if (Buffer.byteLength(tail, "utf-8") > tailMaxBytes) {
const tailBuf = Buffer.from(tail, "utf-8");
tail = tailBuf.subarray(tailBuf.length - tailMaxBytes).toString("utf-8");
}
// Default notice names retrieve_context; callers whose instance never
// registers that tool (see RETRIEVE_CONTEXT_TOOL_NAME usages in
// ToolsManager) pass their own `notice` so the model isn't pointed at a
// tool that doesn't exist.
const renderNotice = (omitted) => options?.notice === undefined
? `\n\n[... ${omitted} bytes omitted. ` +
`Use ${RETRIEVE_CONTEXT_TOOL_NAME} tool to access full output ...]\n\n`
: typeof options.notice === "function"
? options.notice(omitted)
: options.notice;
const omittedFor = (h, t) => Math.max(0, originalSize -
Buffer.byteLength(h, "utf-8") -
Buffer.byteLength(t, "utf-8"));
// `headMaxBytes + tailMaxBytes` is exactly `maxBytes`, leaving no room for
// the notice — so appending it unmeasured pushed every truncated preview
// over the caller's budget (82 bytes over, for the default notice). Give the
// notice its space back out of the tail, then the head. Two passes, because
// shrinking them raises `omittedBytes` and can lengthen the notice by a
// digit; the second pass settles that.
let notice = renderNotice(omittedFor(head, tail));
for (let pass = 0; pass < 2; pass++) {
const excess = Buffer.byteLength(head, "utf-8") +
Buffer.byteLength(notice, "utf-8") +
Buffer.byteLength(tail, "utf-8") -
maxBytes;
if (excess <= 0) {
break;
}
const tailBuf = Buffer.from(tail, "utf-8");
const takeFromTail = Math.min(excess, tailBuf.length);
tail = tailBuf.subarray(takeFromTail).toString("utf-8");
const stillOver = excess - takeFromTail;
if (stillOver > 0) {
const headBuf = Buffer.from(head, "utf-8");
head = headBuf
.subarray(0, Math.max(0, headBuf.length - stillOver))
.toString("utf-8");
}
notice = renderNotice(omittedFor(head, tail));
}
// A notice at least as long as the whole budget cannot be paid for out of
// `head` and `tail`: the loop above empties both and the notice alone is
// still over `maxBytes`. `maxBytes` is a hard cap the caller sizes its
// context window against, so bound the notice itself rather than handing
// back more than was asked for.
if (Buffer.byteLength(head, "utf-8") +
Buffer.byteLength(notice, "utf-8") +
Buffer.byteLength(tail, "utf-8") >
maxBytes) {
return {
preview: takeBytes(notice, maxBytes),
truncated: true,
originalSize,
};
}
return {
preview: head + notice + tail,
truncated: true,
originalSize,
};
}