You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fromtypingimportLiteral, Unionfromcollections.abcimportCallablefrompydanticimportBaseModel, Fieldfromlangchain.agentsimportcreate_agentfromlangchain.agents.middlewareimport (
ModelRequest,
ModelResponse,
wrap_model_call,
before_agent,
)
fromlangchain.agents.structured_outputimportToolStrategy# Two possible response typesclassDetailedAnswer(BaseModel):
type: Literal["DetailedAnswer"] ="DetailedAnswer"content: str=Field(description="A detailed, thorough answer")
classBriefAnswer(BaseModel):
type: Literal["BriefAnswer"] ="BriefAnswer"content: str=Field(description="A brief, one-sentence answer")
FullResponse=Union[DetailedAnswer, BriefAnswer]
# Middleware that should narrow to only DetailedAnswer@wrap_model_callasyncdefforce_detailed(
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) ->ModelResponse:
"""Narrow response format to only allow DetailedAnswer."""narrowed=request.override(
response_format=ToolStrategy(DetailedAnswer)
)
returnawaithandler(narrowed)
agent=create_agent(
model="anthropic:claude-haiku-4-5-20251001",
tools=[],
middleware=[force_detailed],
response_format=ToolStrategy(FullResponse),
)
# Ask the model something — it CAN still choose BriefAnswer# even though middleware narrowed to DetailedAnswer onlyresult=agent.invoke({
"messages": [{"role": "user", "content": "What is 2+2? Be brief."}]
})
# BUG: model may return BriefAnswer despite middleware narrowingprint(type(result["structured_response"]))
# Expected: always DetailedAnswer# Actual: may be BriefAnswer because both tools are bound to the model
Error Message and Stack Trace (if applicable)
Description
Description
When a @wrap_model_call middleware narrows response_format from a union ToolStrategy to a single-type subset via request.override(response_format=ToolStrategy(SubsetType)), the narrowing is validated (lines 1241-1248 of factory.py confirm the subset is valid) but never enforced. The model is still bound with all structured output tools from the original union, and can freely choose any of them.
This contradicts:
The code comment at factory.py:1234-1239: "Middleware is allowed to change the response format to a subset of the original structured tools when using ToolStrategy"
structured_output_tools is a closure variable built once from the initial response_format at create_agent() time. Five locations in _get_bound_model() and the graph edge functions use this full set instead of the runtime-narrowed effective_response_format:
Location
Code
Impact
factory.py:1220
structured_tools = [info.tool for info in structured_output_tools.values()]
All original structured tools are added to final_tools and bound to the model, regardless of narrowed format
factory.py:1071
tc["name"] in structured_output_tools
Output handler accepts tool calls for types outside the narrowed format
factory.py:1100
structured_output_tools[tool_call["name"]]
Successfully parses responses of the wrong type
factory.py:1728
c["name"] not in structured_output_tools
Edge routing treats wrong-type calls as "handled" structured output
factory.py:1808
t.name in structured_output_tools
Agent loop exits on wrong-type structured response
The validation at lines 1241-1248 correctly confirms the narrowed format is a valid subset, but none of the downstream code filters to that subset.
Expected behavior
When middleware narrows response_format to ToolStrategy(SubsetType):
Only the structured output tools in the narrowed format should be bound to the model
The output handler should only accept tool calls matching the narrowed format
Edge routing should only treat narrowed-format tool calls as structured output
Actual behavior
All structured output tools from the original create_agent(response_format=...) are always bound to the model. The model can choose any of them regardless of middleware narrowing. The narrowed effective_response_format is validated but never used for filtering.
Suggested fix
In _get_bound_model(), filter structured output tools to only those present in effective_response_format.schema_specs:
# Current (line 1220): adds ALL structured toolsstructured_tools= [info.toolforinfoinstructured_output_tools.values()]
# Fix: filter to only tools in the narrowed formatifisinstance(effective_response_format, ToolStrategy):
narrowed_names= {tc.namefortcineffective_response_format.schema_specs}
structured_tools= [
info.toolforname, infoinstructured_output_tools.items()
ifnameinnarrowed_names
]
The same filtering pattern would need to be applied to _handle_model_output (line 1071) and the edge functions (lines 1728, 1808) — though these are trickier since they're closures that don't have access to the runtime-narrowed format. One approach: store the effective_response_format in agent state so edge functions can read it, or restructure the edge functions to check both the full set and the state-stored narrowed set.
A simpler alternative for the edge functions: since _handle_model_output already receives effective_response_format, it could reject tool calls not in the narrowed set (returning them as "unhandled" so the agent loop retries), which would make the edge function changes unnecessary.
System Info
⎿ System Information
------------------
> OS: Darwin
> OS Version: Darwin Kernel Version 25.3.0: Wed Jan 28 20:54:46 PST 2026;
root:xnu-12377.91.3~2/RELEASE_ARM64_T6000
> Python Version: 3.13.1 (main, Jan 14 2025, 23:31:50) [Clang 19.1.6 ]
Checked other resources
Package (Required)
Related Issues / PRs
Issue #36420
Reproduction Steps / Example Code (Python)
Error Message and Stack Trace (if applicable)
Description
Description
When a
@wrap_model_callmiddleware narrowsresponse_formatfrom a unionToolStrategyto a single-type subset viarequest.override(response_format=ToolStrategy(SubsetType)), the narrowing is validated (lines 1241-1248 offactory.pyconfirm the subset is valid) but never enforced. The model is still bound with all structured output tools from the original union, and can freely choose any of them.This contradicts:
factory.py:1234-1239: "Middleware is allowed to change the response format to a subset of the original structured tools when using ToolStrategy"ToolStrategyRoot Cause
structured_output_toolsis a closure variable built once from the initialresponse_formatatcreate_agent()time. Five locations in_get_bound_model()and the graph edge functions use this full set instead of the runtime-narrowedeffective_response_format:factory.py:1220structured_tools = [info.tool for info in structured_output_tools.values()]final_toolsand bound to the model, regardless of narrowed formatfactory.py:1071tc["name"] in structured_output_toolsfactory.py:1100structured_output_tools[tool_call["name"]]factory.py:1728c["name"] not in structured_output_toolsfactory.py:1808t.name in structured_output_toolsThe validation at lines 1241-1248 correctly confirms the narrowed format is a valid subset, but none of the downstream code filters to that subset.
Expected behavior
When middleware narrows
response_formattoToolStrategy(SubsetType):Actual behavior
All structured output tools from the original
create_agent(response_format=...)are always bound to the model. The model can choose any of them regardless of middleware narrowing. The narrowedeffective_response_formatis validated but never used for filtering.Suggested fix
In
_get_bound_model(), filter structured output tools to only those present ineffective_response_format.schema_specs:The same filtering pattern would need to be applied to
_handle_model_output(line 1071) and the edge functions (lines 1728, 1808) — though these are trickier since they're closures that don't have access to the runtime-narrowed format. One approach: store theeffective_response_formatin agent state so edge functions can read it, or restructure the edge functions to check both the full set and the state-stored narrowed set.A simpler alternative for the edge functions: since
_handle_model_outputalready receiveseffective_response_format, it could reject tool calls not in the narrowed set (returning them as "unhandled" so the agent loop retries), which would make the edge function changes unnecessary.System Info
⎿ System Information
------------------
> OS: Darwin
> OS Version: Darwin Kernel Version 25.3.0: Wed Jan 28 20:54:46 PST 2026;
root:xnu-12377.91.3~2/RELEASE_ARM64_T6000
> Python Version: 3.13.1 (main, Jan 14 2025, 23:31:50) [Clang 19.1.6 ]