@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
282 lines (281 loc) • 11.4 kB
JavaScript
/**
* ToolExecutionRecorder — real per-call tool execution capture.
*
* One recorder is created per generate()/stream() call and attached to the
* request options (surviving option spreads). Tools are wrapped exactly once
* at the central tool-assembly points (BaseProvider.prepareGenerationContext /
* getToolsForStream), so every loop — the AI-SDK loop and the native
* Gemini/Anthropic loops alike — records through the same wrapper with real
* params, timing, and error status. Memory is bounded: serialized results are
* capped at `maxResultChars` and the record list at `maxRecords`.
*/
import { logger } from "../utils/logger.js";
/** Default cap on serialized result characters kept per record (~8KB). */
export const DEFAULT_TOOL_RESULT_CAPTURE_CHARS = 8192;
/** Default cap on records kept per turn. */
export const DEFAULT_TOOL_EXECUTION_MAX_RECORDS = 500;
/** Marker set on execute functions the recorder has already wrapped. */
const RECORDER_WRAPPED = Symbol.for("neurolink.toolExecutionRecorder.wrapped");
/** Internal options key the per-call recorder rides on (enumerable so it survives option spreads). */
const RECORDER_OPTIONS_KEY = "toolExecutionRecorder";
/**
* Serialize a tool result to bounded text. JSON when serializable, else
* String(); truncation is explicit so consumers know the text is partial.
*/
export function serializeToolResult(result, maxChars) {
let text;
if (typeof result === "string") {
text = result;
}
else {
try {
text = JSON.stringify(result) ?? String(result);
}
catch {
text = String(result);
}
}
if (text.length > maxChars) {
const dropped = text.length - maxChars;
text = `${text.slice(0, maxChars)}…[truncated ${dropped} chars]`;
}
return text;
}
/**
* Error-shaped tool results (MCP `isError`, `{error}` payloads) count as
* failures even though the loop received them as normal returns.
*/
export function isErrorShapedToolResult(result) {
if (!result || typeof result !== "object") {
return false;
}
const record = result;
if (record.isError === true) {
return true;
}
if (record.error !== undefined && record.error !== null) {
return true;
}
if (typeof record.status === "string") {
return ["error", "failed", "failure", "fail"].includes(record.status.toLowerCase().trim());
}
return false;
}
/**
* Per-call recorder for tool executions. See module doc.
*/
export class ToolExecutionRecorder {
// ES #private fields on purpose: the recorder rides on request options as
// an ENUMERABLE property (spread survival), so TS-only `private` fields
// would still be dragged into any JSON.stringify(options) — up to
// maxRecords × maxResultChars of tool results in a debug dump. #fields are
// invisible to serialization: the recorder stringifies as `{}`.
#maxResultChars;
#maxRecords;
#onRecord;
#records = [];
#droppedRecords = 0;
constructor(capture) {
this.#maxResultChars =
capture?.maxResultChars ?? DEFAULT_TOOL_RESULT_CAPTURE_CHARS;
this.#maxRecords =
capture?.maxRecords ?? DEFAULT_TOOL_EXECUTION_MAX_RECORDS;
this.#onRecord = capture?.onRecord;
}
/** Record one completed (or failed) execution. Never throws. */
record(entry) {
try {
const failed = entry.error !== undefined;
const record = {
toolName: entry.toolName,
params: entry.params,
resultText: serializeToolResult(failed
? entry.error instanceof Error
? entry.error.message
: entry.error
: entry.result, this.#maxResultChars),
isError: failed || isErrorShapedToolResult(entry.result),
startedAt: entry.startedAt,
durationMs: entry.durationMs,
};
this.#records.push(record);
if (this.#records.length > this.#maxRecords) {
this.#records.splice(0, this.#records.length - this.#maxRecords);
this.#droppedRecords++;
}
if (this.#onRecord) {
try {
// Swallow async rejections too — the contract is that listener
// errors (sync OR async) never disrupt the turn.
void Promise.resolve(this.#onRecord(record)).catch(() => {
// Listener errors never break the run.
});
}
catch {
// Listener errors never break the run.
}
}
}
catch (recordError) {
logger.debug("[ToolExecutionRecorder] Failed to record execution", {
error: recordError instanceof Error
? recordError.message
: String(recordError),
});
}
}
/** All records captured so far (oldest first, bounded by maxRecords). */
getRecords() {
return [...this.#records];
}
/** True when at least one execution was recorded. */
hasRecords() {
return this.#records.length > 0;
}
/** Number of records evicted by the maxRecords cap. */
getDroppedCount() {
return this.#droppedRecords;
}
/**
* Wrap every executable tool so its invocations are recorded. Wrapping is
* idempotent — already-wrapped executes are left untouched, so a generate
* that falls back to another path never double-records.
*/
wrapTools(tools) {
// Null prototype: a tool named "__proto__" must stay an own entry
// (mirrors BaseProvider.sortToolRecord / toolDiscovery partitioning).
const wrapped = Object.create(null);
for (const [name, tool] of Object.entries(tools)) {
const execute = tool.execute;
if (typeof execute !== "function" || execute[RECORDER_WRAPPED]) {
wrapped[name] = tool;
continue;
}
const recorder = this;
const recordingExecute = async (params, execOptions) => {
const startedAt = Date.now();
try {
const result = await execute(params, execOptions);
recorder.record({
toolName: name,
params,
result,
startedAt,
durationMs: Date.now() - startedAt,
});
return result;
}
catch (error) {
recorder.record({
toolName: name,
params,
error,
startedAt,
durationMs: Date.now() - startedAt,
});
throw error;
}
};
recordingExecute[RECORDER_WRAPPED] = true;
wrapped[name] = { ...tool, execute: recordingExecute };
}
return wrapped;
}
/**
* Attach this recorder to a request options object. The property is
* enumerable on purpose: downstream stages spread options (`{...options}`)
* and the recorder must survive into the provider loops.
*/
attachTo(options) {
options[RECORDER_OPTIONS_KEY] = this;
}
/** Retrieve the recorder a request is carrying, if any. */
static from(options) {
if (!options || typeof options !== "object") {
return undefined;
}
const candidate = options[RECORDER_OPTIONS_KEY];
return candidate instanceof ToolExecutionRecorder ? candidate : undefined;
}
}
/**
* Convert legacy loop-local `{name, input, output}` entries (plus optional
* timing/error fields) into `ToolExecutionRecord`s. Used as the fallback when
* a path produced executions the recorder did not see (e.g. results captured
* before the recorder existed, or breaker/not-found entries that never
* reached a real execute()).
*/
export function toToolExecutionRecords(legacyExecutions, capture) {
if (!legacyExecutions || !Array.isArray(legacyExecutions)) {
return [];
}
const maxChars = capture?.maxResultChars ?? DEFAULT_TOOL_RESULT_CAPTURE_CHARS;
const maxRecords = capture?.maxRecords ?? DEFAULT_TOOL_EXECUTION_MAX_RECORDS;
return legacyExecutions.slice(-maxRecords).map((entry, index) => {
const record = (entry ?? {});
// A canonical record round-tripping through this converter keeps its
// already-serialized resultText verbatim instead of re-serializing an
// absent `output` into the string "undefined".
const output = record.output ??
record.result ??
record.response ??
(typeof record.resultText === "string" ? record.resultText : undefined);
const durationMs = typeof record.durationMs === "number"
? record.durationMs
: typeof record.duration === "number"
? record.duration
: typeof record.executionTime === "number"
? record.executionTime
: 0;
return {
toolName: record.name ||
record.toolName ||
`tool_execution_${index}`,
params: record.input ?? record.params ?? record.args ?? {},
resultText: serializeToolResult(output, maxChars),
isError: record.isError === true ||
record.success === false ||
record.error !== undefined ||
isErrorShapedToolResult(output),
startedAt: typeof record.startedAt === "number" ? record.startedAt : 0,
durationMs,
};
});
}
/**
* Resolve the toolExecutions for a result: recorder records when the request
* carried a recorder that saw executions, else a conversion of the loop's
* legacy accumulator entries.
*/
/**
* The public `toolCalls` view of a native turn's execution summaries.
*
* The native generate loops record every executed call in
* `ToolExecutionSummaryInternal[]` and surface it as `toolExecutions`, but
* never mapped it onto `EnhancedGenerateResult.toolCalls` — the field the type
* has always declared and the ai-package formatter used to fill. A caller
* reading `result.toolCalls` after a tool ran saw nothing. This is the one
* place that mapping lives, so the three native paths cannot drift apart.
*/
export function toolCallsFromSummaries(summaries) {
return summaries.map((summary) => ({
toolCallId: summary.toolCallId,
toolName: summary.toolName,
args: summary.input !== null &&
typeof summary.input === "object" &&
!Array.isArray(summary.input)
? summary.input
: {},
}));
}
export function resolveToolExecutionRecords(options, legacyExecutions) {
const recorder = ToolExecutionRecorder.from(options);
if (recorder?.hasRecords()) {
return recorder.getRecords();
}
const capture = options && typeof options === "object"
? options
.toolExecutionCapture
: undefined;
return toToolExecutionRecords(legacyExecutions, capture);
}