# Bound an agent loop and its budget

URL: /docs/cookbook/budgeted_agent
Markdown: /docs/cookbook/budgeted_agent.md

> Set both a turn limit and a spending limit, and test rejection before an unaffordable call.

Generated by `cargo run -p agent_docs` from `compiler/examples/`. Edit the example files, then regenerate.

Set both a turn limit and a spending limit, and test rejection before an unaffordable call.

`Agent.run` permits at most `max_steps` model turns and offers no tools on the last
turn. The tests verify that a model still requesting a tool then fails with
`InvalidOutput`. `BudgetExceeded`, other `LlmError` variants and `ToolError` propagate
in the declared failure union.

For finite accounting, put `Budget.limits` outside `Llm.anthropic` and replay HTTP
under `Http.recording` with an empty scripted fallback. Do not substitute
`Llm.replay` or `Llm.recording` here: those helpers install `Budget.unlimited`
internally. The placeholder `Secret` is not an API key, and no host HTTP handler or
network permission is installed.

The cassette's two turns report synthetic usage of 140 tokens each. The provider
reserves an estimate and settles that usage; the agent also holds a temporary
one-call reservation while a turn is in progress. Consequently the two-call run
needs a call limit of three for its overlapping reservations. A limit of two
rejects the second provider call. The tests exercise both cases, a token limit that
rejects before any scripted response is consumed, and the final-turn rule. These
are local accounting tests, not measured provider prices or a hard billing guarantee.
The harness uses test mode; no key, paid call or live service is involved.

