Skip to content

Tools

Concept

A tool is not just a function the model can call. It is an interface you have to version, a permission boundary, and a risk you have to be able to audit afterwards. "The agent issued a refund" is a sentence someone will say to you, and you will need to answer which tool, which version, what arguments and who allowed it.

@tool derives the JSON schema from the signature, validates arguments before the function ever runs, and carries the metadata that makes the question answerable: version, owner, risk level and required permissions.

Minimal example

from rewyn import tool


@tool
def get_customer(customer_id: str) -> dict:
    """Fetch a customer record."""
    return {"id": customer_id, "plan": "enterprise"}


get_customer.parameters  # JSON schema derived from the signature
get_customer.fingerprint()  # content hash, recorded with every call

The first line of the docstring becomes the description the model sees. Write it for the model, not for your colleagues.

Production example

from rewyn import tool
from rewyn.tools import CompositePolicy, MaxRiskLevel, RequirePermissions, RiskLevel


@tool(
    version="3",
    owner="payments-team",
    risk_level="high",
    permissions=["billing:write"],
    timeout=10.0,
)
def issue_refund(customer_id: str, amount_cents: int, reason: str) -> str:
    """Issue a refund to a customer. Amounts are in cents."""
    return billing.refund(customer_id, amount_cents, reason)


policy = CompositePolicy(
    [
        MaxRiskLevel(approve_above=RiskLevel.MEDIUM),  # high risk asks a human
        RequirePermissions({"billing:write"}),  # this caller holds this grant
    ]
)

agent = Agent(model=..., tools=[issue_refund], permission_policy=policy)

A policy can allow, deny, or require approval, and the difference matters:

MaxRiskLevel(RiskLevel.MEDIUM)  # high risk is DENIED outright
MaxRiskLevel(approve_above=RiskLevel.MEDIUM)  # high risk ASKS a human
MaxRiskLevel(RiskLevel.HIGH, approve_above=RiskLevel.MEDIUM)  # ask, but never critical

The first argument is the ceiling above which a call is refused. Pass approve_above when you want a person in the loop rather than a wall.

When approval is required the executor asks the configured handler before running anything, and the decision is recorded as HUMAN_APPROVED or HUMAN_REJECTED.

Async, concurrency and timeouts

Async tools are awaited; sync tools run in a thread so they never block the loop. Tool calls in one model turn execute concurrently, bounded by ToolExecutor(max_concurrency=...). timeout is per call.

Reporting progress

A tool written as an async generator streams: each yield is an interim update and the last one is the result.

@tool
async def crawl(pages: int) -> str:
    """Crawl pages, reporting progress."""
    for index in range(pages):
        yield f"crawled page {index + 1}"
    yield f"done: {pages} pages"

Progress is published to the run like a model token delta rather than emitted as an event, so a tool reporting a hundred times reaches a live view without putting a hundred rows in the log. TOOL_RETURNED carries the count. Read them from Agent.astream; see Loops.

Cost

@tool(cost_per_call=0.0025)
def bureau_score(account_id: str) -> int:
    """Fetch a credit bureau score."""
    ...

The cost lands in run.manifest.cost.tool. Prices can also be registered centrally with register_unit_price, which is how you price a whole MCP server by prefix. See Production.

API reference

rewyn/tools/tool.py for tool, Tool, make_tool and as_tool. rewyn/tools/permissions.py for RiskLevel, AllowAll, DenyAll, AllowList, MaxRiskLevel, RequirePermissions and CompositePolicy. rewyn/tools/execution.py for ToolExecutor.

Failure modes

ToolArgumentError. The model sent arguments that do not fit the schema. The error lists each field. This is returned to the model by default so it can correct itself, which is usually what you want.

The model never calls the tool. Almost always the description. It is the only thing the model sees; a docstring written for a human reader often does not say when to use the function.

The schema is wrong. It comes from your type hints. An unannotated parameter becomes Any, which tells the model nothing. Annotate everything.

A denied call looks like a failure. Denials emit TOOL_DENIED and then return an error result to the model. Search for TOOL_DENIED rather than inferring from the result text.

A blocking tool stalls the loop. Sync tools run in a thread, but a sync tool that never returns still holds that thread. Set timeout.