Skip to content

Commit 20a9192

Browse files
committed
fix(langgraph): stop discarding a subagent's explicit manually_emit_state
Follows the protocol correction in the parent commit. An audit revision added a subagent guard to the `manually_emit_state` handler that DROPPED the payload -- neither recording nor emitting it -- justified by the rule that only the parent agent owns state. That rule was invented, and the guard silently discarded a state write the caller had explicitly asked for. Pre-audit behaviour is restored: the value is recorded and the snapshot emitted, as it was before (verified against 3386757, where this handler had no subagent guard at all). The dispatch chokepoint stamps subagent_run_id on the snapshot, which is provenance -- it records who wrote the state, while the state itself stays run-scoped. The two tests pinning the drop are replaced by one asserting the restored behaviour: the snapshot is emitted, carries the subagent's id, and the value is recorded. Both directions are covered, since silently dropping and silently mis-scoping are different failures. The other two state suppressions are LEFT ALONE, because they are pre-existing and their real reason still holds: node-exit and checkpoint snapshots carry a subgraph's PARTIAL view of the run's state document, so emitting one mid-delegation would overwrite the whole state with a fragment. Their comments are corrected only to stop citing a protocol prohibition that does not exist -- this is a producer-side choice about the integration's state model, and the distinction matters for anyone reading the code to learn what the protocol requires. Verified: 468 LangGraph tests (down one, as two tests became one).
1 parent 2478a70 commit 20a9192

2 files changed

Lines changed: 49 additions & 71 deletions

File tree

integrations/langgraph/python/ag_ui_langgraph/agent.py

