@ondeinference / ed / commits / b8d3b5a

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 +}