oxi_agent/tools.rs
1#![allow(unused_doc_comments)]
2/// Agent tools system
3/// This module provides the tool abstraction layer and built-in tools.
4use crate::types::ToolDefinition;
5use async_trait::async_trait;
6use serde_json::Value;
7use std::fmt;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use tokio::sync::oneshot;
11
12/// Context passed to tools at execution time.
13///
14/// This allows tools to operate on a specific workspace without being
15/// rebuilt. When `root_dir` is `Some`, tools use it as their base directory.
16/// When `None`, tools should fall back to `workspace_dir`.
17#[derive(Debug, Clone)]
18pub struct ToolContext {
19 /// Primary workspace directory (used when root_dir is None).
20 pub workspace_dir: PathBuf,
21 /// Optional explicit root directory for file tools.
22 /// Takes priority over workspace_dir if present.
23 pub root_dir: Option<PathBuf>,
24 /// Session identifier for logging/tracing.
25 pub session_id: Option<String>,
26}
27
28impl ToolContext {
29 /// Create a new context with the given workspace.
30 pub fn new(workspace_dir: impl Into<PathBuf>) -> Self {
31 Self {
32 workspace_dir: workspace_dir.into(),
33 root_dir: None,
34 session_id: None,
35 }
36 }
37
38 /// Get the effective root directory.
39 /// Returns root_dir if set, otherwise workspace_dir.
40 pub fn root(&self) -> &Path {
41 self.root_dir.as_deref().unwrap_or(&self.workspace_dir)
42 }
43
44 /// Set a session ID.
45 pub fn with_session(mut self, session_id: impl Into<String>) -> Self {
46 self.session_id = Some(session_id.into());
47 self
48 }
49
50 /// Set an explicit root directory.
51 pub fn with_root(mut self, root_dir: impl Into<PathBuf>) -> Self {
52 self.root_dir = Some(root_dir.into());
53 self
54 }
55}
56
57impl Default for ToolContext {
58 fn default() -> Self {
59 Self {
60 workspace_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
61 root_dir: None,
62 session_id: None,
63 }
64 }
65}
66
67/// Result type for tool execution
68pub type ToolError = String;
69
70/// Result of tool execution
71#[derive(Debug)]
72pub struct AgentToolResult {
73 /// pub.
74 pub success: bool,
75 /// pub.
76 pub output: String,
77 /// pub.
78 pub metadata: Option<serde_json::Value>,
79 /// Optional content blocks (e.g., image blocks) to include in the tool result message.
80 /// When present, these are used as the content of the ToolResultMessage instead of
81 /// wrapping `output` in a Text block.
82 pub content_blocks: Option<Vec<oxi_ai::ContentBlock>>,
83 /// When `true`, signals that the agent loop should terminate after this batch
84 /// of tool calls completes. Defaults to `false` so that the loop continues
85 /// unless a tool explicitly opts-in to termination.
86 pub terminate: bool,
87}
88
89impl AgentToolResult {
90 /// Creates a successful tool result with the given output text.
91 pub fn success(output: impl Into<String>) -> Self {
92 Self {
93 success: true,
94 output: output.into(),
95 metadata: None,
96 content_blocks: None,
97 terminate: false,
98 }
99 }
100
101 /// Creates an error tool result with the given error message.
102 pub fn error(output: impl Into<String>) -> Self {
103 Self {
104 success: false,
105 output: output.into(),
106 metadata: None,
107 content_blocks: None,
108 terminate: false,
109 }
110 }
111
112 /// Attaches structured metadata (JSON) to this result.
113 pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
114 self.metadata = Some(metadata);
115 self
116 }
117
118 /// Attaches rich content blocks (images, code, etc.) to this result.
119 pub fn with_content_blocks(mut self, blocks: Vec<oxi_ai::ContentBlock>) -> Self {
120 self.content_blocks = Some(blocks);
121 self
122 }
123
124 /// Mark this result as requesting agent-loop termination.
125 pub fn with_terminate(mut self) -> Self {
126 self.terminate = true;
127 self
128 }
129}
130
131impl fmt::Display for AgentToolResult {
132 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133 write!(f, "{}", self.output)
134 }
135}
136
137/// Callback type for progress updates
138pub type ProgressCallback = Arc<dyn Fn(String) + Send + Sync>;
139
140/// Tool execution mode for parallel safety.
141#[derive(Debug, Clone)]
142pub enum ToolExecutionMode {
143 /// Safe to run in parallel with any other tool
144 ParallelSafe,
145 /// Must run sequentially — no parallel execution
146 SequentialOnly,
147 /// Mutates a specific file — file_mutation_queue serializes same-file access
148 MutatesFile(std::path::PathBuf),
149 /// Read-only — always parallel safe
150 ReadOnly,
151}
152
153/// Render output for TUI visualization.
154#[derive(Debug, Clone)]
155pub struct RenderOutput {
156 /// Rendered text content (markdown or plain)
157 pub content: String,
158 /// Whether to show collapsed by default
159 pub collapsed: bool,
160 /// Optional summary text for TUI footer
161 pub summary: Option<String>,
162}
163
164/// Core trait for all agent tools
165#[async_trait]
166pub trait AgentTool: Send + Sync {
167 /// Tool name (used in function calls)
168 fn name(&self) -> &str;
169
170 /// Human-readable label
171 fn label(&self) -> &str;
172
173 /// Description for the model
174 fn description(&self) -> &str;
175
176 /// JSON Schema for parameters
177 fn parameters_schema(&self) -> Value;
178
179 /// Whether this tool is essential (cannot be disabled).
180 /// Essential tools: read, write, edit, bash, grep, find, ls
181 /// Optional tools: web_search, github, subagent, etc.
182 fn essential(&self) -> bool {
183 false
184 }
185
186 /// Execute the tool with the given tool call ID and parameters.
187 ///
188 /// The `ctx` parameter provides workspace information. File tools should
189 /// use `ctx.root()` to get the effective directory. Custom tools can use
190 /// `ctx.workspace_dir` for workspace-relative operations.
191 ///
192 /// # Examples
193 ///
194 /// ```ignore
195 /// use oxi_agent::{AgentTool, AgentToolResult, ToolContext};
196 /// use serde_json::json;
197 /// struct MyTool;
198 ///
199 /// #[async_trait]
200 /// impl AgentTool for MyTool {
201 /// fn name(&self) -> &str { "my_tool" }
202 /// fn label(&self) -> &str { "My Tool" }
203 /// fn description(&self) -> &str { "A custom tool" }
204 /// fn parameters_schema(&self) -> Value { json!({
205 /// "type": "object",
206 /// "properties": {}
207 /// }) }
208 ///
209 /// async fn execute(&self, tool_call_id: &str, params: Value, _signal: Option<oneshot::Receiver<()>>, ctx: &ToolContext) -> Result<AgentToolResult, String> {
210 /// println!("Tool '{}' called with params: {:?}, workspace: {:?}", tool_call_id, params, ctx.workspace_dir);
211 /// Ok(AgentToolResult::success("Done!"))
212 /// }
213 /// }
214 /// ```
215 async fn execute(
216 &self,
217 tool_call_id: &str,
218 params: Value,
219 signal: Option<oneshot::Receiver<()>>,
220 ctx: &ToolContext,
221 ) -> Result<AgentToolResult, ToolError>;
222
223 /// Called with progress updates during execution.
224 /// Tools can override this to emit streaming updates.
225 fn on_progress(&self, _callback: ProgressCallback) {
226 // Default no-op
227 }
228
229 /// Structured browse progress callback for browser tool context enrichment.
230 /// Default implementation is no-op. Only browse tools override this to
231 /// register a callback that enriches `ToolCallContext` with structured
232 /// data from `BrowseProgress` events.
233 fn on_browse_progress(&self, _callback: crate::tools::browse::BrowseProgressCallback) {}
234
235 /// Custom rendering for tool call (TUI visualization).
236 /// Return None to use the default tool_renderer.rs formatter.
237 fn render_call(&self, _params: &serde_json::Value) -> Option<RenderOutput> {
238 None
239 }
240
241 /// Custom rendering for tool result (TUI visualization).
242 /// Return None to use the default tool_renderer.rs formatter.
243 fn render_result(&self, _result: &AgentToolResult) -> Option<RenderOutput> {
244 None
245 }
246
247 /// Execution mode for parallel safety.
248 /// Defaults to ParallelSafe. Override for file-mutating or sequential tools.
249 fn execution_mode(&self) -> ToolExecutionMode {
250 ToolExecutionMode::ParallelSafe
251 }
252
253 /// Return the current active tab ID, if this tool manages browser tabs.
254 /// Defaults to `None`. Browser tools override this to return the tab ID
255 /// of the currently-open tab during execution, so the agent loop can
256 /// populate `ToolExecutionUpdate.tab_id`.
257 fn current_tab_id(&self) -> Option<uuid::Uuid> {
258 None
259 }
260
261 /// Receive a shared slot where the tool can write the current tab ID.
262 /// The agent loop creates the slot and passes it before `on_progress`;
263 /// the tool writes `Some(tab_id)` when it opens a tab and `None` when
264 /// it closes it. Defaults to a no-op — only tab-aware tools override.
265 fn set_tab_id_slot(&self, _slot: Arc<parking_lot::Mutex<Option<uuid::Uuid>>>) {}
266
267 /// Convert to ToolDefinition
268 fn to_definition(&self) -> ToolDefinition {
269 ToolDefinition {
270 name: self.name().to_string(),
271 description: self.description().to_string(),
272 input_schema: serde_json::from_value(self.parameters_schema()).unwrap_or_default(),
273 }
274 }
275}
276
277// Built-in tools
278/// Bash shell execution tool.
279pub mod bash;
280/// Browser tools (engine abstraction always compiled).
281pub mod browse;
282/// Context7 documentation tools.
283pub mod context7;
284/// In-place file edit tool.
285pub mod edit;
286/// Diff-based edit helpers.
287pub mod edit_diff;
288/// Serialised file-mutation queue.
289pub mod file_mutation_queue;
290/// File-fsystem find tool.
291pub mod find;
292/// Image generation tool (OpenRouter API).
293pub mod generate_image;
294/// GitHub integration tool (gh CLI-based).
295pub mod github;
296/// GitHub repository search tool (legacy REST API).
297pub mod github_search;
298/// Content search (grep) tool.
299pub mod grep;
300/// Shared HTTP client singleton.
301pub mod http_client;
302/// Directory listing tool.
303pub mod ls;
304/// Path security (traversal protection).
305pub mod path_security;
306/// Path manipulation utilities.
307pub mod path_utils;
308/// Questionnaire tool — interactive multi-question TUI overlay.
309pub mod questionnaire;
310/// File reading tool.
311pub mod read;
312/// Rendering utilities for tool output.
313pub mod render_utils;
314/// Search result cache and get_search_results tool.
315pub mod search_cache;
316/// Sub-agent delegation tool.
317pub mod subagent;
318/// Tool definition wrapper helpers.
319pub mod tool_definition_wrapper;
320/// Output truncation helpers.
321pub mod truncate;
322/// Multi-engine web search tool (oxibrowser search module).
323pub mod web_search;
324/// File writing tool.
325pub mod write;
326
327// Re-export for convenience
328pub use bash::BashTool;
329pub use edit::EditTool;
330pub use find::FindTool;
331pub use grep::GrepTool;
332pub use ls::LsTool;
333pub use read::ReadTool;
334// pub use search_cache;
335
336pub use crate::mcp::McpTool;
337pub use context7::{Context7QueryDocsTool, Context7ResolveLibraryIdTool};
338pub use questionnaire::{QuestionnaireBridge, QuestionnaireTool};
339pub use subagent::SubagentTool;
340pub use write::WriteTool;
341
342/// Tool registry for managing available tools
343#[derive(Clone)]
344pub struct ToolRegistry {
345 tools: Arc<parking_lot::RwLock<std::collections::HashMap<String, Arc<dyn AgentTool>>>>,
346 /// Optional MCP manager, set by `with_builtins_cwd()` so the TUI and
347 /// other consumers can reach the live MCP state (Phase 2+).
348 mcp_manager: Arc<parking_lot::RwLock<Option<Arc<crate::mcp::McpManager>>>>,
349}
350
351impl Default for ToolRegistry {
352 fn default() -> Self {
353 Self::new()
354 }
355}
356
357impl ToolRegistry {
358 /// Creates an empty tool registry.
359 pub fn new() -> Self {
360 Self {
361 tools: Arc::new(parking_lot::RwLock::new(std::collections::HashMap::new())),
362 mcp_manager: Arc::new(parking_lot::RwLock::new(None)),
363 }
364 }
365
366 /// Attach an `McpManager` to this registry. Replaces any previous one.
367 pub fn set_mcp_manager(&self, mgr: Arc<crate::mcp::McpManager>) {
368 *self.mcp_manager.write() = Some(mgr);
369 }
370
371 /// Get the attached `McpManager`, if any.
372 pub fn mcp_manager(&self) -> Option<Arc<crate::mcp::McpManager>> {
373 self.mcp_manager.read().clone()
374 }
375
376 /// Register a tool
377 pub fn register(&self, tool: impl AgentTool + 'static) {
378 let name = tool.name().to_string();
379 self.tools.write().insert(name, Arc::new(tool));
380 }
381
382 /// Register a tool that is already wrapped in an `Arc`.
383 /// This is the primary path for extensions that produce `Arc<dyn AgentTool>`.
384 pub fn register_arc(&self, tool: Arc<dyn AgentTool>) {
385 let name = tool.name().to_string();
386 self.tools.write().insert(name, tool);
387 }
388
389 /// Get a tool by name
390 pub fn get(&self, name: &str) -> Option<Arc<dyn AgentTool>> {
391 self.tools.read().get(name).cloned()
392 }
393
394 /// Unregister a tool by name.
395 /// Returns `true` if the tool was present and removed.
396 pub fn unregister(&self, name: &str) -> bool {
397 self.tools.write().remove(name).is_some()
398 }
399
400 /// List all registered tool names
401 pub fn names(&self) -> Vec<String> {
402 self.tools.read().keys().cloned().collect()
403 }
404
405 /// Get all tool definitions
406 pub fn definitions(&self) -> Vec<ToolDefinition> {
407 self.tools
408 .read()
409 .values()
410 .map(|t| t.to_definition())
411 .collect()
412 }
413
414 /// Get all tools as a slice
415 pub fn get_tools(&self) -> Vec<Arc<dyn AgentTool>> {
416 self.tools.read().values().cloned().collect()
417 }
418
419 /// Check whether all tools in `required` are registered.
420 ///
421 /// Useful for validating program/module dependencies before execution.
422 ///
423 /// # Example
424 ///
425 /// ```
426 /// use oxi_agent::ToolRegistry;
427 /// let registry = ToolRegistry::new();
428 /// assert!(!registry.has_all(&["read", "write"]));
429 /// ```
430 pub fn has_all(&self, required: &[&str]) -> bool {
431 let tools = self.tools.read();
432 required.iter().all(|name| tools.contains_key(*name))
433 }
434
435 /// Return the subset of `required` tool names that are **not** registered.
436 ///
437 /// # Example
438 ///
439 /// ```
440 /// use oxi_agent::ToolRegistry;
441 /// let registry = ToolRegistry::new();
442 /// let missing = registry.missing(&["read", "exec", "nonexistent"]);
443 /// assert_eq!(missing, vec!["read", "exec", "nonexistent"]);
444 /// ```
445 pub fn missing<'a>(&self, required: &[&'a str]) -> Vec<&'a str> {
446 let tools = self.tools.read();
447 required
448 .iter()
449 .filter(|name| !tools.contains_key(**name))
450 .copied()
451 .collect()
452 }
453
454 /// Create a registry with all built-in tools
455 ///
456 /// # Examples
457 ///
458 /// ```
459 /// use oxi_agent::ToolRegistry;
460 /// let registry = ToolRegistry::with_builtins();
461 /// let tools = registry.names();
462 /// assert!(tools.contains(&"read".to_string()));
463 /// assert!(tools.contains(&"write".to_string()));
464 /// assert!(tools.contains(&"bash".to_string()));
465 /// ```
466 pub fn with_builtins() -> Self {
467 Self::with_builtins_cwd(PathBuf::from("."), &[])
468 }
469
470 /// Create a registry with all built-in tools, using the given cwd.
471 ///
472 /// Pass `disabled_tools` to selectively disable built-in tools
473 /// (e.g. `["web_search", "github_search"]` for a minimal setup).
474 pub fn with_builtins_cwd(cwd: PathBuf, disabled_tools: &[String]) -> Self {
475 let registry = Self::new();
476 let disabled: std::collections::HashSet<&str> =
477 disabled_tools.iter().map(|s| s.as_str()).collect();
478
479 // Helper to create shared cache on demand
480 let cache_once: std::cell::OnceCell<Arc<search_cache::SearchCache>> =
481 std::cell::OnceCell::new();
482
483 // MCP: use OnceCell to avoid re-creating McpManager on repeated calls
484 let mcp_once: std::cell::OnceCell<Arc<crate::mcp::McpManager>> = std::cell::OnceCell::new();
485 let mcp_manager = mcp_once.get_or_init(crate::mcp::McpManager::spawn).clone();
486
487 // Register all builtin tools — essential ones ignore disabled list
488 let mut all_tools: Vec<Box<dyn AgentTool>> = vec![
489 Box::new(ReadTool::with_cwd(cwd.clone())),
490 Box::new(WriteTool::with_cwd(cwd.clone())),
491 Box::new(EditTool::with_cwd(cwd.clone())),
492 Box::new(BashTool::with_cwd(cwd.clone())),
493 Box::new(GrepTool::with_cwd(cwd.clone())),
494 Box::new(FindTool::with_cwd(cwd.clone())),
495 Box::new(LsTool::with_cwd(cwd.clone())),
496 Box::new(web_search::WebSearchTool::new(
497 cache_once
498 .get_or_init(|| Arc::new(search_cache::SearchCache::new()))
499 .clone(),
500 )),
501 Box::new(search_cache::GetSearchResultsTool::new(
502 cache_once
503 .get_or_init(|| Arc::new(search_cache::SearchCache::new()))
504 .clone(),
505 )),
506 Box::new(github::GitHubTool::new(
507 cache_once
508 .get_or_init(|| Arc::new(search_cache::SearchCache::new()))
509 .clone(),
510 )),
511 Box::new(SubagentTool::with_cwd(cwd)),
512 ];
513
514 all_tools.push(Box::new(crate::mcp::McpTool::new(mcp_manager.clone())));
515
516 // Phase 3: register direct MCP tools from the metadata cache.
517 for def in mcp_manager.direct_tools_from_cache() {
518 all_tools.push(Box::new(crate::mcp::McpDirectTool::new(
519 mcp_manager.clone(),
520 def,
521 )));
522 }
523
524 // Remember the manager on the registry so the TUI can reach it.
525 registry.set_mcp_manager(mcp_manager);
526
527 all_tools.push(Box::new(context7::Context7ResolveLibraryIdTool::new()));
528 all_tools.push(Box::new(context7::Context7QueryDocsTool::new()));
529 all_tools.push(Box::new(generate_image::GenerateImageTool::new()));
530
531 for tool in all_tools {
532 if tool.essential() || !disabled.contains(tool.name()) {
533 // web_search ↔ get_search_results coupling
534 if tool.name() == "get_search_results" && disabled.contains("web_search") {
535 continue;
536 }
537 registry.register_arc(Arc::from(tool));
538 }
539 }
540
541 registry
542 }
543
544 /// Extend this registry with all tools from another registry.
545 ///
546 /// Useful for composing tool sets from multiple sources
547 /// (e.g., coding tools + kernel tools + browser tools).
548 ///
549 /// # Example
550 ///
551 /// ```ignore
552 /// let base = ToolRegistry::new();
553 /// base.extend_from(&other_registry);
554 /// ```
555 pub fn extend_from(&self, other: &ToolRegistry) {
556 for name in other.names() {
557 if let Some(tool) = other.get(&name) {
558 self.register_arc(tool);
559 }
560 }
561 }
562
563 /// Create registry with selected builtins only.
564 pub fn with_selected_tools(cwd: PathBuf, names: &[&str]) -> Self {
565 let full = Self::with_builtins_cwd(cwd, &[]);
566 let registry = Self::new();
567 let set: std::collections::HashSet<&str> = names.iter().copied().collect();
568 for name in full.names() {
569 if set.contains(name.as_str())
570 && let Some(tool) = full.get(&name)
571 {
572 registry.register_arc(tool);
573 }
574 }
575 registry
576 }
577}