fix(sdk-py): make tools_agent fake model stateless (#7930)

This commit is contained in:
Nick Hollon
2026-05-28 16:42:45 -04:00
committed by GitHub
parent c7792608e3
commit ea4aa79a60
+48 -42
View File
@@ -1,13 +1,13 @@
"""create_agent-based example exercising the v3 ``tools`` channel.
"""create_agent-based example exercising the v3 `tools` channel.
`thread.tool_calls` and the underlying ``tools`` channel only emit
`thread.tool_calls` and the underlying `tools` channel only emit
events when an actual model issues a tool call through langchain's
agent stack. The synthetic ``streaming_graph.py`` hand-builds
agent stack. The synthetic `streaming_graph.py` hand-builds
`AIMessage(tool_calls=[...])` and a `ToolMessage` via the messages
reducer — that gets persisted in state but never produces tool-call
telemetry on the wire. This graph fixes that by going through
`create_agent` with a real tool, driven by a `GenericFakeChatModel` so
the test stays hermetic (no `ANTHROPIC_API_KEY` required).
`create_agent` with a real tool, driven by a hermetic fake chat model
(no `ANTHROPIC_API_KEY` required).
Flow on `run.start`:
@@ -25,7 +25,8 @@ from typing import Any
from langchain.agents import create_agent
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
from langchain_core.messages import AIMessage
from langchain_core.messages import AIMessage, BaseMessage, ToolMessage
from langchain_core.outputs import ChatGeneration, ChatResult
from langchain_core.tools import tool
@@ -36,51 +37,56 @@ def search(query: str) -> str:
class _ToolBindingFakeChatModel(FakeMessagesListChatModel):
"""Fake chat model that satisfies `create_agent`'s ``bind_tools`` call.
"""Stateless fake chat model driving a single `search` tool call.
``create_agent`` calls ``model.bind_tools(tools)`` to attach the tool
schema (``langchain/agents/factory.py:1284``). The base
``FakeMessagesListChatModel`` inherits ``BaseChatModel.bind_tools``,
which raises ``NotImplementedError``. We don't actually need the
bound schema — the fake replays scripted ``AIMessage``s with their
own ``tool_calls`` field — so override ``bind_tools`` as a no-op.
`create_agent` calls `model.bind_tools(tools)` to attach the tool
schema (`langchain/agents/factory.py:1284`). The base
`FakeMessagesListChatModel` inherits `BaseChatModel.bind_tools`,
which raises `NotImplementedError`, so `bind_tools` is overridden as
a no-op (the reply is hand-built and already carries `tool_calls`).
``FakeMessagesListChatModel`` is preferred over
``GenericFakeChatModel`` here because the latter's ``_stream``
breaks the message into content chunks and **drops ``tool_calls``**
when content is empty, causing the v2 streaming path inside
``create_agent`` to raise ``RuntimeError("v2 stream finished
without producing a message")``. ``FakeMessagesListChatModel``
falls back to the default ``_stream`` that yields the whole
message in one chunk, preserving ``tool_calls``.
The reply is derived from conversation state rather than a cycling
response list: the `search` tool call is issued until a `ToolMessage`
appears, then a terminating `AIMessage`. This avoids the response-index
parity flake where `FakeMessagesListChatModel.responses` is shared
process-wide and cycles `0 -> 1 -> 0`; a run that started mid-cycle
(e.g. on a reused server worker) would reply `"done."` first and emit
no tool call. Being order-independent, every run emits exactly one
tool call regardless of how many times the model was previously called.
`FakeMessagesListChatModel` is subclassed (rather than
`GenericFakeChatModel`) because the latter's `_stream` breaks the
message into content chunks and drops `tool_calls` when content is
empty, causing the v2 streaming path inside `create_agent` to raise
`RuntimeError("v2 stream finished without producing a message")`.
The inherited `_stream` yields the whole message in one chunk,
preserving `tool_calls`.
"""
def bind_tools(self, tools: Any, **kwargs: Any) -> _ToolBindingFakeChatModel:
return self
# Two scripted turns. ``FakeMessagesListChatModel`` cycles through
# ``responses`` (resetting to index 0 after the last) so the graph
# can be run many times without restart; per run, ``create_agent``
# invokes the model exactly twice (once to issue the tool call,
# once after the tool result to produce the terminating answer).
_supervisor_responses: list[AIMessage] = [
AIMessage(
content="",
id="ai-tools-1",
tool_calls=[
{
"id": "tc-1",
"name": "search",
"args": {"query": "v3"},
}
],
),
AIMessage(content="done.", id="ai-tools-2"),
]
def _generate(
self,
messages: list[BaseMessage],
stop: list[str] | None = None,
run_manager: Any = None,
**kwargs: Any,
) -> ChatResult:
if any(isinstance(m, ToolMessage) for m in messages):
response = AIMessage(content="done.", id="ai-tools-done")
else:
response = AIMessage(
content="",
id="ai-tools-call",
tool_calls=[{"id": "tc-1", "name": "search", "args": {"query": "v3"}}],
)
return ChatResult(generations=[ChatGeneration(message=response)])
_supervisor_model = _ToolBindingFakeChatModel(responses=_supervisor_responses)
# `responses` is a required field on `FakeMessagesListChatModel`, but the
# overridden `_generate` derives its reply from state and never reads it.
_supervisor_model = _ToolBindingFakeChatModel(responses=[])
graph = create_agent(