Skip to main content

opendev_tools_core/
traits.rs

1//! Core tool traits and types.
2//!
3//! Defines the `BaseTool` async trait that all tools implement, along with
4//! `ToolResult` (execution outcome) and `ToolContext` (session state passed
5//! to tool handlers).
6
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::sync::{Arc, Mutex};
11use tokio_util::sync::CancellationToken;
12
13/// A structured validation error with field path and message.
14///
15/// Used by `BaseTool::format_validation_error` to provide context about
16/// each validation failure.
17#[derive(Debug, Clone)]
18pub struct ValidationError {
19    /// Dot-separated path to the invalid field (e.g. `"tool_calls.0.tool"`).
20    pub path: String,
21    /// Human-readable description of what went wrong.
22    pub message: String,
23}
24
25impl std::fmt::Display for ValidationError {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        if self.path.is_empty() || self.path == "root" {
28            write!(f, "{}", self.message)
29        } else {
30            write!(f, "{}: {}", self.path, self.message)
31        }
32    }
33}
34
35/// A diagnostic reported by a language server for a specific file location.
36#[derive(Debug, Clone)]
37pub struct FileDiagnostic {
38    /// 1-based line number.
39    pub line: u32,
40    /// 1-based column number.
41    pub column: u32,
42    /// Severity: 1 = Error, 2 = Warning, 3 = Info, 4 = Hint.
43    pub severity: u32,
44    /// Diagnostic message.
45    pub message: String,
46}
47
48impl FileDiagnostic {
49    /// Format this diagnostic as a human-readable line.
50    pub fn pretty(&self) -> String {
51        let level = match self.severity {
52            1 => "ERROR",
53            2 => "WARN",
54            3 => "INFO",
55            _ => "HINT",
56        };
57        format!("{level} [{}:{}] {}", self.line, self.column, self.message)
58    }
59}
60
61/// Provider of LSP diagnostics for files after edits.
62///
63/// Implementors connect to language servers and return diagnostics
64/// for modified files. The file tools call this after successful writes
65/// to give the LLM immediate feedback about introduced errors.
66#[async_trait::async_trait]
67pub trait DiagnosticProvider: Send + Sync + std::fmt::Debug {
68    /// Notify the provider that a file was modified and retrieve diagnostics.
69    ///
70    /// Returns diagnostics for the specified file, filtering to the given
71    /// severity threshold (1 = errors only, 2 = errors + warnings, etc.).
72    /// The `max_count` parameter limits how many diagnostics to return.
73    ///
74    /// Returns an empty vec if no diagnostics are available or if the
75    /// language server doesn't support the file type.
76    async fn diagnostics_for_file(
77        &self,
78        file_path: &Path,
79        max_severity: u32,
80        max_count: usize,
81    ) -> Vec<FileDiagnostic>;
82}
83
84/// Errors that can occur during tool execution.
85#[derive(Debug, thiserror::Error)]
86pub enum ToolError {
87    #[error("Tool execution failed: {0}")]
88    Execution(String),
89
90    #[error("Invalid parameters: {0}")]
91    InvalidParams(String),
92
93    #[error("Tool not found: {0}")]
94    NotFound(String),
95
96    #[error("Permission denied: {0}")]
97    PermissionDenied(String),
98
99    #[error("Interrupted by user")]
100    Interrupted,
101
102    #[error("IO error: {0}")]
103    Io(#[from] std::io::Error),
104
105    #[error("{0}")]
106    Other(String),
107}
108
109/// Result of a tool execution.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct ToolResult {
112    /// Whether the tool executed successfully.
113    pub success: bool,
114    /// Tool output text (for successful results).
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub output: Option<String>,
117    /// Error message (for failed results).
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub error: Option<String>,
120    /// Additional metadata (tool-specific).
121    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
122    pub metadata: HashMap<String, serde_json::Value>,
123    /// Execution duration in milliseconds (populated by the registry).
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub duration_ms: Option<u64>,
126    /// Hidden suffix appended to the tool result for the LLM but not shown in the UI.
127    /// Used to silently guide LLM behavior on errors (e.g., retry hints).
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub llm_suffix: Option<String>,
130}
131
132impl ToolResult {
133    /// Create a successful result.
134    pub fn ok(output: impl Into<String>) -> Self {
135        Self {
136            success: true,
137            output: Some(output.into()),
138            error: None,
139            metadata: HashMap::new(),
140            duration_ms: None,
141            llm_suffix: None,
142        }
143    }
144
145    /// Create a successful result with metadata.
146    pub fn ok_with_metadata(
147        output: impl Into<String>,
148        metadata: HashMap<String, serde_json::Value>,
149    ) -> Self {
150        Self {
151            success: true,
152            output: Some(output.into()),
153            error: None,
154            metadata,
155            duration_ms: None,
156            llm_suffix: None,
157        }
158    }
159
160    /// Create a failed result.
161    pub fn fail(error: impl Into<String>) -> Self {
162        Self {
163            success: false,
164            output: None,
165            error: Some(error.into()),
166            metadata: HashMap::new(),
167            duration_ms: None,
168            llm_suffix: None,
169        }
170    }
171
172    /// Attach an LLM-only suffix to this result.
173    pub fn with_llm_suffix(mut self, suffix: impl Into<String>) -> Self {
174        self.llm_suffix = Some(suffix.into());
175        self
176    }
177
178    /// Create a result from a ToolError.
179    pub fn from_error(err: ToolError) -> Self {
180        Self::fail(err.to_string())
181    }
182}
183
184/// Per-tool timeout configuration.
185///
186/// Allows overriding the default idle and maximum timeouts for tools
187/// that execute external processes (e.g., bash).
188#[derive(Debug, Clone)]
189pub struct ToolTimeoutConfig {
190    /// Idle timeout in seconds: kill when no stdout/stderr activity for this long.
191    /// Defaults to 60 seconds.
192    pub idle_timeout_secs: u64,
193    /// Absolute maximum runtime in seconds (safety cap).
194    /// Defaults to 600 seconds.
195    pub max_timeout_secs: u64,
196}
197
198impl Default for ToolTimeoutConfig {
199    fn default() -> Self {
200        Self {
201            idle_timeout_secs: 60,
202            max_timeout_secs: 600,
203        }
204    }
205}
206
207/// Execution context passed to tool handlers.
208///
209/// Carries session state, configuration, and working directory so tools
210/// can resolve paths, check permissions, and access shared resources.
211#[derive(Debug, Clone)]
212pub struct ToolContext {
213    /// Working directory for path resolution.
214    pub working_dir: PathBuf,
215    /// Whether the caller is a subagent (may restrict some operations).
216    pub is_subagent: bool,
217    /// Optional session ID for session-scoped operations.
218    pub session_id: Option<String>,
219    /// Arbitrary context values for tool-specific needs.
220    pub values: HashMap<String, serde_json::Value>,
221    /// Optional per-tool timeout overrides.
222    pub timeout_config: Option<ToolTimeoutConfig>,
223    /// Cancellation token for cooperative interrupt from the UI.
224    pub cancel_token: Option<CancellationToken>,
225    /// Optional LSP diagnostic provider for post-edit feedback.
226    pub diagnostic_provider: Option<Arc<dyn DiagnosticProvider>>,
227    /// Shared mutable state across tool executions within a react loop.
228    /// Used for cross-iteration state like planning phase transitions.
229    pub shared_state: Option<Arc<Mutex<HashMap<String, serde_json::Value>>>>,
230}
231
232impl ToolContext {
233    /// Create a new tool context with a working directory.
234    ///
235    /// Relative paths (including `.`) are resolved to absolute paths using
236    /// `std::env::current_dir()` and then canonicalized. Absolute paths
237    /// are stored as-is to avoid changing paths in tests.
238    pub fn new(working_dir: impl Into<PathBuf>) -> Self {
239        let raw: PathBuf = working_dir.into();
240        let resolved = if raw.is_relative() {
241            // Resolve relative paths (including ".") to absolute
242            if let Ok(cwd) = std::env::current_dir() {
243                let joined = cwd.join(&raw);
244                joined.canonicalize().unwrap_or(joined)
245            } else {
246                raw.canonicalize().unwrap_or(raw)
247            }
248        } else {
249            raw
250        };
251        Self {
252            working_dir: resolved,
253            is_subagent: false,
254            session_id: None,
255            values: HashMap::new(),
256            timeout_config: None,
257            cancel_token: None,
258            diagnostic_provider: None,
259            shared_state: None,
260        }
261    }
262
263    /// Set a cancellation token for cooperative interrupt.
264    pub fn with_cancel_token(mut self, token: CancellationToken) -> Self {
265        self.cancel_token = Some(token);
266        self
267    }
268
269    /// Set the subagent flag.
270    pub fn with_subagent(mut self, is_subagent: bool) -> Self {
271        self.is_subagent = is_subagent;
272        self
273    }
274
275    /// Set the session ID.
276    pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
277        self.session_id = Some(session_id.into());
278        self
279    }
280
281    /// Insert a context value.
282    pub fn with_value(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
283        self.values.insert(key.into(), value);
284        self
285    }
286
287    /// Set a diagnostic provider for post-edit LSP feedback.
288    pub fn with_diagnostic_provider(mut self, provider: Arc<dyn DiagnosticProvider>) -> Self {
289        self.diagnostic_provider = Some(provider);
290        self
291    }
292
293    /// Set timeout configuration.
294    pub fn with_timeout_config(mut self, config: ToolTimeoutConfig) -> Self {
295        self.timeout_config = Some(config);
296        self
297    }
298
299    /// Set shared mutable state for cross-iteration communication.
300    pub fn with_shared_state(
301        mut self,
302        state: Arc<Mutex<HashMap<String, serde_json::Value>>>,
303    ) -> Self {
304        self.shared_state = Some(state);
305        self
306    }
307}
308
309impl Default for ToolContext {
310    fn default() -> Self {
311        Self {
312            working_dir: std::env::current_dir().unwrap_or_default(),
313            is_subagent: false,
314            session_id: None,
315            values: HashMap::new(),
316            timeout_config: None,
317            cancel_token: None,
318            diagnostic_provider: None,
319            shared_state: None,
320        }
321    }
322}
323
324/// Metadata describing how a tool should appear in the TUI.
325///
326/// Tools return this from `display_meta()` so the display registry can
327/// auto-discover formatting without manual static entries.
328#[derive(Debug, Clone, Copy)]
329pub struct ToolDisplayMeta {
330    /// Display verb shown in TUI, e.g. "Read", "Bash".
331    pub verb: &'static str,
332    /// Fallback noun when no arg is available, e.g. "file", "command".
333    pub label: &'static str,
334    /// Category name (matches `ToolCategory` variant names).
335    pub category: &'static str,
336    /// Ordered keys to try when extracting the primary arg for display.
337    pub primary_arg_keys: &'static [&'static str],
338}
339
340/// Base trait for all tools.
341///
342/// Tools implement this trait to provide:
343/// - Identity (name, description)
344/// - Parameter schema (JSON Schema for LLM tool-use)
345/// - Async execution
346#[async_trait::async_trait]
347pub trait BaseTool: Send + Sync + std::fmt::Debug {
348    /// Unique tool name used for dispatch.
349    fn name(&self) -> &str;
350
351    /// Human-readable description shown to the LLM.
352    fn description(&self) -> &str;
353
354    /// JSON Schema describing the tool's parameters.
355    ///
356    /// Returns a JSON object with `type`, `properties`, and `required` fields
357    /// following the JSON Schema specification.
358    fn parameter_schema(&self) -> serde_json::Value;
359
360    /// Execute the tool with the given arguments and context.
361    async fn execute(
362        &self,
363        args: HashMap<String, serde_json::Value>,
364        ctx: &ToolContext,
365    ) -> ToolResult;
366
367    /// Format a validation error into a tool-specific, LLM-friendly message.
368    ///
369    /// When validation fails, the registry calls this method to produce a
370    /// structured error that helps the LLM understand exactly what went wrong
371    /// and how to fix the call. Tools that don't override this get the default
372    /// generic validation error message.
373    ///
374    /// The `errors` slice contains `(field_path, message)` pairs extracted
375    /// from the JSON Schema validation.
376    fn format_validation_error(&self, errors: &[ValidationError]) -> Option<String> {
377        let _ = errors;
378        None
379    }
380
381    /// Return TUI display metadata for this tool.
382    ///
383    /// Tools that override this allow the display registry to auto-discover
384    /// their formatting. Returns `None` by default (falls back to static registry).
385    fn display_meta(&self) -> Option<ToolDisplayMeta> {
386        None
387    }
388}
389
390#[cfg(test)]
391#[path = "traits_tests.rs"]
392mod tests;