Skip to content
beam-agents
GitHub

Architecture

The dataflow shape, the two execution paths through RunAgent, and why iterative loops cycle through the message bus instead of the DAG.

StableImplemented, specified, and covered by tests in the repository.

The dataflow shape

Three streams feed one keyed transform, and four streams come out of it.

The dataflow shapeThree input topics — events, approvals, and tool results — arrive on Kafka or Pub/Sub and are treated identically: the same WithKeys transform keys each of them on entity_key, the three keyed streams are flattened into one, and that stream passes through an optional enrichment stage into RunAgent. RunAgent emits four streams. Three of them terminate: .output, the main terminal output; .traces, bound for an OTLP or BigQuery sink; and .errors, bound for a dead-letter sink. The fourth, .intents, is the only one that comes back. It is written to an outbox topic on Kafka or Pub/Sub, executed by an effector running as a separate service outside the pipeline that deduplicates on the intent id, and the result is published onto the tool-results topic, where it re-enters as an ordinary input on the same key. The loop therefore closes outside the pipeline, and the Beam graph itself stays acyclic.KAFKA / PUB-SUB · ALL THREE ARE INPUTSeventsapprovalstool resultsWithKeys(entity_key)SAME KEY FUNCTION FOR EVERY INPUTFlattenONE KEYED STREAMenrichmentOPTIONALRunAgentSTATEFUL DOFN.outputMAIN OUTPUT.tracesOTLP / BIGQUERY.errorsDEAD-LETTER SINK.intentsINSIDE THE PIPELINE · THE DAG IS ACYCLICOUTSIDE · THE LOOP CLOSES THROUGH THE BUSoutbox topicKAFKA / PUB-SUBeffectorDEDUPES ON INTENT_IDRE-INJECTED
Tool results and approvals are inputs — keyed and flattened exactly like events, not a side channel. Three of the four outputs terminate; only .intents comes back, and it crosses the line to do it. That is how the loop stays unbounded while the graph stays acyclic.

Two things about that diagram are load-bearing.

First, the tool-results and approvals topics are inputs. They are not a side channel or a callback — they are ordinary elements, keyed the same way events are, flattened into the same stream. A resumed activation is a new element on the same key, indistinguishable in the runner's eyes from a fresh event.

Second, the loop closes outside the DAG. Beam DAGs are acyclic. An agent that calls a tool, gets a result, and calls another tool cannot be expressed as a cycle in the graph, and unrolling the loop into N copies of the transform bounds the iteration count at pipeline-construction time. So iteration goes through the message bus instead: out to the outbox topic, through the effector, back in on the results topic. The DAG stays acyclic and the loop stays unbounded.

Two paths through RunAgent

An activation either finishes or suspends. Both tracks below start with one element on one key and end with the same .output; what differs is how many activations it took and what had to survive in between.

Two paths through RunAgentTwo tracks. On the fast path a single activation of RunAgent at sequence number n consumes the event and runs to completion inside one process() call, emitting .output. On the re-injection path the first activation, also at sequence number n, stages a ToolIntent and suspends: it persists a Continuation into keyed state and yields, so the worker moves on. The intent leaves on .intents to the outbox topic, an effector running outside the pipeline executes it and deduplicates on the intent id, and the resulting tool result re-enters on the same key. A second activation rehydrates the continuation and runs with ctx.is_resume true and the same sequence number n, then emits .output. The sequence number is unchanged across the suspension, which is what keeps the replay-cache keys and the deterministic intent ids of the resumed activation continuing the first one's numbering instead of colliding with it.Fast pathONE ACTIVATION · ONE process() CALLeventRunAgentSEQ = NRUNS TO COMPLETION.outputRe-injection pathTWO ACTIVATIONS, ONE UNIT OF WORKContinuationKEYED STATEeventRunAgentSEQ = N · SUSPENDSPERSISTSREHYDRATEDRunAgentSEQ = N · IS_RESUME.output.intentsOUTSIDE THE PIPELINEoutbox topicKAFKA / PUB-SUBeffectorDEDUPES ON INTENT_IDtool result
Same key, same seq, two activations. Suspension is not a blocked thread: the first activation ends, the continuation persists in keyed state, and the second activation starts only when the result arrives.

Fast path

The agent runs to completion inside one process() call. Read-only tools execute inline; model calls go through the async client. One element in, one decision out. Nothing is persisted except working memory and the sequence counter.

async def triage(ctx: ActivationContext) -> Complete:
    """Decide what to do about one event for one entity.

    Module-level, not a closure: the DoFn holding the agent is serialized for
    the runner, so the agent has to pickle by reference.
    """
    ctx.memory.append("recent", ctx.event, max_items=32)
    seen = len(ctx.memory.ring("recent"))

    response = await ctx.call_model(
        LlmRequest(
            model_id="fake-1",
            messages=[f"event={ctx.event.decode()} seen={seen}"],
            tools_schema=None,
            sampling_params=None,
        )
    )
    return Complete(output=b"%s:%d" % (response.response, seen))
