@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
489 lines (488 loc) • 23.7 kB
JavaScript
/**
* Direct Anthropic's adapter onto the shared agentic loop engine.
*
* Everything here is the wire-format half of the turn: building one Messages
* request, folding an SSE event sequence into content blocks, serializing tool
* results back into the conversation, and mapping the stop reason. The turn
* loop, the step cap, tool dispatch, retry and usage accumulation belong to
* `runAgenticLoop`.
*
* Two behaviours here are easy to lose in a migration and are called out
* because losing either is silent:
*
* - Thinking deltas ride the channel as `{ content: "", reasoning }`. The
* engine's chunk type carries `reasoning` for exactly this reason; a
* channel that only understood `content` would drop every thinking delta
* while the text path kept working.
* - `resolveToolOnMiss` is wired to real deferred-catalog hydration, not as
* interface decoration. The pre-migration loop resolves every tool call as
* `toolsRecord[name] ?? resolveDeferredTool(toolsRecord, name)`, which is
* how a cataloged tool the model calls without having loaded it via
* `search_tools` gets found. Dropping it would break those tools with a
* "Tool not found" that looks like a hallucination.
*/
import { resolveDeferredTool } from "../../tools/toolDiscovery.js";
import { stringifyAnthropicToolOutput } from "./toolOutput.js";
import { stringifyFinalResultInput } from "./structuredOutput.js";
import { composeAbortSignalsScoped, createTimeoutController, } from "../../utils/timeout.js";
import { NeuroLinkError } from "../../utils/errorHandling.js";
import { ErrorCategory, ErrorSeverity } from "../../constants/enums.js";
/** Map Anthropic's stop_reason onto the unified finish reason. */
function mapAnthropicFinishReason(rawStopReason, hadToolCallsAtCap) {
switch (rawStopReason) {
case "max_tokens":
return "length";
case "tool_use":
return "tool-calls";
case "refusal":
return "content-filter";
default:
return hadToolCallsAtCap ? "tool-calls" : "stop";
}
}
/**
* Read one streamed Anthropic step off the wire.
*
* Module-level rather than nested inside `executeStep` because it IS the step:
* folding an SSE event sequence into content blocks, usage and a stop reason.
* `executeStep` above it is now only the per-request deadline wiring, which is
* the part a reader looking for "what bounds this request" needs to find.
*/
async function readAnthropicStep({ params, client, channel, stepSignal, requestTimeoutSignal, noteObservedPromptTokens, finalResultToolName, onTerminalResult, requireTerminalEvent, }) {
// Single assertion, not a double: `messages.create` returns a union of
// Message and Stream, and Stream<RawMessageStreamEvent> already IS an
// AsyncIterable of that event, so the two types overlap and the
// compiler still checks the narrowing.
const events = (await client.messages.create({ ...params, stream: true },
// The engine's signal composed with this step's own deadline.
// runAgenticLoop already derives the engine signal from the caller's
// abortSignal, so preferring config's would ignore engine-initiated
// cancellation entirely.
{ signal: stepSignal }));
const textByIndex = new Map();
const toolByIndex = new Map();
const thinkingByIndex = new Map();
const redactedByIndex = new Map();
let text = "";
let reasoning = "";
let rawStopReason;
let inputTokens = 0;
let outputTokens = 0;
let cacheReadTokens = 0;
let cacheWriteTokens = 0;
// Reported alongside the total, not derivable from it: the two TTL
// tiers are priced differently, so a caller that reports them (the
// Claude-on-Vertex turn span does) cannot reconstruct the split from
// cacheWriteTokens alone.
let cacheWrite5mTokens = 0;
let cacheWrite1hTokens = 0;
let stepOutputTokens = 0;
// Thinking tokens are a SUBSET of output_tokens (Anthropic bills them as
// part of the same total), so this is tracked separately for
// observability and never added into outputTokens above.
let reasoningTokens = 0;
let stepReasoningTokens = 0;
// Anthropic closes every complete message with `message_stop`. Its
// absence is the only signal that the response ended early, because the
// content blocks that DID arrive are syntactically complete.
let sawTerminalEvent = false;
for await (const rawEvent of events) {
if (stepSignal.aborted) {
break;
}
// Narrowed through the SDK's own discriminated union rather than
// re-declared: `RawMessageStreamEvent` already describes every event
// shape, so `event.type` checks below are compiler-checked instead of
// asserted.
const event = rawEvent;
if (event.type === "message_start") {
const usage = event.message?.usage;
inputTokens += usage?.input_tokens ?? 0;
const startOutput = usage?.output_tokens ?? 0;
outputTokens += startOutput - stepOutputTokens;
stepOutputTokens = startOutput;
const startReasoning = usage?.output_tokens_details?.thinking_tokens ?? 0;
reasoningTokens += startReasoning - stepReasoningTokens;
stepReasoningTokens = startReasoning;
// Anthropic reports cache reads/writes separately from input_tokens
// on this same event; without these the stream drops all cache
// accounting.
cacheReadTokens += usage?.cache_read_input_tokens ?? 0;
cacheWriteTokens += usage?.cache_creation_input_tokens ?? 0;
// BEST EFFORT, and the limit is worth stating. The nested TTL
// breakdown exists only on `Usage` (this event); `MessageDeltaUsage`
// carries the cache TOTALS but not the split, so message_start is
// the only place in the raw event stream it can come from. The
// pre-migration loop read it off `stream.finalMessage()` — the SDK's
// ACCUMULATED message — so if the API leaves `cache_creation` null
// here and fills it only on the assembled message, these two stay
// zero and the totals above remain correct regardless.
// Reported as undefined rather than a false zero when absent.
cacheWrite5mTokens +=
usage?.cache_creation?.ephemeral_5m_input_tokens ?? 0;
cacheWrite1hTokens +=
usage?.cache_creation?.ephemeral_1h_input_tokens ?? 0;
// The guard calibrates from the FULL prompt size, not input_tokens
// alone: on a cache hit the uncached remainder is tiny and using it
// would let the guard drift far under the real cost.
noteObservedPromptTokens?.((usage?.input_tokens ?? 0) +
(usage?.cache_read_input_tokens ?? 0) +
(usage?.cache_creation_input_tokens ?? 0));
continue;
}
if (event.type === "content_block_start") {
const index = event.index ?? 0;
// A redacted_thinking block carries its whole payload here and
// produces no deltas, so if it is not captured on this event it is
// never seen again. Anthropic validates the thinking chain when
// extended thinking continues across a tool-use turn, so a missing
// one fails the NEXT request — and only for accounts where safety
// redaction actually triggers, which is why it survives testing.
if (event.content_block?.type === "redacted_thinking") {
const data = event.content_block.data;
if (typeof data === "string") {
redactedByIndex.set(index, data);
}
}
if (event.content_block?.type === "tool_use") {
toolByIndex.set(index, {
id: event.content_block.id ?? "",
name: event.content_block.name ?? "",
inputJson: "",
});
}
continue;
}
if (event.type === "content_block_delta") {
const index = event.index ?? 0;
const delta = event.delta;
if (delta?.type === "text_delta" && delta.text) {
text += delta.text;
textByIndex.set(index, (textByIndex.get(index) ?? "") + delta.text);
channel.push({ content: delta.text });
}
else if (delta?.type === "thinking_delta" && delta.thinking) {
const acc = thinkingByIndex.get(index) ?? {
text: "",
signature: "",
};
acc.text += delta.thinking;
thinkingByIndex.set(index, acc);
reasoning += delta.thinking;
// Reasoning rides its own field; `content` stays a present string
// so plain-text consumers are unaffected.
channel.push({ content: "", reasoning: delta.thinking });
}
else if (delta?.type === "signature_delta" && delta.signature) {
const acc = thinkingByIndex.get(index) ?? {
text: "",
signature: "",
};
acc.signature += delta.signature;
thinkingByIndex.set(index, acc);
}
else if (delta?.type === "input_json_delta" && delta.partial_json) {
const pending = toolByIndex.get(index);
if (pending) {
pending.inputJson += delta.partial_json;
}
}
continue;
}
if (event.type === "message_delta") {
rawStopReason = event.delta?.stop_reason ?? rawStopReason;
const cumulative = event.usage?.output_tokens ?? stepOutputTokens;
outputTokens += cumulative - stepOutputTokens;
stepOutputTokens = cumulative;
const cumulativeReasoning = event.usage?.output_tokens_details?.thinking_tokens ??
stepReasoningTokens;
reasoningTokens += cumulativeReasoning - stepReasoningTokens;
stepReasoningTokens = cumulativeReasoning;
continue;
}
if (event.type === "message_stop") {
sawTerminalEvent = true;
}
}
// Ordered ahead of the terminal-event check because a deadline that
// fired IS why the terminal event is missing, and the timer's identity
// is the more useful of the two answers. The SDK's stream iterator exits
// without throwing on an aborted read, so nothing else would report it:
// the step would return the partial content as though the model had
// simply said less.
if (requestTimeoutSignal?.aborted) {
throw requestTimeoutSignal.reason;
}
// A response that ended before its terminal event is not a turn. Its
// tool_use blocks parse perfectly, so returning them here would dispatch
// tools the model never finished asking for, and the turn would go on to
// report itself as a normal stop. Not retriable: the request was already
// answered, and re-sending it would double any side effect the truncated
// half already caused upstream.
if (requireTerminalEvent && !sawTerminalEvent && !stepSignal.aborted) {
throw new NeuroLinkError({
code: "ANTHROPIC_STREAM_TRUNCATED",
message: "Anthropic stream ended before its terminal message_stop event; the turn is incomplete and its content blocks were not dispatched.",
category: ErrorCategory.NETWORK,
severity: ErrorSeverity.HIGH,
retriable: false,
context: { provider: "anthropic", rawStopReason },
});
}
// JSON.parse happily yields null, an array or a primitive, and an
// assertion converts none of them. Both `tool_use.input` and
// AgenticLoopToolCall.args require an object, so anything else becomes
// {} rather than being passed through as invalid wire content.
const parseArgs = (json) => {
if (!json) {
return {};
}
try {
const parsed = JSON.parse(json);
return typeof parsed === "object" &&
parsed !== null &&
!Array.isArray(parsed)
? parsed
: {};
}
catch {
return {};
}
};
// Rebuild the assistant turn in wire order so it can be replayed as the
// assistant message on the next step. Text that arrived before a tool
// call has to survive here, or the model stops seeing its own reasoning
// mid-turn.
const blocks = [];
const indices = new Set([
...textByIndex.keys(),
...toolByIndex.keys(),
...thinkingByIndex.keys(),
...redactedByIndex.keys(),
]);
for (const index of [...indices].sort((a, b) => a - b)) {
// Thinking blocks are replayed with their signature. Anthropic
// validates that signature when extended thinking continues across
// turns, so dropping the block — or keeping the text without the
// signature — breaks the next step of a thinking turn.
const redacted = redactedByIndex.get(index);
if (redacted) {
blocks.push({ type: "redacted_thinking", data: redacted });
}
const thinking = thinkingByIndex.get(index);
// Both halves required: Anthropic's thinking block carries a
// mandatory signature, and replaying one with an empty string is
// rejected outright. A thinking block that never received a
// signature_delta is dropped rather than sent unsigned.
if (thinking?.text && thinking.signature) {
blocks.push({
type: "thinking",
thinking: thinking.text,
signature: thinking.signature,
});
}
const blockText = textByIndex.get(index);
if (blockText) {
blocks.push({
type: "text",
text: blockText,
});
}
const pending = toolByIndex.get(index);
if (pending) {
blocks.push({
type: "tool_use",
id: pending.id,
name: pending.name,
input: parseArgs(pending.inputJson),
});
}
}
const allCalls = [...toolByIndex.values()].map((pending) => ({
id: pending.id,
name: pending.name,
args: parseArgs(pending.inputJson),
}));
// A terminal structured-output call ends the turn: its arguments are the
// answer. Reporting it as text and leaving it out of `toolCalls` is what
// routes it through the engine's ordinary zero-tool-calls exit, so it is
// never dispatched, never counted against the breaker, and never shows
// up as a tool execution.
const terminal = finalResultToolName
? [...toolByIndex.values()].find((pending) => pending.name === finalResultToolName)
: undefined;
const toolCalls = terminal ? [] : allCalls;
// The RAW accumulated input_json, never the parsed-then-restringified
// args. `parseArgs` yields {} for a payload the token cap cut off
// mid-string, so re-stringifying would turn a truncated answer into
// "{}" and lose it outright. `stringifyFinalResultInput` canonicalizes
// when the JSON parses and returns it verbatim when it does not, which
// is what lets the caller's coercion layer repair a partial payload
// into a partial object instead of nothing.
const finalText = terminal
? stringifyFinalResultInput(terminal.inputJson)
: text;
if (terminal) {
onTerminalResult?.(finalText);
}
return {
text: finalText,
...(reasoning ? { reasoning } : {}),
toolCalls,
usage: {
inputTokens,
outputTokens,
cacheReadTokens,
cacheWriteTokens,
// Omitted entirely when the stream never reported a split, so a
// consumer can tell "no TTL breakdown available" from "zero tokens
// in that tier".
...(cacheWrite5mTokens ? { cacheWrite5mTokens } : {}),
...(cacheWrite1hTokens ? { cacheWrite1hTokens } : {}),
...(reasoningTokens ? { reasoningTokens } : {}),
},
rawStopReason,
raw: blocks,
};
}
export function createAnthropicLoopAdapter(config) {
return {
providerLabel: "anthropic",
maxSteps: config.maxSteps,
...(config.toolFailureBreaker
? { toolFailureBreaker: config.toolFailureBreaker }
: {}),
/**
* The engine decides WHEN to reclaim; the provider decides HOW, in its own
* concrete message types. Not optional dressing: this loop appends an
* assistant tool_use message and a user tool_result message every step,
* and nothing else bounds that growth.
*/
...(config.planReclaim
? {
// Passed straight through, INCLUDING `{ stop: true }`. Wrapping the
// return as `{ conversation }` swallowed the stop signal, so a guard
// that could not reclaim enough room had no way to end the turn and
// the loop kept sending oversized requests until the provider
// rejected one.
planReclaim: (conversation, step) => config.planReclaim?.(conversation, step),
}
: {}),
resolveToolOnMiss: (name) => {
const hydrated = resolveDeferredTool(config.toolsRecord, name);
const execute = hydrated?.execute;
if (!execute) {
return undefined;
}
// Wrapped rather than handed over directly: the engine's hook types
// `opts` as `unknown`, and a function declaring a narrower options type
// is not assignable to one accepting `unknown`. One assertion at the
// boundary, never a double assertion through `unknown`.
return {
execute: async (args, opts) => execute(args, opts),
};
},
buildStepRequest(conversation, step) {
return { raw: config.buildParams(conversation, step) };
},
async executeStep(request, channel, signal) {
const params = request.raw;
// Per-REQUEST deadline, armed fresh for this step and disposed the moment
// it settles. Being armed per step is what makes it unresettable from a
// step boundary: the boundary runs between two steps, when no request
// deadline is running at all.
//
// Scoped composition rather than AbortSignal.any: the engine signal lives
// for the whole turn, and `any` keeps its registration on that signal
// until the derived one is collected, so composing per step accumulates
// listeners for the turn's whole length.
const requestTimeout = config.requestTimeoutMs
? createTimeoutController(config.requestTimeoutMs, "anthropic", "stream")
: null;
const composed = composeAbortSignalsScoped(signal, requestTimeout?.controller.signal);
try {
return await readAnthropicStep({
params,
client: config.client,
channel,
// The engine's signal, not config's: runAgenticLoop already derives
// it from the caller's abortSignal, so preferring config's would
// ignore engine-initiated cancellation entirely.
stepSignal: composed.signal ?? signal,
requestTimeoutSignal: requestTimeout?.controller.signal,
...(config.noteObservedPromptTokens
? { noteObservedPromptTokens: config.noteObservedPromptTokens }
: {}),
...(config.finalResultToolName
? { finalResultToolName: config.finalResultToolName }
: {}),
...(config.onTerminalResult
? { onTerminalResult: config.onTerminalResult }
: {}),
...(config.requireTerminalEvent
? { requireTerminalEvent: config.requireTerminalEvent }
: {}),
});
}
finally {
composed.dispose();
requestTimeout?.cleanup();
}
},
appendPlanningNudge(conversation, text) {
// Merged into the trailing user turn when there is one. The step boundary
// runs immediately after `buildToolResultMessages`, so history ends on a
// user tool_result message; opening a second consecutive user message
// there is a shape the wire format does not require anyone to accept.
const nudgeBlock = { type: "text", text };
const last = conversation[conversation.length - 1];
if (last?.role === "user" && Array.isArray(last.content)) {
const merged = {
...last,
content: [...last.content, nudgeBlock],
};
return [...conversation.slice(0, -1), merged];
}
const message = {
role: "user",
content: [nudgeBlock],
};
return [...conversation, message];
},
buildToolResultMessages(conversation, stepResult, toolResults) {
const assistantMessage = {
role: "assistant",
// server_tool_use blocks are stripped before the turn is replayed:
// the API emits them on the way out but REJECTS them on the way back
// in, so echoing one fails the next request outright rather than
// degrading. Both Claude-on-Vertex loops filtered these by hand; doing
// it here means a caller cannot forget to. A provider that never emits
// them sees no change.
content: stepResult.raw.filter((block) => block.type !== "server_tool_use"),
};
const resultMessage = {
role: "user",
content: toolResults.map((result) => ({
type: "tool_result",
tool_use_id: result.id,
content: result.error
? `Error executing tool ${result.name}: ${result.error}`
: stringifyAnthropicToolOutput(result.output),
...(result.error ? { is_error: true } : {}),
})),
};
// The two messages are built in the SDK's own shape and handed back as
// TMessage. Every Anthropic-compatible message type accepts a plain
// assistant turn and a tool_result user turn — that is the wire format,
// not a dialect — and a caller's narrower type differs only in fields
// neither of these sets. One assertion, at the single point where the
// adapter authors content rather than passing it through.
return [
...conversation,
assistantMessage,
resultMessage,
];
},
mapFinishReason: mapAnthropicFinishReason,
};
}