fix(langgraph): let forwardedProps hydrate graph state on every run - #2604
Open
BenTaylorDev wants to merge 1 commit into
Open
fix(langgraph): let forwardedProps hydrate graph state on every run#2604BenTaylorDev wants to merge 1 commit into
BenTaylorDev wants to merge 1 commit into
Conversation
prepare_stream built the graph input as {**forwarded_props, **payload_input},
so the client's synced state won every key collision. On the first run of a
thread the client holds no graph keys, so a forwarded value reached the graph
and worked. From the second run onward the client echoes back the
STATE_SNAPSHOT the graph wrote, and that stale value silently replaced the
forwarded one — identical forwardedProps produced a different graph input on
run 1 and run 2 (CopilotKit#3168).
forwardedProps are an explicit per-run instruction, so they now win over the
synced snapshot. Props the adapter owns stay out of the graph input entirely:
run controls (command, node_name, stream_subgraphs, inject_a2_u_i_tool) and
the channels langgraph_default_merge_state builds (messages, tools, ag-ui,
copilotkit). Leaking those in is what made #3168 look like it worked on run 1
— a caller squatting on the LangGraph-private `command` slot had it written
into graph state.
The deny-list is hand-maintained, so a contract test reads agent.py and fails
when a consumed prop is missing from it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
Python Preview PackagesVersion
Install with uvAdd the TestPyPI index to your [[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
explicit = trueThen install the packages you need: # Core SDK
uv add 'ag-ui-protocol==0.0.0.dev1788354813' --index testpypi
# Integrations (each already depends on the matching ag-ui-protocol preview)
uv add 'ag-ui-langgraph==0.0.0.dev1788354813' --index testpypi
uv add 'ag-ui-crewai==0.0.0.dev1788354813' --index testpypi
# NOTE: ag-ui-agent-spec depends on pyagentspec (git-only, not on PyPI).
# You will need to install pyagentspec separately from its git repo.
uv add 'ag-ui-agent-spec==0.0.0.dev1788354813' --index testpypi
uv add 'ag_ui_adk==0.0.0.dev1788354813' --index testpypi
uv add 'ag_ui_strands==0.0.0.dev1788354813' --index testpypiInstall with pippip install \
--index-url https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple/ \
ag-ui-protocol==0.0.0.dev1788354813
Commit: 358a792 |
@ag-ui/a2a-middleware
@ag-ui/a2ui-middleware
@ag-ui/event-throttle-middleware
@ag-ui/mcp-apps-middleware
@ag-ui/mcp-middleware
@ag-ui/a2a
@ag-ui/adk
@ag-ui/ag2
@ag-ui/agno
@ag-ui/aws-strands
@ag-ui/claude-agent-sdk
@ag-ui/claude-managed-agents
@ag-ui/crewai
@ag-ui/langchain
@ag-ui/langgraph
@ag-ui/llamaindex
@ag-ui/mastra
@ag-ui/pydantic-ai
@ag-ui/vercel-ai-sdk
@ag-ui/watsonx
@ag-ui/a2ui-toolkit
create-ag-ui-app
@ag-ui/client
@ag-ui/core
@ag-ui/encoder
@ag-ui/proto
commit: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
prepare_streambuilt the graph input as{**forwarded_props, **payload_input}, so the client's synced state won every key collision.On the first run of a thread the client holds no graph keys, so a forwarded value reached the graph and worked. From the second run onward the client echoes back the
STATE_SNAPSHOTthe graph wrote, and that stale value silently replaced the forwarded one. IdenticalforwardedPropstherefore produced a different graph input on run 1 and run 2.Reported as CopilotKit/CopilotKit#3168: routing driven by
forwardedPropsworks once, then falls back to the default node because the state key reads asNone.A second, related leak made the first run look correct. Every forwarded prop was spread into the graph input, including the ones the adapter consumes itself. A caller squatting on the LangGraph-private
commandslot hadcommandwritten into their graph state, which is why run 1 routed and run 2 did not.Change
One rule: forwardedProps that this adapter does not own hydrate graph state, and win over the synced snapshot, on every run. Adapter-owned props never enter graph state.
ADAPTER_OWNED_FORWARDED_PROPS(new, inutils.py) covers two groups:command,node_name,stream_subgraphs,inject_a2_u_i_tool(plus the camelCaseinjectA2UIToolthatlanggraph_default_merge_statealso accepts).langgraph_default_merge_statebuilds:messages,tools,ag-ui,copilotkit.config,stream_modeandthread_metadataare reserved too. The Platform (TypeScript) adapter consumes them, so both adapters keep the same reserved namespace.Keys are matched after
run()snake-cases them, so a frontend sendingforwardedProps: { routeCmd: "..." }hydrates theroute_cmdstate key.Behavior change
A forwarded prop whose name collides with an adapter-owned prop no longer reaches graph state at all. That is a deliberate break: it was never graph state, and shipping it there is what made #3168 intermittent rather than consistently broken. Callers using
forwardedProps.commandfor their own payload should move to a key of their own.The alternative — dropping the
forwardedProps→ state channel entirely, matching the Platform adapter, which never had it — would break the first run for everyone who relies on it today. That felt like a deprecation, not a bug fix, so it is not in this PR.The deny-list is pinned
The list is hand-maintained, and an omitted prop silently regresses into graph state — the exact defect #3168 reported.
TestAdapterOwnedForwardedPropsContractreadsagent.py, extracts everyforwarded_props.get("…")/"…" in forwardedread, and fails when one is not declared. This follows the existingtest_camel_to_snake_key_contractprecedent in this package.Testing
Branch built on current
origin/main(e929f557f).1. New unit tests —
tests/test_forwarded_props_state_precedence.py, 10 tests2. Full adapter suite, exactly as CI runs it — no regressions
3. End-to-end against a real compiled graph (
StateGraph+MemorySaver), not mocksTwo consecutive runs on one thread, identical
forwardedProps: { routeCmd: "run_tool_call", command: {...} }, with a node that consumes the value and clears it — so run 2's client state carriesNone, reproducing the reporter's setup.Before:
After:
4. Mutation-checked the new tests — each break is caught, so none of them is self-fulfilling:
{**forwarded_props, **payload_input}orderADAPTER_OWNED_FORWARDED_PROPSstream_subgraphs) from the list5. Checked for downstream reliance on the old behavior — every
forwardedPropsin the dojo e2e event traces is{}, and no dojo or example agent reads a forwarded prop out of graph state. No version bump here; this package releases through its ownchore: releasecommits.Out of scope
While tracing this I found that the Python adapter never reads
forwarded_props["config"]into theRunnableConfig;config["configurable"]is only ever fed fromself.config. SoforwardedProps.config.configurable, which the CopilotKit docs document for this path, does not reach a node's config on the self-hosted Python adapter. Reproduced in the same end-to-end probe (config.configurable.authToken=Noneon both runs). Filed separately rather than mixed in here.