Tool registry
The @tool decorator, argument schemas, and the side-effect guard that makes ctx.act the only effect path.
StableImplemented, specified, and covered by tests in the repository.
beam_agents.tools lets agent code declare tools with a @tool decorator. Each
tool's provider-facing JSON schema is derived from its Python signature through
a generated Pydantic v2 model, and a ToolRegistry collects them for name
resolution and aggregate tools_schema lookup. One definition produces both the
callable and the schema the model is shown, so the two cannot describe different
arguments.
The side-effect flag is the whole design
Every tool declares whether it has side effects, and that single flag splits the runtime in two:
side_effect=False— a read-only tool.ToolRunnervalidates the arguments and calls it inline, inside the activation. Nothing leaves the pipeline; see read-only tools.side_effect=True— an effectful tool. Calling it directly raisesSideEffectToolError. The only way to invoke it isctx.act(...), which stages aToolIntentthat leaves on.intentsand is executed by the effector.
That refusal is correctness invariant 5 made
mechanical. A side effect performed inline inside an activation happens again on
every bundle retry, and it happens on a code path where nothing is staged, so a
failed activation still leaves the effect behind. Raising on direct invocation
turns "please always use ctx.act" from documentation into an error at the
moment the mistake is made — including when the mistake is inside an adapted
third-party agent framework rather than in code anyone here wrote.
Related
- Read-only tools — the inline path.
- Intents and resume — the effects path.
- The effector — what executes an intent, once.
- Correctness invariants — invariant 5 in full.
Published verbatim from openspec/specs/tool-registry/spec.md — 6 requirements, 16 scenarios. Each scenario is the source a test is derived from and named after.
Purpose
beam_agents.tools lets agent code declare tools via the @tool decorator, deriving each tool's provider-facing JSON schema from its Python signature through a generated Pydantic v2 model, and collects them in a ToolRegistry for name resolution and aggregate tools_schema lookup. The side_effect flag on every tool separates the fast path — side_effect=False tools run inline through ToolRunner with argument validation — from the effects path: a side_effect=True tool raises SideEffectToolError on any direct invocation, enforcing correctness invariant 5 that side effects only ever execute through ctx.act(...) and the intents/effector pipeline.
Requirements
Requirement: The @tool decorator registers a callable as a Tool
The system SHALL provide a @tool decorator that wraps a Python callable into a Tool. The decorator SHALL accept an optional name (defaulting to the callable's __name__), an optional description (defaulting to the callable's docstring), and a side_effect: bool flag (defaulting to False). Applying @tool SHALL return an object that exposes the tool's name, description, side_effect, the derived argument model, and the derived JSON schema, and SHALL remain callable with the original function's semantics for read-only tools. The decorator SHALL support both bare (@tool) and parameterized (@tool(side_effect=True)) usage.
Scenario: Bare decorator derives name and description from the function
- WHEN
@toolis applied without arguments to a functionlookup_customerwith a docstring - THEN the resulting
Toolhasname == "lookup_customer",descriptionequal to the function's docstring, andside_effect == False
Scenario: Parameterized decorator overrides name and declares a side effect
- WHEN
@tool(name="charge", side_effect=True)is applied to a function - THEN the resulting
Toolhasname == "charge"andside_effect == True
Requirement: Tool schema is generated from the function signature via Pydantic v2
The system SHALL derive each tool's argument schema from the wrapped callable's parameters and type hints using a generated Pydantic v2 model. The Tool SHALL expose a provider-facing JSON schema (schema) containing the tool name, description, and a JSON Schema parameters object describing the arguments, their types, and which are required. Parameters without defaults SHALL be required; parameters with defaults SHALL be optional with the default reflected. A callable whose parameters lack type annotations SHALL raise a ToolDefinitionError at decoration time.
Scenario: Schema reflects parameter types and required-ness
- WHEN a tool wraps
def f(customer_id: str, limit: int = 10) -> ... - THEN the tool's JSON schema
parametersmarkscustomer_idandlimitasstringandintegerrespectively, lists onlycustomer_idas required, and recordslimit's default of10
Scenario: Missing type annotations are rejected at decoration time
- WHEN
@toolis applied to a function with an un-annotated parameter - THEN decoration raises
ToolDefinitionErrornaming the offending parameter
Requirement: The ToolRegistry collects and resolves tools
The system SHALL provide a ToolRegistry that registers Tool instances by name, rejects duplicate names, resolves a name to its Tool, and exposes the aggregate tools_schema (the list of every registered tool's JSON schema) for passing to LLM providers. Resolving an unregistered name SHALL raise a ToolNotFoundError.
Scenario: A registered tool is resolvable and appears in tools_schema
- WHEN a
Toolnamedlookup_customeris registered andtools_schemais read - THEN
registry.get("lookup_customer")returns thatToolandtools_schemacontains that tool's schema
Scenario: Duplicate registration is rejected
- WHEN two tools with the same
nameare registered into oneToolRegistry - THEN the second registration raises an error identifying the conflicting name
Scenario: Resolving an unknown tool raises
- WHEN
registry.get("does_not_exist")is called - THEN a
ToolNotFoundErroris raised naming the requested tool
Requirement: The ToolRunner executes read-only tools inline with argument validation
The system SHALL provide a ToolRunner that executes side_effect=False tools inline for the fast path. ToolRunner.run SHALL be an async method. Before invoking the callable, the runner SHALL validate the supplied arguments against the tool's Pydantic argument model, coercing and rejecting per the model. Invalid arguments SHALL raise a ToolArgumentError without invoking the underlying callable. On valid arguments the runner SHALL call the tool and, if the call returns an awaitable (an async def tool, or any sync tool returning an awaitable), SHALL await it and return the awaited result; otherwise it SHALL return the result directly.
Scenario: Valid arguments are validated and a sync tool runs
- WHEN the
ToolRunnerruns aside_effect=Falsesync tool with arguments satisfying its schema - THEN the arguments are validated against the tool's Pydantic model, the underlying callable is invoked with the coerced values, and its result is returned directly
Scenario: Invalid arguments are rejected before the callable runs
- WHEN the
ToolRunnerruns a read-only tool with arguments that fail its Pydantic model (missing required field or wrong type) - THEN a
ToolArgumentErroris raised and the underlying callable is never invoked
Scenario: An async tool is awaited and its result returned
- WHEN the
ToolRunnerruns aside_effect=Falsetool defined withasync defand arguments satisfying its schema - THEN the coroutine is awaited to completion and
ToolRunner.runreturns the tool's actual result, not a coroutine object
Requirement: Direct invocation of a side-effecting tool raises
The system SHALL enforce correctness invariant 5: a side_effect=True tool MUST NOT execute inside the pipeline. Any attempt to run a side-effecting tool from an in-pipeline path — via the ToolRunner or by calling the decorated tool as a function — SHALL raise a SideEffectToolError and MUST NOT invoke the underlying callable. Side-effecting tools are requested only through the intents path (ctx.act(...)) and are executed only outside the pipeline, by the effector's EffectorToolRunner, which reaches the callable through Tool.unwrap() and which conversely refuses side_effect=False tools. These two runners are therefore disjoint: neither can execute the other's class of tool, so "side effects only via intents" remains a closed statement.
Scenario: ToolRunner refuses a side-effecting tool
- WHEN the
ToolRunneris asked to run aside_effect=Truetool - THEN a
SideEffectToolErroris raised naming the tool and the underlying callable is never invoked
Scenario: Calling a side-effecting tool directly raises
- WHEN a
side_effect=Truedecorated tool is invoked directly as a callable - THEN a
SideEffectToolErroris raised and no external write occurs
Scenario: The effector's runner is the one sanctioned executor
- WHEN a
side_effect=Truetool is run through the effector'sEffectorToolRunner - THEN the callable is invoked, and this is the only execution path in the codebase that does not raise
SideEffectToolErrorfor such a tool
Scenario: The sanctioned executor refuses read-only tools
- WHEN a
side_effect=Falsetool is run through the effector'sEffectorToolRunner - THEN it raises and the callable is never invoked, since a read-only tool belongs to the in-pipeline fast path
Requirement: Tool exposes a named accessor for its wrapped callable
The Tool type SHALL expose a public unwrap() accessor returning the callable it wraps, bypassing the side_effect guard on Tool.__call__. Its docstring SHALL state that the effector's execution path is its only sanctioned caller. This exists so that the single permitted bypass of correctness invariant 5 is a named, greppable, testable call rather than access to a private attribute.
Scenario: unwrap returns the original callable
- WHEN
unwrap()is called on aToolwrapping a functionf - THEN the returned object is
fitself, and calling it invokesfwithout aside_effectcheck
Scenario: unwrap is available for side-effecting and read-only tools alike
- WHEN
unwrap()is called on aside_effect=Truetool and on aside_effect=Falsetool - THEN both return their wrapped callable, since the guard that decides who may run what lives on the runners, not on the accessor
What backs this page
- Specification
- openspec/specs/tool-registry/spec.md
- Test
- tests/tools/test_registry.py