Skip to main content

vtcode_core/tools/handlers/
tool_handler.rs

1//! Codex-compatible ToolHandler trait and types
2//!
3//! This module implements the handler pattern from OpenAI's Codex project,
4//! providing a more modular and composable approach to tool execution.
5//!
6//! Key patterns from Codex:
7//! - `ToolHandler` trait with kind/matches_kind/is_mutating/handle methods
8//! - `ToolKind` enum for categorizing tool types
9//! - `ToolPayload` for typed tool arguments
10//! - `ToolOutput` for structured tool results
11//! - `ToolInvocation` for execution context
12
13use crate::config::constants::tools;
14use hashbrown::HashMap;
15use std::path::PathBuf;
16use std::sync::Arc;
17
18use anyhow::Result;
19use async_trait::async_trait;
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22use vtcode_macros::DebugNoInline;
23pub use vtcode_utility_tool_specs::{
24    AdditionalProperties, FreeformTool, FreeformToolFormat, JsonSchema, ResponsesApiTool,
25};
26
27/// Tool kind classification (from Codex)
28#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
29pub enum ToolKind {
30    /// Standard function call tool
31    Function,
32    /// MCP (Model Context Protocol) tool
33    Mcp,
34    /// Custom/freeform tool (e.g., apply_patch with custom format)
35    Custom,
36}
37
38/// Payload types for tool invocations (from Codex)
39#[derive(Clone, Debug)]
40pub enum ToolPayload {
41    /// Standard function call with JSON arguments
42    Function { arguments: String },
43    /// Custom tool with freeform input (e.g., apply_patch)
44    Custom { input: String },
45    /// MCP tool call
46    Mcp { arguments: Option<Value> },
47    /// Local shell execution
48    LocalShell { params: ShellToolCallParams },
49}
50
51/// Shell command parameters (from Codex)
52#[derive(Clone, Debug, Deserialize, Serialize)]
53pub struct ShellToolCallParams {
54    pub command: Vec<String>,
55    pub workdir: Option<String>,
56    pub timeout_ms: Option<u64>,
57    pub sandbox_permissions: Option<SandboxPermissions>,
58    pub justification: Option<String>,
59}
60
61// Re-export the canonical SandboxPermissions from the sandboxing module.
62use crate::sandboxing::LinuxSandboxLauncher;
63pub use crate::sandboxing::SandboxPermissions;
64
65/// Tool output types (from Codex)
66#[derive(Clone, Debug)]
67pub enum ToolOutput {
68    /// Function call result
69    Function {
70        content: String,
71        content_items: Option<Vec<ContentItem>>,
72        success: Option<bool>,
73    },
74    /// MCP tool result
75    Mcp { result: McpToolResult },
76}
77
78impl ToolOutput {
79    /// Create a simple function output with just content
80    pub fn simple(content: impl Into<String>) -> Self {
81        Self::Function {
82            content: content.into(),
83            content_items: None,
84            success: Some(true),
85        }
86    }
87
88    /// Create a function output with success status
89    pub fn with_success(content: impl Into<String>, success: bool) -> Self {
90        Self::Function {
91            content: content.into(),
92            content_items: None,
93            success: Some(success),
94        }
95    }
96
97    /// Create an error output
98    pub fn error(message: impl Into<String>) -> Self {
99        Self::Function {
100            content: message.into(),
101            content_items: None,
102            success: Some(false),
103        }
104    }
105
106    /// Get the content string if this is a Function output
107    pub fn content(&self) -> Option<&str> {
108        match self {
109            Self::Function { content, .. } => Some(content),
110            Self::Mcp { result } => result.content.first().and_then(|c| c.as_text()),
111        }
112    }
113
114    /// Check if the output indicates success
115    pub fn is_success(&self) -> bool {
116        match self {
117            Self::Function { success, .. } => success.unwrap_or(true),
118            Self::Mcp { result } => !result.is_error.unwrap_or(false),
119        }
120    }
121}
122
123/// Content item for multi-part responses (from Codex)
124#[derive(Clone, Debug, Serialize, Deserialize)]
125#[serde(tag = "type", rename_all = "snake_case")]
126pub enum ContentItem {
127    Text { text: String },
128    Image { data: String, mime_type: String },
129    Resource { uri: String, mime_type: Option<String> },
130}
131
132impl ContentItem {
133    pub fn as_text(&self) -> Option<&str> {
134        match self {
135            ContentItem::Text { text } => Some(text),
136            _ => None,
137        }
138    }
139}
140
141/// MCP tool result (from Codex)
142#[derive(Clone, Debug, Serialize, Deserialize)]
143pub struct McpToolResult {
144    pub content: Vec<ContentItem>,
145    pub is_error: Option<bool>,
146}
147
148/// Context for tool invocation (from Codex)
149pub struct ToolInvocation {
150    pub session: Arc<dyn ToolSession>,
151    pub turn: Arc<TurnContext>,
152    pub tracker: Option<SharedDiffTracker>,
153    pub call_id: String,
154    pub tool_name: String,
155    pub payload: ToolPayload,
156}
157
158/// Shared diff tracker type alias
159pub type SharedDiffTracker = Arc<tokio::sync::Mutex<DiffTracker>>;
160
161/// Lightweight wrapper used to preserve policy fields as structured values.
162#[derive(Clone, Debug, PartialEq, Eq)]
163pub struct Constrained<T> {
164    value: T,
165}
166
167impl<T> Constrained<T> {
168    pub fn allow_any(initial_value: T) -> Self {
169        Self { value: initial_value }
170    }
171
172    pub fn get(&self) -> &T {
173        &self.value
174    }
175}
176
177// Deref is for ergonomic read-only access to the inner value.
178// The private field + `get()` pattern remains for intentional
179// construction barriers; Deref enables transparent use in contexts
180// that expect &T (e.g., matching on policy enums).
181impl<T> std::ops::Deref for Constrained<T> {
182    type Target = T;
183
184    fn deref(&self) -> &Self::Target {
185        &self.value
186    }
187}
188
189impl<T: Copy> Constrained<T> {
190    pub fn value(&self) -> T {
191        self.value
192    }
193}
194
195impl<T: Default> Default for Constrained<T> {
196    fn default() -> Self {
197        Self::allow_any(T::default())
198    }
199}
200
201/// Session trait for tool execution context
202#[async_trait]
203pub trait ToolSession: Send + Sync {
204    /// Session source ledger. Absence fails closed for edits to existing text.
205    fn source_inspection(&self) -> Option<&crate::tools::source_inspection::SourceInspection> {
206        self.edited_file_monitor().map(|monitor| monitor.source_inspection())
207    }
208
209    fn edited_file_monitor(&self) -> Option<&crate::tools::edited_file_monitor::EditedFileMonitor> {
210        None
211    }
212
213    /// Get the current working directory
214    fn cwd(&self) -> &PathBuf;
215
216    /// Get workspace root
217    fn workspace_root(&self) -> &PathBuf;
218
219    /// Record a warning message
220    async fn record_warning(&self, message: String);
221
222    /// Get user's configured shell
223    fn user_shell(&self) -> &str;
224}
225
226/// Turn context for tool execution
227#[derive(Clone, Debug)]
228pub struct TurnContext {
229    pub cwd: PathBuf,
230    pub turn_id: String,
231    pub sub_id: Option<String>,
232    pub shell_environment_policy: ShellEnvironmentPolicy,
233    pub approval_policy: Constrained<ApprovalPolicy>,
234    pub linux_sandbox_launcher: Option<LinuxSandboxLauncher>,
235    /// Sandbox policy from Codex (for orchestrator integration)
236    pub sandbox_policy: Constrained<super::sandboxing::SandboxConfig>,
237}
238
239impl TurnContext {
240    /// Resolve a path relative to the current working directory
241    pub fn resolve_path(&self, path: Option<String>) -> PathBuf {
242        self.resolve_path_ref(path.as_deref())
243    }
244
245    /// Resolve a path reference relative to the current working directory
246    pub fn resolve_path_ref(&self, path: Option<&str>) -> PathBuf {
247        match path {
248            Some(p) => {
249                let path = PathBuf::from(p);
250                if path.is_absolute() { path } else { self.cwd.join(path) }
251            }
252            None => self.cwd.clone(),
253        }
254    }
255}
256
257/// Shell environment policy
258#[derive(Clone, Debug, Default)]
259pub enum ShellEnvironmentPolicy {
260    #[default]
261    Inherit,
262    Clean,
263    Custom(HashMap<String, String>),
264}
265
266/// Approval policy for tool execution
267#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
268pub enum ApprovalPolicy {
269    #[default]
270    Never,
271    OnMutation,
272    Always,
273}
274
275/// Diff tracker for file changes
276#[derive(Default, Debug)]
277pub struct DiffTracker {
278    pub changes: HashMap<PathBuf, FileChange>,
279}
280
281impl DiffTracker {
282    pub fn on_patch_begin(&mut self, changes: &HashMap<PathBuf, FileChange>) {
283        self.changes.extend(changes.clone());
284    }
285
286    pub fn on_patch_end(&mut self, success: bool) {
287        if !success {
288            self.changes.clear();
289        }
290    }
291}
292
293/// File change types (from Codex protocol)
294#[derive(Clone, Debug, Serialize, Deserialize)]
295#[serde(tag = "type", rename_all = "snake_case")]
296pub enum FileChange {
297    Add { content: String },
298    Delete,
299    Update { old_content: String, new_content: String },
300    Rename { new_path: PathBuf, content: Option<String> },
301}
302
303/// Error type for tool execution (from Codex)
304///
305/// `Debug` via [`DebugNoInline`]: the `Internal(anyhow::Error)` variant carries an
306/// arbitrary source chain formatted on tool-failure paths.
307#[derive(DebugNoInline, thiserror::Error)]
308pub enum ToolCallError {
309    /// Error that should be sent back to the model
310    #[error("Tool error: {0}")]
311    RespondToModel(String),
312
313    /// Internal error that should not be sent to the model
314    #[error("Internal error: {0}")]
315    Internal(#[from] anyhow::Error),
316
317    /// Tool was rejected by approval policy
318    #[error("Tool rejected: {0}")]
319    Rejected(String),
320
321    /// Tool timed out
322    #[error("Tool timed out after {0}ms")]
323    Timeout(u64),
324}
325
326impl ToolCallError {
327    /// Create an error to respond to the model
328    pub fn respond(message: impl Into<String>) -> Self {
329        Self::RespondToModel(message.into())
330    }
331}
332
333impl From<super::sandboxing::ToolError> for ToolCallError {
334    fn from(err: super::sandboxing::ToolError) -> Self {
335        match err {
336            super::sandboxing::ToolError::Rejected(msg) => ToolCallError::Rejected(msg),
337            super::sandboxing::ToolError::Codex(e) => ToolCallError::Internal(e),
338            super::sandboxing::ToolError::SandboxDenied(msg) => {
339                ToolCallError::Rejected(format!("Sandbox denied: {msg}"))
340            }
341            super::sandboxing::ToolError::Timeout(ms) => ToolCallError::Timeout(ms),
342        }
343    }
344}
345
346/// Core trait for tool handlers (from Codex)
347///
348/// This trait provides a modular approach to tool execution, separating
349/// concerns like kind matching, mutation detection, and actual execution.
350#[async_trait]
351pub trait ToolHandler: Send + Sync {
352    /// Get the kind of tool this handler supports
353    fn kind(&self) -> ToolKind;
354
355    /// Check if the handler can process the given payload type
356    fn matches_kind(&self, payload: &ToolPayload) -> bool {
357        matches!(
358            (self.kind(), payload),
359            (ToolKind::Function, ToolPayload::Function { .. })
360                | (ToolKind::Mcp, ToolPayload::Mcp { .. })
361                | (ToolKind::Custom, ToolPayload::Custom { .. })
362        )
363    }
364
365    /// Check if this invocation would mutate state
366    ///
367    /// Used for approval policies - read-only tools can often be auto-approved
368    async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool {
369        false
370    }
371
372    /// Execute the tool and return the output
373    async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, ToolCallError>;
374}
375
376/// Tool spec types (from Codex)
377#[derive(Clone, Debug, Serialize, Deserialize)]
378#[serde(tag = "type", rename_all = "snake_case")]
379pub enum ToolSpec {
380    Function(ResponsesApiTool),
381    Freeform(FreeformTool),
382    WebSearch {},
383    LocalShell {},
384}
385
386impl ToolSpec {
387    pub fn name(&self) -> &str {
388        match self {
389            ToolSpec::Function(tool) => &tool.name,
390            ToolSpec::Freeform(tool) => &tool.name,
391            ToolSpec::WebSearch {} => tools::WEB_SEARCH,
392            ToolSpec::LocalShell {} => "local_shell",
393        }
394    }
395}
396
397/// Configured tool spec with parallel execution support
398#[derive(Clone, Debug)]
399pub struct ConfiguredToolSpec {
400    pub spec: ToolSpec,
401    pub supports_parallel_tool_calls: bool,
402}
403
404impl ConfiguredToolSpec {
405    pub fn new(spec: ToolSpec, supports_parallel: bool) -> Self {
406        Self {
407            spec,
408            supports_parallel_tool_calls: supports_parallel,
409        }
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    #[test]
418    fn test_tool_output_simple() {
419        let output = ToolOutput::simple("Hello, world!");
420        assert!(output.is_success());
421        assert_eq!(output.content(), Some("Hello, world!"));
422    }
423
424    #[test]
425    fn test_tool_output_error() {
426        let output = ToolOutput::error("Something went wrong");
427        assert!(!output.is_success());
428        assert_eq!(output.content(), Some("Something went wrong"));
429    }
430
431    #[test]
432    fn test_sandbox_permissions_default() {
433        let perms = SandboxPermissions::default();
434        assert_eq!(perms, SandboxPermissions::UseDefault);
435    }
436
437    #[test]
438    fn test_turn_context_resolve_path_absolute() {
439        let ctx = TurnContext {
440            cwd: PathBuf::from("/workspace"),
441            turn_id: "test".to_string(),
442            sub_id: None,
443            shell_environment_policy: ShellEnvironmentPolicy::default(),
444            approval_policy: Constrained::allow_any(ApprovalPolicy::default()),
445            linux_sandbox_launcher: None,
446            sandbox_policy: Constrained::allow_any(Default::default()),
447        };
448
449        let resolved = ctx.resolve_path(Some("/absolute/path".to_string()));
450        assert_eq!(resolved, PathBuf::from("/absolute/path"));
451    }
452
453    #[test]
454    fn test_turn_context_resolve_path_relative() {
455        let ctx = TurnContext {
456            cwd: PathBuf::from("/workspace"),
457            turn_id: "test".to_string(),
458            sub_id: None,
459            shell_environment_policy: ShellEnvironmentPolicy::default(),
460            approval_policy: Constrained::allow_any(ApprovalPolicy::default()),
461            linux_sandbox_launcher: None,
462            sandbox_policy: Constrained::allow_any(Default::default()),
463        };
464
465        let resolved = ctx.resolve_path(Some("relative/path".to_string()));
466        assert_eq!(resolved, PathBuf::from("/workspace/relative/path"));
467    }
468}