Skip to content

fix(langgraph): let forwardedProps hydrate graph state on every run - #2604

Open
BenTaylorDev wants to merge 1 commit into
mainfrom
ben/langgraph-forwarded-props-precedence
Open

fix(langgraph): let forwardedProps hydrate graph state on every run#2604
BenTaylorDev wants to merge 1 commit into
mainfrom
ben/langgraph-forwarded-props-precedence

Conversation

@BenTaylorDev

Copy link
Copy Markdown
Contributor

Problem

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 therefore produced a different graph input on run 1 and run 2.

Reported as CopilotKit/CopilotKit#3168: routing driven by forwardedProps works once, then falls back to the default node because the state key reads as None.

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 command slot had command written 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, in utils.py) covers two groups:

  • Run controls the adapter reads: command, node_name, stream_subgraphs, inject_a2_u_i_tool (plus the camelCase injectA2UITool that langgraph_default_merge_state also accepts).
  • Channels langgraph_default_merge_state builds: messages, tools, ag-ui, copilotkit.

config, stream_mode and thread_metadata are 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 sending forwardedProps: { routeCmd: "..." } hydrates the route_cmd state 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.command for 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. TestAdapterOwnedForwardedPropsContract reads agent.py, extracts every forwarded_props.get("…") / "…" in forwarded read, and fails when one is not declared. This follows the existing test_camel_to_snake_key_contract precedent in this package.

Testing

Branch built on current origin/main (e929f557f).

1. New unit tests — tests/test_forwarded_props_state_precedence.py, 10 tests

$ uv run --locked python -m unittest tests.test_forwarded_props_state_precedence
Ran 10 tests in 0.011s
OK

2. Full adapter suite, exactly as CI runs it — no regressions

$ uv run --locked python -m unittest discover tests
Ran 783 tests in 2.846s
OK

3. End-to-end against a real compiled graph (StateGraph + MemorySaver), not mocks

Two 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 carries None, reproducing the reporter's setup.

Before:

run 1: state.command={'cmd': 'run_tool_call'}
run 2: state.command=None
CLAIM A (forwardedProps clobbered by synced state): REPRODUCED

After:

client state carried into run 2: {'route_cmd': None, 'command': None}

run 1: routed to tool_call_node  route_cmd='run_tool_call'  command=None
run 2: routed to tool_call_node  route_cmd='run_tool_call'  command=None

FIXED (routing stable across runs): True
reserved `command` kept out of graph state: True

4. Mutation-checked the new tests — each break is caught, so none of them is self-fulfilling:

Mutation Result
Restore the old {**forwarded_props, **payload_input} order 3 failed
Empty ADAPTER_OWNED_FORWARDED_PROPS 5 failed
Drop one consumed prop (stream_subgraphs) from the list 1 failed (contract test)

5. Checked for downstream reliance on the old behavior — every forwardedProps in 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 own chore: release commits.

Out of scope

While tracing this I found that the Python adapter never reads forwarded_props["config"] into the RunnableConfig; config["configurable"] is only ever fed from self.config. So forwardedProps.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=None on both runs). Filed separately rather than mixed in here.

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>
@BenTaylorDev
BenTaylorDev requested a review from a team as a code owner September 2, 2026 13:13
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Python Preview Packages

Version 0.0.0.dev1788354813 published to TestPyPI.

Warning: These packages are built from contributor code that may not yet have been vetted for correctness or security. Install at your own risk and do not use in production.

Install with uv

Add the TestPyPI index to your pyproject.toml:

[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
explicit = true

Then 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 testpypi

Install with pip

pip install \
  --index-url https://test.pypi.org/simple/ \
  --extra-index-url https://pypi.org/simple/ \
  ag-ui-protocol==0.0.0.dev1788354813

Use --extra-index-url https://pypi.org/simple/ so pip can resolve
transitive dependencies (pydantic, fastapi, etc.) from real PyPI.


Commit: 358a792

@pkg-pr-new

pkg-pr-new Bot commented Sep 2, 2026

Copy link
Copy Markdown

Open in StackBlitz

@ag-ui/a2a-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2a-middleware@2604

@ag-ui/a2ui-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2ui-middleware@2604

@ag-ui/event-throttle-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/event-throttle-middleware@2604

@ag-ui/mcp-apps-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mcp-apps-middleware@2604

@ag-ui/mcp-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mcp-middleware@2604

@ag-ui/a2a

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2a@2604

@ag-ui/adk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/adk@2604

@ag-ui/ag2

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/ag2@2604

@ag-ui/agno

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/agno@2604

@ag-ui/aws-strands

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/aws-strands@2604

@ag-ui/claude-agent-sdk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/claude-agent-sdk@2604

@ag-ui/claude-managed-agents

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/claude-managed-agents@2604

@ag-ui/crewai

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/crewai@2604

@ag-ui/langchain

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/langchain@2604

@ag-ui/langgraph

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/langgraph@2604

@ag-ui/llamaindex

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/llamaindex@2604

@ag-ui/mastra

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mastra@2604

@ag-ui/pydantic-ai

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/pydantic-ai@2604

@ag-ui/vercel-ai-sdk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/vercel-ai-sdk@2604

@ag-ui/watsonx

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/watsonx@2604

@ag-ui/a2ui-toolkit

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2ui-toolkit@2604

create-ag-ui-app

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/create-ag-ui-app@2604

@ag-ui/client

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/client@2604

@ag-ui/core

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/core@2604

@ag-ui/encoder

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/encoder@2604

@ag-ui/proto

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/proto@2604

commit: 74f47fe

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant