Skip to main content

everruns_core/capabilities/
budgeting.rs

1// Budgeting Capability
2//
3// Adds budget awareness to agents:
4// 1. System prompt section informing the agent about budget constraints
5// 2. A check_budget tool to query remaining balance
6//
7// The actual budget data is injected into the system prompt at runtime
8// by the capability's system_prompt_contribution() method, which the
9// service layer calls with budget data from the database.
10//
11// See specs/budgeting.md (Phase 4: Agent awareness)
12
13use 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
22/// Budgeting capability — budget-aware agent behavior.
23pub 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
75// ============================================================================
76// Tool: check_budget
77// ============================================================================
78
79/// Tool that returns budget status for the current session.
80///
81/// In practice, the tool result is populated by the worker which has
82/// access to the BudgetService. This implementation returns a placeholder
83/// that guides the agent to check via the session context.
84pub 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        // Fallback when no context is available (shouldn't happen in practice).
116        // Returns the same BudgetToolResponse shape for contract stability.
117        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            // No budget checker wired — return the no_budgets fallback.
131            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    // Metadata/tool-list constants covered by builtin_capabilities_satisfy_registry_invariants.
154
155    #[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        // Without context, falls back to no_budgets with stable shape
176        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        // With context but no budget_checker, also falls back
191        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}