Skip to main content

oxi_agent/tools/
goal_tool.rs

1/// Goal tool — manage investigation goals with token budgets.
2///
3/// Supports create, get, complete, resume, and drop operations on a single
4/// active goal. Goals track an objective, status, and optional token budget.
5/// In omp this is a HIDDEN_TOOL — available but not in BUILTIN_TOOLS.
6use super::{AgentTool, AgentToolResult, ToolContext, ToolError, ToolExecutionMode, ToolTier};
7use async_trait::async_trait;
8use serde_json::{Value, json};
9use std::sync::LazyLock;
10use std::sync::Mutex;
11use std::sync::atomic::{AtomicU64, Ordering};
12use tokio::sync::oneshot;
13
14/// Next goal ID counter.
15static NEXT_GOAL_ID: AtomicU64 = AtomicU64::new(1);
16
17/// A goal.
18#[derive(Clone, Debug)]
19#[allow(dead_code)]
20struct Goal {
21    #[allow(dead_code)]
22    id: String,
23    objective: String,
24    status: String,
25    token_budget: Option<u64>,
26    tokens_used: u64,
27    created_at: u64,
28    updated_at: u64,
29}
30
31/// Global active goal.
32static ACTIVE_GOAL: LazyLock<Mutex<Option<Goal>>> = LazyLock::new(|| Mutex::new(None));
33
34/// GoalTool — manage investigation goals.
35pub struct GoalTool;
36
37#[async_trait]
38impl AgentTool for GoalTool {
39    fn name(&self) -> &str {
40        "goal"
41    }
42
43    fn label(&self) -> &str {
44        "Goal"
45    }
46
47    fn description(&self) -> &str {
48        concat!(
49            "Manage investigation goals with token budgets. ",
50            "Operations: create (new goal with objective), ",
51            "get (current goal), complete (mark done), ",
52            "resume (reactivate), drop (abandon)."
53        )
54    }
55
56    fn essential(&self) -> bool {
57        false
58    }
59
60    fn parameters_schema(&self) -> Value {
61        json!({
62            "type": "object",
63            "properties": {
64                "op": {
65                    "type": "string",
66                    "enum": ["create", "get", "complete", "resume", "drop"],
67                    "description": "Goal operation."
68                },
69                "objective": {
70                    "type": "string",
71                    "description": "Goal objective (required for create)."
72                },
73                "token_budget": {
74                    "type": "integer",
75                    "description": "Optional token budget limit."
76                }
77            },
78            "required": ["op"]
79        })
80    }
81
82    fn intent(&self) -> Option<&str> {
83        Some("Manage investigation goals")
84    }
85
86    fn execution_mode(&self) -> ToolExecutionMode {
87        ToolExecutionMode::SequentialOnly
88    }
89
90    fn tool_tier(&self) -> ToolTier {
91        ToolTier::Read
92    }
93
94    async fn execute(
95        &self,
96        _tool_call_id: &str,
97        params: Value,
98        _signal: Option<oneshot::Receiver<()>>,
99        _ctx: &ToolContext,
100    ) -> Result<AgentToolResult, ToolError> {
101        let op = params
102            .get("op")
103            .and_then(|v| v.as_str())
104            .ok_or_else(|| "Missing required parameter: op".to_string())?;
105
106        let now = std::time::SystemTime::now()
107            .duration_since(std::time::UNIX_EPOCH)
108            .map(|d| d.as_secs())
109            .unwrap_or(0);
110
111        let mut store = ACTIVE_GOAL
112            .lock()
113            .map_err(|e| format!("Goal lock error: {}", e))?;
114
115        match op {
116            "create" => {
117                let objective = params
118                    .get("objective")
119                    .and_then(|v| v.as_str())
120                    .map(|s| s.to_string())
121                    .ok_or_else(|| "Missing 'objective' parameter for create".to_string())?;
122
123                let token_budget = params.get("token_budget").and_then(|v| v.as_u64());
124
125                if store.is_some() {
126                    return Err("A goal is already active. Complete or drop it first.".to_string());
127                }
128
129                let id = NEXT_GOAL_ID.fetch_add(1, Ordering::Relaxed);
130                let goal = Goal {
131                    id: format!("goal-{}", id),
132                    objective: objective.clone(),
133                    status: "active".to_string(),
134                    token_budget,
135                    tokens_used: 0,
136                    created_at: now,
137                    updated_at: now,
138                };
139
140                *store = Some(goal.clone());
141
142                let mut lines = vec![
143                    format!("Goal created: {}", objective),
144                    format!("Status: active"),
145                ];
146                if let Some(budget) = token_budget {
147                    lines.push(format!("Token budget: {}", budget));
148                }
149
150                Ok(AgentToolResult::success(lines.join("\n")))
151            }
152            "get" => match store.as_ref() {
153                Some(goal) => {
154                    let mut lines = vec![
155                        format!("Goal: {}", goal.objective),
156                        format!("Status: {}", goal.status),
157                        format!("Tokens used: {}", goal.tokens_used),
158                    ];
159                    if let Some(budget) = goal.token_budget {
160                        lines.push(format!("Token budget: {}", budget));
161                        let remaining = budget.saturating_sub(goal.tokens_used);
162                        lines.push(format!("Remaining: {}", remaining));
163                    }
164                    Ok(AgentToolResult::success(lines.join("\n")))
165                }
166                None => Ok(AgentToolResult::success(
167                    "No active goal. Use 'create' to set one.",
168                )),
169            },
170            "complete" => match store.as_mut() {
171                Some(goal) => {
172                    goal.status = "complete".to_string();
173                    goal.updated_at = now;
174                    let goal_clone = goal.clone();
175                    *store = None;
176
177                    Ok(AgentToolResult::success(format!(
178                        "Goal completed: {}\nTokens used: {}",
179                        goal_clone.objective, goal_clone.tokens_used
180                    )))
181                }
182                None => Err("No active goal to complete.".to_string()),
183            },
184            "resume" => match store.as_mut() {
185                Some(goal)
186                    if goal.status == "complete"
187                        || goal.status == "paused"
188                        || goal.status == "dropped" =>
189                {
190                    goal.status = "active".to_string();
191                    goal.updated_at = now;
192                    Ok(AgentToolResult::success(format!(
193                        "Goal resumed: {}",
194                        goal.objective
195                    )))
196                }
197                Some(goal) => Err(format!(
198                    "Goal is currently '{}' and cannot be resumed.",
199                    goal.status
200                )),
201                None => Err("No goal to resume. Use 'create' first.".to_string()),
202            },
203            "drop" => match store.take() {
204                Some(goal) => Ok(AgentToolResult::success(format!(
205                    "Goal dropped: {}",
206                    goal.objective
207                ))),
208                None => Err("No active goal to drop.".to_string()),
209            },
210            _ => Err(format!("Unknown goal operation: {}", op)),
211        }
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[tokio::test]
220    async fn test_goal_create_get_complete() {
221        // Reset
222        *ACTIVE_GOAL.lock().unwrap() = None;
223
224        let tool = GoalTool;
225
226        // Create
227        let params = json!({"op": "create", "objective": "Refactor auth module"});
228        let result = tool
229            .execute("id", params, None, &ToolContext::default())
230            .await
231            .unwrap();
232        assert!(result.success);
233        assert!(result.output.contains("Goal created"));
234
235        // Get
236        let params2 = json!({"op": "get"});
237        let result2 = tool
238            .execute("id", params2, None, &ToolContext::default())
239            .await
240            .unwrap();
241        assert!(result2.success);
242        assert!(result2.output.contains("Refactor auth module"));
243
244        // Create another while active should fail
245        let params3 = json!({"op": "create", "objective": "Second goal"});
246        let result3 = tool
247            .execute("id", params3.clone(), None, &ToolContext::default())
248            .await;
249        assert!(result3.is_err());
250
251        // Complete
252        let params4 = json!({"op": "complete"});
253        let result4 = tool
254            .execute("id", params4.clone(), None, &ToolContext::default())
255            .await
256            .unwrap();
257        assert!(result4.success);
258        assert!(result4.output.contains("Goal completed"));
259
260        // Complete again should fail
261        let result5 = tool
262            .execute("id", params4.clone(), None, &ToolContext::default())
263            .await;
264        assert!(result5.is_err());
265    }
266
267    #[tokio::test]
268    async fn test_goal_create_with_budget() {
269        *ACTIVE_GOAL.lock().unwrap() = None;
270
271        let tool = GoalTool;
272        let params = json!({"op": "create", "objective": "Fix bugs", "token_budget": 5000});
273        let result = tool
274            .execute("id", params, None, &ToolContext::default())
275            .await
276            .unwrap();
277        assert!(result.success);
278        assert!(result.output.contains("5000"));
279    }
280}