Skip to content

feat(langgraph): name tool-dispatched subagents via lc_agent_name#7928

Merged
Nick Hollon (nick-hollon-lc) merged 22 commits into
mainfrom
nh/subagent-name-langgraph
May 29, 2026
Merged

feat(langgraph): name tool-dispatched subagents via lc_agent_name#7928
Nick Hollon (nick-hollon-lc) merged 22 commits into
mainfrom
nh/subagent-name-langgraph

Conversation

@nick-hollon-lc

@nick-hollon-lc Nick Hollon (nick-hollon-lc) commented May 28, 2026

Copy link
Copy Markdown
Contributor

Resolves the labeling half of #7910.


When a tool body invokes a named inner agent (create_agent(name=...)), the supervisor's run.subgraphs / run.lifecycle handle for that dispatch was named after the parent tool node (tools) instead of the agent. This wires the inner agent's own lc_agent_name — already bound into its config by create_agent — through to the lifecycle surfaces, with no tool annotation required.

_TasksLifecycleBase now tracks lc_agent_name per namespace and treats a nested task as a subagent when its lc_agent_name differs from its parent namespace's. graph_name becomes that name; the triggering tool_call_id (for cause) is recovered by joining the child subgraph's namespace task-id to the parent tools task's tool calls (handles the current tool_call_with_context push-task shape and the legacy list shape).

