Skip to content
Case study · Upstream contribution · 2026

Making LangChain’s agent tool binding compatible with Claude’s extended thinking

LangChain agents that force a tool call, such as create_agent with a response_format schema, could not run on Claude with extended thinking enabled. LangChain's structured-output path already handled thinking, but ChatAnthropic.bind_tools() passed a forced tool_choice straight to the API, which rejects that combination at request time. Hermes Labs engineered a 20-line guard in bind_tools(): when thinking is enabled and tool_choice forces a tool, it drops the forced choice and warns that tool calls are not guaranteed, leaving auto and thinking-off bindings unchanged. LangChain merged it as PR #35544, with two test functions covering eight scenarios. A community reporter filed the issue and identified the root cause.

View merged PR #35544 → Read the receipt ↓

Anthropic's API has a hard rule: when extended thinking is enabled, you may not force tool use. A request that sets thinking={ type: "enabled" } and also sends a forcing tool_choice, meaning "any" or a specific named tool, is rejected with a 400: "Thinking may not be enabled when tool_choice forces tool use." The non-forcing value tool_choice="auto" is permitted with thinking and works fine, which is exactly what makes the failure narrow and easy to misattribute.

In langchain-anthropic, ChatAnthropic.bind_tools() normalized tool_choice and passed it straight through to the API, with no awareness of whether thinking was active on the model. Any higher-level construct that forces a tool, the most common being create_agent with a response_format schema, binds the structured-output tool with tool_choice="any". Combine that one forcing path with a thinking-enabled Claude model and the request never reached a useful response. It died at the API boundary with a 400. Agents using tool_choice="auto", the default, were unaffected.

The binding could construct the invalid request without identifying the conflict. Anthropic enforced the constraint at request time, so the visible symptom was an API rejection naming tool_choice. The patch moves handling to the binding that knows both the thinking setting and the requested tool choice. The upstream report and regression diff establish this specific failure; they do not establish that every possible static check would miss it.

The same binary-gated discipline Hermes Labs offers as a diagnostic pipeline for private stacks also governs how it produces a public upstream fix: pass a gate, or stop. No gate is skippable. No gate is judgment-graded. The intent is that an opinionated process produces a falsifiable artifact at each step, so the diff at the end is one a maintainer can adopt without re-deriving the reasoning. The gates below describe that discipline as it bears on this fix.

Gate 0 · Qualify. The target is a top-tier framework, the failure sits squarely in the model-binding layer, and comprehension cost is bounded to one provider package. Above threshold.

Gate 1 · Reproduce. The reporter's example reproduced the 400 exactly: a thinking-enabled ChatAnthropic plus a forced-tool structured-output path, run from a known commit. The repro confirmed the symptom: nothing in the binding flagged the conflict, and the 400 came back only when the request reached the API. No reproduction, no work.

Gate 2 · Investigate. Confirm the reporter's root cause against the code and locate the precise point to guard. Tracing the failing call path line by line: the forced tool_choice="any" originates in the structured-output-to-tool conversion and flows untouched through bind_tools() to the request, where the API rejects it — so the binding is the one place a single guard covers every forcing caller. The same trace surfaced the in-repo contract for the fix: the structured-output path already handles this exact constraint in _get_llm_for_structured_output_when_thinking_is_enabled, which omits tool_choice for the same API reason. That precedent set the shape of the fix, so the guard would look like code already in the repo rather than a novel pattern.

Gate 3 · Audit. Falsify the hypothesis on purpose. Confirm that the rejection is a documented Anthropic constraint and not a transient or a model-version artifact. Confirm tool_choice="auto" is permitted with thinking and returns a valid response, so the guard must drop only the forcing variants ("any" and a named tool), never "auto". This is also the check that disproves any "thinking plus tools is simply broken" reading: only the forced path fails. Confirm the guard must be inert when thinking is absent, None, or explicitly disabled, so existing tool-forcing agents see no behavior change.

Gate 4 · Test. Tests that fail on unfixed code and pass on fixed code, exercising the real bind_tools() behavior rather than a mock at the patched line: forced string, forced dict, the "any" and "tool" dict types, the "auto" passthrough, and the thinking-disabled, None, and absent cases.

Gate 5 · Fix. The minimum diff that closes the test: a guard added after tool_choice normalization in bind_tools(). Every changed line gets a one-sentence justification. The shape was chosen to mirror the existing structured-output guard so reviewers could verify it against precedent already in the repo.

Gate 6 · Review. Adversarial reviewers run in context-isolated subagents and score the same binary gates before submission. An arbiter returns ship or no-ship. Maintainer review is a separate, real bar after that — the value of the internal pass is a defensible diff entering review, not a claim that review found nothing.

Gate 7 · Report. A structured internal report: bug summary, reproduction, root cause, fix, test, contracts verified, version compatibility, review tally, iteration history, falsification attempts, competing hypotheses, lessons.

The internal artifact set for this fix is on file. Sharing the relevant slices is a service deliverable on request.

