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 name(&self) -> &str {
89 "check_budget"
90 }
91
92 fn display_name(&self) -> Option<&str> {
93 Some("Check Budget")
94 }
95
96 fn description(&self) -> &str {
97 "Check the remaining budget for this session. Returns budget balance, limit, currency, and status."
98 }
99
100 fn parameters_schema(&self) -> Value {
101 serde_json::json!({
102 "type": "object",
103 "properties": {},
104 "additionalProperties": false
105 })
106 }
107
108 fn hints(&self) -> ToolHints {
109 ToolHints::default()
110 .with_readonly(true)
111 .with_idempotent(true)
112 }
113
114 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
115 ToolExecutionResult::success(serde_json::json!({
118 "status": "no_budgets",
119 "budgets": [],
120 "hint": "No budgets are configured for this session. You can proceed without budget constraints."
121 }))
122 }
123
124 async fn execute_with_context(
125 &self,
126 _arguments: Value,
127 context: &ToolContext,
128 ) -> ToolExecutionResult {
129 let Some(ref checker) = context.budget_checker else {
130 return self.execute(_arguments).await;
132 };
133
134 let session_id = context.session_id.to_string();
135
136 match checker.check_budgets(&session_id).await {
137 Ok(response) => {
138 ToolExecutionResult::success(serde_json::to_value(&response).unwrap_or_else(
139 |_| serde_json::json!({"status": "no_budgets", "budgets": [], "hint": null}),
140 ))
141 }
142 Err(_) => ToolExecutionResult::tool_error(
143 "Budget check is temporarily unavailable. You can proceed normally.",
144 ),
145 }
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 #[test]
156 fn test_capability_has_system_prompt() {
157 let cap = BudgetingCapability;
158 assert!(cap.system_prompt_addition().is_some());
159 assert!(
160 cap.system_prompt_addition()
161 .unwrap()
162 .contains("enforced budgets")
163 );
164 }
165
166 #[test]
167 fn test_capability_features() {
168 let cap = BudgetingCapability;
169 assert_eq!(cap.features(), vec!["budgeting"]);
170 }
171
172 #[tokio::test]
173 async fn test_check_budget_tool_no_budgets_fallback() {
174 let tool = CheckBudgetTool;
175 let result = tool.execute(serde_json::json!({})).await;
177 if let ToolExecutionResult::Success(value) = result {
178 assert_eq!(value.get("status").unwrap().as_str().unwrap(), "no_budgets");
179 assert!(value.get("budgets").unwrap().as_array().unwrap().is_empty());
180 assert!(value.get("hint").is_some());
181 } else {
182 panic!("Expected success");
183 }
184 }
185
186 #[tokio::test]
187 async fn test_check_budget_tool_with_context_no_checker() {
188 use crate::typed_id::SessionId;
189 let tool = CheckBudgetTool;
190 let context = ToolContext::new(SessionId::new());
192 let result = tool
193 .execute_with_context(serde_json::json!({}), &context)
194 .await;
195 if let ToolExecutionResult::Success(value) = result {
196 assert_eq!(value.get("status").unwrap().as_str().unwrap(), "no_budgets");
197 assert!(value.get("budgets").unwrap().as_array().unwrap().is_empty());
198 } else {
199 panic!("Expected success");
200 }
201 }
202
203 #[tokio::test]
204 async fn test_check_budget_tool_with_mock_checker() {
205 use crate::budget::{BudgetSummary, BudgetToolResponse};
206 use crate::traits::BudgetChecker;
207 use crate::typed_id::SessionId;
208 use std::sync::Arc;
209
210 struct MockBudgetChecker;
211
212 #[async_trait]
213 impl BudgetChecker for MockBudgetChecker {
214 async fn check_budgets(
215 &self,
216 _session_id: &str,
217 ) -> crate::error::Result<BudgetToolResponse> {
218 Ok(BudgetToolResponse {
219 status: "active".into(),
220 budgets: vec![BudgetSummary {
221 currency: "usd".into(),
222 limit: 5.0,
223 balance: 2.56,
224 soft_limit: None,
225 percent_remaining: 51.2,
226 status: "active".into(),
227 }],
228 hint: Some("51.2% of budget remaining.".into()),
229 })
230 }
231 }
232
233 let tool = CheckBudgetTool;
234 let mut context = ToolContext::new(SessionId::new());
235 context.budget_checker = Some(Arc::new(MockBudgetChecker));
236
237 let result = tool
238 .execute_with_context(serde_json::json!({}), &context)
239 .await;
240 if let ToolExecutionResult::Success(value) = result {
241 assert_eq!(value.get("status").unwrap().as_str().unwrap(), "active");
242 let budgets = value.get("budgets").unwrap().as_array().unwrap();
243 assert_eq!(budgets.len(), 1);
244 assert_eq!(budgets[0].get("currency").unwrap().as_str().unwrap(), "usd");
245 assert_eq!(budgets[0].get("balance").unwrap().as_f64().unwrap(), 2.56);
246 assert_eq!(
247 budgets[0]
248 .get("percent_remaining")
249 .unwrap()
250 .as_f64()
251 .unwrap(),
252 51.2
253 );
254 assert!(
255 value
256 .get("hint")
257 .unwrap()
258 .as_str()
259 .unwrap()
260 .contains("51.2%")
261 );
262 } else {
263 panic!("Expected success");
264 }
265 }
266}