website/examples/fast_path.py (region: agent) — executed by the repository’s offline test tier.

Re-injection path

For a side-effectful tool or a human approval, the agent stages a ToolIntent, persists a Continuation in keyed state, and yields. The activation is over — the worker moves on. When the ToolResult or Approval arrives on the same key, the continuation is rehydrated and the agent is invoked again, this time with ctx.is_resume true.

async def refund(ctx: ActivationContext) -> Complete | Suspend:
    """Request a refund, then report what the effector did.

    Two activations, one logical unit of work. `ctx.act` stages the intent and
    returns its deterministic id; `Suspend` persists the continuation and arms
    the fail-closed timeout. Nothing has been written to the outside world when
    this function returns — the intent is a request, not an effect.
    """
    if not ctx.is_resume:
        ctx.act("payments.refund", '{"amount": 4200}', ttl_ms=INTENT_TTL_MS)
        return Suspend(snapshot=b"awaiting-refund", adapter="example", timeout_ms=30_000)

    # On resume the same activation continues: same key, same seq, and the
    # snapshot it persisted is available as ctx.snapshot.
    assert ctx.resume_result is not None
    return Complete(output=b"refunded:" + ctx.resume_result.payload)
website/examples/intents_and_resume.py (region: agent) — executed by the repository’s offline test tier.

The suspended activation keeps its seq. That matters more than it looks: the sequence number scopes both the replay-cache keys and the deterministic intent ids, so a resumed activation staging a second intent produces an id that continues the first one's step index rather than colliding with it.

Inside the stateful DoFn

The runtime is one Beam stateful DoFn. Its state and timers are:

StateKindHolds
MEMORYReadModifyWriteStateWorking memory (MemoryBlob)
CONTINUATIONReadModifyWriteStateResume state for a suspended activation
LLM_CACHEReadModifyWriteStateThe replay cache, bounded
PENDINGBagStateToolIntents awaiting an answer
SEQCombiningValueState (sum)Per-key activation counter
TimerDomainFires for
TTL_TIMERWatermarkWorking-memory garbage collection
HITL_TIMERReal timeApproval/result timeout

The Python SDK has no MapState, so bounded maps live inside single-value protobuf blobs with explicit LRU eviction. Every blob is capped at 100 KiB and working memory carries a 1 MiB soft cap per key with a compaction hook.

State is protobuf, never pickle. That is what makes pipeline --update compatibility tractable: additive proto changes only, and a breaking change requires a state_schema_version bump with lazy migration and a golden-blob compatibility test.

The async bridge

Beam's Python SDK has no portable async DoFn, and the runtime is async internally — model calls, tool execution, the whole activation. The bridge resolves that: setup() starts one background thread per DoFn instance with a dedicated asyncio loop and shared httpx pools. process() submits the activation coroutine and blocks with activation_timeout. On timeout the coroutine is cancelled and the element routes to .errors with no state mutation.

One thread per DoFn instance, not per element, is why the connection pools are shared and why nothing in the activation path may block the loop — the ASYNC lint rules exist to enforce that.

What comes out

RunAgent returns a RunAgentOutputs, and a complete pipeline consumes every field:

outputs = keyed | "Agent" >> RunAgent(
    route_by_event, config=AgentConfig(provider_factory=make_provider)
)

assert_that(outputs.output, equal_to([b"assessed", b"notified"]), label="output")

intents = outputs.intents | "IntentNames" >> beam.Map(
    lambda intent: (intent.entity_key, intent.tool_name)
)
assert_that(intents, equal_to([(b"k-notify", "slack.post")]), label="intents")

# An ActivationError names the key, why it failed, and the element's
# event time — never a wall clock.
errors = outputs.errors | "ErrorShape" >> beam.Map(
    lambda error: (error.entity_key, error.reason, error.event_time_ms)
)
assert_that(errors, equal_to([(b"k-broken", "activation_error", 1_000)]), label="errors")
website/examples/four_outputs.py (region: outputs) — executed by the repository’s offline test tier.

There is a fifth field, dead_letter, which is populated only when intents_to resolved to a WriteIntents outbox writer. It carries intents that could not be serialized. When errors_to is also configured the two streams merge before the sink, so the errors topic carries exactly one record schema.

Next

What backs this page

Symbol
beam_agents.RunAgent
Symbol
beam_agents.RunAgentOutputs
Symbol
beam_agents.core.agent.Suspend
Source
src/beam_agents/core/dofn.py
Source
src/beam_agents/core/transform.py
Source
src/beam_agents/actions/write_intents.py
Source
src/beam_agents/observability/otlp.py
Specification
openspec/specs/wire-schemas/spec.md
Test
tests/core/test_dofn_streaming.py
Test
tests/core/test_dofn_pipeline.py
Example
intents_and_resume.py