The cause sits at a specific seam: bind_tools() passed a forcing tool_choice through to a thinking-enabled request with no guard. The fix is the minimum change that closes that seam. After tool_choice normalization, if thinking is enabled and the choice forces tool use, drop the choice and warn. The precedent surfaced in Gate 2 set the shape of the fix: the structured-output path already omits tool_choice under thinking for the same API reason, so reviewers could check the guard against code already in the repo rather than against a novel pattern.

if (
    self.thinking is not None
    and self.thinking.get("type") == "enabled"
    and "tool_choice" in kwargs
    and kwargs["tool_choice"].get("type") in ("any", "tool")
):
    warnings.warn(
        "tool_choice is forced but thinking is enabled. The Anthropic "
        "API does not support forced tool use with thinking. "
        "Dropping tool_choice to avoid an API error. ...",
        stacklevel=2,
    )
    del kwargs["tool_choice"]

The production change is 20 lines in chat_models.py (the warnings module was already imported there; the only added import was import warnings in the test file). It is paired with two test functions covering eight scenarios across the forced variants, the "auto" passthrough, and the thinking-off, None, and disabled cases. The warning matters as much as the guard: dropping tool_choice means tool calls are no longer guaranteed on that request, so the caller is told explicitly rather than left to discover it. Credit to the reporter for the accurate root cause and clean repro, and to the LangChain maintainers for the structured-output guard that gave the fix its shape. Hermes's contribution was the engineering: generalizing the guard to the binding boundary so every forcing caller is covered, proving which tool_choice values had to be dropped versus preserved, showing the change was inert for every unaffected agent, and landing it through review.

For a team running Claude agents, this is the failure shape that is most expensive to diagnose in-house: a provider constraint, enforced at the API boundary, triggered by a value set two layers up in a framework you did not write. In the reported case nothing flagged it before the request was sent: the types were sound and the binding passed both settings through, so the signal was a live request that came back 400. And the error message, while accurate, is misleading, because it names tool_choice and not the binding that forced it. Teams burn hours bisecting their own agent graph before the cause turns out to be a one-line interaction between two correct-looking settings, which in the reported case surfaced as the API rejecting the request.

This case is public, so the credit is shared. The reporter supplied the diagnosis. The engineering held up in a maintainer's review and merged. When Hermes Labs audits a private stack, the same discipline turns to discovery, and the deliverable is the receipts: the reproduction, the falsification log that rules out competing causes, the failing-then-passing regression tests, and an explicit list of what was checked — that auto stays untouched, that thinking-off agents see no change, and that the change is inert everywhere the failure does not apply. A buyer can read it cold and decide whether the failure applies to their stack and whether the audit checked the right things, without re-running the investigation.

This is the same pipeline Hermes Labs runs whether the target is a model binding, a prompt template, an agent harness, a retrieval layer, or a tool definition. The systematic failure categories vary; the gate structure does not.

Open the public chronologyIssue, implementation, and merge dates
  • 2026-03-03 · A community user files issue #35539 with a self-contained repro and an accurate root cause: create_agent with a response_format hardcodes tool_choice="any" for the structured-output tool, which Claude rejects under extended thinking. The diagnosis and the repro are the reporter's.
  • 2026-03 · Hermes Labs engineers the fix one layer down, at the ChatAnthropic.bind_tools() binding, so any caller that forces a tool, not just create_agent, is covered. The work confirms the reporter's cause against the code, proves which tool_choice values must be dropped versus preserved, verifies the guard is inert when thinking is off, and adds regression tests. Hermes opens PR #35544.
  • 2026-03-05 · LangChain maintainers merge PR #35544 to master, closing #35539.

MethodReproduced the request-time 400 from issue #35539 at a known commit, confirmed the reporter's root cause against the code, and placed one guard in ChatAnthropic.bind_tools(), after tool_choice normalization, matching the thinking handling the structured-output path already had.

ResultLangChain merged PR #35544 on 2026-03-05: a 20-line guard that drops a forced tool_choice ("any" or a named tool) when thinking is enabled and warns, with two test functions covering eight scenarios. Agents that force a tool call can bind with extended thinking on; tool_choice="auto" and thinking-off bindings are unchanged.

Could not detectThe diagnosis is the reporter's, not ours. Dropping the forced choice means a tool call is no longer guaranteed on those requests; the caller gets a warning, not the forced call. Other providers and model bindings were not examined, and the record does not show that every static check would miss the error.

ArtifactsIssue #35539 · PR #35544 · Merge commit · Contribution ledger

Seeing this in your own system? A paid diagnosis applies the same method to your system and symptom. The tools, papers and receipts stay free.

Book a 20-minute scoping call (Calendly, opens in a new tab) →What is paid, what is free →

If you run Claude agents in production, the failures that cost the most are the ones that look like your code but live in a framework binding or a provider constraint. A paid diagnosis can apply this method to your stack under agreed terms.

Talk to us about an audit →

References: #35539 · #35544 · PR filed under our founder's GitHub handle, roli-lpci · Upstream contributions: /open-source/contributions