返回 CodeWhale
tool_call_budget.rs
根目录 / crates / tui / src / tools / tool_call_budget.rs
1 //! Hard per-turn tool-call admission budget (#4415).
2 //!
3 //! One counter per turn, built from the task's structured `max_tool_calls`
4 //! constraint. Every proposed tool call passes the same gate in proposal
5 //! order, so a batch larger than the remaining budget is truncated to
6 //! exactly the calls that still fit and the excess are rejected — never
7 //! executed — with a typed reason carrying the remaining-call count.
8 //!
9 //! The cap counts *admitted* calls: a call that debits a slot but is then
10 //! stopped by a later admission gate (deny-list, allow-list, sandbox,
11 //! hooks, missing tool) is refunded before it would have executed (#5170),
12 //! so blocked calls cannot burn the budget.
13
14 use codewhale_tools::ToolError;
15
16 /// Countdown of tool calls one turn may still admit.
17 ///
18 /// This is the turn's admission state: created when the turn starts and
19 /// decremented at the admission gate. It deliberately does not live in the
20 /// tool catalog or surface policy, which only carry the declared limit.
21 /// `None` means unlimited — the default when a task declares no budget —
22 /// and leaves the gate inert.
23 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
24 pub(crate) struct ToolCallBudget {
25 max: Option<u32>,
26 remaining: Option<u32>,
27 }
28
29 /// Typed rejection for a call that exceeds the remaining budget.
30 ///
31 /// Rendered into the same `PermissionDenied` error the neighboring admission
32 /// gates (deny-list, allow-list) produce, so the denial is visible in the
33 /// transcript and to the model with the remaining-call count spelled out.
34 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
35 pub(crate) struct ToolCallBudgetExceeded {
36 max: u32,
37 }
38
39 impl ToolCallBudgetExceeded {
40 pub(crate) fn into_tool_error(self, tool_name: &str) -> ToolError {
41 ToolError::permission_denied(format!(
42 "Tool '{tool_name}' rejected: per-turn tool-call budget of {} exhausted (remaining=0). \
43 The call was not executed.",
44 self.max
45 ))
46 }
47 }
48
49 impl ToolCallBudget {
50 pub(crate) fn new(max_tool_calls: Option<u32>) -> Self {
51 Self {
52 max: max_tool_calls,
53 remaining: max_tool_calls,
54 }
55 }
56
57 /// Debit one proposed tool call at the admission gate. While budget
58 /// remains, the call is admitted and the remaining count decrements;
59 /// once exhausted, the call is rejected with [`ToolCallBudgetExceeded`].
60 /// A call stopped by a later gate before execution must be handed back
61 /// through [`refund`](Self::refund) so the cap counts admitted calls.
62 pub(crate) fn admit(&mut self) -> Result<(), ToolCallBudgetExceeded> {
63 let (Some(max), Some(remaining)) = (self.max, self.remaining.as_mut()) else {
64 return Ok(());
65 };
66 if *remaining == 0 {
67 return Err(ToolCallBudgetExceeded { max });
68 }
69 *remaining -= 1;
70 Ok(())
71 }
72
73 /// Hand back one debited slot when the call that took it is blocked by
74 /// a later admission gate and never executes (#5170). Clamped at the
75 /// declared maximum so a refund can never grow the budget.
76 pub(crate) fn refund(&mut self) {
77 if let (Some(max), Some(remaining)) = (self.max, self.remaining.as_mut()) {
78 *remaining = (*remaining + 1).min(max);
79 }
80 }
81 }
82
82 lines RUST