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

186 lines (185 loc) • 8.36 kB
/** * In-turn context guard for Anthropic-shaped agent loops. * * The direct Anthropic loop (`providers/anthropic/client.ts`) grows a * `conversation` array on every step: an assistant message carrying `tool_use` * blocks, then a user message carrying the matching `tool_result` blocks. It * had no in-turn guard, so a long agentic run overflowed the window mid-loop * and lost every completed step. * * Granularity differs from the OpenAI-compatible shape: there, each tool result * is its own message; here, ONE user message carries every `tool_result` for a * step. That makes the message the natural batch unit — dropping an assistant * `tool_use` message together with its following `tool_result` message can * never orphan a block, which Anthropic rejects outright. * * The reclaim POLICY lives in `loopGuardCore` and is shared with the other * provider loops. This module owns only the shape mapping. */ import { estimateTokens, TOKENS_PER_MESSAGE, } from "../utils/tokenEstimation.js"; import { exceedsToolOutputPreviewBudget, generateToolOutputPreview, } from "./toolOutputLimits.js"; import { planLoopGuardReclaim } from "./loopGuardCore.js"; import { logger } from "../utils/logger.js"; /** Preview budget for an old tool output. Matches the other loop guards. */ const OLD_TOOL_OUTPUT_PREVIEW_BYTES = 2_048; const OLD_TOOL_OUTPUT_PREVIEW_LINES = 60; /** Marker left where dropped history used to be. */ export const ANTHROPIC_ELISION_NOTE = "[Earlier tool exchanges were removed to fit the context window.]"; /** Serialize any value to text for estimation. Never throws. */ function toText(value) { if (typeof value === "string") { return value; } if (value === null || value === undefined) { return ""; } try { return JSON.stringify(value) ?? ""; } catch { // Past V8's string cap: enormous by definition, so charge a large fixed // size rather than aborting the estimate and with it the turn. return "x".repeat(200_000); } } function blocksOf(message) { return Array.isArray(message.content) ? message.content : []; } function hasBlockType(message, type) { return blocksOf(message).some((block) => block.type === type); } /** Cost of one message including per-message framing. */ function messageTokens(message, provider) { return estimateTokens(toText(message.content), provider) + TOKENS_PER_MESSAGE; } /** * Rewrite a `tool_result` message so every block's payload is a head/tail * preview. `cache_control` and every other block field are preserved — only * the payload shrinks. */ function previewToolResultMessage(message) { if (!Array.isArray(message.content)) { return message; } const content = message.content.map((block) => { if (block.type !== "tool_result") { return block; } const text = toText(block.content); const previewOptions = { maxBytes: OLD_TOOL_OUTPUT_PREVIEW_BYTES, maxLines: OLD_TOOL_OUTPUT_PREVIEW_LINES, }; if (!exceedsToolOutputPreviewBudget(text, previewOptions)) { return block; } const { preview } = generateToolOutputPreview(text, previewOptions); return { ...block, content: preview }; }); return { ...message, content }; } /** Map the loop's history onto the neutral view the policy operates on. */ function toEntries(conversation, provider) { return conversation.map((message) => { const tokens = messageTokens(message, provider); if (hasBlockType(message, "tool_result")) { const previewed = previewToolResultMessage(message); const previewTokens = messageTokens(previewed, provider); return { kind: "toolResult", tokens, // Only advertise a preview when it actually saves something — an // already-small result must fall through to stage 2, not look // shrinkable and stall the reclaim. ...(previewTokens < tokens ? { previewTokens } : {}), }; } if (hasBlockType(message, "tool_use")) { return { kind: "toolCall", tokens }; } return { kind: "other", tokens }; }); } /** * Text-level preview used for an oversized `tool_result` payload. Exposed so * the caller can apply it to its OWN block type — this module never rebuilds * the caller's messages, which keeps it free of generic-spread assignability * problems and of the double type assertions Critical Rule 14 forbids. */ export function previewAnthropicToolResultText(text) { const { preview } = generateToolOutputPreview(text, { maxBytes: OLD_TOOL_OUTPUT_PREVIEW_BYTES, maxLines: OLD_TOOL_OUTPUT_PREVIEW_LINES, }); return preview; } /** True when this message carries `tool_result` blocks worth previewing. */ export function isAnthropicToolResultMessage(message) { return hasBlockType(message, "tool_result"); } /** * Decide what to reclaim from an Anthropic-shaped agent loop. * * Returns `undefined` when the loop still fits — the caller must then leave its * history byte-identical, because any rewrite invalidates the rolling * `cache_control` prefix this path depends on. */ export function planAnthropicLoopReclaim(args) { const { conversation, availableInputTokens, fixedOverheadTokens, provider, observedPromptTokens, previousSentEstimate, onSentEstimate, observedDescribesCurrentPayload = false, } = args; const entries = toEntries(conversation, provider); const rawEstimate = fixedOverheadTokens + entries.reduce((sum, e) => sum + e.tokens, 0); // Calibration divides a real prompt-token count by THIS guard's estimate for // the payload that count describes. Both halves must describe the SAME // payload, and there are two legitimate pairings: // // - A caller that plans on EVERY step passes the provider's count for the // previous request together with `previousSentEstimate`, the estimate // recorded for that same request. Dividing that count by the estimate for // the CURRENT conversation would be a category error — the loop has since // appended an assistant tool_use message plus its tool_result, so the // denominator is always the larger of the two, the ratio reads below 1 and // the `Math.max(1, …)` floor pins calibration at 1. The correction then // silently never applies, which is exactly when a dense-code run overflows. // // - A caller that plans only when a real-token guard trips has no estimate // for an earlier request, but its trigger (`projectedNextPromptTokens`) is // already a projection of the payload ABOUT TO BE SENT. It sets // `observedDescribesCurrentPayload`, and the denominator is this module's // estimate of the current history — the same payload again. // // Without the flag such a caller gets calibration 1, which makes its reclaim // inert: the guard trips on real tokens at the same ratio this planner tests // its (smaller) char estimate against, so the plan never fires and the loop // falls back to stopping the turn. const sentEstimate = observedDescribesCurrentPayload ? rawEstimate : previousSentEstimate; let calibration = 1; if (observedPromptTokens && observedPromptTokens > 0 && sentEstimate && sentEstimate > 0) { // Clamped: real tokenizers run up to ~1.3x the char estimate on dense // code, and an unbounded ratio would compact the loop into uselessness. calibration = Math.min(3, Math.max(1, observedPromptTokens / sentEstimate)); } const plan = planLoopGuardReclaim(entries, { availableInputTokens, fixedOverheadTokens, calibration, }); if (!plan.fire) { onSentEstimate?.(rawEstimate); return undefined; } logger.info("[AnthropicLoopGuard] Reclaiming agent-loop context", { provider, messages: conversation.length, toolResultsTruncated: plan.truncate.length, messagesDropped: plan.drop.length, projectedTokens: plan.projectedTokens, calibration, }); onSentEstimate?.(plan.projectedTokens); return plan; }