LangGraph adapter
Adopt an existing LangGraph graph without editing its topology — three changes, a real streaming pipeline, and a tripwire proving model calls are replay-cached.
StableImplemented, specified, and covered by tests in the repository.
Agent authoring belongs to frameworks. Execution guarantees belong here. This example is the seam between the two: an ordinary model-and-tools graph, adopted whole, running inside a streaming pipeline that gives it durable checkpoints, deduplicated side effects, and replay-cached model calls.
The graph's topology is not edited. Three changes, none of them structural, are the entire adoption cost.
The three changes
1. Re-declare side-effecting tools
@tool(side_effect=True)
def page_oncall(message: str) -> str:
"""Page the on-call engineer.
`side_effect=True` is the whole declaration. Calling this directly from an
agent raises; the runtime turns it into a `ToolIntent` and the effector is
what actually executes it, exactly once per intent id.
"""
return f"paged: {message}"side_effect=True is the whole declaration. A tool carrying it never executes inside
the pipeline: the runtime turns the call into a ToolIntent, and the effector performs
it exactly once per intent id. Calling it directly raises — see
read-only tools and side effects for the guard.
2. and 3. Swap the tool node, wrap the graph
def build_agent() -> LangGraphAgent:
"""An ordinary model/tools graph, wrapped rather than rewritten."""
model = _ChatModel(httpx.MockTransport(_tripwire))
graph: StateGraph = StateGraph(GraphState)
async def call_model(state: GraphState) -> GraphState:
response = await model.root_async_client._client.post(
"https://provider.example/v1/chat",
json={"model": "demo", "messages": _to_wire(state["messages"]), "temperature": 0},
)
data = response.json()
if "tool_call" in data:
return {"messages": [AIMessage(content="", tool_calls=[data["tool_call"]])]}
return {"messages": [AIMessage(content=data["content"])]}
def route(state: GraphState) -> str:
last = state["messages"][-1]
return "tools" if isinstance(last, AIMessage) and last.tool_calls else END
graph.add_node("model", call_model)
# The only topology-adjacent change: BeamToolNode in place of ToolNode.
graph.add_node("tools", BeamToolNode([page_oncall]))
graph.add_edge(START, "model")
graph.add_conditional_edges("model", route, {"tools": "tools", END: END})
graph.add_edge("tools", "model")
return LangGraphAgent(graph, chat_models=[model], encode_output=encode_output)BeamToolNode is a drop-in for LangGraph's prebuilt ToolNode, and it is the only
topology-adjacent line in the file. It sorts each batch of tool calls by the
side_effect flag: read-only tools run inline and come back as ToolMessages in the
same node invocation, while side-effecting ones are collected and raised as a single
LangGraph interrupt(...). It accepts runtime Tool objects only, and raises at
construction if handed something else — so a graph that forgot change 1 fails loudly
rather than executing a write inside the pipeline.
LangGraphAgent wraps the result. A user-compiled graph is never mutated: the
per-activation checkpointer is injected into a copy. The chat_models list is how the
adapter finds the models to instrument — see the transport section below.
Everything else in that function is a plain LangGraph graph. add_node, add_edge,
add_conditional_edges, START, END — unchanged, and portable back out.
Choosing what lands on .output
def encode_output(state: object) -> bytes:
"""Emit just the final assistant message on `.output`.
The adapter's default encoder serializes the whole terminal state as JSON,
which for a message graph includes LangChain's per-message UUIDs — fine for
a debugging tap, awkward for a downstream consumer. `encode_output` is the
hook for deciding what the pipeline actually publishes.
"""
assert isinstance(state, dict)
return str(state["messages"][-1].content).encode()The default encoder serializes the whole terminal state as JSON, which for a message
graph includes LangChain's per-message UUIDs. That is useful as a debugging tap and
awkward as a contract for a downstream consumer, so encode_output is the hook for
deciding what the pipeline actually publishes. The mirror hook, decode_event, turns
the inbound AgentEnvelope payload into graph input.
Pickling: the worker-local singleton
_AGENT: LangGraphAgent | None = None
async def langgraph_agent(ctx: Any) -> Any:
"""Worker-side lazy singleton, so the DoFn pickles by reference.
A compiled LangGraph graph is not something you want to serialize into the
DoFn and ship to every worker. Handing `RunAgent` this module-level
function instead means only a reference travels, and each worker builds its
own graph the first time it activates. It is worker-*local*, so it does not
violate the no-cross-key-shared-mutable-state rule: the graph is rebuilt
per process and holds nothing about any particular key.
"""
global _AGENT # noqa: PLW0603 - worker-local singleton
if _AGENT is None:
_AGENT = build_agent()
return await _AGENT(ctx)RunAgent holds the agent inside a stateful DoFn that Beam serializes and ships to
workers, and a compiled graph is not a thing to serialize. Handing RunAgent a
module-level function means only a reference travels; each worker builds its own graph
on first activation.
This is a worker-local singleton, which is the sanctioned shape. It holds no per-key state, so it does not introduce the cross-key shared mutable state that correctness invariant 4 forbids.
Model calls really do go through the runtime
class _ChatModel:
"""Stands in for a LangChain chat model.
Recognized httpx-backed chat models are served through the runtime's
replay-cached `LLMClient`; the transport below is a tripwire proving the
model's own transport is never reached.
"""
def __init__(self, transport: httpx.AsyncBaseTransport) -> None:
self.root_async_client = _SdkClient(transport)
def _tripwire(request: httpx.Request) -> httpx.Response:
raise AssertionError("the chat model's own transport must never be reached")The claim "recognized httpx-backed chat models are served through the runtime's
replay-cached client" is easy to assert and easy to get wrong. So the example installs a
MockTransport on the model's own client that raises if it is ever reached, and the
program passes — which means every request the graph issued was intercepted and served
by the runtime.
That interception is what buys the graph replay-cached model calls: a retried bundle walking the same path re-reads the cache instead of re-calling the provider, so a retry cannot re-bill the call and cannot take a different branch because the model answered differently the second time. The replay cache spec has the key derivation and the bounds.
An unrecognized chat model falls back to direct calls with a one-time warning and a
transport_fallback metric. It still works; it simply does not carry the replay
guarantee.
The intent id is predictable here too
ENTITY_KEY = b"incident-7"
# The model call consumes step 0; the tool shim's side-effect intent is step 1.
EXPECTED_INTENT_ID = intent_id_for(ENTITY_KEY, 0, 1)Nothing about going through an adapter weakens the determinism. The graph's model call
consumes step 0 of the activation, the tool shim's side-effect intent is step 1, and the
id follows from (key, seq, step_index) alone — so the test can name the intent the
graph is about to stage, before the pipeline is built, and assert on it exactly.
What the graph gains, and what to watch
Adoption buys the four things a graph running by itself does not have: durable keyed checkpoints, side effects behind deduplicated intents, replay-cached model calls, and per-key serialization.
Two caveats, both documented by the adapter itself:
- Checkpoints persist latest-only inside working memory, under the same per-key cap as everything else in that blob. Long message histories have to be trimmed or summarized on the LangGraph side; the adapter will not do it for you.
- An interrupted node re-runs from its start on resume. That is LangGraph's own
resume semantics, not something the adapter adds: code before the
interrupt()executes again. Because side effects can only live behind intents and model calls are replay-cached, re-execution is deterministic and cheap — but pre-interrupt node code should be idempotent regardless.
Run it
uv sync --extra langgraph
uv run python website/examples/langgraph_adapter.py
The program scripts a TestStream: one event, then the effector's ToolResult arriving
on the same key, so the interrupt-and-resume round trip completes in one bounded run.
The complete program
"""Adopt an existing LangGraph graph without editing its topology.
# requires-extra: langgraph
The runtime is not an agent-authoring framework — authoring belongs to
LangGraph and friends. Adopting an existing graph takes three changes, none of
them to the graph's shape:
1. Re-declare side-effectful tools with the runtime decorator:
`@tool(side_effect=True)`.
2. Swap LangGraph's prebuilt `ToolNode` for `BeamToolNode(tools)`.
3. Wrap the graph: `RunAgent(LangGraphAgent(graph, chat_models=[model]))`.
What the graph gains is what LangGraph alone does not provide: durable keyed
checkpoints, side effects behind deduplicated intents, replay-cached model
calls, and per-key serialization.
Two caveats worth knowing before you rely on this, both documented by the
adapter itself. Checkpoints persist latest-only inside working memory and the
1 MiB per-key cap applies, so long message histories must be trimmed or
summarized on the LangGraph side. And an interrupted node re-runs *from its
start* on resume (LangGraph's own semantics), so keep pre-interrupt node code
idempotent.
Install the extra: uv pip install 'beam-agents[langgraph]'
Run it: python website/examples/langgraph_adapter.py
"""
from __future__ import annotations
import json
from typing import Annotated, Any
import apache_beam as beam
import httpx
from apache_beam.options.pipeline_options import PipelineOptions, StandardOptions
from apache_beam.testing.test_stream import TestStream
from apache_beam.testing.util import assert_that, equal_to
from apache_beam.transforms.window import TimestampedValue
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import add_messages
from typing_extensions import TypedDict
from beam_agents import AgentConfig, RunAgent
from beam_agents._protos import AgentEnvelope, ToolResult
from beam_agents.adapters.langgraph import BeamToolNode, LangGraphAgent
from beam_agents.core.agent import intent_id_for
from beam_agents.model.fake import FakeLLM, match_any, match_contains, respond_with
from beam_agents.tools import tool
# region: intent-id
ENTITY_KEY = b"incident-7"
# The model call consumes step 0; the tool shim's side-effect intent is step 1.
EXPECTED_INTENT_ID = intent_id_for(ENTITY_KEY, 0, 1)
# endregion: intent-id
# region: tool
@tool(side_effect=True)
def page_oncall(message: str) -> str:
"""Page the on-call engineer.
`side_effect=True` is the whole declaration. Calling this directly from an
agent raises; the runtime turns it into a `ToolIntent` and the effector is
what actually executes it, exactly once per intent id.
"""
return f"paged: {message}"
# endregion: tool
class GraphState(TypedDict):
messages: Annotated[list, add_messages]
def make_provider() -> FakeLLM:
"""Turn 1 asks for the tool; turn 2 (a tool message is present) finishes."""
return FakeLLM(
[
(
match_contains("'role': 'tool'"),
respond_with(json.dumps({"content": "incident acknowledged"}).encode()),
),
(
match_any(),
respond_with(
json.dumps(
{
"tool_call": {
"name": "page_oncall",
"args": {"message": "disk pressure"},
"id": "call-1",
}
}
).encode()
),
),
]
)
class _SdkClient:
def __init__(self, transport: httpx.AsyncBaseTransport) -> None:
self._client = httpx.AsyncClient(transport=transport)
# region: transport
class _ChatModel:
"""Stands in for a LangChain chat model.
Recognized httpx-backed chat models are served through the runtime's
replay-cached `LLMClient`; the transport below is a tripwire proving the
model's own transport is never reached.
"""
def __init__(self, transport: httpx.AsyncBaseTransport) -> None:
self.root_async_client = _SdkClient(transport)
def _tripwire(request: httpx.Request) -> httpx.Response:
raise AssertionError("the chat model's own transport must never be reached")
# endregion: transport
def _to_wire(messages: list[Any]) -> list[dict[str, str]]:
wire: list[dict[str, str]] = []
for message in messages:
if isinstance(message, HumanMessage):
wire.append({"role": "user", "content": str(message.content)})
elif isinstance(message, ToolMessage):
wire.append({"role": "tool", "content": str(message.content)})
elif isinstance(message, AIMessage):
wire.append({"role": "assistant", "content": str(message.content)})
return wire
# region: encode
def encode_output(state: object) -> bytes:
"""Emit just the final assistant message on `.output`.
The adapter's default encoder serializes the whole terminal state as JSON,
which for a message graph includes LangChain's per-message UUIDs — fine for
a debugging tap, awkward for a downstream consumer. `encode_output` is the
hook for deciding what the pipeline actually publishes.
"""
assert isinstance(state, dict)
return str(state["messages"][-1].content).encode()
# endregion: encode
# region: graph
def build_agent() -> LangGraphAgent:
"""An ordinary model/tools graph, wrapped rather than rewritten."""
model = _ChatModel(httpx.MockTransport(_tripwire))
graph: StateGraph = StateGraph(GraphState)
async def call_model(state: GraphState) -> GraphState:
response = await model.root_async_client._client.post(
"https://provider.example/v1/chat",
json={"model": "demo", "messages": _to_wire(state["messages"]), "temperature": 0},
)
data = response.json()
if "tool_call" in data:
return {"messages": [AIMessage(content="", tool_calls=[data["tool_call"]])]}
return {"messages": [AIMessage(content=data["content"])]}
def route(state: GraphState) -> str:
last = state["messages"][-1]
return "tools" if isinstance(last, AIMessage) and last.tool_calls else END
graph.add_node("model", call_model)
# The only topology-adjacent change: BeamToolNode in place of ToolNode.
graph.add_node("tools", BeamToolNode([page_oncall]))
graph.add_edge(START, "model")
graph.add_conditional_edges("model", route, {"tools": "tools", END: END})
graph.add_edge("tools", "model")
return LangGraphAgent(graph, chat_models=[model], encode_output=encode_output)
# endregion: graph
# region: singleton
_AGENT: LangGraphAgent | None = None
async def langgraph_agent(ctx: Any) -> Any:
"""Worker-side lazy singleton, so the DoFn pickles by reference.
A compiled LangGraph graph is not something you want to serialize into the
DoFn and ship to every worker. Handing `RunAgent` this module-level
function instead means only a reference travels, and each worker builds its
own graph the first time it activates. It is worker-*local*, so it does not
violate the no-cross-key-shared-mutable-state rule: the graph is rebuilt
per process and holds nothing about any particular key.
"""
global _AGENT # noqa: PLW0603 - worker-local singleton
if _AGENT is None:
_AGENT = build_agent()
return await _AGENT(ctx)
# endregion: singleton
def _event(t_ms: int) -> TimestampedValue:
env = AgentEnvelope(
entity_key=ENTITY_KEY,
event_time_ms=t_ms,
external_event=json.dumps(
{"messages": [{"role": "user", "content": "disk is filling up"}]}
).encode(),
)
return TimestampedValue(env, t_ms / 1000)
def _tool_result(t_ms: int) -> TimestampedValue:
env = AgentEnvelope(entity_key=ENTITY_KEY, event_time_ms=t_ms)
env.tool_result.intent_id = EXPECTED_INTENT_ID
env.tool_result.entity_key = ENTITY_KEY
env.tool_result.payload = json.dumps("paged: disk pressure").encode()
env.tool_result.status = ToolResult.OK
return TimestampedValue(env, t_ms / 1000)
def main() -> None:
stream = (
TestStream()
.advance_watermark_to(0)
.add_elements([_event(1_000)])
.add_elements([_tool_result(1_500)])
.advance_watermark_to_infinity()
)
options = PipelineOptions([])
options.view_as(StandardOptions).streaming = True
with beam.Pipeline(options=options) as pipeline:
keyed = (
pipeline
| stream
| "Key"
>> beam.WithKeys(lambda e: e.entity_key).with_output_types(tuple[bytes, AgentEnvelope])
)
outputs = keyed | "Agent" >> RunAgent(
langgraph_agent,
config=AgentConfig(provider_factory=make_provider, ttl_ms=1_000_000_000),
)
names = outputs.intents | "IntentNames" >> beam.Map(
lambda intent: (intent.tool_name, intent.intent_id)
)
assert_that(names, equal_to([("page_oncall", EXPECTED_INTENT_ID)]), label="intents")
assert_that(outputs.output, equal_to([b"incident acknowledged"]), label="output")
print("langgraph_adapter: ok")
if __name__ == "__main__":
main()
Related
- Adapters — which frameworks are supported today, and the conformance matrix that keeps the answer honest.
- Architecture — the re-injection path this graph's interrupt travels.
- Intents and resume — the same round trip without a framework in the way.
- LLM replay cache — what interception buys.
LangGraphAgentin the API reference.
What backs this page
- Symbol
- beam_agents.LangGraphAgent
- Source
- src/beam_agents/adapters/langgraph/agent.py
- Source
- src/beam_agents/adapters/langgraph/toolnode.py
- Source
- src/beam_agents/adapters/langgraph/checkpoint.py
- Specification
- openspec/specs/tool-registry/spec.md
- Specification
- openspec/specs/llm-replay-cache/spec.md
- Test
- tests/docs/test_website_examples.py
- Test
- tests/adapters/test_e2e_pipeline.py
- Test
- tests/adapters/test_toolnode_shim.py
- Test
- tests/adapters/test_transport_hook.py
- Test
- tests/conformance/test_matrix.py
- Example
- langgraph_adapter.py