Tool calls logging for Sub-Agents
Hello there.
I was wondering how to enable tool calls for sub agents. Right now i can see in the subagent.py that the checkpointer is set to false. I tried enabling it but the agent state does not seem to have sub agent tool calls logged.
Any pointers would be appreciated.
wdym exactly? sub agents should be able to call tools. they should also be logged to langsmith as part of the trace. if you are talking about seeing in the chckpointing history, you can only do that when agent is interrupte: https://docs.langchain.com/oss/python/langgraph/use-subgraphs#view-subgraph-state
wdym exactly? sub agents should be able to call tools. they should also be logged to langsmith as part of the trace. if you are talking about seeing in the chckpointing history, you can only do that when agent is interrupte: https://docs.langchain.com/oss/python/langgraph/use-subgraphs#view-subgraph-state
Yes, the sub-agents are able to call tools. I am using postgres checkpointer for persistence. BUt when I try to load a thread from say history, i use the aget_state function to retrieve the graph. This however only has data for the tool calls made by the supervisor. I do not see any tool calls made by the sub agents in this history data that we retrieve from the checkpointer.
I did use the subgraphs= true as linked by you , also i changed the core src code for depagents subagent.py line 71 to set checkpointer = true but still doesn't work.
agents[_agent["name"]] = create_react_agent(
sub_model,
prompt=_agent["prompt"],
tools=_tools,
state_schema=state_schema,
checkpointer=False,
post_model_hook=post_model_hook,
Thanks for flagging, am taking a look into this!
@nhuang-lc Were you able to look into this?
Yes, sorry for the delay, the fix isn't out yet but will be a part of the next patch! I'll ping when it is out.
The change is to remove checkpointer=False
When you call get_state(config, subgraphs=True), each task's .state contains the subgraph's config, including its checkpoint_ns. You can then explicitly call get_state_history() with that config to get the subgraph's history.
` state = graph.get_state(config, subgraphs=True) subgraph_config = state.tasks[0].state.config subgraph_ns = subgraph_config["configurable"]["checkpoint_ns"] print(f"subgraph_ns: {subgraph_ns}\n")
state_history = list(graph.get_state_history( {"configurable":{"checkpoint_ns":subgraph_ns, "thread_id":thread_id}})) for h in state_history: print(h) `
If you want to right now, you can remove checkpointer altogether and try this out. It will be in the next patch
This is in patch >= 0.2.1, let me know if this works for you!
Hi! I’ve been debugging sub-agent tool-call persistence and I still can’t get sub-agent tool messages to appear in the final messages state when I reload a thread. Below is a concise summary of the problem, reproduction notes, and a suggested change to help discussion.
Summary / problem
- I’m using the Postgres checkpointer for persistence.
- During streaming with
subgraphs=TrueI do see all sub-agent tool calls (so the sub-agents themselves call tools and the stream shows them). - However, when I later reload the thread with
graph.get_state(config, subgraphs=True)(orget_statewithoutsubgraphs=True) the returned state does not contain the sub-agent tool messages. In my runstasksends up empty. - The supervisor’s tool calls are present and persisted, but sub-agent tool calls are not present in the persisted
messagesstate that the frontend relies on.
What I tried
using deepagents 0.2.0
- I set
subgraphs=Trueonget_state. - I tried the suggestion
get_state(config, subgraphs=True), but state.tasks is empty.
Observed code that may explain behavior
I noticed that only the last tool message is being sent back in the state update:
def _return_command_with_state_update(result: dict, tool_call_id: str) -> Command:
state_update = {k: v for k, v in result.items() if k not in _EXCLUDED_STATE_KEYS}
return Command(
update={
**state_update,
"messages": [ToolMessage(result["messages"][-1].content, tool_call_id=tool_call_id)],
}
)
That appears to intentionally only include the final tool message. Because of that, when the state is checkpointed, only a single ToolMessage is written back to the supervisor-level messages array. For sub-agents this results in missing sub-agent tool messages in the persisted messages state the frontend consumes.
Proposed minimal change (for discussion)
Would it be acceptable to include all tool messages (in order) in the messages state update rather than only the last one? For example:
def _return_command_with_state_update(result: dict, tool_call_id: str) -> Command:
state_update = {k: v for k, v in result.items() if k not in _EXCLUDED_STATE_KEYS}
# NOTE: Ideally each entry in result["messages"] would include its own tool_call_id.
# If not available, we fallback to the provided tool_call_id.
messages = [
ToolMessage(m.content, tool_call_id=(m.tool_call_id if hasattr(m, "tool_call_id") else tool_call_id))
for m in result["messages"]
]
return Command(
update={
**state_update,
"messages": messages,
}
)
Hey @Jonascsantos-guildaAI
With the code snippet I shared above, this should work on deepagents >= 0.2.1. The snippet I sent in the comment above asked that you remove checkpointer=False because the patch release wasn't in yet, but this is in for 0.2.1.
On your proposed fix - we intentionally only return the final message from the subagent to the main agent state in order to isolate context.
Imagine a scenario where your subagent performs a lot of expensive tool calls (like websearch with lots of content returned). We don't want all of that in the main agent's context window, we want the subagent to "clearly answer the main agent's question", and then only provide that clean info back up to the main agent.
Lmk if that makes sense!
hi @nhuang-lc I followed the approach you suggested, but I still don’t see the tool calls for the subagents, even though they are present in the PostgreSQL database. my code is state = await agent.aget_state( config={"configurable": {"thread_id": thread_id}}, subgraphs=True ) # subgraph_config = state.tasks[0].state.config subgraph_config = state.config subgraph_ns = subgraph_config["configurable"]["checkpoint_ns"] print(f"subgraph_ns: {subgraph_ns}\n")
state_history = []
async for h in agent.aget_state_history(
{"configurable": { "checkpoint_ns":subgraph_ns, "thread_id": thread_id}}
):
state_history.append(h)
while in DB i can see the subagent tools name : SELECT *, encode(blob, 'escape') FROM public.checkpoint_blobs where thread_id='my_thread_id'
Hello @nhuang-lc , @allthatido and everyone,
Yes, that makes sense. Having only the clean, summarized information passed back to the main agent is ideal in most cases. However, there are scenarios where we need specific tool calls to also be preserved in the main messages state for persistence, particularly for UI-related tools.
If we were to link those UI tools directly to the main agent, it would go against the goal of keeping sub-agents' work scoped and would unnecessarily clutter the main agent’s prompt, increasing the context load for the model.
To address this, I modified DeepAgents to include a new parameter called preserve_message_tool_names. This parameter takes an array of tool names whose corresponding messages should be passed up to the main state.
What do you think of this approach?
There’s definitely room for a cleaner or more elegant approach, but here’s the implementation I came up with:
Full code Draft PR: https://github.com/langchain-ai/deepagents/pull/252. Feel free to close it, guys.
deep_agent = create_deep_agent(
model=base_llm,
system_prompt=main_instructions,
subagents=subagents,
preserve_message_tool_names=PRESERVE_MESSAGE_TOOL_NAMES
)
subagents.py
def _return_command_with_state_update(result: dict, tool_call_id: str) -> Command:
state_update = {k: v for k, v in result.items() if k not in _EXCLUDED_STATE_KEYS}
messages_update: list = [
ToolMessage(result["messages"][-1].text, tool_call_id=tool_call_id)
]
if preserve_message_tool_names:
preserve_set = set(preserve_message_tool_names)
tool_call_id_to_tool_msg: dict[str, ToolMessage] = {}
for m in result["messages"]:
if isinstance(m, ToolMessage) and getattr(m, "tool_call_id", None):
tool_call_id_to_tool_msg[m.tool_call_id] = m
latest_ai_for_tool: dict[str, AIMessage] = {}
for m in reversed(result["messages"]):
if isinstance(m, AIMessage) and getattr(m, "tool_calls", None):
names_in_msg = {tc.get("name") for tc in m.tool_calls or []}
intersect = [n for n in names_in_msg if n in preserve_set and n not in latest_ai_for_tool]
if not intersect:
continue
for tool_name in intersect:
filtered_calls = [tc for tc in (m.tool_calls or []) if tc.get("name") == tool_name]
if not filtered_calls:
continue
cloned_ai = AIMessage(
content=m.content,
additional_kwargs=getattr(m, "additional_kwargs", {}),
response_metadata=getattr(m, "response_metadata", {}),
tool_calls=filtered_calls,
)
latest_ai_for_tool[tool_name] = cloned_ai
if latest_ai_for_tool:
ordered_tools = [t for t in preserve_message_tool_names if t in latest_ai_for_tool]
for tool_name in ordered_tools:
ai_msg = latest_ai_for_tool[tool_name]
messages_update.append(ai_msg)
for tc in ai_msg.tool_calls or []:
tc_id = tc.get("id")
if tc_id and tc_id in tool_call_id_to_tool_msg:
messages_update.append(tool_call_id_to_tool_msg[tc_id])
return Command(
update={
**state_update,
"messages": messages_update,
}
)
This approach allows selective propagation of specific tool messages up to the main agent while keeping the rest of the context clean and isolated.
Yes, I agree with @jonascsantos . I've found that if a subagent of deepagent calls Copilotkit frontend actions, Copilotkit does not seem to receive any action call, as if the tool calls are not streamed.