UNPKG

@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

146 lines (145 loc) • 6.64 kB
/** * Provider-neutral agent-loop reclaim policy. * * Every native provider loop grows its own history as the model calls tools, * and every one of them can overflow the model's window mid-turn. The policy * for reclaiming that budget is identical regardless of message shape: * * Stage 1 — shrink old tool outputs to head/tail previews. * Stage 2 — drop the oldest complete tool batches, as units, when previews * are not enough (or when outputs are already small enough that * truncation buys nothing). * * What differs per provider is only the message SHAPE. So this module owns the * decision and adapters own the mechanics: map your history onto * `LoopGuardEntry[]`, call `planLoopGuardReclaim`, apply the returned indices. * * Two invariants the policy never violates: * - entry 0 (the task) is never touched * - a tool batch is dropped whole or not at all, so a `tool_call` never * loses its `tool_result` (providers hard-reject the orphan) */ import { DEFAULT_CONTEXT_GUARD_RATIO } from "../core/constants.js"; /** * Fraction of the window the guard reclaims DOWN TO once it fires. * * The threshold decides *when*; this decides *how far*. Reclaiming only back to * the threshold means the next step — which appends an assistant turn plus its * tool results — crosses it again, so the guard rewrites the oldest messages on * every single step. That mutation invalidates the provider's cached prompt * prefix each time. One deeper reclaim every N steps saves the same tokens and * leaves the prefix stable in between. */ export const DEFAULT_LOOP_GUARD_LOW_WATER_RATIO = 0.6; /** Newest entries the guard never modifies. */ export const DEFAULT_LOOP_GUARD_PROTECTED_TAIL = 4; /** True for the two kinds that make up a tool batch. */ function isToolEntry(entry) { return entry?.kind === "toolCall" || entry?.kind === "toolResult"; } /** * One agent step's tool batch: the run of calls, then the run of results that * answers them. `end` is exclusive. * * The boundary is calls-then-results, NOT any maximal run of tool entries — a * loop's history is `call,result,call,result,…`, so treating a maximal run as * one batch would collapse the entire conversation into a single droppable * unit and scorch it in one pass. Mirrors `collectBatch` in toolPairRepair. */ function findToolBatches(entries, minIndex, maxIndexExclusive) { const batches = []; let i = minIndex; while (i < maxIndexExclusive) { if (!isToolEntry(entries[i])) { i++; continue; } const start = i; // Scan each run to its TRUE end rather than clamping at the protected-tail // boundary. A batch is droppable only as a whole: clamping would emit a // batch ending exactly at the boundary, so stage 2 would remove the // tool-call entry while the results at or past the boundary survive with // nothing to pair against. Providers reject an orphaned tool result, so // that turns a reclaim into a hard request failure. while (i < entries.length && entries[i].kind === "toolCall") { i++; } while (i < entries.length && entries[i].kind === "toolResult") { i++; } if (i > maxIndexExclusive) { // Scanning is oldest-first, so this batch reaching into the tail means // every later one starts inside it. Nothing further is droppable. break; } batches.push({ start, end: i }); } return batches; } /** * Decide what to reclaim, if anything. * * Returns `fire: false` when the projected request is within threshold — the * caller must then leave its history completely untouched, so a loop that fits * never pays a cache invalidation. */ export function planLoopGuardReclaim(entries, policy) { const { availableInputTokens, fixedOverheadTokens, thresholdRatio = DEFAULT_CONTEXT_GUARD_RATIO, lowWaterRatio = DEFAULT_LOOP_GUARD_LOW_WATER_RATIO, protectedTailCount = DEFAULT_LOOP_GUARD_PROTECTED_TAIL, calibration = 1, } = policy; const safeCalibration = calibration > 0 ? calibration : 1; const thresholdTokens = Math.floor((availableInputTokens * thresholdRatio) / safeCalibration); const lowWaterTokens = Math.floor((availableInputTokens * lowWaterRatio) / safeCalibration); let total = fixedOverheadTokens; for (const entry of entries) { total += entry.tokens; } if (total <= thresholdTokens) { return { fire: false, truncate: [], drop: [], projectedTokens: total }; } // Entry 0 is the task and the newest `protectedTailCount` entries are the // live working set — the mutable window sits strictly between them. const minIndex = 1; const maxIndexExclusive = Math.max(minIndex, entries.length - protectedTailCount); const truncate = []; const dropped = new Set(); // Stage 1: shrink old tool outputs, oldest first. for (let i = minIndex; i < maxIndexExclusive && total > lowWaterTokens; i++) { const entry = entries[i]; if (entry.kind !== "toolResult" || entry.previewTokens === undefined) { continue; } const saving = entry.tokens - entry.previewTokens; if (saving <= 0) { continue; } truncate.push(i); total -= saving; } // Stage 2: drop whole batches, oldest first, while still over the low-water // mark. This is the only lever when outputs are already smaller than a // preview — the regime where truncation alone cannot converge. if (total > lowWaterTokens) { const batches = findToolBatches(entries, minIndex, maxIndexExclusive); for (const batch of batches) { if (total <= lowWaterTokens) { break; } for (let i = batch.start; i < batch.end; i++) { const entry = entries[i]; // A truncated entry now costs its preview, not its original payload. const effective = truncate.includes(i) ? (entry.previewTokens ?? entry.tokens) : entry.tokens; total -= effective; dropped.add(i); } } } // Truncating an entry that is being dropped anyway is wasted work. const effectiveTruncate = truncate.filter((i) => !dropped.has(i)); return { fire: effectiveTruncate.length > 0 || dropped.size > 0, truncate: effectiveTruncate, drop: [...dropped].sort((a, b) => a - b), projectedTokens: total, }; }