@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
762 lines (761 loc) • 33.9 kB
JavaScript
/**
* The multi-step tool loop that the ai package's `generateText` used to supply.
*
* It deliberately loops over a provider's own `doGenerate` rather than over any
* streaming machinery. That is the whole lesson of the reverted first attempt:
* `doGenerate` is where the JSON-versus-SSE wire choice lives, along with the
* 400 retry, the context-overflow refit and the provider's own structured-output
* handling. Looping around the streaming path instead silently changed
* generate() to send `stream: true` and broke ten providers against a
* non-streaming body.
*
* Every provider whose delegating model exposes a v3-shaped `doGenerate` can
* share this: the v3 result shape (`content` parts, `finishReason`, `usage`) is
* the same across Anthropic, the OpenAI-compatible family and SageMaker.
*/
import { observeGeneration } from "../telemetry/observation.js";
import { StepSpans, withGeneration } from "../telemetry/withSpan.js";
import { logger } from "../utils/logger.js";
import { guardToolExecutor } from "./toolExecutionGuards.js";
const DEFAULT_TOOL_CHOICE_STEPS = 1;
/**
* A choice that compels a tool call: `"required"` or a named tool, in either
* the NeuroLink string/object shape or the v3 `{ type }` object shape.
* `"auto"` and `"none"` leave the model free (or forbidden) and are never
* time-limited.
*/
const isForcedToolChoice = (choice) => {
if (choice === "required") {
return true;
}
if (typeof choice === "object" && choice !== null) {
const type = choice.type;
return type === "required" || type === "tool" || type === "any";
}
return false;
};
/**
* The tool a choice forces by name, when it forces one. Both wire mappers
* honour exactly `{ type: "tool", toolName }` with a non-empty name, so
* that is the only shape that names a function on the wire.
*/
const forcedToolName = (choice) => {
if (typeof choice !== "object" || choice === null) {
return undefined;
}
const { type, toolName } = choice;
return type === "tool" && typeof toolName === "string" && toolName !== ""
? toolName
: undefined;
};
/**
* The name a choice forces when `declaredTools` is known and lacks it, else
* undefined. Own keys only: the live record may have a null prototype or be
* keyed by a name such as "constructor".
*/
const undeclaredForcedToolName = (choice, declaredTools) => {
if (!declaredTools) {
return undefined;
}
const name = forcedToolName(choice);
return name !== undefined && !Object.hasOwn(declaredTools, name)
? name
: undefined;
};
/**
* A forced choice for a tool the request does not declare is dropped for the
* step rather than sent, so a stale hook or a platform-gated tool degrades
* the turn to the model's own choice instead of failing it with a 400.
*/
const warnUndeclaredForcedTool = (toolName, step, source) => {
logger.warn(source === "prepareStep"
? `prepareStep forced tool "${toolName}" at step ${step}, but the request does not declare it; ignoring the override for this step`
: `toolChoice forces tool "${toolName}" at step ${step}, but the request does not declare it; using "auto" for this step`, { toolName, step, source });
};
/**
* The names this request actually carries, in the record shape
* `declaredTools` takes. The loop sends `args.tools`, built once by the
* caller; `args.toolsRecord` can hold more (discovery hydrates into it, and
* this loop never re-declares), so only `tools` answers "is it offered".
* Null prototype: a tool may be named `__proto__` or `constructor`.
*/
const declaredToolRecord = (tools) => {
const declared = Object.create(null);
for (const tool of tools ?? []) {
if (typeof tool.name === "string") {
declared[tool.name] = true;
}
}
return declared;
};
/**
* `toolChoiceSteps` is a count of leading steps, so anything but a
* non-negative integer is meaningless. Fall back to the default with a WARN
* rather than throw: a bad knob should not fail a turn that would otherwise
* complete, but it must not be silent either.
*/
const normalizeToolChoiceSteps = (value) => {
if (value === undefined) {
return DEFAULT_TOOL_CHOICE_STEPS;
}
if (Number.isInteger(value) && value >= 0) {
return value;
}
logger.warn(`toolChoiceSteps must be a non-negative integer; got ${String(value)} — using ${DEFAULT_TOOL_CHOICE_STEPS}`);
return DEFAULT_TOOL_CHOICE_STEPS;
};
/**
* Fields of a `prepareStep` result that the former AI-SDK loop honoured and
* the native loops do not. Their presence is reported once per process: a
* hook written for the old loop that hides a destructive tool through
* `experimental_activeTools` would otherwise fail open in silence.
*/
const IGNORED_PREPARE_STEP_FIELDS = [
"model",
"activeTools",
"experimental_activeTools",
];
let warnedIgnoredPrepareStepFields = false;
const warnIgnoredPrepareStepFields = (prepared) => {
if (warnedIgnoredPrepareStepFields ||
!prepared ||
typeof prepared !== "object") {
return;
}
const present = IGNORED_PREPARE_STEP_FIELDS.filter((field) => prepared[field] !== undefined);
if (present.length === 0) {
return;
}
warnedIgnoredPrepareStepFields = true;
logger.warn(`prepareStep returned ${present.join(", ")}, which the native loops ignore — only toolChoice is honoured. Use toolFilter / excludeTools for tool visibility and set the model on the request. (reported once)`);
};
/**
* Await the caller's hook without letting it outlive the turn: a hook that
* stalls (a remote policy service that never answers) parked the loop past
* every deadline, because nothing observed the abort signal while the hook
* was pending. An abort rejects with the signal's reason so the loop's
* cancellation path handles it like any other abort.
*/
const awaitPrepareStep = (pending, abortSignal) => {
if (!abortSignal) {
return Promise.resolve(pending);
}
if (abortSignal.aborted) {
// The hook has already been invoked. Its promise is abandoned here, so
// a rejection it settles with later must have a handler: without one it
// is an unhandled rejection, which terminates Node by default.
void Promise.resolve(pending).catch(() => undefined);
return Promise.reject(abortReason(abortSignal));
}
return new Promise((resolve, reject) => {
const onAbort = () => reject(abortReason(abortSignal));
abortSignal.addEventListener("abort", onAbort, { once: true });
// Both handlers stay attached after an abort wins the race, so a hook
// that rejects afterwards is observed (the second `reject` is a no-op).
Promise.resolve(pending).then((value) => {
abortSignal.removeEventListener("abort", onAbort);
resolve(value);
}, (error) => {
abortSignal.removeEventListener("abort", onAbort);
reject(error);
});
});
};
const abortReason = (signal) => {
if (signal.reason instanceof Error) {
return signal.reason;
}
const error = new Error(typeof signal.reason === "string" ? signal.reason : "Request was aborted.");
error.name = "AbortError";
return error;
};
/**
* Decide the tool choice for ONE step of a multi-step loop.
*
* A forced choice held on every step compels a tool call on every step, so
* the loop could only end when `maxSteps` ran out — which is exactly what
* happened before this existed. The rule: a forced choice applies while
* `step < toolChoiceSteps` (default 1), after which the model chooses
* (`"auto"`). `"auto"` and `"none"` pass through unchanged on every step.
*
* A `prepareStep` hook, when supplied, runs first and its `toolChoice` wins
* for the step — including a forced choice past `toolChoiceSteps`, which is
* how a caller forces a tool on step 3 only. A hook that returns no
* `toolChoice` defers to the rule. Nothing else on the hook's result is read.
*
* A forced single-tool choice, from the hook or from the turn, that names a
* tool outside `declaredTools` is warned about and not sent. A hook's choice
* is then treated as "no override" and the rule decides; the turn's own
* choice becomes `"auto"`. Without `declaredTools` nothing is checked.
*
* A hook that THROWS is logged and treated as "no override": the steps
* already billed are kept and the rule decides. An abort of the turn while
* the hook is pending is not a hook failure and is re-thrown. The two are
* told apart by the LOOP's signal, never by the error's name: a hook that
* rejects with its own `AbortError` (its policy service timed out) has
* failed, and must not end a turn the caller never cancelled.
*/
export async function resolveStepToolChoice(input) {
if (input.prepareStep) {
let prepared;
try {
prepared = await awaitPrepareStep(input.prepareStep({
steps: input.steps,
stepNumber: input.step,
maxSteps: input.maxSteps,
model: input.model,
}), input.abortSignal);
}
catch (hookError) {
if (input.abortSignal?.aborted) {
throw abortReason(input.abortSignal);
}
logger.warn(`prepareStep threw at step ${input.step}; using the default tool choice for this step`, {
error: hookError instanceof Error ? hookError.message : String(hookError),
});
prepared = undefined;
}
warnIgnoredPrepareStepFields(prepared);
if (prepared?.toolChoice !== undefined) {
const undeclaredPrepared = undeclaredForcedToolName(prepared.toolChoice, input.declaredTools);
if (undeclaredPrepared === undefined) {
return prepared.toolChoice;
}
warnUndeclaredForcedTool(undeclaredPrepared, input.step, "prepareStep");
}
}
if (input.base === undefined) {
return undefined;
}
if (!isForcedToolChoice(input.base)) {
return input.base;
}
if (input.step >= normalizeToolChoiceSteps(input.toolChoiceSteps)) {
return "auto";
}
const undeclaredBase = undeclaredForcedToolName(input.base, input.declaredTools);
if (undeclaredBase === undefined) {
return input.base;
}
warnUndeclaredForcedTool(undeclaredBase, input.step, "base");
return "auto";
}
/**
* Translate a NeuroLink-shape tool choice (`"auto"` / `"none"` /
* `"required"` / `{ type: "tool", toolName }`) into the v3 call-option
* OBJECT every `doGenerate` converter switches on. The bare strings were
* being handed straight to `doGenerate`; Anthropic's converter normalises
* them, but the OpenAI-compatible one reads `.type` off the value and
* silently dropped every string form. An already-object value passes
* through untouched.
*/
export const toV3ToolChoice = (choice) => {
if (choice === undefined || choice === null) {
return undefined;
}
if (typeof choice === "string") {
return choice === "auto" || choice === "none" || choice === "required"
? { type: choice }
: undefined;
}
if (typeof choice === "object") {
return choice;
}
return undefined;
};
const KNOWN_FINISH_REASONS = new Set([
"stop",
"length",
"content-filter",
"tool-calls",
"error",
"other",
]);
const toFinishReason = (value) => KNOWN_FINISH_REASONS.has(value) ? value : "other";
/**
* The record of one completed step that `prepareStep` receives in `steps`.
* Built the same way by every native loop so a hook sees one shape.
*/
export const toPrepareStepRecord = (step) => {
const hasCacheDetail = step.cacheReadTokens !== undefined || step.cacheWriteTokens !== undefined;
return {
stepNumber: step.stepNumber,
content: step.content,
text: step.text,
toolCalls: step.toolCalls,
toolResults: step.toolResults,
finishReason: toFinishReason(step.finishReason),
usage: {
inputTokens: step.inputTokens,
outputTokens: step.outputTokens,
totalTokens: step.inputTokens + step.outputTokens,
...(hasCacheDetail
? {
inputTokenDetails: {
noCacheTokens: step.inputTokens,
cacheReadTokens: step.cacheReadTokens ?? 0,
cacheWriteTokens: step.cacheWriteTokens ?? 0,
},
cachedInputTokens: step.cacheReadTokens ?? 0,
}
: {}),
},
};
};
/**
* Narrow a model handle to the delegating shape this loop drives.
* `LanguageModel` is a union that includes a bare string id, and a double
* assertion through unknown is banned by Critical Rule 14.
*/
export const hasNativeDoGenerate = (value) => typeof value === "object" &&
value !== null &&
typeof value.doGenerate === "function";
const asParts = (value) => Array.isArray(value) ? value : [];
const readTotal = (value) => typeof value === "object" &&
value !== null &&
typeof value.total === "number"
? value.total
: 0;
/**
* Parse a tool call's arguments, distinguishing "no arguments" from "the model
* emitted something that is not JSON". Silently substituting `{}` for the
* second case ran the tool with empty input and reported success, so a
* malformed call looked identical to a legitimate no-arg one.
*/
const parseToolInput = (raw) => {
if (typeof raw !== "string") {
return { input: raw ?? {} };
}
if (raw.trim() === "") {
return { input: {} };
}
try {
return { input: JSON.parse(raw) };
}
catch {
return { input: {}, error: "arguments were not valid JSON" };
}
};
/**
* Validate parsed input against the tool's own schema when it exposes one.
*
* `generateText` validated tool input before dispatch; the native loop did
* not, so a call whose shape the tool rejects reached `execute` and failed
* inside user code — or worse, did not fail. A Zod schema is detected by
* `safeParse`; anything else is passed through, since a JSON Schema needs a
* validator this loop has no business carrying.
*/
const validateToolInput = (tool, input) => {
const schema = tool?.inputSchema;
if (typeof schema?.safeParse !== "function") {
return undefined;
}
const result = schema.safeParse(input);
if (result.success) {
return undefined;
}
const detail = result.error instanceof Error ? result.error.message : "schema mismatch";
return `input did not match the tool's schema: ${detail}`;
};
/**
* Spell a JSON Schema into the conversation's system turn.
*
* The structured-output fallback for vendors that reject or ignore
* `response_format`. Merged into an existing trailing system message rather
* than appended as a second one: several self-hosted OpenAI-compatible stacks
* honour only the first system message, so a second would be dropped and the
* fallback would silently do nothing.
*/
export const appendJsonSchemaInstruction = (conversation, schema) => {
// "value", not "object": a ValidationSchema also accepts array and scalar
// roots (z.array(...), z.string()), and demanding an object told a
// compliant model to emit something those roots can never satisfy — so the
// fallback silently produced nothing for them however well the model
// complied. The generate path's own re-ask prompt was corrected for this
// reason; this shared helper, used by both paths, was missed.
const instruction = "When you give your final answer, respond with only a single JSON value " +
"that conforms to the following JSON Schema. No prose before or after it, " +
`and no markdown code fence. JSON Schema: ${JSON.stringify(schema)}`;
const lastSystemIndex = conversation.reduce((found, message, index) => (message.role === "system" ? index : found), -1);
if (lastSystemIndex === -1) {
return [{ role: "system", content: instruction }, ...conversation];
}
const existing = conversation[lastSystemIndex];
const content = typeof existing.content === "string"
? `${existing.content}\n\n${instruction}`
: instruction;
return conversation.map((message, index) => index === lastSystemIndex ? { ...message, content } : message);
};
function describeV3Response(res) {
const parts = asParts(res.content);
const usage = res.usage;
const finish = res.finishReason;
const response = res.response;
const toolCalls = parts
.filter((p) => p.type === "tool-call")
.map((p) => ({
id: p.toolCallId,
name: p.toolName,
args: p.input,
}));
const reasoning = parts
.filter((p) => p.type === "reasoning" && typeof p.text === "string")
.map((p) => p.text)
.join("");
return {
responseModel: typeof response?.modelId === "string" ? response.modelId : undefined,
output: {
text: parts
.filter((p) => p.type === "text" && typeof p.text === "string")
.map((p) => p.text)
.join(""),
...(reasoning ? { reasoning } : {}),
...(toolCalls.length > 0 ? { toolCalls } : {}),
},
usage: usage
? {
inputTokens: usage.inputTokens?.noCache ?? usage.inputTokens?.total,
outputTokens: usage.outputTokens?.total,
cacheReadTokens: usage.inputTokens?.cacheRead,
cacheCreationTokens: usage.inputTokens?.cacheWrite,
reasoningTokens: usage.outputTokens?.reasoning,
}
: undefined,
finishReason: typeof finish === "string"
? finish
: finish?.unified,
};
}
function generateObservedStep(args, request) {
return withGeneration({
provider: args.provider,
model: args.modelId,
modelParameters: {
temperature: args.temperature,
maxTokens: args.maxOutputTokens,
},
input: { messages: args.conversation },
}, async (span) => {
const res = await args.runStep(() => args.doGenerate(request));
observeGeneration(span, {
provider: args.provider,
model: args.modelId,
...describeV3Response(res),
aborted: args.abortSignal?.aborted,
});
return res;
});
}
export async function runNativeGenerateLoop(args, toolExecutionSummaries) {
const stepSpans = new StepSpans();
try {
return await runGenerateSteps(args, toolExecutionSummaries, stepSpans);
}
finally {
stepSpans.end();
}
}
async function runGenerateSteps(args, toolExecutionSummaries, stepSpans) {
const toolsUsed = [];
let text = "";
let reasoning = "";
let finishReason = "stop";
let rawFinishReason;
let inputTokens = 0;
let outputTokens = 0;
let cacheReadTokens = 0;
let cacheWriteTokens = 0;
let steps = 0;
// One bounded recovery re-ask per turn; see the empty-tool-calls branch.
let reasked = false;
// True only while the NEXT step is the recovery re-ask. `reasked` stays set
// for the rest of the turn, so it cannot distinguish "this step is the
// re-ask" from "the re-ask already happened" — and degrading a later,
// unrelated failure would swallow a real error.
let reaskPending = false;
// The turn as it stood before the re-ask. The re-ask is a bonus request on
// top of a call that already produced a result, so if it fails the honest
// answer is that result — not a thrown turn.
let preReask;
const hasTools = Boolean(args.tools && args.tools.length > 0);
// Completed-step records for `prepareStep`; one per step that returned.
const stepRecords = [];
for (let step = 0; step < args.maxSteps; step++) {
steps = step + 1;
stepSpans.begin(steps);
// The forced-choice window and the caller's hook are resolved BEFORE the
// re-ask override below: the re-ask has to win, since its whole point is
// a request with tools disabled.
// A request with no tools declares no tool_choice either: a hook that
// forces a tool on a tool-less request (the schema reformat pass starts
// every pass at step 0) would be a 400 on OpenAI-compatible backends.
const stepToolChoice = hasTools
? await resolveStepToolChoice({
base: args.toolChoice,
step,
toolChoiceSteps: args.toolChoiceSteps,
prepareStep: args.prepareStep,
steps: stepRecords,
maxSteps: args.maxSteps,
model: args.modelId,
declaredTools: declaredToolRecord(args.tools),
...(args.abortSignal ? { abortSignal: args.abortSignal } : {}),
})
: undefined;
const wireToolChoice = toV3ToolChoice(stepToolChoice);
// Per-step context reclaim. The pre-dispatch budget check runs ONCE and
// never sees the assistant turns and tool results this loop appends, which
// is how a long agentic run overflows the model window mid-loop and loses
// every completed step to a provider 400. The streaming paths have guarded
// this for a while; generate() did not, so the identical turn reclaimed on
// stream() and overflowed on generate().
//
// The provider supplies the guard because only it knows its wire shape.
// Returning undefined leaves the conversation byte-identical, so a turn
// that fits never pays a prompt-cache invalidation.
const guarded = args.guardConversation?.(args.conversation);
if (guarded) {
args.conversation.splice(0, args.conversation.length, ...guarded);
}
const runThisStep = () => generateObservedStep(args, {
prompt: args.conversation,
...(args.tools && args.tools.length > 0 ? { tools: args.tools } : {}),
// The v3 call option is an OBJECT — `{ type: "none" }`. Passing the
// bare string "none" type-checks against `unknown` and is then
// dropped by every converter that switches on `choice.type`, so the
// re-ask silently went out unchanged. Caught by the stand-in asserting
// the wire body, not by any live provider. The same applied to the
// caller's own string-form choice, hence `toV3ToolChoice` above.
...(reasked
? { toolChoice: { type: "none" } }
: wireToolChoice !== undefined
? { toolChoice: wireToolChoice }
: {}),
...(args.responseFormat ? { responseFormat: args.responseFormat } : {}),
...(args.providerOptions
? { providerOptions: args.providerOptions }
: {}),
...(args.maxOutputTokens
? { maxOutputTokens: args.maxOutputTokens }
: {}),
...(args.temperature !== undefined
? { temperature: args.temperature }
: {}),
...(args.abortSignal ? { abortSignal: args.abortSignal } : {}),
});
let res;
try {
res = await stepSpans.run(runThisStep);
}
catch (stepError) {
// Ported from GenerationHandler.recoverEmptyToolCallsFinish's catch: a
// failed re-ask hands back the turn that already succeeded rather than
// turning a degraded turn into a thrown one. The native port made the
// re-ask a `continue`, so the failure surfaced from the NEXT step's
// doGenerate and propagated instead.
if (reaskPending && preReask) {
logger.warn("toolChoice: none re-ask failed; returning the original result", {
error: stepError instanceof Error
? stepError.message
: String(stepError),
});
text = preReask.text;
finishReason = preReask.finishReason;
rawFinishReason = preReask.rawFinishReason;
break;
}
throw stepError;
}
reaskPending = false;
args.observeUsage?.(res.usage);
const parts = asParts(res.content);
// Each step REPLACES the text rather than appending: the final step's
// answer is the turn's answer, matching what generateText reported.
text = parts
.filter((p) => p.type === "text" && typeof p.text === "string")
.map((p) => p.text)
.join("");
// Reasoner models (DeepSeek `reasoning_content`, gateway `reasoning`,
// OpenAI o-series) emit reasoning as its own V3 content part.
//
// Unlike `text`, this ACCUMULATES across steps. Each step's answer
// supersedes the last, but each step's reasoning is its own thought:
// replacing meant a tool loop reported only whatever the final step
// happened to think, which is usually the shortest part and frequently
// empty once the model just answers from the tool result. Concatenating
// with nothing between them is what a stream() consumer accumulates from
// the per-step reasoning chunks, so both surfaces agree on the turn.
reasoning += parts
.filter((p) => p.type === "reasoning" && typeof p.text === "string")
.map((p) => p.text)
.join("");
const fr = res.finishReason;
if (typeof fr === "string") {
finishReason = fr;
}
else if (typeof fr === "object" && fr !== null) {
const shaped = fr;
finishReason = shaped.unified ?? finishReason;
rawFinishReason = shaped.raw ?? rawFinishReason;
}
const usage = res.usage;
outputTokens += readTotal(usage?.outputTokens);
const inShaped = usage?.inputTokens;
// `total` on the shaped input-tokens object is cache-INCLUSIVE (noCache +
// cacheRead + cacheWrite). Accumulating it into `inputTokens` — while ALSO
// accumulating cacheRead/cacheWrite below — double-billed every cached
// token: once folded into `input`, once again as the flat cache fields
// `calculateCost` prices separately. `noCache` is the disjoint figure
// `calculateCost` expects; fall back to the inclusive total only when a
// provider hasn't populated `noCache` (i.e. it never reports cache usage).
inputTokens +=
typeof inShaped?.noCache === "number"
? inShaped.noCache
: readTotal(usage?.inputTokens);
cacheReadTokens += inShaped?.cacheRead ?? 0;
cacheWriteTokens += inShaped?.cacheWrite ?? 0;
const calls = parts.filter((p) => p.type === "tool-call");
const stepInputTokens = typeof inShaped?.noCache === "number"
? inShaped.noCache
: readTotal(usage?.inputTokens);
const stepOutputTokens = readTotal(usage?.outputTokens);
// `input` is the PARSED object, as the stream loops record it: the v3
// part carries the raw JSON string, and a hook reading
// `steps.at(-1)?.toolCalls[0]?.input?.orderId` got undefined on
// generate() while the same hook worked on stream().
const stepToolCalls = calls.map((call) => ({
toolName: String(call.toolName ?? ""),
toolCallId: String(call.toolCallId ?? ""),
input: parseToolInput(call.input).input,
}));
// Pushed now with empty results and filled in below once the tools have
// run, so a hook consulted on the next step sees this step's outputs.
const stepRecord = toPrepareStepRecord({
stepNumber: step,
content: parts,
text,
toolCalls: stepToolCalls,
toolResults: [],
finishReason,
inputTokens: stepInputTokens,
outputTokens: stepOutputTokens,
...(inShaped?.cacheRead !== undefined ||
inShaped?.cacheWrite !== undefined
? {
cacheReadTokens: inShaped?.cacheRead ?? 0,
cacheWriteTokens: inShaped?.cacheWrite ?? 0,
}
: {}),
});
stepRecords.push(stepRecord);
if (calls.length === 0) {
// io.net's Llama endpoint ends a tool loop on `finish_reason:
// tool_calls` carrying neither a tool call nor any text: the model's
// JSON-shaped answer trips the vendor's tool-call parser, which drops it
// and reports `content: null` with no `tool_calls`. There is nothing to
// execute, so the loop would stop and hand the caller an empty turn even
// though the tool ran. Replaying the request once with
// `toolChoice: "none"` returns the answer.
//
// Ported from GenerationHandler.recoverEmptyToolCallsFinish, which runs
// this on the ai-package path. That path is unreachable for every
// provider driven by this loop — io.net among them, since it is a
// Tier-2 catalog provider on the OpenAI-compatible base — so without
// this the recovery would simply not happen for the provider it was
// written for.
const emptyToolCallsFinish = finishReason === "tool-calls" &&
text.trim() === "" &&
hasTools &&
!reasked &&
step + 1 < args.maxSteps;
if (emptyToolCallsFinish) {
reasked = true;
reaskPending = true;
preReask = { text, finishReason, rawFinishReason };
args.conversation.push({ role: "assistant", content: parts });
continue;
}
break;
}
// Tool turns go back in the message-builder shape each provider's own
// conversion already round-trips: an assistant message of tool-call parts,
// then one tool message of tool-result parts.
args.conversation.push({ role: "assistant", content: parts });
const resultParts = [];
for (const call of calls) {
const name = String(call.toolName ?? "");
const id = String(call.toolCallId ?? "");
const startTime = new Date();
const parsed = parseToolInput(call.input);
const input = parsed.input;
const tool = args.toolsRecord[name];
let output;
let failure;
const rejection = parsed.error ??
(typeof tool?.execute === "function"
? validateToolInput(tool, input)
: undefined);
if (typeof tool?.execute !== "function") {
failure = `Tool not found: ${name}`;
output = { error: failure };
args.onRejectedToolCall?.(name, failure, id);
}
else if (rejection) {
// An error tool-result rather than a throw: the model gets to see what
// was wrong and correct it on the next step, which is what the SDK's
// own validation did.
failure = `Tool ${name}: ${rejection}`;
output = { error: failure };
args.onRejectedToolCall?.(name, failure, id);
}
else {
try {
// The turn's abort signal and the per-tool cap have to reach the
// tool, or a wedged tool parks the loop inside `await execute` and
// outlives the deadline that withTurnTimeout composed. Reuses the
// guard every other native loop already applies.
const guarded = guardToolExecutor(name, tool.execute, {
...(args.abortSignal ? { abortSignal: args.abortSignal } : {}),
...(args.toolTimeoutMs !== undefined
? { toolTimeoutMs: args.toolTimeoutMs }
: {}),
});
output = await stepSpans.run(() => guarded(input, {
toolCallId: id,
messages: [],
...(args.abortSignal ? { abortSignal: args.abortSignal } : {}),
}));
toolsUsed.push(name);
}
catch (err) {
failure = err instanceof Error ? err.message : String(err);
output = { error: failure };
}
}
toolExecutionSummaries.push({
toolCallId: id,
toolName: name,
input,
...(failure ? { error: failure } : { output }),
startTime,
endTime: new Date(),
stepIndex: step,
});
resultParts.push({ type: "tool-result", toolCallId: id, output });
stepRecord.toolResults.push({ toolName: name, toolCallId: id, output });
}
args.conversation.push({ role: "tool", content: resultParts });
}
return {
text,
...(reasoning ? { reasoning } : {}),
finishReason,
...(rawFinishReason ? { rawFinishReason } : {}),
inputTokens,
outputTokens,
cacheReadTokens,
cacheWriteTokens,
toolsUsed,
steps,
};
}