Skip to content
Kavachकवच

Agents: limit

● Intermediate4 min

limit sets hard boundaries on a capability method: on each call, on totals, and on the number of calls in a window.

type PayError = Declined | Denied(reason: String)

capability Payments {
    fn refund(order: Int, amount: Int) ! PayError
}

type Bank: Payments {
    fn refund(self, order: Int, amount: Int) ! PayError {
        print("refunded {amount} on order {order}")
    }
}

agent Refunder {
    can Payments.refund

    limit Payments.refund {
        amount <= 500
        sum(amount) <= 700 per day
    }

    on refund(order: Int, amount: Int) ! PayError {
        try Payments.refund(order, amount)
    }
}

fn main() {
    const bot = Refunder.start(Payments: Bank())
    bot.refund(1, 400) catch e => print("refused: {e}")
    bot.refund(2, 900) catch e => print("refused: {e}")
    bot.refund(3, 250) catch e => print("refused: {e}")
    bot.refund(4, 300) catch e => print("refused: {e}")
}

The program ran and printed:

kavach run limit.kav

refunded 400 on order 1refused: Denied(reason: "limit broken: amount <= 500")refunded 250 on order 3refused: Denied(reason: "limit broken: sum(amount) <= 700 per day")
limit.kav: Kavach code, then what running it prints

The kinds of rule, as spec 8.3 lists them:

RuleMeaning
amount <= 10_000Every single call must satisfy this
sum(amount) <= 50_000 per dayTotal across calls in the window
count <= 100 per dayNumber of calls in the window
tokens <= 200_000 per dayIn limit Ai: the tokens (in and out) the window's earlier asks took, with those still waiting for their answer counted as the agent's settings say (below). Once they pass the limit, the next ask is refused; the one that crosses it still runs, since an answer's size is known only after it. Anywhere else, tokens is a compile error.

The method's parameters are in scope by name: amount is the amount of each call. Call by call:

CallThe checksPrinted
refund(1, 400)400 ≤ 500, and the day's total 0 + 400 = 400 ≤ 700refunded 400 on order 1
refund(2, 900)900 > 500: the rule amount <= 500 is brokenrefused: Denied(reason: "limit broken: amount <= 500")
refund(3, 250)250 ≤ 500, and 400 + 250 = 650 ≤ 700refunded 250 on order 3
refund(4, 300)300 ≤ 500, but 650 + 300 = 950 > 700refused: Denied(reason: "limit broken: sum(amount) <= 700 per day")

A refused call never runs, so it doesn't count toward the totals: after order 2, the day's total is still 400.

How long each window is

WrittenMeans the lastIn seconds
per minute60 seconds60
per hour60 minutes3,600
per day24 hours86,400
per week7 days604,800
per month30 days2,592,000

Move "now" along two days and watch which calls count:

count <= 3 per day, with three calls on day 1

day 1 starts
midnight
day 2 ends
now

The window is day 1, 05:00 to day 2, 05:00. It holds 3 of the calls, so a call now is refused: it would be the fourth in 24 hours. Midnight has passed, yet day 1's calls still count: nothing resets at midnight.

  • A rolling window, not the calendar. per day counts the calls of the last 24 hours before each new call, not the calls since midnight.
  • A month is always 30 days. February, a 31-day month or a leap year don't change it. For calendar months, with their real lengths, use dates: add_months.
  • No time zones, no daylight saving. Time is measured on a clock that never jumps, so a day is always exactly 24 hours, in every country.
  • Across restarts, with a state file, each call's time is saved in UTC, so the window carries on where it was.

A token budget for AI calls

In limit Ai, tokens counts the tokens, in and out, that the window's earlier asks took. Once they pass the limit, the next ask is refused and never sent. Here each answer takes 60 tokens in and 10 out, against a budget of 100 a day:

type Yes {
    ok: Bool
}

agent Bot {
    can Ai
    limit Ai {
        tokens <= 100 per day
    }
    on check() -> Bool ! Error {
        (try ask<Yes>("a")).ok
    }
}

fn main() ! Error uses Ai, Console {
    const b = Bot.start()
    for _ in 0..3 {
        const ok = b.check() catch e => {
            print("refused: {e}")
            continue
        }
        print(ok)
    }
}

The program ran and printed:

