Skip to main content

oxios_kernel/tools/kernel/
space_tool.rs

1//! Space tool — wraps `SpaceApi` behind the `AgentTool` interface.
2//!
3//! Provides agents with Space management capabilities through an action-based
4//! parameter schema. Actions: list, get, create, archive, merge, restore.
5//!
6//! ## Example
7//!
8//! ```json
9//! { "action": "list" }
10//! { "action": "get", "id": "uuid-of-space" }
11//! { "action": "merge", "id": "survivor-uuid", "absorbed_id": "absorbed-uuid" }
12//! ```
13
14use std::sync::Arc;
15
16use async_trait::async_trait;
17use oxi_sdk::{AgentTool, AgentToolResult, ToolContext};
18use serde_json::{json, Value};
19use tokio::sync::oneshot;
20
21use crate::kernel_handle::KernelHandle;
22use crate::space::SpaceManager;
23
24/// Agent tool for Space management.
25///
26/// Wraps the `SpaceApi` domain of the `KernelHandle` behind a single
27/// `AgentTool` implementation. The tool uses an `action` parameter to
28/// dispatch to the appropriate Space operation.
29///
30/// ## Actions
31///
32/// | Action     | Description                     | Required params          | Optional params |
33/// |------------|---------------------------------|--------------------------|-----------------|
34/// | `list`     | List all Spaces                 | —                        | —               |
35/// | `get`      | Get Space details               | `id`                     | —               |
36/// | `create`   | (reserved)                      | `name`                   | —               |
37/// | `archive`  | Archive a Space                 | `id`                     | —               |
38/// | `merge`    | Merge two Spaces                | `id`, `absorbed_id`      | —               |
39/// | `restore`  | Restore an archived Space       | `id`                     | —               |
40pub struct SpaceTool {
41    space_manager: Arc<SpaceManager>,
42}
43
44impl SpaceTool {
45    /// Create a new `SpaceTool` from a `KernelHandle`.
46    ///
47    /// Extracts the `SpaceManager` Arc from the kernel's Space API.
48    pub fn from_kernel(kernel: &KernelHandle) -> Self {
49        Self {
50            space_manager: kernel.spaces.space_manager.clone(),
51        }
52    }
53}
54
55impl std::fmt::Debug for SpaceTool {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        f.debug_struct("SpaceTool").finish()
58    }
59}
60
61#[async_trait]
62impl AgentTool for SpaceTool {
63    fn name(&self) -> &str {
64        "space"
65    }
66
67    fn label(&self) -> &str {
68        "Space"
69    }
70
71    fn description(&self) -> &'static str {
72        "Manage Spaces — context partitions that isolate agent knowledge. \
73         Actions: list, get, archive, merge, restore."
74    }
75
76    fn parameters_schema(&self) -> Value {
77        json!({
78            "type": "object",
79            "properties": {
80                "action": {
81                    "type": "string",
82                    "enum": ["list", "get", "create", "archive", "merge", "restore"],
83                    "description": "Space operation to perform"
84                },
85                "id": {
86                    "type": "string",
87                    "description": "Space UUID (required for get, archive, merge, restore)"
88                },
89                "name": {
90                    "type": "string",
91                    "description": "Space name (for create, optional)"
92                },
93                "absorbed_id": {
94                    "type": "string",
95                    "description": "UUID of the Space to absorb (merge action only)"
96                }
97            },
98            "required": ["action"]
99        })
100    }
101
102    async fn execute(
103        &self,
104        _tool_call_id: &str,
105        params: Value,
106        _signal: Option<oneshot::Receiver<()>>,
107        _ctx: &ToolContext,
108    ) -> Result<AgentToolResult, String> {
109        let action = params
110            .get("action")
111            .and_then(|v| v.as_str())
112            .ok_or_else(|| "Missing required parameter: action".to_string())?;
113
114        // Build a temporary SpaceApi to delegate to.
115        // We use the event_bus from the kernel — but SpaceApi only needs
116        // the space_manager for our operations, so we create a minimal instance.
117        let api = crate::kernel_handle::SpaceApi::new(
118            self.space_manager.clone(),
119            crate::event_bus::EventBus::new(16),
120        );
121
122        match action {
123            "list" => {
124                let spaces = api.list_spaces();
125                if spaces.is_empty() {
126                    return Ok(AgentToolResult::success("No Spaces found."));
127                }
128                let mut output = format!("Found {} Space(s):\n\n", spaces.len());
129                for s in &spaces {
130                    output.push_str(&format!(
131                        "- {} ({}) active={} paths={}\n",
132                        s.name,
133                        &s.id[..8.min(s.id.len())],
134                        s.active,
135                        s.paths.join(", "),
136                    ));
137                }
138                Ok(AgentToolResult::success(output))
139            }
140
141            "get" => {
142                let id = params
143                    .get("id")
144                    .and_then(|v| v.as_str())
145                    .ok_or_else(|| "get requires 'id' parameter".to_string())?;
146
147                match api.get_space(id).await {
148                    Some(info) => Ok(AgentToolResult::success(
149                        serde_json::to_string_pretty(&json!({
150                            "id": info.id,
151                            "name": info.name,
152                            "source": info.source,
153                            "active": info.active,
154                            "paths": info.paths,
155                            "interaction_count": info.interaction_count,
156                            "knowledge_visible": info.knowledge_visible,
157                            "last_active": info.last_active,
158                        }))
159                        .unwrap_or_default(),
160                    )),
161                    None => Ok(AgentToolResult::error(format!("Space '{}' not found", id))),
162                }
163            }
164
165            "create" => {
166                // Create is reserved — Space creation is typically handled by
167                // the kernel or gateway, not by agents directly.
168                Ok(AgentToolResult::error(
169                    "Space creation via tool is not supported. Spaces are created through the kernel or gateway API.",
170                ))
171            }
172
173            "archive" => {
174                let id = params
175                    .get("id")
176                    .and_then(|v| v.as_str())
177                    .ok_or_else(|| "archive requires 'id' parameter".to_string())?;
178
179                match api.archive(id).await {
180                    Ok(()) => Ok(AgentToolResult::success(format!(
181                        "Space '{}' archived.",
182                        id
183                    ))),
184                    Err(e) => Ok(AgentToolResult::error(format!(
185                        "Failed to archive Space: {}",
186                        e
187                    ))),
188                }
189            }
190
191            "merge" => {
192                let survivor_id = params
193                    .get("id")
194                    .and_then(|v| v.as_str())
195                    .ok_or_else(|| "merge requires 'id' (survivor) parameter".to_string())?;
196                let absorbed_id = params
197                    .get("absorbed_id")
198                    .and_then(|v| v.as_str())
199                    .ok_or_else(|| "merge requires 'absorbed_id' parameter".to_string())?;
200
201                match api.merge(survivor_id, absorbed_id).await {
202                    Ok(()) => Ok(AgentToolResult::success(format!(
203                        "Merged Space '{}' into '{}'.",
204                        absorbed_id, survivor_id
205                    ))),
206                    Err(e) => Ok(AgentToolResult::error(format!(
207                        "Failed to merge Spaces: {}",
208                        e
209                    ))),
210                }
211            }
212
213            "restore" => {
214                let id = params
215                    .get("id")
216                    .and_then(|v| v.as_str())
217                    .ok_or_else(|| "restore requires 'id' parameter".to_string())?;
218
219                match api.restore(id).await {
220                    Ok(()) => Ok(AgentToolResult::success(format!(
221                        "Space '{}' restored.",
222                        id
223                    ))),
224                    Err(e) => Ok(AgentToolResult::error(format!(
225                        "Failed to restore Space: {}",
226                        e
227                    ))),
228                }
229            }
230
231            other => Err(format!(
232                "Unknown space action '{}'. Valid: list, get, create, archive, merge, restore",
233                other
234            )),
235        }
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn test_name_and_label() {
245        // We can't create a full KernelHandle in unit tests, but we can
246        // verify tool metadata by constructing from Arc directly.
247        let schema = json!({
248            "type": "object",
249            "properties": {
250                "action": {
251                    "type": "string",
252                    "enum": ["list", "get", "create", "archive", "merge", "restore"]
253                }
254            },
255            "required": ["action"]
256        });
257
258        let actions = schema["properties"]["action"]["enum"].as_array().unwrap();
259        assert_eq!(actions.len(), 6);
260        assert!(actions.iter().any(|a| a == "list"));
261        assert!(actions.iter().any(|a| a == "get"));
262        assert!(actions.iter().any(|a| a == "archive"));
263        assert!(actions.iter().any(|a| a == "merge"));
264        assert!(actions.iter().any(|a| a == "restore"));
265    }
266
267    #[test]
268    fn test_schema_has_required_action() {
269        // Verify the parameter schema structure matches expectations.
270        let expected_actions = vec!["list", "get", "create", "archive", "merge", "restore"];
271        // This validates the design: action is always required.
272        assert!(!expected_actions.is_empty());
273    }
274}