Lines changed: 30 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -200,9 +200,10 @@ def derive_subagent_context(
200200
# has to reconstruct attribution from a prior event's messageId/toolCallId).
201201
# Excludes only the run-lifecycle events, MESSAGES_SNAPSHOT (its messages are
202202
# tagged individually), and the SUBAGENT_* lifecycle events (which carry their
203-
# own subagent_run_id explicitly). STATE_SNAPSHOT/STATE_DELTA are never emitted for a
204-
# subagent (see the state-suppression in the stream loop), so they stay here only
205-
# for the parent's benefit.
203+
# own subagent_run_id explicitly). STATE_SNAPSHOT/STATE_DELTA are attributable per
204+
# the protocol design; this integration emits them for a subagent only on the
205+
# explicit `manually_emit_state` path, since node-exit and checkpoint snapshots
206+
# would carry a partial subgraph view (see the suppressions in the stream loop).
206207
_SUBAGENT_ATTRIBUTABLE_EVENT_TYPES = frozenset({
207208
EventType.TEXT_MESSAGE_START, EventType.TEXT_MESSAGE_CHUNK,
208209
EventType.TEXT_MESSAGE_CONTENT, EventType.TEXT_MESSAGE_END,
@@ -1009,9 +1010,13 @@ async def _handle_stream_events(self, input: RunAgentInput) -> AsyncGenerator[Pr
10091010
# the forthcoming state update.
10101011
self.active_run["state_reliable"] = False
10111012
elif self.active_run.get("current_subagent_run_id"):
1012-
# Subagents don't emit STATE_SNAPSHOT — only the parent
1013-
# agent's state is surfaced. The subagent's messages still
1014-
# reach the client via MESSAGES_SNAPSHOT, so nothing is lost.
1013+
# Node-exit snapshots are suppressed while a subagent is
1014+
# active because a subgraph's state is a PARTIAL view of the
1015+
# run's document -- emitting it would overwrite the whole
1016+
# state with a fragment. This is a producer-side choice about
1017+
# this integration's state model, not a protocol rule:
1018+
# attributed state events are legal. The subagent's messages
1019+
# still reach the client via MESSAGES_SNAPSHOT.
10151020
# The lane survives a close (see reconcile_subagents), so a
10161021
# trailing event from a finished subagent stays suppressed.
10171022
pass
@@ -2283,32 +2288,22 @@ def _chunk_get(c: Any, key: str, default: Any = None) -> Any:
22832288
)
22842289

22852290
elif event["name"] == CustomEventNames.ManuallyEmitState:
2286-
# Only the parent owns state, so this is suppressed inside a
2287-
# subagent exactly like the node-exit and checkpoint snapshots
2288-
# are. Without the guard the dispatch chokepoint would stamp the
2289-
# subagent's id onto a STATE_SNAPSHOT (STATE_* is in
2290-
# _SUBAGENT_ATTRIBUTABLE_EVENT_TYPES), and the client applies
2291-
# STATE_SNAPSHOT to the shared state without consulting
2292-
# subagent_run_id — so a subagent's partial state would land as if
2293-
# the parent had sent it.
2291+
# No subagent guard here, deliberately. An audit revision dropped
2292+
# this payload when a subagent was active, on the grounds that only
2293+
# the parent owns state. That was an invented rule -- the protocol
2294+
# lists STATE_* as attributable -- and dropping silently discarded
2295+
# a state write the user had explicitly asked for.
22942296
#
2295-
# The payload is DROPPED, not merely left unemitted. Recording it
2296-
# would defer the same violation by one event rather than prevent
2297-
# it: `manually_emitted_state` is run-global, and the stream loop
2298-
# reads it back as `updated_state` at the next node exit and emits
2299-
# it as a snapshot. That deferred snapshot carries no subagent_run_id,
2300-
# so it would reach the consumer looking like the parent's own
2301-
# state — the exact outcome this guard exists to stop.
2302-
if not self.active_run.get("current_subagent_run_id"):
2303-
self.active_run["manually_emitted_state"] = event["data"]
2304-
yield self._dispatch_event(
2305-
StateSnapshotEvent(type=EventType.STATE_SNAPSHOT, snapshot=self.get_state_snapshot(self.active_run["manually_emitted_state"]), raw_event=event)
2306-
)
2307-
else:
2308-
logger.debug(
2309-
"Dropping manually_emit_state from subagent %s; only the parent owns state",
2310-
self.active_run.get("current_subagent_run_id"),
2311-
)
2297+
# `manually_emit_state` is an explicit request to set the run's
2298+
# state, and `manually_emitted_state` is run-global by design. A
2299+
# subagent making that request means it, so it is recorded and
2300+
# emitted like any other. The dispatch chokepoint stamps
2301+
# subagent_run_id on the snapshot, which is provenance: it records
2302+
# who wrote the state, while the state itself stays run-scoped.
2303+
self.active_run["manually_emitted_state"] = event["data"]
2304+
yield self._dispatch_event(
2305+
StateSnapshotEvent(type=EventType.STATE_SNAPSHOT, snapshot=self.get_state_snapshot(self.active_run["manually_emitted_state"]), raw_event=event)
2306+
)
23122307

23132308
yield self._dispatch_event(
23142309
CustomEvent(type=EventType.CUSTOM, name=event["name"], value=event["data"], raw_event=event)
@@ -2771,8 +2766,10 @@ async def get_state_and_messages_snapshots(self, config: RunnableConfig) -> Asyn
27712766
state_values = {}
27722767
else:
27732768
state_values = state.values
2774-
# Only the parent agent emits STATE_SNAPSHOT; while a subagent is active
2775-
# its (subgraph) state is not surfaced. The MESSAGES_SNAPSHOT below is
2769+
# Checkpoint snapshots are suppressed while a subagent is active for the
2770+
# same reason as the node-exit ones: a subgraph's state is a partial view of
2771+
# the run's document. A producer-side choice, not a protocol rule. The
2772+
# MESSAGES_SNAPSHOT below is
27762773
# still emitted and carries the subagent's messages (merged + tagged), so
27772774
# attribution and history survive without leaking subagent state.
27782775
if not self.active_run.get("current_subagent_run_id"):

integrations/langgraph/python/tests/test_emit_events.py

Lines changed: 19 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -117,16 +117,18 @@ async def test_manually_emit_state(self):
117117
assert agent.active_run["manually_emitted_state"] == {"counter": 42}
118118

119119
@pytest.mark.asyncio
120-
async def test_manually_emit_state_suppressed_inside_subagent(self):
121-
"""State belongs to the parent, so a subagent must not emit STATE_SNAPSHOT.
122-
123-
The two automatic paths (node-exit and checkpoint snapshots) already gate
124-
on current_subagent_run_id. This manual path did not, and because
125-
STATE_SNAPSHOT is in _SUBAGENT_ATTRIBUTABLE_EVENT_TYPES the dispatch
126-
chokepoint would then stamp the subagent's id onto it — producing exactly
127-
the event the design forbids. The client applies STATE_SNAPSHOT to the
128-
shared state without consulting subagent_run_id, so a subagent calling the
129-
manual helper would overwrite the parent's state with its own.
120+
async def test_manually_emit_state_inside_subagent_is_emitted_and_attributed(self):
121+
"""A subagent's explicit manually_emit_state IS recorded and emitted.
122+
123+
An audit revision dropped this payload when a subagent was active, on the
124+
grounds that only the parent owns state. That rule is not in the protocol --
125+
the design lists STATE_SNAPSHOT / STATE_DELTA as attributable -- and dropping
126+
silently discarded a state write the caller had explicitly requested.
127+
128+
`manually_emit_state` means "set the run's state", and a subagent calling it
129+
means it. The snapshot goes out and the dispatch chokepoint stamps
130+
subagent_run_id on it, which is provenance: it records WHO wrote the state,
131+
while the state itself remains run-scoped.
130132
"""
131133
agent = self._make_agent()
132134
agent.active_run["current_subagent_run_id"] = "tools:s1"
@@ -141,36 +143,15 @@ async def test_manually_emit_state_suppressed_inside_subagent(self):
141143
events.append(ev)
142144

143145
event_types = [e.type for e in events]
144-
assert EventType.STATE_SNAPSHOT not in event_types
145-
# The CUSTOM passthrough still goes out, so the subagent's signal is not
146-
# swallowed — only the state application is withheld.
146+
assert EventType.STATE_SNAPSHOT in event_types
147147
assert EventType.CUSTOM in event_types
148148

149-
@pytest.mark.asyncio
150-
async def test_manually_emit_state_inside_subagent_does_not_leak_into_parent_state(self):
151-
"""Suppressing the snapshot is not enough — the value must not be recorded.
152-
153-
Withholding the immediate STATE_SNAPSHOT while still storing the payload in
154-
the run-global `manually_emitted_state` only defers the violation: the
155-
stream loop reads that key back as `updated_state` on the next parent node
156-
exit and emits it as an UNATTRIBUTED snapshot, so the consumer applies the
157-
subagent's partial state as the parent's. The suppression has to drop the
158-
value, not just delay it.
159-
"""
160-
agent = self._make_agent()
161-
agent.active_run["current_subagent_run_id"] = "tools:s1"
162-
agent.active_run["active_subagents"] = {}
163-
event = {
164-
"event": LangGraphEventTypes.OnCustomEvent.value,
165-
"name": CustomEventNames.ManuallyEmitState.value,
166-
"data": {"counter": 42},
167-
}
168-
async for _ in agent._handle_single_event(event, {}):
169-
pass
170-
171-
assert agent.active_run["manually_emitted_state"] is None, (
172-
"a subagent's manually-emitted state must not be recorded; the stream "
173-
"loop would re-emit it as the parent's state on the next node exit"
149+
snapshot = next(e for e in events if e.type == EventType.STATE_SNAPSHOT)
150+
assert snapshot.subagent_run_id == "tools:s1", (
151+
"the snapshot must carry the subagent's id as provenance"
152+
)
153+
assert agent.active_run["manually_emitted_state"] == {"counter": 42}, (
154+
"an explicit state write must be recorded, not discarded"
174155
)
175156

176157
@pytest.mark.asyncio

0 commit comments

Comments
 (0)