Skip to content

maxThinkingTokens breaks all Agent SDK hooks regardless of value #7

Description

@Number531

Summary

Setting maxThinkingTokens to any value in agentQuery() options breaks all hook dispatch — zero SubagentStart, SubagentStop, Notification, PreCompact, and all other hook events fire for custom agents. This is not a threshold issue; values of 1000, 4096, 10000, 16000, and 100000 all reproduce the identical failure. The parameter must be entirely absent from the options object.

Tracked upstream: anthropics/claude-agent-sdk-typescript#25


Technical Root Cause — Full Trace

1. Parameter Flow Through the SDK

When maxThinkingTokens is passed to agentQuery(), the SDK processes it through this chain:

agentQuery({ options: { maxThinkingTokens: N } })
  → query() in sdk.mjs:15087
    → destructured at sdk.mjs:15127: maxThinkingTokens
    → passed to ProcessTransport at sdk.mjs:15181: maxThinkingTokens
      → ProcessTransport.spawn() at sdk.mjs:6534-6536:
          if (maxThinkingTokens !== undefined) {
            args.push("--max-thinking-tokens", maxThinkingTokens.toString());
          }
        → spawns: node cli.js --output-format stream-json --verbose
                              --input-format stream-json
                              --max-thinking-tokens 4096    ← THIS FLAG
                              --max-turns 500
                              --model claude-sonnet-4-5-20250929
                              --betas context-1m-...,interleaved-thinking-...
                              --permission-mode bypassPermissions
                              ...

The SDK spawns cli.js (the bundled Claude Code executable) as a child process. The --max-thinking-tokens flag is passed as a CLI argument.

2. Hook Registration (Works Correctly)

Hooks are registered during Query.initialize() (sdk.mjs:7891-7925):

async initialize() {
  let hooks;
  if (this.hooks) {
    hooks = {};
    for (const [event, matchers] of Object.entries(this.hooks)) {
      if (matchers.length > 0) {
        hooks[event] = matchers.map((matcher) => {
          const callbackIds = [];
          for (const callback of matcher.hooks) {
            const callbackId = `hook_${this.nextCallbackId++}`;
            this.hookCallbacks.set(callbackId, callback);  // Stored in Map
            callbackIds.push(callbackId);
          }
          return { matcher: matcher.matcher, hookCallbackIds: callbackIds, timeout: matcher.timeout };
        });
      }
    }
  }
  const initRequest = {
    subtype: "initialize",
    hooks,                    // Sent to CLI process
    sdkMcpServers,
    agents: this.initConfig?.agents
  };
  const response = await this.request(initRequest);
}

The hooks are correctly serialized and sent to the CLI process via the initialize control request. The callback IDs are stored in this.hookCallbacks Map. This part works — the hooks are registered.

3. Hook Dispatch (Broken by --max-thinking-tokens)

When the CLI process needs to fire a hook, it sends a control_request with subtype hook_callback back to the SDK:

