Tools returning a Command are missing from messages-streaming #546

Closed
opened 2026-02-20 17:40:39 -05:00 by yindo · 6 comments
Owner

Originally created by @stt-mapa on GitHub (Apr 1, 2025).

Checked other resources

  • This is a bug, not a usage question. For questions, please use GitHub Discussions.
  • I added a clear and detailed title that summarizes the issue.
  • I read what a minimal reproducible example is (https://stackoverflow.com/help/minimal-reproducible-example).
  • I included a self-contained, minimal example that demonstrates the issue INCLUDING all the relevant imports. The code run AS IS to reproduce the issue.

Example Code

from typing import Annotated, Any

from langchain_core.messages import ToolMessage
from langchain_core.runnables.config import RunnableConfig
from langchain_core.tools import tool
from langchain_core.tools.base import InjectedToolCallId
from langchain_openai import ChatOpenAI
from langgraph.prebuilt import create_react_agent
from langgraph.prebuilt.chat_agent_executor import AgentState
from langgraph.types import Command

USER_INFO = [
    {"user_id": "1", "name": "Bob Dylan", "location": "New York, NY"},
    {"user_id": "2", "name": "Taylor Swift", "location": "Beverly Hills, CA"},
]

USER_ID_TO_USER_INFO = {info["user_id"]: info for info in USER_INFO}


class State(AgentState):
    # updated by the tool
    user_info: dict[str, Any]


def main() -> None:
    @tool
    def lookup_user_info(tool_call_id: Annotated[str, InjectedToolCallId], config: RunnableConfig):
        """Use this to look up user information to better assist them with their questions."""
        user_id = config.get("configurable", {}).get("user_id")
        if user_id is None:
            raise ValueError("Please provide user ID")

        if user_id not in USER_ID_TO_USER_INFO:
            raise ValueError(f"User '{user_id}' not found")

        user_info = USER_ID_TO_USER_INFO[user_id]
        return Command(
            update={
                # update the state keys
                "user_info": user_info,
                # update the message history
                "messages": [
                    ToolMessage(
                        "Successfully looked up user information", tool_call_id=tool_call_id
                    )
                ],
            }
        )

    def prompt(state: State):
        user_info = state.get("user_info")
        if user_info is None:
            return state["messages"]

        system_msg = (
            f"User name is {user_info['name']}. User lives in {user_info['location']}"
        )
        return [{"role": "system", "content": system_msg}] + state["messages"]

    model = ChatOpenAI(model="gpt-4o")

    agent = create_react_agent(
        model,
        # pass the tool that can update state
        [lookup_user_info],
        state_schema=State,
        # pass dynamic prompt function
        prompt=prompt,
    )

    agent_input = {"messages": [("user", "hi, where do I live?")]}
    agent_config = {"configurable": {"user_id": "1"}}

    invoke_result = agent.invoke(
        agent_input,
        agent_config,
    )

    # print(invoke_result)

    for chunk in agent.stream(agent_input, agent_config, stream_mode='messages'):
        print(chunk)


if __name__ == '__main__':
    main()

Error Message and Stack Trace (if applicable)


Description

As the title says, if you define a tool returning a Command to update the state, there is no ToolMessage for the tool call when using the messages streaming mode.

This is easily reproducible using the example in the How to update graph state from tools doc page.

System Info

System Information

OS: Windows
OS Version: 10.0.19045
Python Version: 3.13.0 (main, Oct 16 2024, 00:33:24) [MSC v.1929 64 bit (AMD64)]

Package Information

langchain_core: 0.3.49
langsmith: 0.3.21
langchain_openai: 0.3.11
langgraph_sdk: 0.1.60

Optional packages not installed

langserve

Other Dependencies

httpx: 0.28.1
jsonpatch<2.0,>=1.33: Installed. No version info available.
langchain-core<1.0.0,>=0.3.49: Installed. No version info available.
langsmith-pyo3: Installed. No version info available.
langsmith<0.4,>=0.1.125: Installed. No version info available.
openai-agents: Installed. No version info available.
openai<2.0.0,>=1.68.2: Installed. No version info available.
opentelemetry-api: Installed. No version info available.
opentelemetry-exporter-otlp-proto-http: Installed. No version info available.
opentelemetry-sdk: Installed. No version info available.
orjson: 3.10.16
packaging: 24.2
packaging<25,>=23.2: Installed. No version info available.
pydantic: 2.11.1
pydantic<3.0.0,>=2.5.2;: Installed. No version info available.
pydantic<3.0.0,>=2.7.4;: Installed. No version info available.
pytest: Installed. No version info available.
PyYAML>=5.3: Installed. No version info available.
requests: 2.32.3
requests-toolbelt: 1.0.0
rich: Installed. No version info available.
tenacity!=8.4.0,<10.0.0,>=8.1.0: Installed. No version info available.
tiktoken<1,>=0.7: Installed. No version info available.
typing-extensions>=4.7: Installed. No version info available.
zstandard: 0.23.0

Originally created by @stt-mapa on GitHub (Apr 1, 2025). ### Checked other resources - [x] This is a bug, not a usage question. For questions, please use GitHub Discussions. - [x] I added a clear and detailed title that summarizes the issue. - [x] I read what a minimal reproducible example is (https://stackoverflow.com/help/minimal-reproducible-example). - [x] I included a self-contained, minimal example that demonstrates the issue INCLUDING all the relevant imports. The code run AS IS to reproduce the issue. ### Example Code ```python from typing import Annotated, Any from langchain_core.messages import ToolMessage from langchain_core.runnables.config import RunnableConfig from langchain_core.tools import tool from langchain_core.tools.base import InjectedToolCallId from langchain_openai import ChatOpenAI from langgraph.prebuilt import create_react_agent from langgraph.prebuilt.chat_agent_executor import AgentState from langgraph.types import Command USER_INFO = [ {"user_id": "1", "name": "Bob Dylan", "location": "New York, NY"}, {"user_id": "2", "name": "Taylor Swift", "location": "Beverly Hills, CA"}, ] USER_ID_TO_USER_INFO = {info["user_id"]: info for info in USER_INFO} class State(AgentState): # updated by the tool user_info: dict[str, Any] def main() -> None: @tool def lookup_user_info(tool_call_id: Annotated[str, InjectedToolCallId], config: RunnableConfig): """Use this to look up user information to better assist them with their questions.""" user_id = config.get("configurable", {}).get("user_id") if user_id is None: raise ValueError("Please provide user ID") if user_id not in USER_ID_TO_USER_INFO: raise ValueError(f"User '{user_id}' not found") user_info = USER_ID_TO_USER_INFO[user_id] return Command( update={ # update the state keys "user_info": user_info, # update the message history "messages": [ ToolMessage( "Successfully looked up user information", tool_call_id=tool_call_id ) ], } ) def prompt(state: State): user_info = state.get("user_info") if user_info is None: return state["messages"] system_msg = ( f"User name is {user_info['name']}. User lives in {user_info['location']}" ) return [{"role": "system", "content": system_msg}] + state["messages"] model = ChatOpenAI(model="gpt-4o") agent = create_react_agent( model, # pass the tool that can update state [lookup_user_info], state_schema=State, # pass dynamic prompt function prompt=prompt, ) agent_input = {"messages": [("user", "hi, where do I live?")]} agent_config = {"configurable": {"user_id": "1"}} invoke_result = agent.invoke( agent_input, agent_config, ) # print(invoke_result) for chunk in agent.stream(agent_input, agent_config, stream_mode='messages'): print(chunk) if __name__ == '__main__': main() ``` ### Error Message and Stack Trace (if applicable) ```shell ``` ### Description As the title says, if you define a tool returning a `Command` to update the state, there is no ToolMessage for the tool call when using the `messages` streaming mode. This is easily reproducible using the example in the [How to update graph state from tools](https://langchain-ai.github.io/langgraph/how-tos/update-state-from-tools/) doc page. ### System Info System Information ------------------ > OS: Windows > OS Version: 10.0.19045 > Python Version: 3.13.0 (main, Oct 16 2024, 00:33:24) [MSC v.1929 64 bit (AMD64)] Package Information ------------------- > langchain_core: 0.3.49 > langsmith: 0.3.21 > langchain_openai: 0.3.11 > langgraph_sdk: 0.1.60 Optional packages not installed ------------------------------- > langserve Other Dependencies ------------------ > httpx: 0.28.1 > jsonpatch<2.0,>=1.33: Installed. No version info available. > langchain-core<1.0.0,>=0.3.49: Installed. No version info available. > langsmith-pyo3: Installed. No version info available. > langsmith<0.4,>=0.1.125: Installed. No version info available. > openai-agents: Installed. No version info available. > openai<2.0.0,>=1.68.2: Installed. No version info available. > opentelemetry-api: Installed. No version info available. > opentelemetry-exporter-otlp-proto-http: Installed. No version info available. > opentelemetry-sdk: Installed. No version info available. > orjson: 3.10.16 > packaging: 24.2 > packaging<25,>=23.2: Installed. No version info available. > pydantic: 2.11.1 > pydantic<3.0.0,>=2.5.2;: Installed. No version info available. > pydantic<3.0.0,>=2.7.4;: Installed. No version info available. > pytest: Installed. No version info available. > PyYAML>=5.3: Installed. No version info available. > requests: 2.32.3 > requests-toolbelt: 1.0.0 > rich: Installed. No version info available. > tenacity!=8.4.0,<10.0.0,>=8.1.0: Installed. No version info available. > tiktoken<1,>=0.7: Installed. No version info available. > typing-extensions>=4.7: Installed. No version info available. > zstandard: 0.23.0
yindo closed this issue 2026-02-20 17:40:39 -05:00
Author
Owner

@rs-benatti commented on GitHub (Apr 1, 2025):

Indeed it does not return a message as the function didn't return a message and simply updated the state of the graph using Command. Still you can access the ToolMessage by using also the stream_mode 'updates'.

for chunk in agent.stream(agent_input, agent_config, stream_mode=['messages', 'updates']):
        print(chunk)

Then, you may find among the chunks a structure like:

('updates', {'tools': {'user_info': {'user_id': '1', 'name': 'Bob Dylan', 'location': 'New York, NY'}, 'messages': [ToolMessage(content='Successfully looked up user information', name='lookup_user_info', tool_call_id='call_rwUieKngmiAAUy2vnZJaOlEq')]}})

@rs-benatti commented on GitHub (Apr 1, 2025): Indeed it does not return a message as the function didn't return a message and simply updated the state of the graph using Command. Still you can access the ToolMessage by using also the stream_mode 'updates'. ``` for chunk in agent.stream(agent_input, agent_config, stream_mode=['messages', 'updates']): print(chunk) ``` Then, you may find among the chunks a structure like: ('updates', {'tools': {'user_info': {'user_id': '1', 'name': 'Bob Dylan', 'location': 'New York, NY'}, 'messages': [ToolMessage(content='Successfully looked up user information', name='lookup_user_info', tool_call_id='call_rwUieKngmiAAUy2vnZJaOlEq')]}})
Author
Owner

@afnan-davia commented on GitHub (Apr 8, 2025):

Indeed it does not return a message as the function didn't return a message and simply updated the state of the graph using Command. Still you can access the ToolMessage by using also the stream_mode 'updates'.

for chunk in agent.stream(agent_input, agent_config, stream_mode=['messages', 'updates']):
        print(chunk)

Then, you may find among the chunks a structure like:

('updates', {'tools': {'user_info': {'user_id': '1', 'name': 'Bob Dylan', 'location': 'New York, NY'}, 'messages': [ToolMessage(content='Successfully looked up user information', name='lookup_user_info', tool_call_id='call_rwUieKngmiAAUy2vnZJaOlEq')]}})

Unfortunately, when using the prebuilt ReAct agent, the update chunk is not triggered... And even leveraging this workaround does not seem to be the best way, as the whole purpose of using LangGraph's Command is to keep the same behavior as normal tools.

This is a big issue for receiving streams on a front-end as the Tool is never considered to be finished.

@afnan-davia commented on GitHub (Apr 8, 2025): > Indeed it does not return a message as the function didn't return a message and simply updated the state of the graph using Command. Still you can access the ToolMessage by using also the stream_mode 'updates'. > > ``` > for chunk in agent.stream(agent_input, agent_config, stream_mode=['messages', 'updates']): > print(chunk) > ``` > > Then, you may find among the chunks a structure like: > > ('updates', {'tools': {'user_info': {'user_id': '1', 'name': 'Bob Dylan', 'location': 'New York, NY'}, 'messages': [ToolMessage(content='Successfully looked up user information', name='lookup_user_info', tool_call_id='call_rwUieKngmiAAUy2vnZJaOlEq')]}}) Unfortunately, when using the prebuilt ReAct agent, the update chunk is not triggered... And even leveraging this workaround does not seem to be the best way, as the whole purpose of using LangGraph's Command is to keep the same behavior as normal tools. This is a big issue for receiving streams on a front-end as the Tool is never considered to be finished.
Author
Owner

@vbarda commented on GitHub (Apr 11, 2025):

should be fixed in #4250

@vbarda commented on GitHub (Apr 11, 2025): should be fixed in #4250
Author
Owner

@vbarda commented on GitHub (Apr 14, 2025):

Fixed in 0.3.30!

@vbarda commented on GitHub (Apr 14, 2025): Fixed in 0.3.30!
Author
Owner

@injeniero commented on GitHub (Apr 17, 2025):

@vbarda I was testing this and still not fixed. The issue is the ToolNode returns a Sequence[Command], it then gets converted to Sequence[dict] which is not handled in https://github.com/langchain-ai/langgraph/pull/4250/files#diff-94068bbb7b34d5364c475a36ed7bc0e67d7c1e8ce7e8de4994ef77b489a39c23R169

The fix I tested locally is:

def on_chain_end(
        self,
        response: Any,
        *,
        run_id: UUID,
        parent_run_id: Optional[UUID] = None,
        **kwargs: Any,
    ) -> Any:
        if meta := self.metadata.pop(run_id, None):
            if isinstance(response, Command):
                response = response.update

            if isinstance(response, Sequence) and any(
                isinstance(value, Command) for value in response
            ):
                response = [
                    value.update if isinstance(value, Command) else value
                    for value in response
                ]
            def _find_and_emit(value, the_type, try_dir=True):
                nonlocal recur_count
                recur_count += 1
                if recur_count > 100:
                    raise AssertionError("Wtf!: " + str(value))
                if isinstance(value, the_type):
                    self._emit(meta, value, dedupe=True)
                elif isinstance(value, Sequence) and not isinstance(value, str):
                    for item in value:
                        _find_and_emit(item, the_type)
                elif isinstance(value, dict):
                    for item in value.values():
                        _find_and_emit(item, the_type)
                elif try_dir and hasattr(value, "__dir__") and callable(value.__dir__):
                    for key in dir(value):
                        try:
                            item = getattr(value, key)
                            _find_and_emit(item, the_type, try_dir=False)
                        except AttributeError:
                            pass

            _find_and_emit(response, BaseMessage)
@injeniero commented on GitHub (Apr 17, 2025): @vbarda I was testing this and still not fixed. The issue is the ToolNode returns a Sequence[Command], it then gets converted to Sequence[dict] which is not handled in https://github.com/langchain-ai/langgraph/pull/4250/files#diff-94068bbb7b34d5364c475a36ed7bc0e67d7c1e8ce7e8de4994ef77b489a39c23R169 The fix I tested locally is: ```python def on_chain_end( self, response: Any, *, run_id: UUID, parent_run_id: Optional[UUID] = None, **kwargs: Any, ) -> Any: if meta := self.metadata.pop(run_id, None): if isinstance(response, Command): response = response.update if isinstance(response, Sequence) and any( isinstance(value, Command) for value in response ): response = [ value.update if isinstance(value, Command) else value for value in response ] def _find_and_emit(value, the_type, try_dir=True): nonlocal recur_count recur_count += 1 if recur_count > 100: raise AssertionError("Wtf!: " + str(value)) if isinstance(value, the_type): self._emit(meta, value, dedupe=True) elif isinstance(value, Sequence) and not isinstance(value, str): for item in value: _find_and_emit(item, the_type) elif isinstance(value, dict): for item in value.values(): _find_and_emit(item, the_type) elif try_dir and hasattr(value, "__dir__") and callable(value.__dir__): for key in dir(value): try: item = getattr(value, key) _find_and_emit(item, the_type, try_dir=False) except AttributeError: pass _find_and_emit(response, BaseMessage) ```
Author
Owner

@vbarda commented on GitHub (Apr 23, 2025):

Thanks for reporting - patched up the issue and will be released in 0.3.32

@vbarda commented on GitHub (Apr 23, 2025): Thanks for reporting - patched up the issue and will be released in `0.3.32`
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: langchain-ai/langgraph#546