Skip to main content

vtcode_core/tools/handlers/
events.rs

1//! Tool event emitter (from Codex)
2//!
3//! Handles the tool execution lifecycle:
4//! - Diff-tracker updates for apply_patch begin/end
5//! - Structured tracing for begin/success/failure stages
6//!
7//! Lifecycle visibility into the canonical event stream flows through the
8//! runloop's `ThreadEvent` recorder (`item.*`), not through this module.
9
10use crate::config::constants::tools;
11use crate::types::CompactStr;
12use hashbrown::HashMap;
13use std::fmt::Write as _;
14use std::path::PathBuf;
15use std::sync::Arc;
16
17use super::sandboxing::{ExecToolCallOutput, ToolError};
18use super::tool_handler::{DiffTracker, FileChange, ToolCallError, TurnContext};
19
20/// Context for emitting tool events
21pub struct ToolEventCtx<'a> {
22    pub turn: &'a TurnContext,
23    pub call_id: &'a str,
24    pub turn_diff_tracker: Option<&'a Arc<tokio::sync::Mutex<DiffTracker>>>,
25}
26
27impl<'a> ToolEventCtx<'a> {
28    pub fn new(
29        turn: &'a TurnContext,
30        call_id: &'a str,
31        tracker: Option<&'a Arc<tokio::sync::Mutex<DiffTracker>>>,
32    ) -> Self {
33        Self { turn, call_id, turn_diff_tracker: tracker }
34    }
35}
36
37/// Event stage during tool execution
38#[derive(Clone, Debug)]
39pub enum ToolEventStage {
40    Begin,
41    Success(ExecToolCallOutput),
42    Failure(ToolEventFailureKind),
43}
44
45/// Failure kind for tool events
46#[derive(Clone, Debug)]
47pub enum ToolEventFailureKind {
48    Output(ExecToolCallOutput),
49    Message(String),
50    Error(String),
51}
52
53/// Command source for tracking where commands originate
54#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
55pub enum ExecCommandSource {
56    #[default]
57    Agent,
58    User,
59    CommandSessionStartup,
60    CommandSessionWriteStdin,
61}
62
63/// Parsed command information
64#[derive(Clone, Debug)]
65pub struct ParsedCommand {
66    pub program: String,
67    pub args: Vec<String>,
68}
69
70/// Parse command from argv
71pub fn parse_command(command: &[String]) -> ParsedCommand {
72    let program = command.first().cloned().unwrap_or_default();
73    let args = command.get(1..).map(|s| s.to_vec()).unwrap_or_default();
74    ParsedCommand { program, args }
75}
76
77/// Tool event emitter for different tool types (from Codex)
78#[derive(Clone, Debug)]
79pub enum ToolEmitter {
80    /// Shell command execution
81    Shell {
82        command: Vec<String>,
83        cwd: PathBuf,
84        source: ExecCommandSource,
85        parsed_cmd: ParsedCommand,
86        freeform: bool,
87    },
88    /// Apply patch operation
89    ApplyPatch {
90        changes: HashMap<PathBuf, FileChange>,
91        auto_approved: bool,
92    },
93    /// Unified command-session execution
94    CommandSession {
95        command: Vec<String>,
96        cwd: PathBuf,
97        source: ExecCommandSource,
98        interaction_input: Option<String>,
99        parsed_cmd: ParsedCommand,
100        process_id: Option<String>,
101    },
102    /// Generic tool execution
103    Generic { tool_name: CompactStr },
104}
105
106impl ToolEmitter {
107    /// Create emitter for shell commands
108    pub fn shell(command: Vec<String>, cwd: PathBuf, source: ExecCommandSource, freeform: bool) -> Self {
109        let parsed_cmd = parse_command(&command);
110        Self::Shell { command, cwd, source, parsed_cmd, freeform }
111    }
112
113    /// Create emitter for apply_patch
114    pub fn apply_patch(changes: HashMap<PathBuf, FileChange>, auto_approved: bool) -> Self {
115        Self::ApplyPatch { changes, auto_approved }
116    }
117
118    /// Create emitter for command-session execution.
119    pub fn command_session(
120        command: &[String],
121        cwd: PathBuf,
122        source: ExecCommandSource,
123        process_id: Option<String>,
124    ) -> Self {
125        let parsed_cmd = parse_command(command);
126        Self::CommandSession {
127            command: command.to_vec(),
128            cwd,
129            source,
130            interaction_input: None,
131            parsed_cmd,
132            process_id,
133        }
134    }
135
136    /// Create emitter for generic tools
137    pub fn generic(tool_name: impl Into<CompactStr>) -> Self {
138        Self::Generic { tool_name: tool_name.into() }
139    }
140
141    /// Emit event for current stage
142    pub async fn emit(&self, ctx: ToolEventCtx<'_>, stage: ToolEventStage) {
143        match (self, &stage) {
144            // Apply patch begin
145            (Self::ApplyPatch { changes, auto_approved }, ToolEventStage::Begin) => {
146                // Update diff tracker
147                if let Some(tracker) = ctx.turn_diff_tracker {
148                    let mut guard = tracker.lock().await;
149                    guard.on_patch_begin(changes);
150                }
151
152                tracing::debug!(
153                    call_id = %ctx.call_id,
154                    turn_id = %ctx.turn.turn_id,
155                    changes = changes.len(),
156                    auto_approved = *auto_approved,
157                    "patch apply began"
158                );
159            }
160
161            // Apply patch success
162            (Self::ApplyPatch { .. }, ToolEventStage::Success(output)) => {
163                self.emit_patch_end(ctx, output.stdout.clone(), output.stderr.clone(), true)
164                    .await;
165            }
166
167            // Apply patch failure
168            (Self::ApplyPatch { .. }, ToolEventStage::Failure(ToolEventFailureKind::Output(output))) => {
169                self.emit_patch_end(ctx, output.stdout.clone(), output.stderr.clone(), false)
170                    .await;
171            }
172
173            (Self::ApplyPatch { .. }, ToolEventStage::Failure(ToolEventFailureKind::Message(msg))) => {
174                self.emit_patch_end(ctx, String::new(), msg.clone(), false).await;
175            }
176
177            // Shell/CommandSession begin
178            (Self::Shell { .. } | Self::CommandSession { .. }, ToolEventStage::Begin) => {
179                tracing::debug!(
180                    tool = %self.tool_name(),
181                    call_id = %ctx.call_id,
182                    turn_id = %ctx.turn.turn_id,
183                    "Tool execution started"
184                );
185            }
186
187            // Shell/CommandSession success
188            (Self::Shell { .. } | Self::CommandSession { .. }, ToolEventStage::Success(_)) => {
189                tracing::debug!(call_id = %ctx.call_id, "Tool execution succeeded");
190            }
191
192            // Shell/CommandSession failure
193            (Self::Shell { .. } | Self::CommandSession { .. }, ToolEventStage::Failure(kind)) => {
194                let error = match kind {
195                    ToolEventFailureKind::Output(output) => output.combined_output(),
196                    ToolEventFailureKind::Message(msg) => msg.clone(),
197                    ToolEventFailureKind::Error(err) => err.clone(),
198                };
199                tracing::warn!(call_id = %ctx.call_id, error = %error, "Tool execution failed");
200            }
201
202            // Generic tool events
203            (Self::Generic { tool_name }, ToolEventStage::Begin) => {
204                tracing::debug!(
205                    tool = %tool_name,
206                    call_id = %ctx.call_id,
207                    turn_id = %ctx.turn.turn_id,
208                    "Tool execution started"
209                );
210            }
211
212            (Self::Generic { .. }, ToolEventStage::Success(_)) => {
213                tracing::debug!(call_id = %ctx.call_id, "Tool execution succeeded");
214            }
215
216            (Self::Generic { .. }, ToolEventStage::Failure(kind)) => {
217                let error = match kind {
218                    ToolEventFailureKind::Output(output) => output.combined_output(),
219                    ToolEventFailureKind::Message(msg) => msg.clone(),
220                    ToolEventFailureKind::Error(err) => err.clone(),
221                };
222                tracing::warn!(call_id = %ctx.call_id, error = %error, "Tool execution failed");
223            }
224
225            _ => {}
226        }
227    }
228
229    /// Emit begin event
230    pub async fn begin(&self, ctx: ToolEventCtx<'_>) {
231        self.emit(ctx, ToolEventStage::Begin).await;
232    }
233
234    /// Complete execution and format output for model
235    pub async fn finish(
236        &self,
237        ctx: ToolEventCtx<'_>,
238        result: Result<ExecToolCallOutput, ToolError>,
239    ) -> Result<String, ToolCallError> {
240        match result {
241            Ok(output) => {
242                self.emit(ctx, ToolEventStage::Success(output.clone())).await;
243                Ok(self.format_output_for_model(&output))
244            }
245            Err(ToolError::Rejected(msg)) => {
246                self.emit(ctx, ToolEventStage::Failure(ToolEventFailureKind::Message(msg.clone())))
247                    .await;
248                Err(ToolCallError::Rejected(msg))
249            }
250            Err(ToolError::Timeout(ms)) => {
251                let msg = format!("Command timed out after {ms}ms");
252                self.emit(ctx, ToolEventStage::Failure(ToolEventFailureKind::Message(msg.clone())))
253                    .await;
254                Err(ToolCallError::Timeout(ms))
255            }
256            Err(e) => {
257                let msg = e.to_string();
258                self.emit(ctx, ToolEventStage::Failure(ToolEventFailureKind::Error(msg.clone())))
259                    .await;
260                Err(ToolCallError::Internal(e.into()))
261            }
262        }
263    }
264
265    /// Format output for model consumption
266    fn format_output_for_model(&self, output: &ExecToolCallOutput) -> String {
267        let mut result = String::with_capacity(output.stdout.len() + output.stderr.len() + 32);
268
269        if !output.stdout.is_empty() {
270            result.push_str(&output.stdout);
271        }
272
273        if !output.stderr.is_empty() {
274            if !result.is_empty() {
275                result.push_str("\n\n[stderr]\n");
276            }
277            result.push_str(&output.stderr);
278        }
279
280        if output.exit_code != 0 {
281            if !result.is_empty() {
282                result.push('\n');
283            }
284            let _ = write!(result, "[exit code: {}]", output.exit_code);
285        }
286
287        if result.is_empty() {
288            result.push_str("[no output]");
289        }
290
291        result
292    }
293
294    /// Get tool name for this emitter
295    fn tool_name(&self) -> CompactStr {
296        match self {
297            Self::Shell { .. } => CompactStr::from(tools::SHELL),
298            Self::ApplyPatch { .. } => CompactStr::from(tools::APPLY_PATCH),
299            Self::CommandSession { .. } => CompactStr::from(tools::EXEC_COMMAND),
300            Self::Generic { tool_name } => tool_name.clone(),
301        }
302    }
303
304    /// Emit patch end event
305    async fn emit_patch_end(&self, ctx: ToolEventCtx<'_>, _stdout: String, _stderr: String, success: bool) {
306        // Update diff tracker
307        if let Some(tracker) = ctx.turn_diff_tracker {
308            let mut guard = tracker.lock().await;
309            guard.on_patch_end(success);
310        }
311
312        tracing::debug!(call_id = %ctx.call_id, success, "patch apply finished");
313    }
314}
315
316/// Input for exec commands
317#[derive(Clone, Debug)]
318pub struct ExecCommandInput<'a> {
319    pub command: &'a [String],
320    pub cwd: &'a std::path::Path,
321    pub parsed_cmd: &'a ParsedCommand,
322    pub source: ExecCommandSource,
323    pub timeout_ms: Option<u64>,
324    pub justification: Option<&'a str>,
325}
326
327impl<'a> ExecCommandInput<'a> {
328    pub fn new(
329        command: &'a [String],
330        cwd: &'a std::path::Path,
331        parsed_cmd: &'a ParsedCommand,
332        source: ExecCommandSource,
333        timeout_ms: Option<u64>,
334        justification: Option<&'a str>,
335    ) -> Self {
336        Self {
337            command,
338            cwd,
339            parsed_cmd,
340            source,
341            timeout_ms,
342            justification,
343        }
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn test_parse_command() {
353        let cmd = vec!["ls".to_string(), "-la".to_string(), "/tmp".to_string()];
354        let parsed = parse_command(&cmd);
355
356        assert_eq!(parsed.program, "ls");
357        assert_eq!(parsed.args, vec!["-la", "/tmp"]);
358    }
359
360    #[test]
361    fn test_parse_command_empty() {
362        let cmd: Vec<String> = vec![];
363        let parsed = parse_command(&cmd);
364
365        assert_eq!(parsed.program, "");
366        assert!(parsed.args.is_empty());
367    }
368
369    #[test]
370    fn test_emitter_tool_names() {
371        let shell = ToolEmitter::shell(vec!["ls".to_string()], PathBuf::new(), ExecCommandSource::Agent, false);
372        assert_eq!(shell.tool_name(), "shell");
373
374        let patch = ToolEmitter::apply_patch(HashMap::new(), true);
375        assert_eq!(patch.tool_name(), "apply_patch");
376
377        let exec = ToolEmitter::command_session(&["echo".to_string()], PathBuf::new(), ExecCommandSource::Agent, None);
378        assert_eq!(exec.tool_name(), "exec_command");
379
380        let generic = ToolEmitter::generic("custom_tool");
381        assert_eq!(generic.tool_name(), "custom_tool");
382    }
383
384    #[test]
385    fn test_format_output_for_model() {
386        let emitter = ToolEmitter::generic("test");
387
388        // Success with output
389        let output = ExecToolCallOutput {
390            stdout: "Hello, world!".to_string(),
391            stderr: String::new(),
392            exit_code: 0,
393        };
394        assert_eq!(emitter.format_output_for_model(&output), "Hello, world!");
395
396        // Failure with stderr
397        let output = ExecToolCallOutput {
398            stdout: String::new(),
399            stderr: "Error!".to_string(),
400            exit_code: 1,
401        };
402        assert_eq!(emitter.format_output_for_model(&output), "Error!\n[exit code: 1]");
403
404        // No output
405        let output = ExecToolCallOutput::default();
406        assert_eq!(emitter.format_output_for_model(&output), "[no output]");
407    }
408}