// sdk.mjs:7865-7866 — inside processControlRequest()
} else if (request.request.subtype === "hook_callback") {
  const result = await this.handleHookCallbacks(
    request.request.callback_id, request.request.input, request.request.tool_use_id, signal
  );
// sdk.mjs:8045-8052
handleHookCallbacks(callbackId, input, toolUseID, abortSignal) {
  const callback = this.hookCallbacks.get(callbackId);
  if (!callback) {
    throw new Error(`No hook callback found for ID: ${callbackId}`);
  }
  return callback(input, toolUseID, { signal: abortSignal });
}

The CLI process never sends these hook_callback control requests when --max-thinking-tokens is active. The CLI's internal event loop/streaming pipeline is disrupted by the thinking flag, causing it to suppress all lifecycle events while still producing correct final output.

4. What the CLI Sends vs. Doesn't Send

Without --max-thinking-tokens (WORKING):

CLI → SDK: control_request { subtype: "hook_callback", callback_id: "hook_0", input: { agent_type: "Explore" } }
CLI → SDK: control_request { subtype: "hook_callback", callback_id: "hook_0", input: { agent_type: "Plan" } }
CLI → SDK: { type: "system", subtype: "init", session_id: "..." }
CLI → SDK: { type: "stream_event", event: { type: "content_block_delta", ... } }  ← streaming works
CLI → SDK: control_request { subtype: "hook_callback", callback_id: "hook_0", input: { agent_type: "securities-researcher" } }
CLI → SDK: control_request { subtype: "hook_callback", callback_id: "hook_0", input: { agent_type: "case-law-analyst" } }
... (all 40+ subagent hooks fire)

With --max-thinking-tokens (BROKEN):

CLI → SDK: control_request { subtype: "hook_callback", callback_id: "hook_0", input: { agent_type: "Explore" } }
CLI → SDK: control_request { subtype: "hook_callback", callback_id: "hook_0", input: { agent_type: "Plan" } }
CLI → SDK: { type: "system", subtype: "init", session_id: "..." }
CLI → SDK: { type: "stream_event", event: { type: "content_block_delta", ... } }  ← streaming partially works
                                                                                    ← ZERO further hook_callbacks
                                                                                    ← custom agent hooks NEVER fire

Built-in agents (Explore, Plan) fire at initialization time — before the thinking pipeline engages. Custom agents fire during execution when the Task tool dispatches them, by which point the thinking pipeline has suppressed all event dispatch.


Affected Hook Types

All hook types registered in HOOK_EVENTS (sdk.mjs:6381-6394) are affected:

Hook Type Status with maxThinkingTokens Status without
SubagentStart BROKEN — zero events for custom agents Working
SubagentStop BROKEN — zero events Working
Notification BROKEN — zero events Working
PreCompact BROKEN — zero events Working
PreToolUse BROKEN — zero events Working
PostToolUse BROKEN — zero events Working
PostToolUseFailure BROKEN — untested, assumed broken Untested
SessionStart Fires (init-time) Fires
SessionEnd Untested Untested
Stop Untested Untested

The pattern: hooks that fire at initialization time (before the first API call) still work. Hooks that fire during execution (after the thinking pipeline starts) are completely suppressed.


Empirical Evidence

Broken Run (Feb 17 — maxThinkingTokens: 16000)

From a 3,759-line SSE capture with maxThinkingTokens: 16000, SDK 0.2.44:

SSE Event Type Count
system_info 1
system_init 1
thinking_start / thinking ~495
tool_call (Task dispatching subagents) 8
hook_event (SubagentStart) for custom agents 0
hook_event (SubagentStop) 0
hook_event (Notification) 0
hook_event (PreCompact) 0

The orchestrator spawned 8 subagents via Task tool calls but zero hook callbacks were received.

Broken Run (Feb 18 — maxThinkingTokens: 4096)

Same behavior with lower threshold. SDK 0.1.61:

  • Hooks registered correctly (hookCallbacks Map populated)
  • Explore/Plan SubagentStart fired at init (before thinking)
  • Custom agent SubagentStart: 0 events

Working Run (Feb 18 — maxThinkingTokens omitted)

From 5,606-line SSE capture, SDK 0.1.61:

SSE Event Type Count
hook_event (SubagentStart) — Explore 1
hook_event (SubagentStart) — Plan 1
hook_event (SubagentStart) — custom agents 9+
hook_event (SubagentStop) — custom agents 9+
hook_event (PreCompact) 1
Text streaming (delta events) Hundreds

Custom agent hooks fired correctly:

  • employment-labor-analyst (timestamp 1771438545610)
  • commercial-contracts-analyst (1771438545611)
  • tax-structure-analyst (1771438545612)
  • case-law-analyst (1771438545613)
  • patent-analyst (1771438545615)
  • regulatory-rulemaking-analyst (1771438545616)

Working Run (Feb 16 — maxThinkingTokens absent, Opus 4.6)

From session manifest 2026-02-16-1771206424/session-manifest.log:

  • 67+ SubagentStart events for custom agents
  • All hooks firing correctly
  • Model was claude-opus-4-6, no maxThinkingTokens, only context-1m beta

Commit History — When maxThinkingTokens Was Introduced

Commit Date Change Hook Status
f7af3e7 Feb 16 No maxThinkingTokens in agentQuery Working — 67+ custom agent hooks
b5f47bc Feb 17 Added maxThinkingTokens: 16000 Broken — 0 custom agent hooks
46be06c Feb 17 SDK upgrade 0.39→0.74, 0.1.61→0.2.44 (kept maxThinkingTokens) Broken — 0 custom agent hooks
Current Feb 18 Commented out maxThinkingTokens, reverted SDK Working — 9+ custom agent hooks

The Feb 16 working state (f7af3e7) had these agentQuery options:

{
  model: 'claude-opus-4-6',
  maxTurns: 500,
  // NO maxThinkingTokens
  betas: ['context-1m-2025-08-07'],  // No interleaved-thinking, no effort
  hooks: manifestHooksConfig,
  agents: getLegalSubagents()
}

Reproduction

// BROKEN — ANY value breaks hooks (tested: 1000, 4096, 10000, 16000)
for await (const message of agentQuery({
  prompt,
  options: {
    model: 'claude-sonnet-4-5-20250929',
    maxThinkingTokens: 4096,  // any value
    hooks: manifestHooksConfig,
    agents: getLegalSubagents(),
    includePartialMessages: true,
    betas: ['interleaved-thinking-2025-05-14', 'context-1m-2025-08-07']
  }
}))
// WORKING — parameter must be entirely absent (not null, not 0, absent)
for await (const message of agentQuery({
  prompt,
  options: {
    model: 'claude-sonnet-4-5-20250929',
    // maxThinkingTokens: OMITTED
    hooks: manifestHooksConfig,
    agents: getLegalSubagents(),
    includePartialMessages: true,
    betas: ['interleaved-thinking-2025-05-14', 'context-1m-2025-08-07']
  }
}))

Impact on Our System

  • Frontend dashboard: Phase pipeline UI (SubagentStart/SubagentStop drive hookSSEBridge.jsclassifyAgent() → SSE subagent_start/subagent_stop events) shows zero agent activity
  • Session manifest: NDJSON event log (session-manifest.log) contains zero custom agent lifecycle events, making post-run analysis impossible
  • Tool usage tracking: SubagentStop hooks parse transcripts for tool usage counters (subagentToolUsage) — all counters stay at 0
  • Gate checks: SubagentStop hook runs report verification against expected file paths — silently skipped
  • Auto-recovery: PreCompact hook injects recovery context before compaction — never fires, so compaction loses critical state
  • Silent failure: No errors, warnings, or degraded output quality — the orchestrator still produces correct final output, hooks are just silently dead

Upstream Confirmation

anthropics/claude-agent-sdk-typescript#25 confirms this is a known issue:

  • @InsanePrototyper (Jan 6, 2026): "When maxThinkingTokens is set, StreamEvent stops emitting entirely... 0 StreamEvents — nothing comes through. No tool start, no input deltas, no text deltas. Completely silent until the final AssistantMessage."
  • @ashwin-ant (Anthropic, Jan 6): Acknowledged, requested repro details
  • @sccorby (Jan 12): "Can confirm I am encountering this issue with the Python SDK also."
  • Our comment (Feb 18): Confirmed zero hook events with raw SSE log evidence across SDK versions 0.1.61 and 0.2.44

The issue affects both TypeScript and Python Agent SDKs and has been open since October 2025.


Workaround

Remove maxThinkingTokens entirely from agentQuery() options. The bundled CLI process (cli.js) manages thinking internally per model using its own default budget. Thinking still occurs (confirmed by output quality analysis) — just without an explicit external budget constraint.

// In claude-sdk-server.js line 938:
// maxThinkingTokens: Number(process.env.SDK_MAX_THINKING_TOKENS || 4096),
//   ↑ Disabled — breaks SubagentStart/Stop hooks (Agent SDK Issue #25)

Environment

  • @anthropic-ai/claude-agent-sdk: 0.1.61 (also reproduced on 0.2.44)
  • @anthropic-ai/sdk: 0.39.0 (also reproduced on 0.74.0)
  • Model: claude-sonnet-4-5-20250929 (also reproduced on claude-opus-4-6)
  • Betas: interleaved-thinking-2025-05-14, context-1m-2025-08-07
  • Node: v22.x, macOS Darwin 23.5.0
  • 40 registered custom subagents, 134+ MCP tools

Related Issues

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions