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")
The kinds of rule, as spec 8.3 lists them:
| Rule | Meaning |
|---|---|
amount <= 10_000 | Every single call must satisfy this |
sum(amount) <= 50_000 per day | Total across calls in the window |
count <= 100 per day | Number of calls in the window |
tokens <= 200_000 per day | In 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:
| Call | The checks | Printed |
|---|---|---|
refund(1, 400) | 400 ≤ 500, and the day's total 0 + 400 = 400 ≤ 700 | refunded 400 on order 1 |
refund(2, 900) | 900 > 500: the rule amount <= 500 is broken | refused: Denied(reason: "limit broken: amount <= 500") |
refund(3, 250) | 250 ≤ 500, and 400 + 250 = 650 ≤ 700 | refunded 250 on order 3 |
refund(4, 300) | 300 ≤ 500, but 650 + 300 = 950 > 700 | refused: 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
| Written | Means the last | In seconds |
|---|---|---|
per minute | 60 seconds | 60 |
per hour | 60 minutes | 3,600 |
per day | 24 hours | 86,400 |
per week | 7 days | 604,800 |
per month | 30 days | 2,592,000 |
Move "now" along two days and watch which calls count:
count <= 3 per day, with three calls on day 1
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 daycounts 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")
- 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
Deniedbefore 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, likesumandcount.
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)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_way | An ask under way counts as | So |
|---|---|---|
UnderWay.Average (the default) | The average size of the window's answered asks | A 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.Largest | The 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.NotCounted | Nothing | Nothing 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}")
}
| asks_under_way | Answered | Why |
|---|---|---|
UnderWay.Average | 3 of 10 | as when they run one at a time (0, 70 and 140 pass; 210 doesn't), so 3 |
UnderWay.Largest | 2 of 10 | 93 and 186 pass, 279 doesn't, so 2 |
UnderWay.NotCounted | 10 of 10 | none 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)