feat(langgraph): name tool-dispatched subagents via lc_agent_name#7928
Conversation
36d6ff7 to
1370fd6
Compare
766a881 to
b1543fe
Compare
6eea7c5 to
85f4620
Compare
b1543fe to
8c4afa1
Compare
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.
This reverts commit c94009e.
8c4afa1 to
6a93a36
Compare
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.
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: |
There was a problem hiding this comment.
nit, this is really filtering out internal LG/LC tags right?
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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 {}) |
There was a problem hiding this comment.
why do we need to wrap in dict
There was a problem hiding this comment.
defensive copy so we don't mutate it later on accident
| md["tags"] = filtered_tags | ||
| if md: | ||
| payload["metadata"] = md |
There was a problem hiding this comment.
for the other one (messages), we put the tags in the metadata, can we do the same here
There was a problem hiding this comment.
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(), |
There was a problem hiding this comment.
curious, what does this look like here?
There was a problem hiding this comment.
{
"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 |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
… 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.
…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.
lc_agent_name
Resolves the labeling half of #7910.
When a tool body invokes a named inner agent (
create_agent(name=...)), the supervisor'srun.subgraphs/run.lifecyclehandle for that dispatch was named after the parent tool node (tools) instead of the agent. This wires the inner agent's ownlc_agent_name— already bound into its config bycreate_agent— through to the lifecycle surfaces, with no tool annotation required._TasksLifecycleBasenow trackslc_agent_nameper namespace and treats a nested task as a subagent when itslc_agent_namediffers from its parent namespace's.graph_namebecomes that name; the triggeringtool_call_id(forcause) is recovered by joining the child subgraph's namespace task-id to the parenttoolstask's tool calls (handles the currenttool_call_with_contextpush-task shape and the legacy list shape).TaskPayload.metadataforwarding (fromtask.config["metadata"]) is retained as the transport. Plain non-agent subgraphs (which inherit the parent'slc_agent_name) and unnamed agents are correctly excluded.This PR supersedes its own earlier approach, which added a
subagent_nameparameter to tools and stamped it via aToolNode→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).