everruns_core/capabilities/
budgeting.rs1use super::{Capability, CapabilityLocalization, CapabilityStatus};
14use crate::tool_types::ToolHints;
15use crate::tools::{Tool, ToolExecutionResult};
16use crate::traits::ToolContext;
17use async_trait::async_trait;
18use serde_json::Value;
19
20pub const BUDGETING_CAPABILITY_ID: &str = "budgeting";
21
22pub struct BudgetingCapability;
24
25impl Capability for BudgetingCapability {
26 fn id(&self) -> &str {
27 BUDGETING_CAPABILITY_ID
28 }
29
30 fn name(&self) -> &str {
31 "Budgeting"
32 }
33
34 fn description(&self) -> &str {
35 "Enables budget awareness. The agent receives information about active budgets \
36 and can check remaining balance. When budget is running low, the agent will \
37 prioritize completing current tasks efficiently."
38 }
39
40 fn localizations(&self) -> Vec<CapabilityLocalization> {
41 vec![CapabilityLocalization::text(
42 "uk",
43 "Бюджетування",
44 "Вмикає обізнаність про бюджет. Агент отримує інформацію про активні бюджети й може перевіряти залишок коштів. Коли бюджет добігає кінця, агент надає пріоритет ефективному завершенню поточних завдань.",
45 )]
46 }
47
48 fn status(&self) -> CapabilityStatus {
49 CapabilityStatus::Available
50 }
51
52 fn icon(&self) -> Option<&str> {
53 Some("wallet")
54 }
55
56 fn category(&self) -> Option<&str> {
57 Some("System")
58 }
59
60 fn system_prompt_addition(&self) -> Option<&str> {
61 Some(BUDGET_SYSTEM_PROMPT)
62 }
63
64 fn tools(&self) -> Vec<Box<dyn Tool>> {
65 vec![Box::new(CheckBudgetTool)]
66 }
67
68 fn features(&self) -> Vec<&'static str> {
69 vec!["budgeting"]
70 }
71}
72
73const BUDGET_SYSTEM_PROMPT: &str = "This session may have enforced budgets. Check budget before expensive work; when remaining budget is low, finish the core task efficiently and avoid unnecessary output. Exhaustion may pause or stop the session.";
74
75pub struct CheckBudgetTool;
85
86#[async_trait]
87impl Tool for CheckBudgetTool {
88 fn narrate(
89 &self,
90 _tool_call: &crate::tool_types::ToolCall,
91 phase: crate::tool_narration::ToolNarrationPhase,
92 locale: Option<&str>,
93 _ctx: crate::tool_narration::ToolNarrationContext<'_>,
94 ) -> Option<String> {
95 Some(crate::tool_narration::narrate_check_budget(phase, locale))
96 }
97
98 fn name(&self) -> &str {
99 "check_budget"
100 }
101
102 fn display_name(&self) -> Option<&str> {
103 Some("Check Budget")
104 }
105
106 fn description(&self) -> &str {
107 "Check the remaining budget for this session. Returns budget balance, limit, currency, and status."
108 }
109
110 fn parameters_schema(&self) -> Value {
111 serde_json::json!({
112 "type": "object",
113 "properties": {},
114 "additionalProperties": false
115 })
116 }
117
118 fn hints(&self) -> ToolHints {
119 ToolHints::default()
120 .with_readonly(true)
121 .with_idempotent(true)
122 }
123
124 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
125 ToolExecutionResult::success(serde_json::json!({
128 "status": "no_budgets",
129 "budgets": [],
130 "hint": "No budgets are configured for this session. You can proceed without budget constraints."
131 }))
132 }
133
134 async fn execute_with_context(
135 &self,
136 _arguments: Value,
137 context: &ToolContext,
138 ) -> ToolExecutionResult {
139 let Some(ref checker) = context.budget_checker else {
140 return self.execute(_arguments).await;
142 };
143
144 let session_id = context.session_id.to_string();
145
146 match checker.check_budgets(&session_id).await {
147 Ok(response) => {
148 ToolExecutionResult::success(serde_json::to_value(&response).unwrap_or_else(
149 |_| serde_json::json!({"status": "no_budgets", "budgets": [], "hint": null}),
150 ))
151 }
152 Err(_) => ToolExecutionResult::tool_error(
153 "Budget check is temporarily unavailable. You can proceed normally.",
154 ),
155 }
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[test]
166 fn test_capability_has_system_prompt() {
167 let cap = BudgetingCapability;
168 assert!(cap.system_prompt_addition().is_some());
169 assert!(
170 cap.system_prompt_addition()
171 .unwrap()
172 .contains("enforced budgets")
173 );
174 }
175
176 #[test]
177 fn test_capability_features() {
178 let cap = BudgetingCapability;
179 assert_eq!(cap.features(), vec!["budgeting"]);
180 }
181
182 #[tokio::test]
183 async fn test_check_budget_tool_no_budgets_fallback() {
184 let tool = CheckBudgetTool;
185 let result = tool.execute(serde_json::json!({})).await;
187 if let ToolExecutionResult::Success(value) = result {
188 assert_eq!(value.get("status").unwrap().as_str().unwrap(), "no_budgets");
189 assert!(value.get("budgets").unwrap().as_array().unwrap().is_empty());
190 assert!(value.get("hint").is_some());
191 } else {
192 panic!("Expected success");
193 }
194 }
195
196 #[tokio::test]
197 async fn test_check_budget_tool_with_context_no_checker() {
198 use crate::typed_id::SessionId;
199 let tool = CheckBudgetTool;
200 let context = ToolContext::new(SessionId::new());
202 let result = tool
203 .execute_with_context(serde_json::json!({}), &context)
204 .await;
205 if let ToolExecutionResult::Success(value) = result {
206 assert_eq!(value.get("status").unwrap().as_str().unwrap(), "no_budgets");
207 assert!(value.get("budgets").unwrap().as_array().unwrap().is_empty());
208 } else {
209 panic!("Expected success");
210 }
211 }
212
213 #[tokio::test]
214 async fn test_check_budget_tool_with_mock_checker() {
215 use crate::budget::{BudgetSummary, BudgetToolResponse};
216 use crate::traits::BudgetChecker;
217 use crate::typed_id::SessionId;
218 use std::sync::Arc;
219
220 struct MockBudgetChecker;
221
222 #[async_trait]
223 impl BudgetChecker for MockBudgetChecker {
224 async fn check_budgets(
225 &self,
226 _session_id: &str,
227 ) -> crate::error::Result<BudgetToolResponse> {
228 Ok(BudgetToolResponse {
229 status: "active".into(),
230 budgets: vec![BudgetSummary {
231 currency: "usd".into(),
232 limit: 5.0,
233 balance: 2.56,
234 soft_limit: None,
235 percent_remaining: 51.2,
236 status: "active".into(),
237 }],
238 hint: Some("51.2% of budget remaining.".into()),
239 })
240 }
241 }
242
243 let tool = CheckBudgetTool;
244 let mut context = ToolContext::new(SessionId::new());
245 context.budget_checker = Some(Arc::new(MockBudgetChecker));
246
247 let result = tool
248 .execute_with_context(serde_json::json!({}), &context)
249 .await;
250 if let ToolExecutionResult::Success(value) = result {
251 assert_eq!(value.get("status").unwrap().as_str().unwrap(), "active");
252 let budgets = value.get("budgets").unwrap().as_array().unwrap();
253 assert_eq!(budgets.len(), 1);
254 assert_eq!(budgets[0].get("currency").unwrap().as_str().unwrap(), "usd");
255 assert_eq!(budgets[0].get("balance").unwrap().as_f64().unwrap(), 2.56);
256 assert_eq!(
257 budgets[0]
258 .get("percent_remaining")
259 .unwrap()
260 .as_f64()
261 .unwrap(),
262 51.2
263 );
264 assert!(
265 value
266 .get("hint")
267 .unwrap()
268 .as_str()
269 .unwrap()
270 .contains("51.2%")
271 );
272 } else {
273 panic!("Expected success");
274 }
275 }
276}