terminal_tool.rs

  1use agent_client_protocol as acp;
  2use agent_settings::AgentSettings;
  3use anyhow::Result;
  4use futures::FutureExt as _;
  5use gpui::{App, Entity, SharedString, Task};
  6use project::Project;
  7use schemars::JsonSchema;
  8use serde::{Deserialize, Serialize};
  9use settings::Settings;
 10use std::{
 11    path::{Path, PathBuf},
 12    rc::Rc,
 13    sync::Arc,
 14    time::Duration,
 15};
 16use util::markdown::MarkdownInlineCode;
 17
 18use crate::{
 19    AgentTool, ThreadEnvironment, ToolCallEventStream, ToolPermissionDecision,
 20    decide_permission_from_settings,
 21};
 22
 23const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024;
 24
 25/// Executes a shell one-liner and returns the combined output.
 26///
 27/// This tool spawns a process using the user's shell, reads from stdout and stderr (preserving the order of writes), and returns a string with the combined output result.
 28///
 29/// The output results will be shown to the user already, only list it again if necessary, avoid being redundant.
 30///
 31/// Make sure you use the `cd` parameter to navigate to one of the root directories of the project. NEVER do it as part of the `command` itself, otherwise it will error.
 32///
 33/// Do not use this tool for commands that run indefinitely, such as servers (like `npm run start`, `npm run dev`, `python -m http.server`, etc) or file watchers that don't terminate on their own.
 34///
 35/// For potentially long-running commands, prefer specifying `timeout_ms` to bound runtime and prevent indefinite hangs.
 36///
 37/// Remember that each invocation of this tool will spawn a new shell process, so you can't rely on any state from previous invocations.
 38#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
 39pub struct TerminalToolInput {
 40    /// The one-liner command to execute.
 41    pub command: String,
 42    /// Working directory for the command. This must be one of the root directories of the project.
 43    pub cd: String,
 44    /// Optional maximum runtime (in milliseconds). If exceeded, the running terminal task is killed.
 45    pub timeout_ms: Option<u64>,
 46}
 47
 48pub struct TerminalTool {
 49    project: Entity<Project>,
 50    environment: Rc<dyn ThreadEnvironment>,
 51}
 52
 53impl TerminalTool {
 54    pub fn new(project: Entity<Project>, environment: Rc<dyn ThreadEnvironment>) -> Self {
 55        Self {
 56            project,
 57            environment,
 58        }
 59    }
 60}
 61
 62impl AgentTool for TerminalTool {
 63    type Input = TerminalToolInput;
 64    type Output = String;
 65
 66    fn name() -> &'static str {
 67        "terminal"
 68    }
 69
 70    fn kind() -> acp::ToolKind {
 71        acp::ToolKind::Execute
 72    }
 73
 74    fn initial_title(
 75        &self,
 76        input: Result<Self::Input, serde_json::Value>,
 77        _cx: &mut App,
 78    ) -> SharedString {
 79        if let Ok(input) = input {
 80            let mut lines = input.command.lines();
 81            let first_line = lines.next().unwrap_or_default();
 82            let remaining_line_count = lines.count();
 83            match remaining_line_count {
 84                0 => MarkdownInlineCode(first_line).to_string().into(),
 85                1 => MarkdownInlineCode(&format!(
 86                    "{} - {} more line",
 87                    first_line, remaining_line_count
 88                ))
 89                .to_string()
 90                .into(),
 91                n => MarkdownInlineCode(&format!("{} - {} more lines", first_line, n))
 92                    .to_string()
 93                    .into(),
 94            }
 95        } else {
 96            "".into()
 97        }
 98    }
 99