TaskPayload.metadata forwarding (from task.config["metadata"]) is retained as the transport. Plain non-agent subgraphs (which inherit the parent's lc_agent_name) and unnamed agents are correctly excluded.

This PR supersedes its own earlier approach, which added a subagent_name parameter to tools and stamped it via a ToolNode→Pregel scheduling hook. That machinery is removed, and the companion langchain-core PR (#37721) is closed — no langchain-core change is needed.

Builds on #7926 (now merged).

@nick-hollon-lc Nick Hollon (nick-hollon-lc) force-pushed the nh/subagent-name-langgraph branch 3 times, most recently from 36d6ff7 to 1370fd6 Compare May 28, 2026 01:44
@nick-hollon-lc Nick Hollon (nick-hollon-lc) marked this pull request as draft May 28, 2026 15:05
@nick-hollon-lc Nick Hollon (nick-hollon-lc) changed the base branch from main to nh/ensure-config-merge-semantics May 28, 2026 19:27
@nick-hollon-lc Nick Hollon (nick-hollon-lc) force-pushed the nh/ensure-config-merge-semantics branch from 6eea7c5 to 85f4620 Compare May 28, 2026 19:31
@nick-hollon-lc Nick Hollon (nick-hollon-lc) marked this pull request as ready for review May 28, 2026 21:05
Base automatically changed from nh/ensure-config-merge-semantics to main May 29, 2026 13:40
Generic dict carrier on the task stream event payload, mirroring the
metadata pattern that already exists on MessagesStreamPart. Used by
downstream transformers to project framework-resolved keys (subagent_name,
tool_call_id, etc.) onto typed wire fields without langgraph itself
needing to know what those keys mean semantically.

Pure type addition; no behavior change. Subsequent commits populate
and consume this field.
map_debug_tasks now copies the task config's metadata dict into the
emitted TaskPayload (when non-empty). Mirrors how MessagesStreamPart
already forwards metadata. Enables downstream transformers to project
specific keys onto typed wire surfaces.
When a tool declares BaseTool.subagent_name (static string or callable),
ToolNode resolves it at dispatch time and stamps the resolved value
plus the LLM tool_call_id into the runtime config's metadata. The
metadata then flows through TaskPayload.metadata to downstream
transformers, which project it onto LifecyclePayload.graphName and
LifecyclePayload.cause (the existing wire fields for nested-run
identity and dispatch origin).

ToolNode itself does not interpret the keys — it only resolves and
stamps. Semantic interpretation happens at the transformer layer.

Callable resolvers are invoked best-effort: any exception returns
None (no stamping) rather than failing dispatch.
LifecycleTransformer now reads two specific metadata keys from observed
tasks events and projects them onto typed wire fields:

- metadata.subagent_name -> LifecyclePayload.graph_name (overrides the
  parser-derived segment name like "tools")
- metadata.tool_call_id -> LifecyclePayload.cause as a
  LifecycleCauseToolCall variant ({"type": "toolCall",
  "toolCallId": <id>}) matching the langchain-protocol CDDL spec

cause is gated on subagent_name being present. subagent_name is the
discriminator for "this is a subagent dispatch"; populating cause from
a stray tool_call_id without that signal would mislead frontends that
can't otherwise distinguish subagent runs from arbitrary nested-graph
dispatches.

langgraph treats both keys as opaque strings. The values are produced
by upstream code (ToolNode, in the common case) and projected onto
typed wire fields here, surfacing on the supervisor's depth-1
lifecycle event.

Also exports LifecycleCause and LifecycleCauseToolCall from
langgraph.stream so downstream consumers can type-check against the
projected shape.
Temporary git-URL pin to the BaseTool.subagent_name branch in
langchain-ai/langchain#37721. Once that PR merges and a langchain-core
release ships, this pin should be replaced with a version constraint
like "langchain-core>=X.Y.Z".

Required so CI on this PR can resolve the new BaseTool attribute that
the rest of this branch depends on.
ToolNode previously stamped subagent_name and tool_call_id onto
tool_runtime.config["metadata"] inside the tool body. That mutation
happens AFTER Pregel has already emitted the start `tasks` event for
the tools node, so the lifecycle event arrives at downstream
transformers with metadata=None and the projection chain never sees
the resolved name.

Adds a new Pregel extension point: PregelNode.dynamic_metadata, a
property that proxies to the bound runnable's pregel_dynamic_metadata
attribute when present. prepare_single_task (PULL) and
prepare_push_task_send (Send-dispatched PUSH) call it with the task
input and merge the returned dict into task.config["metadata"]
before the start event fires. Exceptions are swallowed so a buggy
resolver cannot block dispatch.

ToolNode implements pregel_dynamic_metadata: it parses the input,
iterates the dispatched tool calls, and returns
{"subagent_name": ..., "tool_call_id": ...} from the first call
whose tool declares subagent_name. The in-body stamping is removed.

Tests rewritten to call pregel_dynamic_metadata directly with the
input shape rather than asserting on the side-effect inside the tool
body. Adds a mixed-batch test documenting that the first resolved
match wins (one lifecycle payload per task, so a single cause /
graph_name can surface).
map_debug_tasks previously filtered task.config["metadata"] through
a frozen allowlist of two keys (subagent_name, tool_call_id). The
allowlist undermined the extensibility story we want for the
pregel_dynamic_metadata hook: a node that stamps a new key would need
to upstream a change to langgraph's internal constant before
downstream consumers could see it on TaskPayload.metadata, even
though the same dict already flows through to stream_mode='messages'
consumers without any filtering (see StreamMessagesHandler).

Drop the filter; forward the metadata dict (copied, for safety).
Framework keys (langgraph_step, langgraph_node, ...) ride along the
same way they already do on the messages-mode surface; consumers that
don't want them can filter client-side. Any key a user stamps via
proc.metadata or the dynamic_metadata hook now flows through to
v3 lifecycle events without further plumbing.

Tests updated to reflect the new shape and to verify the dict is
defensively copied so post-emission mutations of task.config do not
poison the payload.
map_debug_tasks now forwards task.config['metadata'] whole, so every
'type': 'task' debug-stream event carries a metadata dict with the
framework keys (langgraph_step, langgraph_node, langgraph_triggers,
langgraph_path, langgraph_checkpoint_ns). Inline payload expectations
in test_pregel and test_large_cases assumed no metadata key and
diffed on the extra field.

Anchor each task-payload assertion with AnyObject() rather than
pinning specific framework key values so future framework metadata
additions don't ripple through these tests again.
The subagent-name stamping hook is unnecessary: lc_agent_name is bound
onto the inner agent's config by create_agent and already flows into
task.config metadata. Drops the PregelNode.dynamic_metadata property and
its two application sites in prepare_single_task/prepare_push_task_send.
Drops _resolve_subagent_name and ToolNode.pregel_dynamic_metadata. Subagent
identity now comes from the inner agent's own lc_agent_name, not a tool-level
declaration stamped at dispatch.
_TasksLifecycleBase now tracks lc_agent_name per namespace and treats a
nested task as a subagent when its lc_agent_name differs from its parent's.
graph_name becomes that name; the triggering tool_call_id (for cause) is
recovered by joining the child segment's task-id to the parent task's tool
calls. Replaces the subagent_name metadata discriminator.
Current langgraph schedules each tool call as its own push task whose
input is a `tool_call_with_context` dict (tool_call_id at
input["tool_call"]["id"]), not a list of tool calls. _record_pending_tool_calls
now handles both the dict shape and the legacy/batched list shape, so
cause recovery fires on the production path. Synthetic tests updated to
use the real dict input shape, with an added test for the legacy list.
The _pending_tool_calls __init__ comment now describes the current
tool_call_with_context dict shape first and the legacy list shape as
fallback. _record_identity's docstring frames the parent-before-child
ordering as a Pregel emission assumption rather than an invariant. Adds
a terminal-roundtrip test that closes a detected subagent on its parent
push task's result and asserts the started payload's graph_name/cause
survive.
@nick-hollon-lc Nick Hollon (nick-hollon-lc) changed the title feat(langgraph): surface subagent_name on lifecycle events feat(langgraph): name tool-dispatched subagents via lc_agent_name May 29, 2026
Import LifecycleCause from langchain_protocol.protocol (already a dependency,
used for MessagesData) rather than redefining LifecycleCauseToolCall + a
single-member alias in transformers.py. Aligns the cause field name with the
protocol (tool_call_id, snake_case) and drops the redundant langgraph.stream
re-exports. SubgraphTransformer documents that it intentionally ignores cause.
Nick Hollon (nick-hollon-lc) added a commit to langchain-ai/langchain that referenced this pull request May 29, 2026
create_agent now automatically registers SubagentTransformer alongside
the existing ToolCallTransformer. Users with @tool(subagent_name=...)
declarations get a typed run.subagents projection automatically, no
extra wiring required.

The transformer reads from langgraph's TaskPayload.metadata
(populated by ToolNode in langchain-ai/langgraph#7928 from the
BaseTool.subagent_name attribute in #37721).
A null subagent_name produces no handle; valid subagent dispatches
surface on run.subagents with the declared name and tool_call_id
anchor.
Extracts the seq:step tag filter the messages stream handler applies into a
shared filter_user_tags helper and reuses it in map_debug_tasks, so the tasks
channel surfaces the same filtered tag set (under metadata.tags) that messages
consumers already receive.
)


def filter_user_tags(tags: Sequence[str] | None) -> list[str] | None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit, this is really filtering out internal LG/LC tags right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renamed as filter_to_user_tags

if tags:
if filtered_tags := [t for t in tags if not t.startswith("seq:step")]:
metadata["tags"] = filtered_tags
if (filtered_tags := filter_user_tags(tags)) is not None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

walruses make me happy

# without needing to be enumerated here. Filtered config tags are
# folded in under `tags`, mirroring the messages stream handler.
if task.config is not None:
md = dict(task.config.get("metadata") or {})

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need to wrap in dict

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

defensive copy so we don't mutate it later on accident

Comment on lines +58 to +60
md["tags"] = filtered_tags
if md:
payload["metadata"] = md

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for the other one (messages), we put the tags in the metadata, can we do the same here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think it is in there md = metadata

"name": "rewrite_query",
"input": {"query": "what is weather in sf", "docs": []},
"triggers": ("branch:to:rewrite_query",),
"metadata": AnyObject(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

curious, what does this look like here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{
"langgraph_step": 1,
"langgraph_node": "rewrite_query",
"langgraph_triggers": ("branch:to:rewrite_query",),
"langgraph_path": ("__pregel_pull", "rewrite_query"),
"langgraph_checkpoint_ns": "rewrite_query:",
"checkpoint_ns": "",
"thread_id": "",
}

but asserted as any object because the values are non deterministic

)
from langchain_core.messages import AIMessageChunk, BaseMessage, ToolMessage
from langchain_protocol.protocol import MessagesData
from langchain_protocol.protocol import LifecycleCause, MessagesData

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not blocking this PR, but i don't like the structure of LifecycleCause, the send + edge ones make total sense for langgraph workflows, tool call is a bit odd, would love to thread that info through in another way, but here we are

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would be nicer if lifecycle had metadata and we just put the tool call id in metadata instead of saying "cause" but i'd want to do that in a separate PR because it touches a lot more

Discriminator relaxed to 'lc_agent_name present' (was 'differs from parent'),
so a subagent that invokes itself (or any same-named nested agent) is surfaced
instead of being folded into the parent. Trade-off: a non-agent subgraph that
inherited the parent's name also surfaces; a caller can null lc_agent_name in
the config it invokes such a graph with.

Review fixes: clarify filter_user_tags drops internal seq:step tags; note the
metadata-copy in map_debug_tasks; lifecycle test now asserts same-name nesting
is surfaced.
Clearer that it returns the user-facing tags (dropping internal seq:step
tags), per review feedback.
map_debug_tasks now drops EXCLUDED_METADATA_KEYS (langgraph_*, thread_id,
checkpoint_*) from the metadata it forwards onto TaskPayload — those are
redundant with the task's own fields/namespace. User-meaningful keys
(lc_agent_name, ls_integration, user metadata) and filtered tags ride along; a
task with only framework metadata now carries no metadata key. Reuses the
canonical EXCLUDED_METADATA_KEYS set from langgraph.checkpoint.base.
@nick-hollon-lc Nick Hollon (nick-hollon-lc) merged commit ac3f5b0 into main May 29, 2026
68 checks passed
@nick-hollon-lc Nick Hollon (nick-hollon-lc) deleted the nh/subagent-name-langgraph branch May 29, 2026 20:46
Nick Hollon (nick-hollon-lc) added a commit that referenced this pull request Jun 2, 2026
… state

#7928 added a keyword-only `cause` argument to the `_TasksLifecycleBase._on_started` hook and passed it unconditionally, breaking overrides that predate it — including downstream/third-party transformers like deepagents' `SubagentTransformer` (`TypeError: _on_started() got an unexpected keyword argument 'cause'`), which errored subagent runs.

Deliver `cause` via instance state (`self._pending_cause`, set immediately before the call) instead of the call signature. `_on_started` reverts to its original `(ns, graph_name, trigger_call_id)` signature, so no override — old or new — ever breaks, with no introspection and no lockstep downstream release. `LifecycleTransformer` reads `self._pending_cause`; `SubgraphTransformer` ignores it as before. Adds a regression test.
Mason Daugherty (mdrxy) pushed a commit to langchain-ai/deepagents that referenced this pull request Jun 2, 2026
…3644)

Removes deepagents' bespoke `SubagentTransformer` and its scope-factory
wiring. Subagents already bind `lc_agent_name` (including
`CompiledSubAgent` for raw graphs), so the `SubagentTransformer` that
`create_agent` now registers (langchain-ai/langchain#37739) surfaces
them on `run.subagents` automatically — with no per-tool `subagent_name`
annotation and no `subagent_type`-arg parsing. The `task` tool drops its
`subagent_name=` declaration accordingly.

Also keeps the `ensure_config`-merge propagation work so a subagent
inherits the parent's user metadata while preserving its own bound
`lc_agent_name` (deepagents#3634).

Depends on langchain-ai/langchain#37739 and langchain-ai/langgraph#7928
(both unreleased) — pinned via temporary git revs in
`[tool.uv.sources]`; replace with released floors before merge.
@mdrxy Mason Daugherty (mdrxy) changed the title feat(langgraph): name tool-dispatched subagents via lc_agent_name feat(langgraph): name tool-dispatched subagents via lc_agent_name Jun 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants