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 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        // Fallback when no context is available (shouldn't happen in practice).
126        // Returns the same BudgetToolResponse shape for contract stability.
127        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            // No budget checker wired — return the no_budgets fallback.
141            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    // Metadata/tool-list constants covered by builtin_capabilities_satisfy_registry_invariants.
164
165    #[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        // Without context, falls back to no_budgets with stable shape
186        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        // With context but no budget_checker, also falls back
201        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}