[Source project](https://github.com/icantcodefyi/sakuralang/tree/master/compiler/examples/budgeted_agent) · harness mode: `test`.

From the repository root, with this checkout's `sakura` on `PATH`:

```sh
cd compiler/examples/budgeted_agent
sakura check --json
sakura test --json
```

### Manifest and permissions [#manifest-and-permissions]

```toml
[package]
name = "budgeted_agent"
version = "0.1.0"
edition = "2026"

[permissions]
```

### Source and tests [#source-and-tests]

`src/main.sakura` includes all required imports and tests.

```sakura test id=cookbook-budgeted_agent project=budgeted_agent
// milestone: M3
// mode: test
// spec: design §17.145

import std/agent.{Agent}
import std/budget.{Budget, BudgetExceeded, Calls, Limits, Tokens}
import std/fs.{Fs}
import std/http.{Http}
import std/llm.{InvalidOutput, Llm, LlmError, Model, Request}
import std/path.{Path}
import std/prompt.{Prompt}
import std/tool.{Tool, ToolError}

@tool("Count waiting tasks")
fn waiting() -> Int { 2 }

fn count_tasks(max_steps: Int) -> Int
  ! {Budget, Llm, Fail<LlmError | ToolError>}
  requires max_steps > 0
{
  let request: Request<Int> = Llm.request("Use the waiting tool.", Prompt.text("How many tasks?"))
  Agent.run(Agent { request, tools: [Tool.of(waiting)], max_steps, stop: fn(_) { false } })
}

fn replay_count(limits: Limits) -> Int ! {Fs, Fail<LlmError | ToolError>} {
  // Keep the finite budget outside the provider. Llm.replay installs Budget.unlimited.
  with Budget.limits(limits),
      Http.scripted([]),
      Http.recording(Path.of("tasks.jsonl")),
      Llm.anthropic(Secret.of("offline-placeholder"), Model.default()) { count_tasks(3) }
}

test "two provider turns settle within a finite budget" {
  with Fs.rooted(Test.fixtures()) {
    expect_eq(replay_count(Limits { tokens: Some(5_000), usd: Some(0.10), calls: Some(3) }), 2)
  }
}

test "the second turn cannot reserve past the call limit" {
  with Fs.rooted(Test.fixtures()) {
    expect_fails_with(
      fn() { replay_count(Limits { tokens: Some(5_000), usd: Some(0.10), calls: Some(2) }) },
      fn(e) {
        match e {
          BudgetExceeded { kind } => kind == Calls,
          _ => false,
        }
      },
    )
  }
}

test "an impossible token reservation never reaches the model" {
  with Budget.limits(Limits { tokens: Some(10), usd: None, calls: None }), Llm.scripted([]) {
    expect_fails_with(fn() { count_tasks(3) }, fn(e) {
      match e {
        BudgetExceeded { kind } => kind == Tokens,
        _ => false,
      }
    })
  }
}

test "the last turn must answer instead of requesting another tool" {
  with Budget.unlimited(),
      Llm.scripted(["\{\"tool_use\":[\{\"name\":\"waiting\",\"input\":\{\}\}]\}"]) {
    expect_fails_with(fn() { count_tasks(1) }, fn(e) {
      match e {
        InvalidOutput { .. } => true,
        _ => false,
      }
    })
  }
}
```

### `tests/fixtures/README.md` [#testsfixturesreadmemd]

```text
# Synthetic cassette

Hand-authored request/response exchanges in the version 1 cassette format.
No live request, provider account, API key or recorded personal data was used.
Requests match method, URL, headers and body; secret headers are absent.
Tests install an empty scripted HTTP fallback, so a missing exchange fails offline.
```

### `tests/fixtures/tasks.jsonl` [#testsfixturestasksjsonl]

```jsonl
{"schema_version":"1.0","kind":"cassette"}
{"method":"POST","url":"https://api.anthropic.com/v1/messages","headers":[["content-type","application/json"],["anthropic-version","2023-06-01"],["anthropic-beta","server-side-fallback-2026-07-01"]],"body":"{\"model\":\"claude-opus-5-5\",\"max_tokens\":1024,\"system\":\"Use the waiting tool.\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"How many tasks?\"}]}],\"tools\":[{\"name\":\"waiting\",\"description\":\"Count waiting tasks\",\"input_schema\":{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"type\":\"object\",\"properties\":{},\"required\":[],\"additionalProperties\":false}}],\"output_config\":{\"format\":{\"type\":\"json_schema\",\"schema\":{\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"integer\"}},\"required\":[\"value\"],\"additionalProperties\":false}}},\"fallbacks\":\"default\"}","status":200,"response_headers":[["content-type","application/json"]],"response_body":"{\"id\":\"msg_SYNTHETIC_0\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-opus-5-5\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_SYNTHETIC_1\",\"name\":\"waiting\",\"input\":{}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":120,\"output_tokens\":20}}"}
{"method":"POST","url":"https://api.anthropic.com/v1/messages","headers":[["content-type","application/json"],["anthropic-version","2023-06-01"],["anthropic-beta","server-side-fallback-2026-07-01"]],"body":"{\"model\":\"claude-opus-5-5\",\"max_tokens\":1024,\"system\":\"Use the waiting tool.\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"How many tasks?\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_SYNTHETIC_1\",\"name\":\"waiting\",\"input\":{}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_SYNTHETIC_1\",\"content\":\"2\"}]}],\"tools\":[{\"name\":\"waiting\",\"description\":\"Count waiting tasks\",\"input_schema\":{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"type\":\"object\",\"properties\":{},\"required\":[],\"additionalProperties\":false}}],\"output_config\":{\"format\":{\"type\":\"json_schema\",\"schema\":{\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"integer\"}},\"required\":[\"value\"],\"additionalProperties\":false}}},\"fallbacks\":\"default\"}","status":200,"response_headers":[["content-type","application/json"]],"response_body":"{\"id\":\"msg_SYNTHETIC_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-opus-5-5\",\"content\":[{\"type\":\"text\",\"text\":\"{\\\"value\\\":2}\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":120,\"output_tokens\":20}}"}
```

### Compiler provenance and regeneration [#compiler-provenance-and-regeneration]

These eight projects teach supported Sakura APIs on the JavaScript backend. Each
project is exercised offline by `cargo test -p sakura_cli --test examples`;
HTTP and LLM exchanges are synthetic committed cassettes, with no network or API keys.
Use Node 26 for execution, including its built-in SQLite implementation.

The initial API baseline is compiler revision
[5cae349516c21714d90d36e87f1cfd6debe980c6](https://github.com/icantcodefyi/sakuralang/commit/5cae349516c21714d90d36e87f1cfd6debe980c6).
CI reruns these projects against the compiler in the same checkout. Build that
checkout's CLI and run `sakura --version` and `sakura prompt` before adapting a recipe;
pre-alpha APIs can change. An installed skill's frontmatter records its compiler
version and build revision. These teaching examples are separate from the agent
evaluation tasks.

The source of each page is its example's `recipe.md`, `sakura.toml`,
`src/main.sakura` and fixture files. Run `cargo run -p agent_docs` from `compiler/`
to regenerate the cookbook and skill reference. Edit those sources, not the rendered pages.
