terminal_tool.rs

  1use agent_client_protocol as acp;
  2use anyhow::Result;
  3use futures::FutureExt as _;
  4use gpui::{App, AppContext, Entity, SharedString, Task};
  5use project::Project;
  6use schemars::JsonSchema;
  7use serde::{Deserialize, Serialize};
  8use std::{
  9    path::{Path, PathBuf},
 10    rc::Rc,
 11    sync::Arc,
 12    time::Duration,
 13};
 14use util::markdown::MarkdownInlineCode;
 15
 16use crate::{AgentTool, ThreadEnvironment, ToolCallEventStream};
 17
 18const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024;
 19
 20/// Executes a shell one-liner and returns the combined output.
 21///
 22/// 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.
 23///
 24/// The output results will be shown to the user already, only list it again if necessary, avoid being redundant.
 25///
 26/// 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.
 27///
 28/// 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.
 29///
 30/// For potentially long-running commands, prefer specifying `timeout_ms` to bound runtime and prevent indefinite hangs.
 31///
 32/// Remember that each invocation of this tool will spawn a new shell process, so you can't rely on any state from previous invocations.
 33#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
 34pub struct TerminalToolInput {
 35    /// The one-liner command to execute.
 36    pub command: String,
 37    /// Working directory for the command. This must be one of the root directories of the project.
 38    pub cd: String,
 39    /// Optional maximum runtime (in milliseconds). If exceeded, the running terminal task is killed.
 40    pub timeout_ms: Option<u64>,
 41}
 42
 43pub struct TerminalTool {
 44    project: Entity<Project>,
 45    environment: Rc<dyn ThreadEnvironment>,
 46}
 47
 48impl TerminalTool {
 49    pub fn new(project: Entity<Project>, environment: Rc<dyn ThreadEnvironment>) -> Self {
 50        Self {
 51            project,
 52            environment,
 53        }
 54    }
 55}
 56
 57impl AgentTool for TerminalTool {
 58    type Input = TerminalToolInput;
 59    type Output = String;
 60
 61    fn name() -> &'static str {
 62        "terminal"
 63    }
 64
 65    fn kind() -> acp::ToolKind {
 66        acp::ToolKind::Execute
 67    }
 68
 69    fn initial_title(
 70        &self,
 71        input: Result<Self::Input, serde_json::Value>,
 72        _cx: &mut App,
 73    ) -> SharedString {
 74        if let Ok(input) = input {
 75            let mut lines = input.command.lines();
 76            let first_line = lines.next().unwrap_or_default();
 77            let remaining_line_count = lines.count();
 78            match remaining_line_count {
 79                0 => MarkdownInlineCode(first_line).to_string().into(),
 80                1 => MarkdownInlineCode(&format!(
 81                    "{} - {} more line",
 82                    first_line, remaining_line_count
 83                ))
 84                .to_string()
 85                .into(),
 86                n => MarkdownInlineCode(&format!("{} - {} more lines", first_line, n))
 87                    .to_string()
 88                    .into(),
 89            }
 90        } else {
 91            "".into()
 92        }
 93    }
 94
 95    fn run(
 96        self: Arc<Self>,
 97        input: Self::Input,
 98        event_stream: ToolCallEventStream,
 99        cx: &mut App,
100    ) -> Task<Result<Self::Output>> {
101        let working_dir = match working_dir(&input, &self.project, cx) {
102            Ok(dir) => dir,
103            Err(err) => return Task::ready(Err(err)),
104        };
105
106        let authorize = event_stream.authorize(self.initial_title(Ok(input.clone()), cx), cx);
107        cx.spawn(async move |cx| {
108            authorize.await?;
109
110            let terminal = self
111                .environment
112                .create_terminal(
113                    input.command.clone(),
114                    working_dir,
115                    Some(COMMAND_OUTPUT_LIMIT),
116                    cx,
117                )
118                .await?;
119
120            let terminal_id = terminal.id(cx)?;
121            event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![
122                acp::ToolCallContent::Terminal(acp::Terminal::new(terminal_id)),
123            ]));
124
125            let timeout = input.timeout_ms.map(Duration::from_millis);
126
127            let exit_status = match timeout {
128                Some(timeout) => {
129                    let wait_for_exit = terminal.wait_for_exit(cx)?;
130                    let timeout_task = cx.background_spawn(async move {
131                        smol::Timer::after(timeout).await;
132                    });
133
134                    futures::select! {
135                        status = wait_for_exit.clone().fuse() => status,
136                        _ = timeout_task.fuse() => {
137                            terminal.kill(cx)?;
138                            wait_for_exit.await
139                        }
140                    }
141                }
142                None => terminal.wait_for_exit(cx)?.await,
143            };
144
145            let output = terminal.current_output(cx)?;
146
147            Ok(process_content(output, &input.command, exit_status))
148        })
149    }
150}
151
152fn process_content(
153    output: acp::TerminalOutputResponse,
154    command: &str,
155    exit_status: acp::TerminalExitStatus,
156) -> String {
157    let content = output.output.trim();
158    let is_empty = content.is_empty();
159
160    let content = format!("```\n{content}\n```");
161    let content = if output.truncated {
162        format!(
163            "Command output too long. The first {} bytes:\n\n{content}",
164            content.len(),
165        )
166    } else {
167        content
168    };
169
170    let content = match exit_status.exit_code {
171        Some(0) => {
172            if is_empty {
173                "Command executed successfully.".to_string()
174            } else {
175                content
176            }
177        }
178        Some(exit_code) => {
179            if is_empty {
180                format!("Command \"{command}\" failed with exit code {}.", exit_code)
181            } else {
182                format!(
183                    "Command \"{command}\" failed with exit code {}.\n\n{content}",
184                    exit_code
185                )
186            }
187        }
188        None => {
189            format!(
190                "Command failed or was interrupted.\nPartial output captured:\n\n{}",
191                content,
192            )
193        }
194    };
195    content
196}
197
198fn working_dir(
199    input: &TerminalToolInput,
200    project: &Entity<Project>,
201    cx: &mut App,
202) -> Result<Option<PathBuf>> {
203    let project = project.read(cx);
204    let cd = &input.cd;
205
206    if cd == "." || cd.is_empty() {
207        // Accept "." or "" as meaning "the one worktree" if we only have one worktree.
208        let mut worktrees = project.worktrees(cx);
209
210        match worktrees.next() {
211            Some(worktree) => {
212                anyhow::ensure!(
213                    worktrees.next().is_none(),
214                    "'.' is ambiguous in multi-root workspaces. Please specify a root directory explicitly.",
215                );
216                Ok(Some(worktree.read(cx).abs_path().to_path_buf()))
217            }
218            None => Ok(None),
219        }
220    } else {
221        let input_path = Path::new(cd);
222
223        if input_path.is_absolute() {
224            // Absolute paths are allowed, but only if they're in one of the project's worktrees.
225            if project
226                .worktrees(cx)
227                .any(|worktree| input_path.starts_with(&worktree.read(cx).abs_path()))
228            {
229                return Ok(Some(input_path.into()));
230            }
231        } else if let Some(worktree) = project.worktree_for_root_name(cd, cx) {
232            return Ok(Some(worktree.read(cx).abs_path().to_path_buf()));
233        }
234
235        anyhow::bail!("`cd` directory {cd:?} was not in any of the project's worktrees.");
236    }
237}