Skip to main content

Turn Time Budget

Turn-lifecycle limits for agentic (multi-step tool-calling) turns, enforced inside NeuroLink's native Vertex loops (Gemini and Claude-on-Vertex, both generate() and stream()). 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
});

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.

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
provider-errorProvider/model failure (e.g. persistent MALFORMED_FUNCTION_CALL)

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.

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? }
});