Skip to main content

oxios_kernel/tools/builtin/
project_tool.rs

1//! Project tool — wraps `ProjectManager` behind the `AgentTool` interface.
2//!
3//! Provides agents with Project query capabilities through an action-based
4//! parameter schema. Actions: list, get, link_memory, unlink_memory.
5//!
6//! Agents can query projects and manage memory associations,
7//! but cannot create, update, or remove projects (user-level only).
8
9use std::sync::Arc;
10
11use async_trait::async_trait;
12use oxi_sdk::{AgentTool, AgentToolResult, ToolContext};
13use serde_json::{json, Value};
14use tokio::sync::oneshot;
15
16use crate::kernel_handle::KernelHandle;
17use crate::project::ProjectManager;
18
19/// Agent tool for Project queries (RFC-011).
20///
21/// Wraps the `ProjectManager` behind a single `AgentTool` implementation.
22/// The tool uses an `action` parameter to dispatch operations.
23///
24/// ## Actions
25///
26/// | Action          | Description                      | Required params           |
27/// |-----------------|----------------------------------|---------------------------|
28/// | `list`          | List all Projects                | —                         |
29/// | `get`           | Get Project details              | `id` or `name`            |
30/// | `link_memory`   | Link a memory to a project       | `project_id`, `memory_id` |
31/// | `unlink_memory` | Unlink a memory from a project   | `project_id`, `memory_id` |
32pub struct ProjectTool {
33    project_manager: Option<Arc<ProjectManager>>,
34}
35
36impl ProjectTool {
37    /// Create a new `ProjectTool` from a `KernelHandle`.
38    pub fn from_kernel(kernel: &KernelHandle) -> Self {
39        Self {
40            project_manager: kernel.projects.as_ref().map(|p| p.project_manager.clone()),
41        }
42    }
43}
44
45impl std::fmt::Debug for ProjectTool {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        f.debug_struct("ProjectTool").finish()
48    }
49}
50
51#[async_trait]
52impl AgentTool for ProjectTool {
53    fn name(&self) -> &str {
54        "project"
55    }
56
57    fn label(&self) -> &str {
58        "Project"
59    }
60
61    fn description(&self) -> &'static str {
62        "Query registered Projects — work contexts with paths and memory associations. \
63         Actions: list, get, link_memory, unlink_memory."
64    }
65
66    fn parameters_schema(&self) -> Value {
67        json!({
68            "type": "object",
69            "properties": {
70                "action": {
71                    "type": "string",
72                    "enum": ["list", "get", "link_memory", "unlink_memory"],
73                    "description": "Project operation to perform"
74                },
75                "id": {
76                    "type": "string",
77                    "description": "Project UUID"
78                },
79                "name": {
80                    "type": "string",
81                    "description": "Project name (alternative to id for 'get')"
82                },
83                "project_id": {
84                    "type": "string",
85                    "description": "Project UUID (for link_memory/unlink_memory)"
86                },
87                "memory_id": {
88                    "type": "string",
89                    "description": "Memory UUID (for link_memory/unlink_memory)"
90                }
91            },
92            "required": ["action"]
93        })
94    }
95
96    async fn execute(
97        &self,
98        _tool_call_id: &str,
99        params: Value,
100        _signal: Option<oneshot::Receiver<()>>,
101        _ctx: &ToolContext,
102    ) -> Result<AgentToolResult, String> {
103        let action = params
104            .get("action")
105            .and_then(|v| v.as_str())
106            .ok_or_else(|| "Missing required parameter: action".to_string())?;
107
108        let pm = self
109            .project_manager
110            .as_ref()
111            .ok_or_else(|| "Project system not available (SQLite not enabled)".to_string())?;
112
113        match action {
114            "list" => {
115                let projects = pm.list_projects();
116                if projects.is_empty() {
117                    return Ok(AgentToolResult::success("No Projects registered."));
118                }
119                let mut output = format!("Found {} Project(s):\n\n", projects.len());
120                for p in &projects {
121                    let paths_str = if p.paths.is_empty() {
122                        "(no paths)".to_string()
123                    } else {
124                        p.paths
125                            .iter()
126                            .map(|p| p.to_string_lossy().to_string())
127                            .collect::<Vec<_>>()
128                            .join(", ")
129                    };
130                    output.push_str(&format!(
131                        "- {} {} ({}) paths={} tags={}\n",
132                        p.emoji,
133                        p.name,
134                        &p.id.to_string()[..8.min(p.id.to_string().len())],
135                        paths_str,
136                        p.tags.join(", "),
137                    ));
138                }
139                Ok(AgentToolResult::success(output))
140            }
141
142            "get" => {
143                let project = if let Some(id_str) = params.get("id").and_then(|v| v.as_str()) {
144                    let id = uuid::Uuid::parse_str(id_str)
145                        .map_err(|e| format!("Invalid project ID: {e}"))?;
146                    pm.get_project(id)
147                } else if let Some(name) = params.get("name").and_then(|v| v.as_str()) {
148                    pm.get_project_by_name(name)
149                } else {
150                    return Err("'get' requires 'id' or 'name' parameter".to_string());
151                };
152
153                match project {
154                    Some(p) => {
155                        // Also get associated memory IDs
156                        let memory_ids = pm.get_project_memory_ids(p.id).unwrap_or_default();
157                        Ok(AgentToolResult::success(
158                            serde_json::to_string_pretty(&json!({
159                                "id": p.id.to_string(),
160                                "name": p.name,
161                                "description": p.description,
162                                "emoji": p.emoji,
163                                "source": p.source.to_string(),
164                                "paths": p.paths.iter().map(|p| p.to_string_lossy().to_string()).collect::<Vec<_>>(),
165                                "tags": p.tags,
166                                "memory_visible": p.memory_visible,
167                                "associated_memory_count": memory_ids.len(),
168                                "last_active": p.last_active_at.to_rfc3339(),
169                            }))
170                            .unwrap_or_default(),
171                        ))
172                    }
173                    None => Ok(AgentToolResult::error("Project not found")),
174                }
175            }
176
177            "link_memory" => {
178                let project_id_str = params
179                    .get("project_id")
180                    .and_then(|v| v.as_str())
181                    .ok_or_else(|| "link_memory requires 'project_id'".to_string())?;
182                let memory_id = params
183                    .get("memory_id")
184                    .and_then(|v| v.as_str())
185                    .ok_or_else(|| "link_memory requires 'memory_id'".to_string())?;
186
187                let project_id = uuid::Uuid::parse_str(project_id_str)
188                    .map_err(|e| format!("Invalid project_id: {e}"))?;
189
190                match pm.link_memory(project_id, memory_id) {
191                    Ok(()) => Ok(AgentToolResult::success(format!(
192                        "Linked memory {} to project {}",
193                        &memory_id[..8.min(memory_id.len())],
194                        &project_id_str[..8.min(project_id_str.len())],
195                    ))),
196                    Err(e) => Ok(AgentToolResult::error(format!(
197                        "Failed to link memory: {e}"
198                    ))),
199                }
200            }
201
202            "unlink_memory" => {
203                let project_id_str = params
204                    .get("project_id")
205                    .and_then(|v| v.as_str())
206                    .ok_or_else(|| "unlink_memory requires 'project_id'".to_string())?;
207                let memory_id = params
208                    .get("memory_id")
209                    .and_then(|v| v.as_str())
210                    .ok_or_else(|| "unlink_memory requires 'memory_id'".to_string())?;
211
212                let project_id = uuid::Uuid::parse_str(project_id_str)
213                    .map_err(|e| format!("Invalid project_id: {e}"))?;
214
215                match pm.unlink_memory(project_id, memory_id) {
216                    Ok(()) => Ok(AgentToolResult::success(format!(
217                        "Unlinked memory {} from project {}",
218                        &memory_id[..8.min(memory_id.len())],
219                        &project_id_str[..8.min(project_id_str.len())],
220                    ))),
221                    Err(e) => Ok(AgentToolResult::error(format!(
222                        "Failed to unlink memory: {e}"
223                    ))),
224                }
225            }
226
227            other => Err(format!(
228                "Unknown project action '{other}'. Valid: list, get, link_memory, unlink_memory"
229            )),
230        }
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn test_schema_actions() {
240        let schema = serde_json::json!({
241            "properties": {
242                "action": {
243                    "enum": ["list", "get", "link_memory", "unlink_memory"]
244                }
245            }
246        });
247        let actions = schema["properties"]["action"]["enum"].as_array().unwrap();
248        assert_eq!(actions.len(), 4);
249        assert!(actions.iter().any(|a| a == "list"));
250        assert!(actions.iter().any(|a| a == "get"));
251        assert!(actions.iter().any(|a| a == "link_memory"));
252        assert!(actions.iter().any(|a| a == "unlink_memory"));
253    }
254}