100    fn run(
101        self: Arc<Self>,
102        input: Self::Input,
103        event_stream: ToolCallEventStream,
104        cx: &mut App,
105    ) -> Task<Result<Self::Output>> {
106        let working_dir = match working_dir(&input, &self.project, cx) {
107            Ok(dir) => dir,
108            Err(err) => return Task::ready(Err(err)),
109        };
110
111        let settings = AgentSettings::get_global(cx);
112        let decision = decide_permission_from_settings("terminal", &input.command, settings);
113
114        let authorize = match decision {
115            ToolPermissionDecision::Allow => None,
116            ToolPermissionDecision::Deny(reason) => {
117                return Task::ready(Err(anyhow::anyhow!("{}", reason)));
118            }
119            ToolPermissionDecision::Confirm => {
120                // Use authorize_required since permission rules already determined confirmation is needed
121                Some(event_stream.authorize_required(self.initial_title(Ok(input.clone()), cx), cx))
122            }
123        };
124        cx.spawn(async move |cx| {
125            if let Some(authorize) = authorize {
126                authorize.await?;
127            }
128
129            let terminal = self
130                .environment
131                .create_terminal(
132                    input.command.clone(),
133                    working_dir,
134                    Some(COMMAND_OUTPUT_LIMIT),
135                    cx,
136                )
137                .await?;
138
139            let terminal_id = terminal.id(cx)?;
140            event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![
141                acp::ToolCallContent::Terminal(acp::Terminal::new(terminal_id)),
142            ]));
143
144            let timeout = input.timeout_ms.map(Duration::from_millis);
145
146            let mut timed_out = false;
147            let mut user_stopped_via_signal = false;
148            let wait_for_exit = terminal.wait_for_exit(cx)?;
149
150            match timeout {
151                Some(timeout) => {
152                    let timeout_task = cx.background_executor().timer(timeout);
153
154                    futures::select! {
155                        _ = wait_for_exit.clone().fuse() => {},
156                        _ = timeout_task.fuse() => {
157                            timed_out = true;
158                            terminal.kill(cx)?;
159                            wait_for_exit.await;
160                        }
161                        _ = event_stream.cancelled_by_user().fuse() => {
162                            user_stopped_via_signal = true;
163                            terminal.kill(cx)?;
164                            wait_for_exit.await;
165                        }
166                    }
167                }
168                None => {
169                    futures::select! {
170                        _ = wait_for_exit.clone().fuse() => {},
171                        _ = event_stream.cancelled_by_user().fuse() => {
172                            user_stopped_via_signal = true;
173                            terminal.kill(cx)?;
174                            wait_for_exit.await;
175                        }
176                    }
177                }
178            };
179
180            // Check if user stopped - we check both:
181            // 1. The cancellation signal from RunningTurn::cancel (e.g. user pressed main Stop button)
182            // 2. The terminal's user_stopped flag (e.g. user clicked Stop on the terminal card)
183            // Note: user_stopped_via_signal is already set above if we detected cancellation in the select!
184            // but we also check was_cancelled_by_user() for cases where cancellation happened after wait_for_exit completed
185            let user_stopped_via_signal =
186                user_stopped_via_signal || event_stream.was_cancelled_by_user();
187            let user_stopped_via_terminal = terminal.was_stopped_by_user(cx).unwrap_or(false);
188            let user_stopped = user_stopped_via_signal || user_stopped_via_terminal;
189
190            let output = terminal.current_output(cx)?;
191
192            Ok(process_content(
193                output,
194                &input.command,
195                timed_out,
196                user_stopped,
197            ))
198        })
199    }
200}
201
202fn process_content(
203    output: acp::TerminalOutputResponse,
204    command: &str,
205    timed_out: bool,
206    user_stopped: bool,
207) -> String {
208    let content = output.output.trim();
209    let is_empty = content.is_empty();
210
211    let content = format!("```\n{content}\n```");
212    let content = if output.truncated {
213        format!(
214            "Command output too long. The first {} bytes:\n\n{content}",
215            content.len(),
216        )
217    } else {
218        content
219    };
220
221    let content = if user_stopped {
222        if is_empty {
223            "The user stopped this command. No output was captured before stopping.\n\n\
224            Since the user intentionally interrupted this command, ask them what they would like to do next \
225            rather than automatically retrying or assuming something went wrong.".to_string()
226        } else {
227            format!(
228                "The user stopped this command. Output captured before stopping:\n\n{}\n\n\
229                Since the user intentionally interrupted this command, ask them what they would like to do next \
230                rather than automatically retrying or assuming something went wrong.",
231                content
232            )
233        }
234    } else if timed_out {
235        if is_empty {
236            format!("Command \"{command}\" timed out. No output was captured.")
237        } else {
238            format!(
239                "Command \"{command}\" timed out. Output captured before timeout:\n\n{}",
240                content
241            )
242        }
243    } else {
244        let exit_code = output.exit_status.as_ref().and_then(|s| s.exit_code);
245        match exit_code {
246            Some(0) => {
247                if is_empty {
248                    "Command executed successfully.".to_string()
249                } else {
250                    content
251                }
252            }
253            Some(exit_code) => {
254                if is_empty {
255                    format!("Command \"{command}\" failed with exit code {}.", exit_code)
256                } else {
257                    format!(
258                        "Command \"{command}\" failed with exit code {}.\n\n{content}",
259                        exit_code
260                    )
261                }
262            }
263            None => {
264                if is_empty {
265                    "Command terminated unexpectedly. No output was captured.".to_string()
266                } else {
267                    format!(
268                        "Command terminated unexpectedly. Output captured:\n\n{}",
269                        content
270                    )
271                }
272            }
273        }
274    };
275    content
276}
277
278fn working_dir(
279    input: &TerminalToolInput,
280    project: &Entity<Project>,
281    cx: &mut App,
282) -> Result<Option<PathBuf>> {
283    let project = project.read(cx);
284    let cd = &input.cd;
285
286    if cd == "." || cd.is_empty() {
287        // Accept "." or "" as meaning "the one worktree" if we only have one worktree.
288        let mut worktrees = project.worktrees(cx);
289
290        match worktrees.next() {
291            Some(worktree) => {
292                anyhow::ensure!(
293                    worktrees.next().is_none(),
294                    "'.' is ambiguous in multi-root workspaces. Please specify a root directory explicitly.",
295                );
296                Ok(Some(worktree.read(cx).abs_path().to_path_buf()))
297            }
298            None => Ok(None),
299        }
300    } else {
301        let input_path = Path::new(cd);
302
303        if input_path.is_absolute() {
304            // Absolute paths are allowed, but only if they're in one of the project's worktrees.
305            if project
306                .worktrees(cx)
307                .any(|worktree| input_path.starts_with(&worktree.read(cx).abs_path()))
308            {
309                return Ok(Some(input_path.into()));
310            }
311        } else if let Some(worktree) = project.worktree_for_root_name(cd, cx) {
312            return Ok(Some(worktree.read(cx).abs_path().to_path_buf()));
313        }
314
315        anyhow::bail!("`cd` directory {cd:?} was not in any of the project's worktrees.");
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn test_process_content_user_stopped() {
325        let output = acp::TerminalOutputResponse::new("partial output".to_string(), false);
326
327        let result = process_content(output, "cargo build", false, true);
328
329        assert!(
330            result.contains("user stopped"),
331            "Expected 'user stopped' message, got: {}",
332            result
333        );
334        assert!(
335            result.contains("partial output"),
336            "Expected output to be included, got: {}",
337            result
338        );
339        assert!(
340            result.contains("ask them what they would like to do"),
341            "Should instruct agent to ask user, got: {}",
342            result
343        );
344    }
345
346    #[test]
347    fn test_process_content_user_stopped_empty_output() {
348        let output = acp::TerminalOutputResponse::new("".to_string(), false);
349
350        let result = process_content(output, "cargo build", false, true);
351
352        assert!(
353            result.contains("user stopped"),
354            "Expected 'user stopped' message, got: {}",
355            result
356        );
357        assert!(
358            result.contains("No output was captured"),
359            "Expected 'No output was captured', got: {}",
360            result
361        );
362    }
363
364    #[test]
365    fn test_process_content_timed_out() {
366        let output = acp::TerminalOutputResponse::new("build output here".to_string(), false);
367
368        let result = process_content(output, "cargo build", true, false);
369
370        assert!(
371            result.contains("timed out"),
372            "Expected 'timed out' message for timeout, got: {}",
373            result
374        );
375        assert!(
376            result.contains("build output here"),
377            "Expected output to be included, got: {}",
378            result
379        );
380    }
381
382    #[test]
383    fn test_process_content_timed_out_with_empty_output() {
384        let output = acp::TerminalOutputResponse::new("".to_string(), false);
385
386        let result = process_content(output, "sleep 1000", true, false);
387
388        assert!(
389            result.contains("timed out"),
390            "Expected 'timed out' for timeout, got: {}",
391            result
392        );
393        assert!(
394            result.contains("No output was captured"),
395            "Expected 'No output was captured' for empty output, got: {}",
396            result
397        );
398    }
399
400    #[test]
401    fn test_process_content_with_success() {
402        let output = acp::TerminalOutputResponse::new("success output".to_string(), false)
403            .exit_status(acp::TerminalExitStatus::new().exit_code(0));
404
405        let result = process_content(output, "echo hello", false, false);
406
407        assert!(
408            result.contains("success output"),
409            "Expected output to be included, got: {}",
410            result
411        );
412        assert!(
413            !result.contains("failed"),
414            "Success should not say 'failed', got: {}",
415            result
416        );
417    }
418
419    #[test]
420    fn test_process_content_with_success_empty_output() {
421        let output = acp::TerminalOutputResponse::new("".to_string(), false)
422            .exit_status(acp::TerminalExitStatus::new().exit_code(0));
423
424        let result = process_content(output, "true", false, false);
425
426        assert!(
427            result.contains("executed successfully"),
428            "Expected success message for empty output, got: {}",
429            result
430        );
431    }
432
433    #[test]
434    fn test_process_content_with_error_exit() {
435        let output = acp::TerminalOutputResponse::new("error output".to_string(), false)
436            .exit_status(acp::TerminalExitStatus::new().exit_code(1));
437
438        let result = process_content(output, "false", false, false);
439
440        assert!(
441            result.contains("failed with exit code 1"),
442            "Expected failure message, got: {}",
443            result
444        );
445        assert!(
446            result.contains("error output"),
447            "Expected output to be included, got: {}",
448            result
449        );
450    }
451
452    #[test]
453    fn test_process_content_with_error_exit_empty_output() {
454        let output = acp::TerminalOutputResponse::new("".to_string(), false)
455            .exit_status(acp::TerminalExitStatus::new().exit_code(1));
456
457        let result = process_content(output, "false", false, false);
458
459        assert!(
460            result.contains("failed with exit code 1"),
461            "Expected failure message, got: {}",
462            result
463        );
464    }
465
466    #[test]
467    fn test_process_content_unexpected_termination() {
468        let output = acp::TerminalOutputResponse::new("some output".to_string(), false);
469
470        let result = process_content(output, "some_command", false, false);
471
472        assert!(
473            result.contains("terminated unexpectedly"),
474            "Expected 'terminated unexpectedly' message, got: {}",
475            result
476        );
477        assert!(
478            result.contains("some output"),
479            "Expected output to be included, got: {}",
480            result
481        );
482    }
483
484    #[test]
485    fn test_process_content_unexpected_termination_empty_output() {
486        let output = acp::TerminalOutputResponse::new("".to_string(), false);
487
488        let result = process_content(output, "some_command", false, false);
489
490        assert!(
491            result.contains("terminated unexpectedly"),
492            "Expected 'terminated unexpectedly' message, got: {}",
493            result
494        );
495        assert!(
496            result.contains("No output was captured"),
497            "Expected 'No output was captured' for empty output, got: {}",
498            result
499        );
500    }
501}