truetruerefused: Denied(reason: "limit broken: tokens <= 100 per day") 
main.kav, run against a test model: Kavach code, then what running it prints
  • The first call starts at 0 tokens, the second at 70: both are under 100, so both run.
  • The second takes the total to 140. The third is refused with Denied before it is sent, so it costs nothing.
  • The call that crosses the limit still runs, because an answer's size is known only after it. So the total can end above the budget by one call's tokens, as it does here.
  • The tokens counted are the ones in the audit log's model: line, and with a state file they are kept across restarts, like sum and count.

Tokens are counted only for AI calls, so on any other capability tokens is a compile error:

type PayError = Declined | Denied(reason: String)

capability Payments {
    fn refund(order: Int, amount: Int) ! PayError
}

agent Refunder {
    can Payments.refund

    limit Payments.refund {
        tokens <= 100 per day
    }

    on refund(order: Int, amount: Int) ! PayError {
        try Payments.refund(order, amount)
    }
}

fn main() {
    print("never runs")
}

The compiler refused the program:

kavach run limit-tokens.kav

error: `tokens` counts what AI calls took: it is for `limit Ai` rules (spec 8.3)
limit-tokens.kav: Kavach code, then what running it prints

Many asks at once

Many asks can run at once, one per task, and each answer's size is known only when it comes. So tokens also counts the asks still waiting for their answer. The agent's settings choose how, with asks_under_way:

asks_under_wayAn ask under way counts asSo
UnderWay.Average (the default)The average size of the window's answered asksA new ask the limit would refuse waits while others are under way, then is checked again with their real sizes. When others are under way and none in the window has answered yet, their size isn't known, so it waits for the first answer. The total passes the limit by about one answer at most, as when asks run one at a time. Near the limit, asks run one after another.
UnderWay.LargestThe most it can take: for each of its 3 tries (8.4), its prompt's length in bytes (no token is shorter than a byte) and its model's max_tokens. The new ask counts this way too.The total doesn't pass the limit, except by text the provider adds to a request, such as its own instructions for structured output. But asks are refused well before it, as most answers are much shorter than max_tokens. With the default max_tokens of 4,096, each ask counts as at least 12,288 tokens, so a smaller limit refuses every one: give the agent's model a smaller max_tokens.
UnderWay.NotCountedNothingNothing waits. 10 asks started together under tokens <= 100 per day all run, and can take 700 tokens. For a limit kept as a rough guide.

Here 10 asks start together under a budget of 200 tokens a day, and each answer takes 70 tokens. The test runs it once with each setting, in the interpreter and in native code:

type Yes {
    ok: Bool
}

agent Writer {
    model Model(name: "claude-haiku-5-5", max_tokens: 30)
    settings AgentSettings(asks_under_way: UnderWay.Average)
    can Ai
    limit Ai {
        tokens <= 200 per day
    }
    on write() -> Bool ! Error {
        const _y = ask<Yes>("a") catch _ => {
            return false
        }
        true
    }
}

fn main() ! Error uses Ai, Console {
    const w = Writer.start()
    let asked: [Task<Bool>] = []
    for _ in 0..10 {
        asked.push(spawn w.write())
    }
    let answered = 0
    for a in asked {
        if try a.wait() {
            answered += 1
        }
    }
    print("answered {answered}")
}
main.kav, run against a test model: Kavach code
asks_under_wayAnsweredWhy
UnderWay.Average3 of 10as when they run one at a time (0, 70 and 140 pass; 210 doesn't), so 3
UnderWay.Largest2 of 1093 and 186 pass, 279 doesn't, so 2
UnderWay.NotCounted10 of 10none is counted before it answers, so all 10

Under Largest, each ask counts the most it could take: for each of its tries, its prompt's length in bytes and its model's max_tokens. Here that is 3 × (1 + 30) = 93.

In Observe mode nothing waits: the limit is only reported.

The setting is chosen when the program is built, like the rest of the settings:

fn way(careful: Bool) -> UnderWay {
    if careful { UnderWay.Largest } else { UnderWay.Average }
}

agent Writer {
    settings AgentSettings(asks_under_way: way(true))
    can Ai
    limit Ai {
        tokens <= 200_000 per day
    }
}

fn main() {
    print("never runs")
}

The compiler refused the program:

kavach run under-way-known.kav

error: an agent's settings are chosen when the program is built: write `AgentSettings(…)` with plain values, or call a function with no parameters that gives one, such as `standard()`, perhaps `with { … }` (spec 8.5)
under-way-known.kav: Kavach code, then what running it prints