Skip to main content

Turn Time Budget

Turn-lifecycle limits for agentic (multi-step tool-calling) turns, enforced inside NeuroLink's native Gemini and Claude loops — Vertex (Gemini and Claude-on-Vertex) and Google AI Studio, both generate() and stream() in each case. NeuroLink sees every model call, every tool start/finish, and every stream chunk — so it is the layer that can tell a productive long turn from a wedged one, and end each with an honest message and a machine-readable reason.

Options​

All four options ride generate() / stream() alongside maxSteps and the per-model-call timeout:

OptionMeaningDefault
turnTimeoutMsHard wall-clock cap for the WHOLE agentic turn (all model calls + tool executions)see "Defensive defaults" below
stallTimeoutMsMax time with no progress (no stream chunk, no tool start/finish, no step start) before the turn endsdisabled
wrapupTimeLeadMsWith less than this much turn time remaining, a wrap-up nudge rides the next tool-result turn120_000 when turnTimeoutMs is set
toolTimeoutMsPer-tool-execution timeout; a timed-out tool fails that step (error tool_result) and the turn continues300_000
const result = await neurolink.generate({
input: { text: "Investigate the amount mismatch across services" },
provider: "vertex",
maxSteps: 50,
turnTimeoutMs: 1_800_000, // 30 min whole-turn budget
stallTimeoutMs: 300_000, // no progress for 5 min => end the turn
toolTimeoutMs: 300_000, // a wedged tool costs one step, not the turn
});

turnTimeoutMs vs timeout on the native loop path​

On the native loop path (direct Anthropic, LiteLLM, OpenAI-compatible), an explicit turnTimeoutMs owns the whole-turn hard abort, and timeout keeps its per-model-call meaning. Historically timeout alone bounded the ENTIRE multi-step loop there, so { timeout: 300_000, turnTimeoutMs: 2_400_000 } killed a 40-minute turn at 5 minutes flat — surfacing as the provider SDK's generic cancel (Request was aborted.) mid-loop. When the hard cap does fire, the error now carries the timer's own identity (… timed out after …) instead of that generic cancel shape.

Defensive defaults​

When turnTimeoutMs is unset, each loop keeps its pre-existing behavior:

  • Vertex Gemini (generate + stream) and Vertex Claude stream: the defensive whole-turn bound of timeout ?? 300_000 ms still applies (a turn must never hang forever) — but its firing is now labeled honestly as a time-limit exit instead of masquerading as a step-cap exit.
  • Vertex Claude generate: historically had no whole-turn bound (only the per-call timeout), and still has none — long multi-step turns keep running. Set turnTimeoutMs explicitly to bound them.
  • Google AI Studio (generate + stream): the pre-existing per-request timeout controller (timeout, defaulting to the provider's own budget) remains the whole-turn bound, and no second deadline is armed alongside it. Its firing is now reported as stopReason: "time-limit" rather than as a bare abort. An explicit turnTimeoutMs adds a tighter deadline on top.

stopReason — the turn-exit discriminator​

finishReason is provider-shaped and historically overloaded ("tool-calls" covered both step-cap exits and Gemini MALFORMED_FUNCTION_CALL failures). Branch on result.stopReason instead:

stopReasonMeaning
completedThe model finished on its own (text answer or final_result)
step-capThe maxSteps budget ran out while the model still wanted tools
time-limitThe turnTimeoutMs (or defensive) wall-clock deadline passed
stalledNo progress for stallTimeoutMs
abortedThe caller's abortSignal ended the turn
context-capThe in-loop context guard stopped the turn (Vertex loops only)
provider-errorProvider/model failure (e.g. persistent MALFORMED_FUNCTION_CALL)

A loop only reports what it can observe. The AI Studio loops never report context-cap, because their context guard reclaims room or does nothing — it has no stop-the-turn branch to report.

Also on the result: rawFinishReason (the verbatim provider value, e.g. MALFORMED_FUNCTION_CALL, max_tokens) and stepsUsed. On stream() results, read metadata.stopReason / metadata.rawFinishReason / metadata.stepsUsed after draining the stream — background loops resolve them at close, and metadata is the mutable reference that survives wrapper spreads.

Providers without a native loop leave stopReason undefined — keep any legacy finishReason heuristics as a fallback.

Honest terminal messages​

Each exit cause has its own user-facing message; the step-cap text ("...reached the N-step limit...") is emitted only when step >= maxSteps genuinely terminated the loop:

  • time-limit: "I had to stop after Xm Ys — this turn hit its processing time limit. I completed N tool calls before stopping; ask me to continue and I'll pick up from there."
  • stalled: "I had to stop because this turn made no progress for Xs — a tool or model call appears to be stuck. ..."
  • aborted: "This turn was stopped before I could finish. ..."

MALFORMED_FUNCTION_CALL handling​

Gemini's MALFORMED_FUNCTION_CALL / UNEXPECTED_TOOL_CALL finish reasons map to unified finishReason: "error" (never "tool-calls"). A MALFORMED_FUNCTION_CALL step is retried once with a corrective note; if it persists, the turn ends with stopReason: "provider-error" — usually worth a caller-side retry.

Other Gemini finish reasons​

The native Gemini loops (AI Studio and Vertex) map the SDK's finish reason onto the unified values below and keep the verbatim value in rawFinishReason. Vertex puts the unified value on finishReason; on AI Studio it is the unified field of the finish part that model middleware receives.

Gemini finish reasonfinishReason
MAX_TOKENS, CONTINUATION (token limit reached before the model was done)length
TOO_MANY_TOOL_CALLS (the model called tools until the system cut the turn off)tool-calls
MALFORMED_FUNCTION_CALL, UNEXPECTED_TOOL_CALLerror
SAFETY, RECITATION, BLOCKLIST, PROHIBITED_CONTENT, SPII, LANGUAGE, IMAGE_*¹content-filter
STOP, OTHER, FINISH_REASON_UNSPECIFIED, IMAGE_OTHER, NO_IMAGEstop

¹ IMAGE_SAFETY, IMAGE_PROHIBITED_CONTENT and IMAGE_RECITATION.

tool-calls also marks a turn that ran out of maxSteps, so branch on stopReason to tell the two apart: TOO_MANY_TOOL_CALLS leaves stopReason: "completed", a spent step budget reports step-cap. A value the SDK adds later reads stop until it is mapped.

Telemetry​

  • result.analytics (when enableAnalytics is on) carries stepsUsed, toolCallCount, stopReason, elapsedMs, rawFinishReason.
  • The SDK emitter fires turn:lifecycle events (alongside tool:start/tool:end) for non-completed exits, tool timeouts, and malformed-call retries:
neurolink.getEventEmitter().on("turn:lifecycle", (event) => {
// { provider, timestamp, phase: "time-limit" | "stalled" | "aborted" |
// "step-cap" | "provider-error" | "tool-timeout" | "malformed-retry",
// step?, maxSteps?, toolName?, toolCallCount?, elapsedMs? }
});