1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
29pub enum ToolKind {
30 Function,
32 Mcp,
34 Custom,
36}
37
38#[derive(Clone, Debug)]
40pub enum ToolPayload {
41 Function { arguments: String },
43 Custom { input: String },
45 Mcp { arguments: Option<Value> },
47 LocalShell { params: ShellToolCallParams },
49}
50
51#[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
61use crate::sandboxing::LinuxSandboxLauncher;
63pub use crate::sandboxing::SandboxPermissions;
64
65#[derive(Clone, Debug)]
67pub enum ToolOutput {
68 Function {
70 content: String,
71 content_items: Option<Vec<ContentItem>>,
72 success: Option<bool>,
73 },
74 Mcp { result: McpToolResult },
76}
77
78impl ToolOutput {
79 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 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 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 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 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#[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#[derive(Clone, Debug, Serialize, Deserialize)]
143pub struct McpToolResult {
144 pub content: Vec<ContentItem>,
145 pub is_error: Option<bool>,
146}
147
148pub 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
158pub type SharedDiffTracker = Arc<tokio::sync::Mutex<DiffTracker>>;
160
161#[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
177impl<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#[async_trait]
203pub trait ToolSession: Send + Sync {
204 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 fn cwd(&self) -> &PathBuf;
215
216 fn workspace_root(&self) -> &PathBuf;
218
219 async fn record_warning(&self, message: String);
221
222 fn user_shell(&self) -> &str;
224}
225
226#[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 pub sandbox_policy: Constrained<super::sandboxing::SandboxConfig>,
237}
238
239impl TurnContext {
240 pub fn resolve_path(&self, path: Option<String>) -> PathBuf {
242 self.resolve_path_ref(path.as_deref())
243 }
244
245 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#[derive(Clone, Debug, Default)]
259pub enum ShellEnvironmentPolicy {
260 #[default]
261 Inherit,
262 Clean,
263 Custom(HashMap<String, String>),
264}
265
266#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
268pub enum ApprovalPolicy {
269 #[default]
270 Never,
271 OnMutation,
272 Always,
273}
274
275#[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#[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#[derive(DebugNoInline, thiserror::Error)]
308pub enum ToolCallError {
309 #[error("Tool error: {0}")]
311 RespondToModel(String),
312
313 #[error("Internal error: {0}")]
315 Internal(#[from] anyhow::Error),
316
317 #[error("Tool rejected: {0}")]
319 Rejected(String),
320
321 #[error("Tool timed out after {0}ms")]
323 Timeout(u64),
324}
325
326impl ToolCallError {
327 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#[async_trait]
351pub trait ToolHandler: Send + Sync {
352 fn kind(&self) -> ToolKind;
354
355 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 async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool {
369 false
370 }
371
372 async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, ToolCallError>;
374}
375
376#[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#[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}