Skip to content
beam-agents
GitHub

Read-only tools and side effects

Inline execution for reads, staged intents for writes, and a program that asserts on the guard refusing a direct side-effecting call.

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

Every tool an agent can reach is on one side of a single line: does calling it change anything outside this pipeline?

A read-only tool — a lookup, a cache probe, an enrichment read — executes inline, right there in the activation, because re-running it on a bundle retry is harmless. A side-effecting tool does not execute in the pipeline at all. It is requested as an intent and performed by the effector, which deduplicates on the intent id.

The split is enforced, not advised: calling a side_effect=True tool directly raises. This example shows both halves, and asserts on the refusal so the guarantee is demonstrated rather than claimed.

Two kinds of tool

@tool
def risk_band(customer_id: str) -> str:
    """Look up a customer's risk band. Read-only: safe to run inline."""
    return "high" if customer_id.endswith("9") else "normal"


@tool(side_effect=True)
def freeze_account(customer_id: str) -> str:
    """Freeze an account. Side-effecting: never executes inside the pipeline."""
    return f"frozen:{customer_id}"


def make_registry() -> ToolRegistry:
    """A fresh registry per call — no module-level mutable state."""
    registry = ToolRegistry()
    registry.register(risk_band)
    return registry
website/examples/read_only_tools.py (region: tools) — executed by the repository’s offline test tier.

@tool derives the tool's provider-facing JSON schema from the Python signature via a generated Pydantic model, so the argument contract and the implementation cannot drift. side_effect=True is the entire declaration of the second kind — there is no separate registration path, no wrapper, and no configuration flag to forget.

Two structural choices in that snippet are worth copying.

The tools are module-level. They pickle by reference into the DoFn, exactly like the agent function itself.

The registry is built per call, not kept as a module global. The project's convention is no global mutable state, and a registry is mutable. make_registry() returns a fresh one each time it is invoked, which also means a test can build a different registry without unpicking a global.

Note what the registry contains: only risk_band. The registry is what ctx.run_tool resolves against, and — as the wiring section below shows — a side-effecting tool never needs to be resolvable inside the pipeline.

Using both in one activation

async def assess(ctx: ActivationContext) -> Complete:
    """Enrich inline, then request the freeze as an intent."""
    customer = ctx.event.decode()

    # Inline: this runs here, now, in the activation.
    band = await ctx.run_tool("risk_band", {"customer_id": customer})

    if band == "high":
        # Not inline: staged as an intent for the effector to execute.
        ctx.act("freeze_account", f'{{"customer_id": "{customer}"}}', ttl_ms=60_000)
        return Complete(output=b"freeze-requested")

    return Complete(output=b"cleared")
website/examples/read_only_tools.py (region: agent) — executed by the repository’s offline test tier.

ctx.run_tool validates the arguments against the tool's schema, executes it inline, and returns the value. It is traced as a TOOL_CALL child event, but it deliberately does not advance the step cursor: the cursor mints intent ids and orders replay-cache entries, and an inline read must not perturb either.

ctx.act does the opposite. Nothing executes; a ToolIntent is staged with a deterministic id and leaves on .intents when the activation commits. The agent has requested a freeze, not performed one, and it can complete without waiting.

This agent completes immediately after ctx.act rather than suspending, which is a legitimate choice: it does not need the effector's answer to decide what to emit. When the agent does need the result, it returns Suspend instead — intents and resume.

The refusal is asserted, not described

try:
    freeze_account(customer_id="cust-9")
except SideEffectToolError as exc:
    refusal = str(exc)
else:
    raise AssertionError("a side-effect tool must refuse a direct call")
website/examples/read_only_tools.py (region: refusal) — executed by the repository’s offline test tier.

The else branch is the one doing the work. If freeze_account ever executed on a direct call, the except would not run, the else would fire, and the example would fail — so the guarantee is checked by the same offline test tier that runs every other example, on every change.

