diff --git a/libs/sdk-py/integration/graph/tools_agent.py b/libs/sdk-py/integration/graph/tools_agent.py index 505c311dd..a4aeb5649 100644 --- a/libs/sdk-py/integration/graph/tools_agent.py +++ b/libs/sdk-py/integration/graph/tools_agent.py @@ -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(