Skip to content
beam-agents
GitHub

Intents and resume

Emit a ToolIntent, suspend, and resume when the effector's result re-enters on the same key.

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

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.

The example can name the intent id before the pipeline runs, because the id is uuid5(namespace, key|seq|step_index) — a pure function of the activation's position. That is what makes deduplication a lookup rather than a guess.

Where the intent id is usedA box in the middle states that intent_id is uuid5 of a fixed namespace with the entity key, the activation seq, and the step index — a pure function of the position, so no clock and no counter is involved. Three faded leader lines run from that box to the three places the same value appears. The upper row runs left to right: ctx.act stages a ToolIntent at seq 0 step 0, the intent leaves on the intents output to an outbox topic that deduplicates on the intent id, and the effector runs the tool once. The lower row runs right to left: the effector publishes a ToolResult carrying the same intent id, it arrives on the results topic as an ordinary element keyed by entity key, and RunAgent matches the id against the suspended continuation and resumes the activation with ctx.is_resume true. The turn between the two rows travels through the message bus rather than through the pipeline graph, because a Beam DAG is acyclic.THE SAME VALUE IN ALL THREE PLACESctx.act(...)SEQ 0 · STEP 0.intentsoutbox topicDEDUP BY INTENT_IDeffectorRUNS IT ONCEintent_id = uuid5(ns, key|seq|step_index)A PURE FUNCTION OF THE POSITIONTHROUGH THEMESSAGE BUSNOT THE DAGToolResultSAME INTENT_IDresults topicKEYED BY ENTITY_KEYresumeCTX.IS_RESUME TRUE
One value, derived once from the activation's position, is what lets the outbox deduplicate and the resume find its continuation. Nothing in that derivation reads a clock, which is also why a caller can compute the id before the pipeline runs.

Follow the three faint lines out of the middle box. The same value is what ctx.act stamps on the intent, what the outbox deduplicates on, and what the resume is matched against — and because nothing in that derivation reads a clock or a counter, the intent_id_for(b"acct-1", 0, 0) call in main below arrives at it with no pipeline built and no element in flight. A replayed bundle that walks the same path mints the byte-identical id, which is the whole effectively-once argument.

Run it:

uv run python website/examples/intents_and_resume.py
"""Side effects: emit an intent, suspend, resume when the result comes back.

An agent never performs an external write itself. It stages a `ToolIntent` —
a declarative request — and suspends. The intent leaves on `.intents`, an
external effector executes it, and the resulting `ToolResult` re-enters the
pipeline on the same key, resuming the activation where it stopped.

The reason this is the only effect path is the intent id. It is
`uuid5(namespace, key|seq|step_index)` — a pure function of the activation's
position, never a clock or a counter — so a replayed bundle that walks the same
path mints byte-identical intents and the effector deduplicates on them. That
is the whole effectively-once argument, and it is why calling a
`side_effect=True` tool directly raises instead of working.

The loop runs through the message bus, not the DAG: Beam DAGs are acyclic, so
resumption is a new element on the same key rather than a cycle in the graph.

This example scripts the result's arrival with `TestStream` so the ordering is
deterministic — the same technique the repository's own timer tests use. In a
real deployment the result arrives from the effector's results topic.

Run it:  python website/examples/intents_and_resume.py
"""

from __future__ import annotations

import apache_beam as beam
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 beam_agents import AgentConfig, RunAgent
from beam_agents._protos import AgentEnvelope, ToolResult
from beam_agents.core.agent import Complete, Suspend, intent_id_for
from beam_agents.core.context import ActivationContext
from beam_agents.model.fake import FakeLLM, match_any, respond_with

INTENT_TTL_MS = 60_000


def make_provider() -> FakeLLM:
    return FakeLLM([(match_any(), respond_with(b"ok"))])


# region: agent
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)


# endregion: agent


def _event(key: bytes, payload: bytes, t_ms: int) -> TimestampedValue[AgentEnvelope]:
    env = AgentEnvelope(entity_key=key, event_time_ms=t_ms, external_event=payload)
    return TimestampedValue(env, t_ms / 1000)


# region: result
def _tool_result(key: bytes, intent_id: str, payload: bytes, t_ms: int):
    """One effector result, shaped as it arrives from the results topic."""
    env = AgentEnvelope(entity_key=key, event_time_ms=t_ms)
    env.tool_result.intent_id = intent_id
    env.tool_result.entity_key = key
    env.tool_result.payload = payload
    env.tool_result.status = ToolResult.OK
    return TimestampedValue(env, t_ms / 1000)


# endregion: result


def main() -> None:
    # The id is computable ahead of time precisely because it is deterministic:
    # first activation of key b"acct-1" (seq 0), first staged intent (step 0).
    intent_id = intent_id_for(b"acct-1", 0, 0)

    stream = (
        TestStream()
        .advance_watermark_to(0)
        .add_elements([_event(b"acct-1", b"chargeback", 1_000)])
        .add_elements([_tool_result(b"acct-1", intent_id, b"txn-88", 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(
            refund,
            config=AgentConfig(provider_factory=make_provider, ttl_ms=1_000_000_000),
        )

        # The suspension emits no main output; the resume completes.
        assert_that(outputs.output, equal_to([b"refunded:txn-88"]), label="output")

        staged = outputs.intents | "Describe" >> beam.Map(
            lambda intent: (intent.tool_name, intent.intent_id)
        )
        assert_that(staged, equal_to([("payments.refund", intent_id)]), label="intents")

    print("intents_and_resume: ok")


if __name__ == "__main__":
    main()
website/examples/intents_and_resume.py — executed by the repository’s offline test tier.

What backs this page

Example
intents_and_resume.py
Specification
openspec/specs/wire-schemas/spec.md
Test
tests/docs/test_website_examples.py
Test
tests/core/test_dofn_streaming.py