SideEffectToolError names the offending tool and points at ctx.act(...), the path that does work. The program prints the message it caught, so the refusal is visible in the run output rather than only in a traceback.

The guard is enforced in two places, because there are two ways to reach a tool. Tool.__call__ refuses a direct call — that is the branch above. ToolRunner.run refuses again before validating arguments, which covers ctx.run_tool. Either way the tool is not counted as an execution and not traced as one, so a refusal cannot masquerade as work that happened.

There is exactly one sanctioned bypass, Tool.unwrap(), and it exists for the effector's own runner: side-effecting tools have to execute somewhere, and that somewhere is outside the pipeline. Keeping it a named, documented accessor rather than a private-attribute poke is deliberate — the one permitted exception to invariant 5 should be greppable. Nothing inside the pipeline may call it.

Wiring the registry

# The registry carries only the read-only tool, because the registry is
# what `ctx.run_tool` resolves against. `ctx.act` stages an intent by
# name for the effector to route, so the side-effecting tool never
# needs to be resolvable inside the pipeline at all.
outputs = keyed | "Agent" >> RunAgent(
    assess,
    config=AgentConfig(provider_factory=make_provider, tool_registry=make_registry()),
)

assert_that(outputs.output, equal_to([b"freeze-requested", b"cleared"]), label="output")

# Exactly one intent, from the one key whose band came back "high".
# The cleared key executed a tool inline and staged nothing.
names = outputs.intents | "Names" >> beam.Map(lambda intent: intent.tool_name)
assert_that(names, equal_to(["freeze_account"]), label="intents")
website/examples/read_only_tools.py (region: wiring) — executed by the repository’s offline test tier.

AgentConfig.tool_registry defaults to an empty registry. An unconfigured pipeline therefore refuses every inline call by name rather than executing something that happened to be importable — the safe default, and the one that fails loudly.

Why the line is drawn here

The reason is retries, not taste.

A Beam bundle can be retried, and a retried activation walks the same code again. A read-only tool re-executing is invisible: the lookup returns the same answer and nothing outside the pipeline notices. A side-effecting tool re-executing is a duplicate refund, a second page to the on-call engineer, a frozen account frozen twice.

ctx.act is the only effect path because the intent id uuid5(namespace, key|seq|step_index) is a pure function of the activation's position. A replayed bundle that walks the same path mints byte-identical intents, and the effector suppresses duplicates by looking the id up. That is the whole effectively-once argument, and it only holds if nothing can slip around it — which is precisely why the direct call raises instead of working.

There is one documented exception: idempotent upserts to the long-term MemoryStore, keyed by (key, seq).

Run it

uv run python website/examples/read_only_tools.py

The program prints the refusal message before running the pipeline, so a successful run shows both halves of the split.

The complete program

"""Read-only tools run inline; side-effecting ones cannot.

Tools split in two by their `side_effect` flag, and the split is enforced
rather than advised:

- A read-only tool executes inline, inside the activation, via
  `ctx.run_tool(name, args)`. It is a lookup — an enrichment read, a cache
  probe — and re-running it on a bundle retry is harmless.
- A `side_effect=True` tool raises if called directly. The only path to an
  external write is `ctx.act(...)`, which stages an intent for the effector.

This example shows both halves, including the refusal. The registry is built
per call rather than kept as a module global, matching the project's
no-global-mutable-state convention; the tools themselves are module-level so
they pickle by reference into the DoFn.

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

from __future__ import annotations

import apache_beam as beam
from apache_beam.testing.util import assert_that, equal_to

from beam_agents import AgentConfig, RunAgent
from beam_agents._protos import AgentEnvelope
from beam_agents.core.agent import Complete
from beam_agents.core.context import ActivationContext
from beam_agents.model.fake import FakeLLM, match_any, respond_with
from beam_agents.tools import ToolRegistry, tool
from beam_agents.tools.errors import SideEffectToolError


# region: tools
@tool
def risk_band(customer_id: str) -> str:
    """Look up a customer's risk band. Read-only: safe to run inline."""
    return "high" if customer_id.endswith("9") else "normal"


