Cogitator
Tools

Stage Timeouts

Give the whole run, each model call, each streamed chunk and each tool call a deadline of its own.

Overview

A run's timeout bounds the whole run. That is often too coarse: a model call that hangs uses up all of it, and so does one slow tool. timeouts gives each stage of a run its own deadline:

const agent = new Agent({
  name: 'researcher',
  model: 'openai/gpt-5.5',
  instructions: 'Research the question and answer with sources.',
  tools: [webSearch, fetchPage],
  timeouts: { totalMs: 300_000, stepMs: 60_000, toolMs: 20_000 },
});

await cog.run(agent, {
  input: 'What changed in the latest TypeScript release?',
  stream: true,
  onToken: (token) => process.stdout.write(token),
  timeouts: { chunkMs: 15_000 }, // over the agent's, field by field
});
FieldBoundsWhen it runs out
totalMsthe whole runthe run fails with RUN_TIMEOUT (504)
stepMsone model call, from sending it to its last chunk, retries and fallback models includedthe run fails with RUN_STEP_TIMEOUT (504)
chunkMsa streamed model call without a chunk for this long, the first one includedthe run fails with RUN_CHUNK_TIMEOUT (504), not retried
toolMseach call of a tool that has no timeout of its ownthe call fails, the model reads why and the run goes on

Every field is off unless set, so a run without timeouts behaves as before. RunOptions.timeouts and AgentConfig.timeouts (a RunTimeouts) merge field by field, the run's fields winning. The agent's timeouts travel with it in agent.serialize() and the agent wire format.

The Whole Run

totalMs is the run's deadline, as timeout is. The first one set wins, in this order: the run's timeouts.totalMs, the run's timeout, the agent's timeouts.totalMs, the agent's timeout, limits.defaultTimeout of the Cogitator, and 2 minutes. So a run's timeout still overrides what an agent says in timeouts.

A paused run keeps its own timeouts in checkpoint.toolExecution, and cog.resume() uses them again unless its options set them. A kept totalMs gives the resumed run that deadline anew, unless the resume passes timeouts.totalMs or timeout.

Model Calls

stepMs bounds one model turn as the run sees it: every retry of llm.retry and every model of fallbackModels it tried count against it. A call that runs out fails the run with RUN_STEP_TIMEOUT (details.stepMs), also when the backend ignores the abort.

chunkMs watches a streamed call: once it waited that long for the next chunk (or the first one), the stream is closed and the run fails with RUN_CHUNK_TIMEOUT (details.chunkMs). It only applies to runs that stream (stream: true with onToken, onReasoning or a tool input callback).

Both are different from llm.retry.requestTimeout, which aborts one attempt and retries it as LLM_TIMEOUT (see Retries). A stage timeout ends the run instead: use requestTimeout for a provider that sometimes hangs, stepMs and chunkMs for a ceiling on how long a turn may take.

Tool Calls

toolMs is the timeout of every tool call whose tool sets no timeout of its own, sandboxed tools included. A call that runs out fails like any tool error, so the model reads it and the run goes on:

await cog.run(agent, {
  input: 'Check the status page',
  timeouts: { toolMs: 10_000 },
  onToolResult: (result) => {
    if (result.errorCode === 'TOOL_TIMEOUT')
      metrics.increment('tool.timeout', { tool: result.name });
  },
});
// the model reads: { "error": "Tool \"fetch_page\" timed out after 10000ms" }

The call's signal aborts when it runs out, so a tool that passes context.signal to fetch or a child process stops its work. The ToolResult has errorCode: 'TOOL_TIMEOUT' (ErrorCode.TOOL_TIMEOUT), and the tool call's span records it as error.type. A tool's own timeout always wins over toolMs.

The clock starts when the tool starts: time a call waits for its lock keys, or for its turn among the calls of the step, does not count toward toolMs or the tool's timeout, while totalMs goes on. A call that timed out keeps its lock keys until the tool's own promise settles, so a tool that ignores its signal holds the file it writes until it really stops, and the next writer of that file waits for it. With parallelToolCalls a call of the step that runs alone waits the same way for a safe call that timed out and still runs.

A sandboxed command that runs out of time fails the same way, in Docker as in WASM: the call gets errorCode: 'TOOL_TIMEOUT' and an error naming the sandbox, instead of a result with timedOut: true.

On this page