ed-acp: workspace tools, tool dispatch, ACP server
Second part of the ed-acp crate: the workspace tools routed through the client's fs and terminal methods, tool dispatch with approval, and the server loop that advertises mcpCapabilities.http. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: siGit Code v1.6.3 <noreply@sigit.si>
Seto Elkahfi committed
Oct 9, 2026 at 00:12 UTC
b8d3b5a490d2df7ec49a2c8c853438a033740179
3 files changed
+1204
crates/ed-acp/src/server.rs
new
+285
@@ -0,0 +1,285 @@
1
+//! The stdio wiring: one handler per ACP method, each spawned off the dispatch loop when it
2
+//! awaits anything (a model list, MCP servers, disk, or a turn that makes client requests).
3
+
4
+use std::sync::Arc;
5
+
6
+use agent_client_protocol::schema::v1::{
7
+ AgentAuthCapabilities, AgentCapabilities, AuthenticateRequest, CancelNotification,
8
+ CloseSessionRequest, DeleteSessionRequest, Implementation, InitializeRequest,
9
+ InitializeResponse, ListSessionsRequest, LoadSessionRequest, LogoutCapabilities, LogoutRequest,
10
+ McpCapabilities, NewSessionRequest, PromptRequest, ResumeSessionRequest,
11
+ SessionAdditionalDirectoriesCapabilities, SessionCapabilities, SessionCloseCapabilities,
12
+ SessionDeleteCapabilities, SessionListCapabilities, SessionResumeCapabilities,
13
+ SetSessionConfigOptionRequest,
14
+};
15
+use agent_client_protocol::{Agent, Stdio};
16
+
17
+use crate::agent::{self, AcpServer};
18
+use crate::{AgentInfo, Profile, cli};
19
+
20
+/// How the server was started.
21
+#[derive(Debug, Clone, Copy)]
22
+pub struct ServeOptions {
23
+ /// Default for each session's auto-approve: `--yolo` or `<PREFIX>_YOLO=1`.
24
+ pub yolo: bool,
25
+ /// `tui` when launched by the terminal UI (it sets `<PREFIX>_SURFACE=tui`), else `acp`.
26
+ pub surface: &'static str,
27
+}
28
+
29
+impl ServeOptions {
30
+ /// `<PREFIX>_YOLO` and `<PREFIX>_SURFACE` from the environment.
31
+ pub fn from_env(info: &AgentInfo) -> Self {
32
+ Self {
33
+ yolo: cli::env_flag(&info.env.var("YOLO")),
34
+ surface: cli::surface(&info.env),
35
+ }
36
+ }
37
+}
38
+
39
+/// Serve ACP over stdin/stdout until the client disconnects, then stop every MCP server.
40
+/// Logs must go to stderr: stdout carries JSON-RPC only.
41
+pub async fn serve_stdio(
42
+ profile: Arc<dyn Profile>,
43
+ opts: ServeOptions,
44
+) -> agent_client_protocol::Result<()> {
45
+ let info = profile.info();
46
+ let prompt_caps = profile.prompt_capabilities();
47
+ let agent = AcpServer::new(profile, opts.yolo, opts.surface);
48
+ tracing::info!("{} starting with model {}", info.name, agent.model());
49
+
50
+ let result = Agent
51
+ .builder()
52
+ .name(info.name)
53
+ .on_receive_request(
54
+ {
55
+ let agent = agent.clone();
56
+ async move |req: InitializeRequest, responder, _cx| {
57
+ agent.set_client_caps(req.client_capabilities.clone());
58
+ // Terminal auth is only usable by clients that can run it.
59
+ let methods = if agent::supports_terminal_auth(&req.client_capabilities) {
60
+ agent::auth_methods()
61
+ } else {
62
+ Vec::new()
63
+ };
64
+ responder.respond(
65
+ InitializeResponse::new(agent::negotiate_version(req.protocol_version))
66
+ .auth_methods(methods)
67
+ .agent_capabilities(
68
+ AgentCapabilities::new()
69
+ .prompt_capabilities(prompt_caps.clone())
70
+ .session_capabilities(
71
+ SessionCapabilities::new()
72
+ .list(SessionListCapabilities::new())
73
+ .additional_directories(
74
+ SessionAdditionalDirectoriesCapabilities::new(),
75
+ )
76
+ .resume(SessionResumeCapabilities::new())
77
+ .close(SessionCloseCapabilities::new())
78
+ .delete(SessionDeleteCapabilities::new()),
79
+ )
80
+ .auth(
81
+ AgentAuthCapabilities::new()
82
+ .logout(LogoutCapabilities::new()),
83
+ )
84
+ .mcp_capabilities(McpCapabilities::new().http(true))
85
+ .load_session(true),
86
+ )
87
+ .agent_info(Implementation::new(info.name, info.version)),
88
+ )
89
+ }
90
+ },
91
+ agent_client_protocol::on_receive_request!(),
92
+ )
93
+ .on_receive_request(
94
+ {
95
+ let agent = agent.clone();
96
+ async move |req: AuthenticateRequest, responder, cx| {
97
+ let agent = agent.clone();
98
+ cx.spawn(async move {
99
+ match agent.authenticate(req).await {
100
+ Ok(resp) => responder.respond(resp),
101
+ Err(e) => responder.respond_with_error(e),
102
+ }
103
+ })
104
+ }
105
+ },
106
+ agent_client_protocol::on_receive_request!(),
107
+ )
108
+ .on_receive_request(
109
+ {
110
+ let agent = agent.clone();
111
+ async move |_req: LogoutRequest, responder, cx| {
112
+ let agent = agent.clone();
113
+ cx.spawn(async move {
114
+ match agent.logout().await {
115
+ Ok(resp) => responder.respond(resp),
116
+ Err(e) => responder.respond_with_error(e),
117
+ }
118
+ })
119
+ }
120
+ },
121
+ agent_client_protocol::on_receive_request!(),
122
+ )
123
+ .on_receive_request(
124
+ {
125
+ let agent = agent.clone();
126
+ async move |req: NewSessionRequest, responder, cx| {
127
+ // Listing models and starting MCP servers are slow; keep them off the
128
+ // dispatch loop.
129
+ let agent = agent.clone();
130
+ let connection = cx.clone();
131
+ cx.spawn(async move {
132
+ // Picks up a key stored by `--setup` since this process started.
133
+ if !agent.refresh_credentials().await {
134
+ return responder.respond_with_error(agent.auth_required());
135
+ }
136
+ match agent.new_session(req).await {
137
+ Ok(resp) => {
138
+ let id = resp.session_id.clone();
139
+ responder.respond(resp)?;
140
+ agent.send_commands(&connection, &id)
141
+ }
142
+ Err(e) => responder.respond_with_error(e),
143
+ }
144
+ })
145
+ }
146
+ },
147
+ agent_client_protocol::on_receive_request!(),
148
+ )
149
+ .on_receive_request(
150
+ {
151
+ let agent = agent.clone();
152
+ async move |req: ListSessionsRequest, responder, cx| {
153
+ // Reads the session directory from disk.
154
+ let agent = agent.clone();
155
+ cx.spawn(async move {
156
+ match tokio::task::spawn_blocking(move || agent.list_sessions(req)).await {
157
+ Ok(Ok(resp)) => responder.respond(resp),
158
+ Ok(Err(e)) => responder.respond_with_error(e),
159
+ Err(e) => responder.respond_with_internal_error(e.to_string()),
160
+ }
161
+ })
162
+ }
163
+ },
164
+ agent_client_protocol::on_receive_request!(),
165
+ )
166
+ .on_receive_request(
167
+ {
168
+ let agent = agent.clone();
169
+ async move |req: LoadSessionRequest, responder, cx| {
170
+ let agent = agent.clone();
171
+ let connection = cx.clone();
172
+ cx.spawn(async move {
173
+ // Editors reopen a thread with session/load after terminal auth, so
174
+ // AUTH_REQUIRED here shows sign-in instead of a dead thread.
175
+ if !agent.refresh_credentials().await {
176
+ return responder.respond_with_error(agent.auth_required());
177
+ }
178
+ let id = req.session_id.clone();
179
+ match agent.load_session(req, &connection).await {
180
+ Ok(resp) => {
181
+ responder.respond(resp)?;
182
+ agent.send_commands(&connection, &id)
183
+ }
184
+ Err(e) => responder.respond_with_error(e),
185
+ }
186
+ })
187
+ }
188
+ },
189
+ agent_client_protocol::on_receive_request!(),
190
+ )
191
+ .on_receive_request(
192
+ {
193
+ let agent = agent.clone();
194
+ async move |req: ResumeSessionRequest, responder, cx| {
195
+ let agent = agent.clone();
196
+ let connection = cx.clone();
197
+ cx.spawn(async move {
198
+ if !agent.refresh_credentials().await {
199
+ return responder.respond_with_error(agent.auth_required());
200
+ }
201
+ let id = req.session_id.clone();
202
+ match agent.resume_session(req, &connection).await {
203
+ Ok(resp) => {
204
+ responder.respond(resp)?;
205
+ agent.send_commands(&connection, &id)
206
+ }
207
+ Err(e) => responder.respond_with_error(e),
208
+ }
209
+ })
210
+ }
211
+ },
212
+ agent_client_protocol::on_receive_request!(),
213
+ )
214
+ .on_receive_request(
215
+ {
216
+ let agent = agent.clone();
217
+ async move |req: CloseSessionRequest, responder, _cx| match agent.close_session(req)
218
+ {
219
+ Ok(resp) => responder.respond(resp),
220
+ Err(e) => responder.respond_with_error(e),
221
+ }
222
+ },
223
+ agent_client_protocol::on_receive_request!(),
224
+ )
225
+ .on_receive_request(
226
+ {
227
+ let agent = agent.clone();
228
+ async move |req: DeleteSessionRequest, responder, _cx| match agent
229
+ .delete_session(req)
230
+ {
231
+ Ok(resp) => responder.respond(resp),
232
+ Err(e) => responder.respond_with_error(e),
233
+ }
234
+ },
235
+ agent_client_protocol::on_receive_request!(),
236
+ )
237
+ .on_receive_request(
238
+ {
239
+ let agent = agent.clone();
240
+ async move |req: SetSessionConfigOptionRequest, responder, cx| {
241
+ let agent = agent.clone();
242
+ let connection = cx.clone();
243
+ cx.spawn(async move {
244
+ match agent.set_config_option(req, &connection).await {
245
+ Ok(resp) => responder.respond(resp),
246
+ Err(e) => responder.respond_with_error(e),
247
+ }
248
+ })
249
+ }
250
+ },
251
+ agent_client_protocol::on_receive_request!(),
252
+ )
253
+ .on_receive_request(
254
+ {
255
+ let agent = agent.clone();
256
+ async move |req: PromptRequest, responder, cx| {
257
+ // Run the turn off the dispatch loop so it can make requests to the client.
258
+ let agent = agent.clone();
259
+ let connection = cx.clone();
260
+ cx.spawn(async move {
261
+ if !agent.has_api_key() && !agent.refresh_credentials().await {
262
+ return responder.respond_with_error(agent.auth_required());
263
+ }
264
+ agent.prompt(req, responder, connection).await
265
+ })
266
+ }
267
+ },
268
+ agent_client_protocol::on_receive_request!(),
269
+ )
270
+ .on_receive_notification(
271
+ {
272
+ let agent = agent.clone();
273
+ async move |n: CancelNotification, _cx| {
274
+ agent.cancel(&n.session_id);
275
+ Ok(())
276
+ }
277
+ },
278
+ agent_client_protocol::on_receive_notification!(),
279
+ )
280
+ .connect_to(Stdio::new())
281
+ .await;
282
+ // The editor went away: stop MCP servers instead of leaving them to be orphaned.
283
+ agent.shutdown().await;
284
+ result
285
+}
crates/ed-acp/src/tools.rs
new
+460
@@ -0,0 +1,460 @@
1
+//! The tool layer: what a tool gets ([`ToolCtx`]), what it returns ([`ToolOutcome`]), the
2
+//! approval flow, ACP path rules, and dispatch to product [`Toolset`]s and MCP servers.
3
+
4
+use std::collections::HashSet;
5
+use std::path::{Component, Path, PathBuf};
6
+use std::sync::atomic::{AtomicBool, Ordering};
7
+use std::sync::{Arc, Mutex};
8
+
9
+use agent_client_protocol::schema::v1::{
10
+ ClientCapabilities, PermissionOption, PermissionOptionKind, RequestPermissionOutcome,
11
+ RequestPermissionRequest, SessionId, SessionNotification, SessionUpdate, ToolCallContent,
12
+ ToolCallLocation, ToolCallUpdate, ToolCallUpdateFields, ToolKind,
13
+};
14
+use agent_client_protocol::{Client, ConnectionTo};
15
+use anyhow::{Result, anyhow, bail};
16
+use async_trait::async_trait;
17
+use ed_mcp::McpToolset;
18
+use serde_json::{Value, json};
19
+use tokio_util::sync::CancellationToken;
20
+
21
+use crate::{McpCall, Profile};
22
+
23
+/// Cap on tool output sent to the model and on client terminal output.
24
+pub const MAX_OUTPUT_BYTES: usize = 32 * 1024;
25
+
26
+/// Everything a tool needs to act on behalf of one session.
27
+pub struct ToolCtx {
28
+ pub connection: ConnectionTo<Client>,
29
+ pub session_id: SessionId,
30
+ /// Primary working directory: the base for relative paths and `run_command`.
31
+ pub cwd: PathBuf,
32
+ /// Additional workspace roots the model may address with the `root` parameter.
33
+ pub roots: Vec<PathBuf>,
34
+ pub caps: ClientCapabilities,
35
+ pub cancel: CancellationToken,
36
+ /// Approve tool calls without asking: `--yolo`, or the session's `auto_approve` option.
37
+ pub auto_approve: Arc<AtomicBool>,
38
+ /// Tool names the user chose "always allow" for in this session.
39
+ pub always_allowed: Arc<Mutex<HashSet<String>>>,
40
+ /// Tool names the user chose "always reject" for in this session.
41
+ pub always_rejected: Arc<Mutex<HashSet<String>>>,
42
+ /// Tools from MCP servers available to this session.
43
+ pub mcp: Arc<McpToolset>,
44
+}
45
+
46
+/// What a tool call produced.
47
+pub struct ToolOutcome {
48
+ /// Text returned to the model.
49
+ pub text: String,
50
+ /// Rich content shown to the user in the client.
51
+ pub content: Vec<ToolCallContent>,
52
+ pub failed: bool,
53
+ /// Files the tool produced or touched, surfaced as tool-call locations.
54
+ pub locations: Vec<PathBuf>,
55
+}
56
+
57
+impl ToolOutcome {
58
+ pub fn ok(text: impl Into<String>) -> Self {
59
+ let text = text.into();
60
+ Self {
61
+ content: vec![ToolCallContent::from(text.clone())],
62
+ text,
63
+ failed: false,
64
+ locations: Vec::new(),
65
+ }
66
+ }
67
+ pub fn err(text: impl Into<String>) -> Self {
68
+ let text = text.into();
69
+ Self {
70
+ content: vec![ToolCallContent::from(text.clone())],
71
+ text,
72
+ failed: true,
73
+ locations: Vec::new(),
74
+ }
75
+ }
76
+}
77
+
78
+/// An OpenAI `tools` entry.
79
+pub fn function_def(name: &str, desc: &str, params: Value) -> Value {
80
+ json!({ "type": "function", "function": { "name": name, "description": desc, "parameters": params } })
81
+}
82
+
83
+/// The session's directories, for describing a tool call without running it (also used when
84
+/// `session/load` replays history).
85
+pub struct DescribeCtx<'a> {
86
+ pub cwd: &'a Path,
87
+ pub roots: &'a [PathBuf],
88
+}
89
+
90
+/// A group of tools a product offers to the model.
91
+#[async_trait]
92
+pub trait Toolset: Send + Sync {
93
+ /// OpenAI `tools` entries, built with [`function_def`].
94
+ fn definitions(&self) -> Vec<Value>;
95
+ /// Whether `name` is one of this toolset's tools.
96
+ fn handles(&self, name: &str) -> bool;
97
+ /// Title, kind and affected locations shown in the client when the call starts. Locations
98
+ /// must be absolute ([`absolutize`]).
99
+ fn describe(
100
+ &self,
101
+ name: &str,
102
+ args: &Value,
103
+ ctx: &DescribeCtx<'_>,
104
+ ) -> (String, ToolKind, Vec<ToolCallLocation>);
105
+ /// Run the tool. A mutating tool asks first with [`ToolCtx::permit`]. An `Err` is shown to
106
+ /// the model as `Error: …` and the call is marked failed.
107
+ async fn execute(
108
+ &self,
109
+ ctx: &ToolCtx,
110
+ tool_call_id: &str,
111
+ name: &str,
112
+ args: Value,
113
+ ) -> Result<ToolOutcome>;
114
+}
115
+
116
+/// All tool definitions for one session, in OpenAI `tools` format: product toolsets first,
117
+/// then MCP tools sorted by name.
118
+pub fn definitions(toolsets: &[Arc<dyn Toolset>], mcp: &McpToolset) -> Value {
119
+ let mut defs: Vec<Value> = toolsets.iter().flat_map(|t| t.definitions()).collect();
120
+ defs.extend(
121
+ mcp.tools()
122
+ .into_iter()
123
+ .map(|t| function_def(&t.name, &t.description, t.parameters.clone())),
124
+ );
125
+ Value::Array(defs)
126
+}
127
+
128
+/// Title, kind and affected locations shown in the client when the call starts.
129
+pub fn describe(
130
+ toolsets: &[Arc<dyn Toolset>],
131
+ cwd: &Path,
132
+ roots: &[PathBuf],
133
+ name: &str,
134
+ args: &Value,
135
+) -> (String, ToolKind, Vec<ToolCallLocation>) {
136
+ if let Some((server, tool)) = ed_mcp::split_qualified(name) {
137
+ let loc = ["input", "path", "file"]
138
+ .iter()
139
+ .filter_map(|k| args.get(*k).and_then(Value::as_str))
140
+ .map(PathBuf::from)
141
+ .filter(|p| p.is_absolute())
142
+ .map(|p| ToolCallLocation::new(absolutize(&p)))
143
+ .collect();
144
+ return (format!("{server}: {tool}"), ToolKind::Other, loc);
145
+ }
146
+ let ctx = DescribeCtx { cwd, roots };
147
+ match toolsets.iter().find(|t| t.handles(name)) {
148
+ Some(t) => t.describe(name, args, &ctx),
149
+ None => (name.to_string(), ToolKind::Other, vec![]),
150
+ }
151
+}
152
+
153
+/// Run one tool call: a product toolset's, or an MCP server's. Never fails; errors become a
154
+/// failed outcome the model sees.
155
+pub async fn execute(
156
+ ctx: &ToolCtx,
157
+ toolsets: &[Arc<dyn Toolset>],
158
+ profile: &dyn Profile,
159
+ tool_call_id: &str,
160
+ name: &str,
161
+ args: Value,
162
+) -> ToolOutcome {
163
+ let result = if ed_mcp::split_qualified(name).is_some() {
164
+ execute_mcp(ctx, profile, tool_call_id, name, args).await
165
+ } else if let Some(t) = toolsets.iter().find(|t| t.handles(name)) {
166
+ t.execute(ctx, tool_call_id, name, args).await
167
+ } else {
168
+ Err(anyhow!("unknown tool `{name}`"))
169
+ };
170
+ result.unwrap_or_else(|e| ToolOutcome::err(format!("Error: {e:#}")))
171
+}
172
+
173
+async fn execute_mcp(
174
+ ctx: &ToolCtx,
175
+ profile: &dyn Profile,
176
+ tool_call_id: &str,
177
+ name: &str,
178
+ args: Value,
179
+) -> Result<ToolOutcome> {
180
+ let Some(tool) = ctx.mcp.tool(name) else {
181
+ bail!("unknown tool `{name}`");
182
+ };
183
+ if !tool.read_only {
184
+ let call = McpCall {
185
+ tool,
186
+ args: &args,
187
+ connection: ctx.mcp.connection(name),
188
+ };
189
+ let preview = match profile.mcp_permission_preview(call).await {
190
+ Some(text) => text,
191
+ None => default_mcp_preview(tool, &args),
192
+ };
193
+ let preview = vec![ToolCallContent::from(preview)];
194
+ if !ctx.permit(tool_call_id, name, preview).await? {
195
+ return Ok(ToolOutcome::err("User rejected running this tool."));
196
+ }
197
+ }
198
+ let on_progress = |line: String| {
199
+ let _ = ctx.update(
200
+ tool_call_id,
201
+ ToolCallUpdateFields::new().content(vec![ToolCallContent::from(line)]),
202
+ );
203
+ };
204
+ let out = ctx.mcp.call(name, args, &ctx.cancel, &on_progress).await?;
205
+ let mut outcome = if out.failed {
206
+ ToolOutcome::err(truncate(out.text))
207
+ } else {
208
+ ToolOutcome::ok(truncate(out.text))
209
+ };
210
+ if let Some(structured) = &out.structured {
211
+ outcome.locations = profile
212
+ .mcp_result_locations(tool, structured)
213
+ .into_iter()
214
+ .filter(|p| p.is_absolute())
215
+ .map(|p| absolutize(&p))
216
+ .collect();
217
+ }
218
+ Ok(outcome)
219
+}
220
+
221
+/// The permission prompt for an MCP tool when the product has nothing better to say.
222
+fn default_mcp_preview(tool: &ed_mcp::McpTool, args: &Value) -> String {
223
+ let mut text = format!("Run `{}` on the {} MCP server.", tool.tool, tool.server);
224
+ if args.as_object().is_some_and(|o| !o.is_empty()) {
225
+ let shown = serde_json::to_string_pretty(args).unwrap_or_default();
226
+ text.push_str(&format!("\nArguments:\n```json\n{}\n```", truncate(shown)));
227
+ }
228
+ text
229
+}
230
+
231
+/// The base directory for a tool call's `root` parameter: a workspace root or any directory
232
+/// beneath one (so the model can pass e.g. `root/crate/src`). Paths that escape a root via
233
+/// `..` are rejected.
234
+pub fn base_for(cwd: &Path, roots: &[PathBuf], root: Option<&str>) -> Result<PathBuf> {
235
+ let Some(root) = root else {
236
+ return Ok(cwd.to_path_buf());
237
+ };
238
+ // Lexical normalization resolves `..`, so `root/../elsewhere` can't pass the prefix check.
239
+ let candidate = normalize(Path::new(root));
240
+ let all_roots = all_roots(cwd, roots);
241
+ for ws_root in &all_roots {
242
+ if candidate == *ws_root {
243
+ return Ok(ws_root.clone());
244
+ }
245
+ // Allow any directory below a root (e.g. a crate dir like `root/crate/src`),
246
+ // as long as it doesn't escape the root via `..`.
247
+ if let Ok(rest) = candidate.strip_prefix(ws_root)
248
+ && rest.components().all(|c| matches!(c, Component::Normal(_)))
249
+ {
250
+ return Ok(candidate);
251
+ }
252
+ }
253
+ let known = all_roots
254
+ .iter()
255
+ .map(|r| r.display().to_string())
256
+ .collect::<Vec<_>>()
257
+ .join(", ");
258
+ bail!(
259
+ "unknown root `{}` (workspace roots: {known})",
260
+ candidate.display()
261
+ )
262
+}
263
+
264
+fn all_roots(cwd: &Path, roots: &[PathBuf]) -> Vec<PathBuf> {
265
+ std::iter::once(cwd.to_path_buf())
266
+ .chain(roots.iter().filter(|r| *r != cwd).cloned())
267
+ .collect()
268
+}
269
+
270
+impl ToolCtx {
271
+ pub fn base_for(&self, root: Option<&str>) -> Result<PathBuf> {
272
+ base_for(&self.cwd, &self.roots, root)
273
+ }
274
+
275
+ pub fn resolve_with(&self, root: Option<&str>, path: &str) -> Result<PathBuf> {
276
+ Ok(resolve_in(&self.base_for(root)?, path))
277
+ }
278
+
279
+ /// Ask the user for permission. Returns `Ok(false)` if rejected or cancelled. Honours
280
+ /// "always allow" and "always reject" choices for `tool` and the session's auto-approve.
281
+ pub async fn permit(
282
+ &self,
283
+ id: &str,
284
+ tool: &str,
285
+ content: Vec<ToolCallContent>,
286
+ ) -> Result<bool> {
287
+ if self.always_rejected.lock().unwrap().contains(tool) {
288
+ return Ok(false);
289
+ }
290
+ if self.auto_approve.load(Ordering::Relaxed)
291
+ || self.always_allowed.lock().unwrap().contains(tool)
292
+ {
293
+ return Ok(true);
294
+ }
295
+ let mut fields = ToolCallUpdateFields::new();
296
+ if !content.is_empty() {
297
+ fields = fields.content(content);
298
+ }
299
+ let req = RequestPermissionRequest::new(
300
+ self.session_id.clone(),
301
+ ToolCallUpdate::new(id.to_string(), fields),
302
+ vec![
303
+ PermissionOption::new("allow_once", "Allow", PermissionOptionKind::AllowOnce),
304
+ PermissionOption::new(
305
+ "allow_always",
306
+ "Always allow",
307
+ PermissionOptionKind::AllowAlways,
308
+ ),
309
+ PermissionOption::new("reject_once", "Reject", PermissionOptionKind::RejectOnce),
310
+ PermissionOption::new(
311
+ "reject_always",
312
+ "Always reject",
313
+ PermissionOptionKind::RejectAlways,
314
+ ),
315
+ ],
316
+ );
317
+ let resp = tokio::select! {
318
+ r = self.connection.send_request(req).block_task() => r?,
319
+ () = self.cancel.cancelled() => return Ok(false),
320
+ };
321
+ Ok(match resp.outcome {
322
+ RequestPermissionOutcome::Selected(sel) => match &*sel.option_id.0 {
323
+ "allow_always" => {
324
+ self.always_allowed.lock().unwrap().insert(tool.to_string());
325
+ true
326
+ }
327
+ "allow_once" => true,
328
+ "reject_always" => {
329
+ self.always_rejected
330
+ .lock()
331
+ .unwrap()
332
+ .insert(tool.to_string());
333
+ false
334
+ }
335
+ _ => false,
336
+ },
337
+ _ => false,
338
+ })
339
+ }
340
+
341
+ pub fn update(&self, id: &str, fields: ToolCallUpdateFields) -> Result<()> {
342
+ let update = SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(id.to_string(), fields));
343
+ self.connection
344
+ .send_notification(SessionNotification::new(self.session_id.clone(), update))?;
345
+ Ok(())
346
+ }
347
+}
348
+
349
+/// Join `path` onto `base`, keeping absolute paths as-is.
350
+pub fn resolve_in(base: &Path, path: &str) -> PathBuf {
351
+ let p = Path::new(path);
352
+ if p.is_absolute() {
353
+ p.to_path_buf()
354
+ } else {
355
+ base.join(p)
356
+ }
357
+}
358
+
359
+/// Cut `s` to [`MAX_OUTPUT_BYTES`] on a character boundary, marking the cut.
360
+pub fn truncate(mut s: String) -> String {
361
+ if s.len() > MAX_OUTPUT_BYTES {
362
+ let mut cut = MAX_OUTPUT_BYTES;
363
+ while !s.is_char_boundary(cut) {
364
+ cut -= 1;
365
+ }
366
+ s.truncate(cut);
367
+ s.push_str("\n[truncated]");
368
+ }
369
+ s
370
+}
371
+
372
+/// Make `path` absolute and free of `.`/`..` components, as the ACP v1 spec requires
373
+/// absolute paths everywhere. Existing paths are canonicalized (resolving symlinks
374
+/// and giving the client the true on-disk location); paths that don't exist yet
375
+/// are normalized lexically with the existing parent canonicalized when possible.
376
+pub fn absolutize(path: &Path) -> PathBuf {
377
+ if let Ok(c) = std::fs::canonicalize(path) {
378
+ return c;
379
+ }
380
+ // Not on disk (yet): normalize lexically, anchoring at the canonicalized parent.
381
+ let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
382
+ let base = parent.and_then(|p| std::fs::canonicalize(p).ok());
383
+ let file_name = path.file_name().map(|f| f.to_os_string());
384
+ match (base, file_name) {
385
+ (Some(mut b), Some(f)) => {
386
+ b.push(f);
387
+ b
388
+ }
389
+ _ => normalize(path),
390
+ }
391
+}
392
+
393
+/// Lexically normalize a path: resolve `.` and `..` without touching the filesystem.
394
+/// Absolute paths stay absolute; relative paths are made absolute against the cwd.
395
+pub fn normalize(path: &Path) -> PathBuf {
396
+ let absolute = if path.is_absolute() {
397
+ path.to_path_buf()
398
+ } else {
399
+ std::env::current_dir()
400
+ .unwrap_or_else(|_| PathBuf::from("."))
401
+ .join(path)
402
+ };
403
+ let mut out: Vec<std::ffi::OsString> = Vec::new();
404
+ for c in absolute.components() {
405
+ match c {
406
+ Component::CurDir => {}
407
+ Component::ParentDir => {
408
+ out.pop();
409
+ }
410
+ other => out.push(other.as_os_str().to_os_string()),
411
+ }
412
+ }
413
+ out.into_iter().collect()
414
+}
415
+
416
+#[cfg(test)]
417
+mod fs_path_tests {
418
+ use super::*;
419
+
420
+ #[test]
421
+ fn normalize_resolves_dot_dot_lexically() {
422
+ assert_eq!(normalize(Path::new("/a/b/../c")), PathBuf::from("/a/c"));
423
+ assert_eq!(normalize(Path::new("/a/./b")), PathBuf::from("/a/b"));
424
+ assert_eq!(normalize(Path::new("/a/b/..")), PathBuf::from("/a"));
425
+ }
426
+
427
+ #[test]
428
+ fn normalize_makes_relative_paths_absolute() {
429
+ let n = normalize(Path::new("src/../README.md"));
430
+ assert!(n.is_absolute());
431
+ assert!(n.ends_with("README.md"));
432
+ assert!(!n.components().any(|c| c == Component::ParentDir));
433
+ }
434
+
435
+ #[test]
436
+ fn absolutize_canonicalizes_existing_paths() {
437
+ let n = absolutize(&std::env::temp_dir());
438
+ assert!(n.is_absolute());
439
+ assert!(!n.components().any(|c| c == Component::ParentDir));
440
+ }
441
+
442
+ #[test]
443
+ fn absolutize_normalizes_missing_paths() {
444
+ let n = absolutize(Path::new("/definitely/does/not/../exist.txt"));
445
+ assert_eq!(n, PathBuf::from("/definitely/does/exist.txt"));
446
+ }
447
+
448
+ #[test]
449
+ fn base_for_rejects_escapes_and_accepts_subdirs() {
450
+ let cwd = PathBuf::from("/ws/a");
451
+ let roots = vec![PathBuf::from("/ws/b")];
452
+ assert_eq!(base_for(&cwd, &roots, None).unwrap(), cwd);
453
+ assert_eq!(
454
+ base_for(&cwd, &roots, Some("/ws/b/sub/../x")).unwrap(),
455
+ PathBuf::from("/ws/b/x")
456
+ );
457
+ assert!(base_for(&cwd, &roots, Some("/ws/b/../c")).is_err());
458
+ assert!(base_for(&cwd, &roots, Some("/etc")).is_err());
459
+ }
460
+}
crates/ed-acp/src/workspace.rs
new
+459
@@ -0,0 +1,459 @@
1
+//! Workspace tools: `read_file`, `write_file`, `edit_file`, `list_directory` and
2
+//! `run_command`, routed through the ACP client (`fs/*`, `terminal/*`) when it advertises
3
+//! support and falling back to the local filesystem and `sh -c` otherwise. Every tool takes an
4
+//! optional `root` for multi-root workspaces. Writes and commands ask for approval.
5
+
6
+use std::path::Path;
7
+use std::time::Duration;
8
+
9
+use agent_client_protocol::ErrorCode;
10
+use agent_client_protocol::schema::v1::{
11
+ CreateTerminalRequest, Diff, KillTerminalRequest, ReadTextFileRequest, ReleaseTerminalRequest,
12
+ Terminal, TerminalOutputRequest, ToolCallContent, ToolCallLocation, ToolCallUpdateFields,
13
+ ToolKind, WaitForTerminalExitRequest, WriteTextFileRequest,
14
+};
15
+use anyhow::{Context, Result, anyhow, bail};
16
+use async_trait::async_trait;
17
+use serde::Deserialize;
18
+use serde_json::{Value, json};
19
+
20
+use crate::tools::{
21
+ DescribeCtx, MAX_OUTPUT_BYTES, ToolCtx, ToolOutcome, Toolset, absolutize, base_for,
22
+ function_def, resolve_in, truncate,
23
+};
24
+
25
+const COMMAND_TIMEOUT: Duration = Duration::from_secs(120);
26
+
27
+/// The workspace tools as a [`Toolset`].
28
+#[derive(Debug, Default, Clone, Copy)]
29
+pub struct WorkspaceTools;
30
+
31
+const NAMES: &[&str] = &[
32
+ "read_file",
33
+ "write_file",
34
+ "edit_file",
35
+ "list_directory",
36
+ "run_command",
37
+];
38
+
39
+fn tool_definitions() -> Vec<Value> {
40
+ let f = |name: &str, desc: &str, params: Value| function_def(name, desc, params);
41
+ let root_prop = || {
42
+ json!({
43
+ "type": "string",
44
+ "description": "Workspace root to act in: absolute path of a session root or a directory beneath one. Defaults to the primary working directory."
45
+ })
46
+ };
47
+ vec![
48
+ f(
49
+ "read_file",
50
+ "Read a text file. Paths may be relative to the working directory.",
51
+ json!({
52
+ "type": "object",
53
+ "properties": {
54
+ "path": { "type": "string" },
55
+ "line": { "type": "integer", "description": "1-based line to start from" },
56
+ "limit": { "type": "integer", "description": "Max lines to read" },
57
+ "root": root_prop()
58
+ },
59
+ "required": ["path"]
60
+ }),
61
+ ),
62
+ f(
63
+ "write_file",
64
+ "Create or overwrite a file with the given content.",
65
+ json!({
66
+ "type": "object",
67
+ "properties": { "path": { "type": "string" }, "content": { "type": "string" }, "root": root_prop() },
68
+ "required": ["path", "content"]
69
+ }),
70
+ ),
71
+ f(
72
+ "edit_file",
73
+ "Replace an exact, unique occurrence of old_string with new_string in a file.",
74
+ json!({
75
+ "type": "object",
76
+ "properties": {
77
+ "path": { "type": "string" },
78
+ "old_string": { "type": "string" },
79
+ "new_string": { "type": "string" },
80
+ "root": root_prop()
81
+ },
82
+ "required": ["path", "old_string", "new_string"]
83
+ }),
84
+ ),
85
+ f(
86
+ "list_directory",
87
+ "List entries of a directory (directories end with '/').",
88
+ json!({
89
+ "type": "object",
90
+ "properties": { "path": { "type": "string", "description": "Defaults to the working directory" }, "root": root_prop() }
91
+ }),
92
+ ),
93
+ f(
94
+ "run_command",
95
+ "Run a shell command (sh -c) in the working directory and return its output.",
96
+ json!({
97
+ "type": "object",
98
+ "properties": { "command": { "type": "string" }, "root": root_prop() },
99
+ "required": ["command"]
100
+ }),
101
+ ),
102
+ ]
103
+}
104
+
105
+#[async_trait]
106
+impl Toolset for WorkspaceTools {
107
+ fn definitions(&self) -> Vec<Value> {
108
+ tool_definitions()
109
+ }
110
+
111
+ fn handles(&self, name: &str) -> bool {
112
+ NAMES.contains(&name)
113
+ }
114
+
115
+ fn describe(
116
+ &self,
117
+ name: &str,
118
+ args: &Value,
119
+ ctx: &DescribeCtx<'_>,
120
+ ) -> (String, ToolKind, Vec<ToolCallLocation>) {
121
+ let str_arg = |k: &str| args.get(k).and_then(Value::as_str);
122
+ let base =
123
+ base_for(ctx.cwd, ctx.roots, str_arg("root")).unwrap_or_else(|_| ctx.cwd.to_path_buf());
124
+ // ACP v1: ToolCallLocation.path must be an absolute path.
125
+ let path = str_arg("path").map(|p| absolutize(&resolve_in(&base, p)));
126
+ let loc = path
127
+ .clone()
128
+ .map(ToolCallLocation::new)
129
+ .into_iter()
130
+ .collect();
131
+ let shown = path
132
+ .as_deref()
133
+ .map_or_else(|| base.display().to_string(), |p| p.display().to_string());
134
+ match name {
135
+ "read_file" => (format!("Read {shown}"), ToolKind::Read, loc),
136
+ "write_file" => (format!("Write {shown}"), ToolKind::Edit, loc),
137
+ "edit_file" => (format!("Edit {shown}"), ToolKind::Edit, loc),
138
+ "list_directory" => (format!("List {shown}"), ToolKind::Search, loc),
139
+ "run_command" => {
140
+ let cmd = str_arg("command").unwrap_or("");
141
+ let where_ = if base == ctx.cwd {
142
+ String::new()
143
+ } else {
144
+ format!(" in {}", base.display())
145
+ };
146
+ (format!("`{cmd}`{where_}"), ToolKind::Execute, vec![])
147
+ }
148
+ _ => (name.to_string(), ToolKind::Other, vec![]),
149
+ }
150
+ }
151
+
152
+ async fn execute(
153
+ &self,
154
+ ctx: &ToolCtx,
155
+ tool_call_id: &str,
156
+ name: &str,
157
+ args: Value,
158
+ ) -> Result<ToolOutcome> {
159
+ match name {
160
+ "read_file" => ctx.read_file(args).await,
161
+ "write_file" => ctx.write_file(tool_call_id, args).await,
162
+ "edit_file" => ctx.edit_file(tool_call_id, args).await,
163
+ "list_directory" => ctx.list_directory(args).await,
164
+ "run_command" => ctx.run_command(tool_call_id, args).await,
165
+ other => Err(anyhow!("unknown tool `{other}`")),
166
+ }
167
+ }
168
+}
169
+
170
+#[derive(Deserialize)]
171
+struct ReadArgs {
172
+ path: String,
173
+ line: Option<u32>,
174
+ limit: Option<u32>,
175
+ root: Option<String>,
176
+}
177
+#[derive(Deserialize)]
178
+struct WriteArgs {
179
+ path: String,
180
+ content: String,
181
+ root: Option<String>,
182
+}
183
+#[derive(Deserialize)]
184
+struct EditArgs {
185
+ path: String,
186
+ old_string: String,
187
+ new_string: String,
188
+ root: Option<String>,
189
+}
190
+#[derive(Deserialize)]
191
+struct ListArgs {
192
+ path: Option<String>,
193
+ root: Option<String>,
194
+}
195
+#[derive(Deserialize)]
196
+struct CommandArgs {
197
+ command: String,
198
+ root: Option<String>,
199
+}
200
+
201
+impl ToolCtx {
202
+ pub(crate) async fn read_file(&self, args: Value) -> Result<ToolOutcome> {
203
+ let a: ReadArgs = serde_json::from_value(args)?;
204
+ let path = self.resolve_with(a.root.as_deref(), &a.path)?;
205
+ let text = if self.caps.fs.read_text_file {
206
+ // ACP v1 fs/read_text_file requires an absolute path.
207
+ let mut req = ReadTextFileRequest::new(self.session_id.clone(), absolutize(&path));
208
+ if let Some(l) = a.line {
209
+ req = req.line(l);
210
+ }
211
+ if let Some(l) = a.limit {
212
+ req = req.limit(l);
213
+ }
214
+ self.connection
215
+ .send_request(req)
216
+ .block_task()
217
+ .await?
218
+ .content
219
+ } else {
220
+ let full = tokio::fs::read_to_string(&path)
221
+ .await
222
+ .with_context(|| format!("reading {}", path.display()))?;
223
+ let skip = a.line.map_or(0, |l| l.saturating_sub(1) as usize);
224
+ let take = a.limit.map_or(usize::MAX, |l| l as usize);
225
+ full.lines()
226
+ .skip(skip)
227
+ .take(take)
228
+ .collect::<Vec<_>>()
229
+ .join("\n")
230
+ };
231
+ // Show the user a short summary; give the model the full text.
232
+ let mut out = ToolOutcome::ok(format!("{} lines", text.lines().count()));
233
+ out.text = truncate(text);
234
+ Ok(out)
235
+ }
236
+
237
+ /// Read the current file contents for diffs/edits; `Ok(None)` if it doesn't exist.
238
+ /// A client-fs read failure is an error, distinct from a missing file (ACP
239
+ /// `ResourceNotFound`), so callers don't mistake a failed read for a new file.
240
+ pub(crate) async fn read_existing(&self, path: &Path) -> Result<Option<String>> {
241
+ if self.caps.fs.read_text_file {
242
+ let req = ReadTextFileRequest::new(self.session_id.clone(), absolutize(path));
243
+ match self.connection.send_request(req).block_task().await {
244
+ Ok(r) => Ok(Some(r.content)),
245
+ Err(e) if e.code == ErrorCode::ResourceNotFound => Ok(None),
246
+ Err(e) => Err(anyhow!("client read of {} failed: {e}", path.display())),
247
+ }
248
+ } else {
249
+ match tokio::fs::read_to_string(path).await {
250
+ Ok(content) => Ok(Some(content)),
251
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
252
+ Err(e) => Err(anyhow!("reading {}: {e}", path.display())),
253
+ }
254
+ }
255
+ }
256
+
257
+ pub(crate) async fn write_text(&self, path: &Path, content: &str) -> Result<()> {
258
+ if self.caps.fs.write_text_file {
259
+ let req = WriteTextFileRequest::new(self.session_id.clone(), absolutize(path), content);
260
+ self.connection.send_request(req).block_task().await?;
261
+ } else {
262
+ if let Some(parent) = path.parent() {
263
+ tokio::fs::create_dir_all(parent).await?;
264
+ }
265
+ tokio::fs::write(path, content).await?;
266
+ }
267
+ Ok(())
268
+ }
269
+
270
+ pub(crate) async fn write_file(&self, id: &str, args: Value) -> Result<ToolOutcome> {
271
+ let a: WriteArgs = serde_json::from_value(args)?;
272
+ let path = self.resolve_with(a.root.as_deref(), &a.path)?;
273
+ let old = self.read_existing(&path).await?;
274
+ // ACP v1: Diff.path must be an absolute file path.
275
+ let diff = Diff::new(absolutize(&path), a.content.clone()).old_text(old);
276
+ self.apply_edit(id, "write_file", &path, &a.content, diff)
277
+ .await
278
+ }
279
+
280
+ pub(crate) async fn edit_file(&self, id: &str, args: Value) -> Result<ToolOutcome> {
281
+ let a: EditArgs = serde_json::from_value(args)?;
282
+ let path = self.resolve_with(a.root.as_deref(), &a.path)?;
283
+ let old = self
284
+ .read_existing(&path)
285
+ .await?
286
+ .ok_or_else(|| anyhow!("{} does not exist", path.display()))?;
287
+ match old.matches(&a.old_string).count() {
288
+ 0 => bail!("old_string not found in {}", path.display()),
289
+ 1 => {}
290
+ n => bail!(
291
+ "old_string occurs {n} times in {}; make it unique",
292
+ path.display()
293
+ ),
294
+ }
295
+ let new = old.replacen(&a.old_string, &a.new_string, 1);
296
+ let diff = Diff::new(absolutize(&path), new.clone()).old_text(old);
297
+ self.apply_edit(id, "edit_file", &path, &new, diff).await
298
+ }
299
+
300
+ pub(crate) async fn apply_edit(
301
+ &self,
302
+ id: &str,
303
+ tool: &str,
304
+ path: &Path,
305
+ content: &str,
306
+ diff: Diff,
307
+ ) -> Result<ToolOutcome> {
308
+ let diff = ToolCallContent::from(diff);
309
+ if !self.permit(id, tool, vec![diff.clone()]).await? {
310
+ return Ok(ToolOutcome::err("User rejected this change."));
311
+ }
312
+ self.write_text(path, content).await?;
313
+ Ok(ToolOutcome {
314
+ text: format!("Wrote {}", path.display()),
315
+ content: vec![diff],
316
+ failed: false,
317
+ locations: Vec::new(),
318
+ })
319
+ }
320
+
321
+ pub(crate) async fn list_directory(&self, args: Value) -> Result<ToolOutcome> {
322
+ let a: ListArgs = serde_json::from_value(args)?;
323
+ let base = self.base_for(a.root.as_deref())?;
324
+ let path = a
325
+ .path
326
+ .map_or_else(|| base.clone(), |p| resolve_in(&base, &p));
327
+ let mut entries = Vec::new();
328
+ let mut rd = tokio::fs::read_dir(&path)
329
+ .await
330
+ .with_context(|| format!("listing {}", path.display()))?;
331
+ while let Some(e) = rd.next_entry().await? {
332
+ let mut name = e.file_name().to_string_lossy().into_owned();
333
+ if e.file_type().await.is_ok_and(|t| t.is_dir()) {
334
+ name.push('/');
335
+ }
336
+ entries.push(name);
337
+ }
338
+ entries.sort();
339
+ Ok(ToolOutcome::ok(entries.join("\n")))
340
+ }
341
+
342
+ pub(crate) async fn run_command(&self, id: &str, args: Value) -> Result<ToolOutcome> {
343
+ let a: CommandArgs = serde_json::from_value(args)?;
344
+ let base = self.base_for(a.root.as_deref())?;
345
+ if !self.permit(id, "run_command", vec![]).await? {
346
+ return Ok(ToolOutcome::err("User rejected running this command."));
347
+ }
348
+ if self.caps.terminal {
349
+ self.run_in_client_terminal(id, &a.command, &base).await
350
+ } else {
351
+ self.run_locally(&a.command, &base).await
352
+ }
353
+ }
354
+
355
+ pub(crate) async fn run_in_client_terminal(
356
+ &self,
357
+ id: &str,
358
+ command: &str,
359
+ cwd: &Path,
360
+ ) -> Result<ToolOutcome> {
361
+ let sid = self.session_id.clone();
362
+ let req = CreateTerminalRequest::new(sid.clone(), "sh")
363
+ .args(vec!["-c".into(), command.into()])
364
+ // ACP v1: the terminal cwd must be an absolute path.
365
+ .cwd(absolutize(cwd))
366
+ .output_byte_limit(MAX_OUTPUT_BYTES as u64);
367
+ let terminal_id = self
368
+ .connection
369
+ .send_request(req)
370
+ .block_task()
371
+ .await?
372
+ .terminal_id;
373
+ // Embed the live terminal in the tool call so the user can watch it.
374
+ let content = vec![ToolCallContent::Terminal(Terminal::new(
375
+ terminal_id.clone(),
376
+ ))];
377
+ self.update(id, ToolCallUpdateFields::new().content(content.clone()))?;
378
+
379
+ let wait = self
380
+ .connection
381
+ .send_request(WaitForTerminalExitRequest::new(
382
+ sid.clone(),
383
+ terminal_id.clone(),
384
+ ))
385
+ .block_task();
386
+ let exit = tokio::select! {
387
+ r = wait => Some(r?.exit_status),
388
+ () = tokio::time::sleep(COMMAND_TIMEOUT) => None,
389
+ () = self.cancel.cancelled() => None,
390
+ };
391
+ if exit.is_none() {
392
+ let _ = self
393
+ .connection
394
+ .send_request(KillTerminalRequest::new(sid.clone(), terminal_id.clone()))
395
+ .block_task()
396
+ .await;
397
+ }
398
+ let output = self
399
+ .connection
400
+ .send_request(TerminalOutputRequest::new(sid.clone(), terminal_id.clone()))
401
+ .block_task()
402
+ .await?;
403
+ let _ = self
404
+ .connection
405
+ .send_request(ReleaseTerminalRequest::new(sid, terminal_id))
406
+ .block_task()
407
+ .await;
408
+
409
+ let status = match &exit {
410
+ Some(s) => match (s.exit_code, &s.signal) {
411
+ (Some(c), _) => format!("exit code {c}"),
412
+ (None, Some(sig)) => format!("killed by {sig}"),
413
+ _ => "exited".into(),
414
+ },
415
+ None if self.cancel.is_cancelled() => "cancelled".into(),
416
+ None => format!("timed out after {}s", COMMAND_TIMEOUT.as_secs()),
417
+ };
418
+ let failed = !matches!(exit.as_ref().and_then(|s| s.exit_code), Some(0));
419
+ let trunc = if output.truncated {
420
+ "\n[output truncated]"
421
+ } else {
422
+ ""
423
+ };
424
+ Ok(ToolOutcome {
425
+ text: format!("{}{trunc}\n[{status}]", output.output),
426
+ content,
427
+ failed,
428
+ locations: Vec::new(),
429
+ })
430
+ }
431
+
432
+ pub(crate) async fn run_locally(&self, command: &str, cwd: &Path) -> Result<ToolOutcome> {
433
+ let child = tokio::process::Command::new("sh")
434
+ .arg("-c")
435
+ .arg(command)
436
+ .current_dir(cwd)
437
+ .stdin(std::process::Stdio::null())
438
+ .kill_on_drop(true)
439
+ .output();
440
+ let out = tokio::select! {
441
+ r = tokio::time::timeout(COMMAND_TIMEOUT, child) => match r {
442
+ Ok(r) => r?,
443
+ Err(_) => return Ok(ToolOutcome::err(format!("Command timed out after {}s", COMMAND_TIMEOUT.as_secs()))),
444
+ },
445
+ () = self.cancel.cancelled() => return Ok(ToolOutcome::err("Command cancelled")),
446
+ };
447
+ let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
448
+ text.push_str(&String::from_utf8_lossy(&out.stderr));
449
+ let status = out
450
+ .status
451
+ .code()
452
+ .map_or_else(|| "killed by signal".into(), |c| format!("exit code {c}"));
453
+ let text = format!("{}\n[{status}]", truncate(text));
454
+ let mut outcome = ToolOutcome::ok(format!("```\n{text}\n```"));
455
+ outcome.text = text;
456
+ outcome.failed = !out.status.success();
457
+ Ok(outcome)
458
+ }
459
+}