Skip to content

Preserve full message content from CompiledSubAgent #2512

@SP-John

Description

@SP-John

Problem

When a CompiledSubAgent returns structured data alongside its final message (e.g., via additional_kwargs or by setting a ToolMessage.artifact), the upstream _return_command_with_state_update in subagents.py discards everything except .text:

message_text = result["messages"][-1].text.rstrip() if result["messages"][-1].text else ""
return Command(
    update={
        **state_update,
        "messages": [ToolMessage(message_text, tool_call_id=tool_call_id)],
    }
)

This means there is no supported way for a CompiledSubAgent to return structured data to the parent agent through the ToolMessage. The structured_response state key is explicitly excluded via _EXCLUDED_STATE_KEYS, additional_kwargs on the last message are dropped, and -- critically -- if content is a list of standard content blocks rather than a plain string, the non-text blocks are also lost.

LangChain Core's standard content blocks provide a typed, provider-agnostic way to carry structured data alongside text in a single message (e.g., NonStandardContentBlock for application-specific payloads). A CompiledSubAgent could naturally use this:

content = [
    {"type": "text", "text": "Summary of results..."},
    {"type": "non_standard", "value": {"sql": "SELECT ...", "tier": 2, "rows": 14}},
]
return {"messages": [AIMessage(content=content)]}

But because _return_command_with_state_update extracts .text (which flattens to only TextContentBlock entries), the NonStandardContentBlock is silently discarded.

The docs say:

The structured object itself is not returned to the parent agent. When using structured output with subagents, include the structured data in the ToolMessage.

But for CompiledSubAgent, users don't control the ToolMessage construction -- the framework creates it from .text only. The guidance is impossible to follow for compiled subagents, whether using additional_kwargs, artifact, or standard content blocks.

Use case

I have a CompiledSubAgent that runs a multi-node LangGraph pipeline. The final node produces a human-readable summary as the message content, plus a structured payload containing intermediate results built across the various nodes. The parent agent needs the summary for conversation, but the API consumer needs the structured payload for other purposes.

Today the only options are:

  1. Serialize the structured data into the message text (increases parent context token cost, parent LLM may post-process it).
  2. Monkey-patch _build_task_tool to propagate additional_kwargs or artifact (fragile, breaks on upstream changes).

Neither is great.

Requested feature

_return_command_with_state_update should forward the full content from the subagent's last message (preserving content blocks when present) rather than flattening to .text. It should also propagate artifact when the last message carries one. This would let CompiledSubAgent authors use any of LangChain Core's existing mechanisms -- content blocks, artifact, or plain text -- to return structured data alongside a human-readable summary.

Proposed solution

The most aligned fix is to forward the full content value (preserving content blocks when present) rather than flattening to .text. A one-line change in _return_command_with_state_update in deepagents/middleware/subagents.py:

def _return_command_with_state_update(result: dict, tool_call_id: str) -> Command:
    if "messages" not in result:
        error_msg = (
            "CompiledSubAgent must return a state containing a 'messages' key. "
            "Custom StateGraphs used with CompiledSubAgent should include 'messages' "
            "in their state schema to communicate results back to the main agent."
        )
        raise ValueError(error_msg)

    state_update = {k: v for k, v in result.items() if k not in _EXCLUDED_STATE_KEYS}
    last_msg = result["messages"][-1]

    # Forward full content (preserves content blocks) instead of flattening to .text
    raw_content = last_msg.content
    if isinstance(raw_content, str):
        raw_content = raw_content.rstrip()

    # Propagate artifact if the last message carries one
    artifact = getattr(last_msg, "artifact", None)

    return Command(
        update={
            **state_update,
            "messages": [ToolMessage(content=raw_content, tool_call_id=tool_call_id, artifact=artifact)],
        }
    )

Why this works:

  • ToolMessage.content already accepts both str and list[dict] (content blocks). This is part of the LangChain Core message contract.
  • When content is a plain string (the common case today), behavior is identical to the current code.
  • When content is a list of standard content blocks, they are preserved. The parent agent's LLM sees the TextContentBlock entries as text, while NonStandardContentBlock entries survive for programmatic access by API consumers.
  • artifact propagation covers the case where a CompiledSubAgent uses ToolMessage as its final message type.

Subagent graph usage (content blocks approach)

The final node in a CompiledSubAgent graph could use standard content blocks:

from langchain_core.messages import AIMessage

return {
    "messages": [AIMessage(content=[
        {"type": "text", "text": "inf_member -- Tier 2 (Guided), 14 rows\n\nSQL:\n..."},
        {"type": "non_standard", "value": {
            "sql": "SELECT state, COUNT(*) ...",
            "confidence_tier": 2,
            "data": [{"state": "CA", "count": 5}, ...],
            "columns": ["state", "count"],
        }},
    ])],
}

Or use artifact on a ToolMessage:

from langchain_core.messages import ToolMessage

return {
    "messages": [ToolMessage(content=summary, artifact=structured_payload, tool_call_id=...)],
}

Or continue using a plain string content (existing behavior, no change needed):

from langchain_core.messages import AIMessage

return {
    "messages": [AIMessage(content="Just a text summary, no structured data.")],
}

All three patterns would work with the proposed change.

Additional context

Metadata

Metadata

Labels

deepagentsRelated to the `deepagents` SDK / agent harnessexternalUser is not a member of the `langchain-ai` GitHub organization
No fields configured for Feature.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions