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;