tesoro 0.1.3

Frameworks & rails

Two boundaries, deliberately separate. A framework adapter governs the tokens an agent burns thinking; a rail adapter governs what it pays out. Merging them would hurt the moment a second payment rail arrives.

GovernsDirection of controlContract
Framework the internal channel — tokens the framework calls the adapter RunGuard
Rail the external channel — payouts the agent calls the adapter PaymentClient

They differ in currency, in counterparty, in failure mode, and in that last column — which is the one people miss. When AP2 or a card rail arrives it needs the second contract and none of the first, and a merged interface would make that a rewrite rather than an addition.

The dependency arrow points one way

Your agent imports nothing of ours RunGuard start() should_stop() finish() Tesoro decides calls asks a test fails if the agent ever imports the governor
An agent does not import the governor; the governor wraps the agent. Duck-typed — nothing to subclass, nothing to register. If that arrow ever reverses, “plugin” and “dependency” become indistinguishable, so a test fails when it does.

Three calls, and that is the whole surface

from tesoro import Governor
from tesoro.adapters.base import RunGuard

guard = RunGuard(Governor.load(), budget_usd="0.40")

allowed, why = guard.start(model="claude-sonnet-4", provider="anthropic")
if not allowed:
    raise SystemExit(why)          # refused before a single token is spent

for step in agent_loop():
    if guard.should_stop(spent_so_far):
        break                      # once per step

guard.finish(actual_cost_usd)      # envelopes consume here

A framework adapter is nothing more than the code that arranges for a particular framework to make these three calls. Which is why an agent on a framework with no adapter here can still be fully governed by using RunGuard directly — it imports nothing, so it needs no extra at all.

Four properties worth knowing

The four framework adapters

Each is an optional extra. Nobody pays for a dependency they do not use, and the core imports no framework — checked by an AST walk, and import tesoro pulls in no adapter at all, checked in a subprocess because an in-process check would pass regardless once a test file had run its own imports.

tesoro[claude] — Claude Agent SDK

The one framework here that already ships a cost ceiling: max_budget_usd stops a run when estimated LLM cost reaches it. So this adapter's job is not to add a ceiling the SDK lacks — it is to make two ceilings agree, and to supply what a single per-run number cannot express:

adapter = ClaudeAgentAdapter(Governor.load(), budget_usd="0.40")
options = adapter.options_for({"model": "claude-sonnet-4"})   # carries max_budget_usd

options_for() returns the tighter of the two and never raises the caller's own limit — a governance layer that widened a limit somebody set would be doing the one thing no control in this system may do. stopped_by_sdk(result) tells the SDK's ceiling apart from the layer's, because they are adjusted in different places and reporting one as the other sends whoever is debugging to the wrong file.

tesoro[adk] — Google ADK

ADK caps max_llm_calls. A step ceiling is not a spend ceiling: one long-context call can cost more than fifty short ones, so max_llm_calls=50 bounds nothing about money. Here should_stop() is not a second opinion — nothing else in an ADK run is watching money.

run_config_for() leaves max_llm_calls alone rather than deriving it from the budget. The two bound different things: a step ceiling catches a runaway loop whose steps are nearly free, which a spend ceiling notices only slowly; a spend ceiling catches one enormous call, which a step ceiling never notices at all.

tesoro[langgraph] — LangGraph

Same situation: recursion_limit bounds a graph's shape and says nothing about money. LangGraph has no “stop politely” return value the way ADK's callback does — a graph ends when a node says so or when something raises — so the ceiling surfaces as GovernedBudgetExceeded, carrying the attributed control. The run stopped is not actionable; the daily envelope stopped it is.

Only on_llm_start is implemented, because that is the moment before money is spent. A callback that fires after a call has already cost what it cost is a report, not a control. Every other hook is a no-op so a new one in LangChain's moving callback surface cannot break a governed run.

tesoro[crewai] — CrewAI

This is the one adapter whose framework surface is unverified. The asymmetry is worth being blunt about:

AdapterGovernance behaviourFramework hook names
claude · adk · langgraphtestedfrom SDKs that ran end to end in the proof-of-concept
crewaitested identicallydocumentation only — no precedent, SDK not installed

So step_callback, max_iter and max_rpm are CrewAI's documented names, not names observed in a run. A version that renames one breaks this adapter without breaking a single test. If they are wrong, crew_kwargs() produces a dict CrewAI rejects immediately — the loud kind of wrong, and the kind to prefer.

One note on max_rpm, because it looks like a cost control and is not: pacing at a rate limit is unbounded in total. Sixty requests a minute, held all day, is eighty-six thousand requests.

The rail contract

x402 is the only rail today — HTTP 402, USDC, Base Sepolia. Keeping settlement behind one boundary is what makes the layer rail-agnostic: nothing above adapters/ knows what a 402 is.

governed = gov.wrap(buyer)   # the ONLY object holding the signer

The returned object is the only thing holding the signer, so the agent cannot pay without a decision. A client that cannot pay is refused by name — isinstance against a runtime protocol returns a bare False, which tells an integrator nothing, so the error lists the missing members.

What is verified, and what is not

Verified here

All four adapters against fakes, with no SDK installed — the three calls, the tightening rules, channel separation, consumption on finish, refusal before start, attribution on every stop, and that no module imports its framework. 49 tests.

Not verified here

That the hook names and signatures still match the current SDKs. An SDK that moves a hook breaks the integration without breaking these tests. That is what the version pins are for, and why end-to-end runs live in the examples repo.

Testing against fakes is deliberate rather than lazy: a contract exercised only with a real SDK present is a contract nobody checks, and all four adapters would then be verified by their absence. But a suite that passes with nothing installed cannot tell you the SDK still looks the way it did.

Writing your own

class MyFrameworkAdapter:
    channel = "internal"

    def __init__(self, governor=None, *, budget_usd=None):
        self.guard = RunGuard(governor, budget_usd=budget_usd)

    def before_run(self, *, model=""):
        return self.guard.start(model=model, provider="my-provider")

    def should_stop(self, spent_usd):
        return self.guard.should_stop(spent_usd)

    def after_run(self, *, actual_cost_usd=None, success=True):
        self.guard.finish(actual_cost_usd, success=success)

The remaining work in any adapter is always the framework-shaped part: where its hooks live, what they are called, and what a stop must return to end a run. That is the only thing the four shipped adapters do differently from each other.

Adapter reference

Both contracts in full, with the verification table.

adapters.md →

Working examples

Three framework agents, a mock x402 seller, six use cases.

tesoro-integrations →