@tool(side_effect=True)
def freeze_account(customer_id: str) -> str:
    """Freeze an account. Side-effecting: never executes inside the pipeline."""
    return f"frozen:{customer_id}"


def make_registry() -> ToolRegistry:
    """A fresh registry per call — no module-level mutable state."""
    registry = ToolRegistry()
    registry.register(risk_band)
    return registry


# endregion: tools


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


# region: agent
async def assess(ctx: ActivationContext) -> Complete:
    """Enrich inline, then request the freeze as an intent."""
    customer = ctx.event.decode()

    # Inline: this runs here, now, in the activation.
    band = await ctx.run_tool("risk_band", {"customer_id": customer})

    if band == "high":
        # Not inline: staged as an intent for the effector to execute.
        ctx.act("freeze_account", f'{{"customer_id": "{customer}"}}', ttl_ms=60_000)
        return Complete(output=b"freeze-requested")

    return Complete(output=b"cleared")


# endregion: agent


def _event(key: bytes, payload: bytes) -> AgentEnvelope:
    return AgentEnvelope(entity_key=key, event_time_ms=1_000, external_event=payload)


def check_direct_call_is_refused() -> None:
    """Calling a side-effecting tool directly raises. That is the guarantee."""
    # region: refusal
    try:
        freeze_account(customer_id="cust-9")
    except SideEffectToolError as exc:
        refusal = str(exc)
    else:
        raise AssertionError("a side-effect tool must refuse a direct call")
    # endregion: refusal
    print(f"read_only_tools: {refusal}")


def main() -> None:
    check_direct_call_is_refused()

    with beam.Pipeline() as pipeline:
        keyed = (
            pipeline
            | "Events" >> beam.Create([_event(b"c-9", b"cust-9"), _event(b"c-1", b"cust-1")])
            | "Key"
            >> beam.WithKeys(lambda e: e.entity_key).with_output_types(tuple[bytes, AgentEnvelope])
        )
        # region: wiring
        # The registry carries only the read-only tool, because the registry is
        # what `ctx.run_tool` resolves against. `ctx.act` stages an intent by
        # name for the effector to route, so the side-effecting tool never
        # needs to be resolvable inside the pipeline at all.
        outputs = keyed | "Agent" >> RunAgent(
            assess,
            config=AgentConfig(provider_factory=make_provider, tool_registry=make_registry()),
        )

        assert_that(outputs.output, equal_to([b"freeze-requested", b"cleared"]), label="output")

        # Exactly one intent, from the one key whose band came back "high".
        # The cleared key executed a tool inline and staged nothing.
        names = outputs.intents | "Names" >> beam.Map(lambda intent: intent.tool_name)
        assert_that(names, equal_to(["freeze_account"]), label="intents")
        # endregion: wiring

    print("read_only_tools: ok")


if __name__ == "__main__":
    main()
website/examples/read_only_tools.py — executed by the repository’s offline test tier.
  • Tool registry — the capability specification behind @tool, the schema derivation, and the guard.
  • Correctness invariants — invariant 5, side effects only via intents.
  • Intents and resume — what happens after an intent leaves, and how the result comes back.
  • The effector — the service that executes intents, and the honest bound on its guarantees.

What backs this page

Symbol
beam_agents.AgentConfig
Source
src/beam_agents/tools/registry.py
Source
src/beam_agents/tools/runner.py
Source
src/beam_agents/tools/errors.py
Specification
openspec/specs/tool-registry/spec.md
Test
tests/docs/test_website_examples.py
Test
tests/tools/test_side_effect_guard.py
Test
tests/tools/test_runner.py
Test
tests/tools/test_registry.py
Example
read_only_tools.py