-
Notifications
You must be signed in to change notification settings - Fork 3.2k
Delegate developer extension file I/O through ACP fs methods #7451
Description
Please explain the motivation behind the feature request.
When goose runs inside an editor via ACP (Zed, VS Code, Neovim), its developer extension reads and writes files directly on disk. The editor never gets a chance to return unsaved buffer contents on reads or track modifications on writes.
The ACP file system spec defines fs/read_text_file and fs/write_text_file for exactly this. During initialization, the client advertises clientCapabilities.fs.readTextFile / .writeTextFile. The agent checks these and delegates file I/O to the client when supported, falling back to local disk otherwise. Tool names don't change, the LLM still sees write and edit, the delegation is invisible to the model.
Other ACP agents (claude-agent-acp, gemini-cli, cursor-agent-acp, stakpak-agent) already delegate through these methods. Goose should too.
Relates to #6894.
Describe the solution you'd like
The developer extension (DeveloperClient) already does the same file operations that the ACP spec wants to delegate. Every tool maps to an ACP method, but the disk I/O is hardcoded in EditTools:
| tool | disk reads | disk writes | ACP equivalent |
|---|---|---|---|
write |
none | fs::write |
fs/write_text_file |
edit |
fs::read_to_string |
fs::write |
fs/read_text_file + fs/write_text_file |
Three fs:: calls total. Those are the injection points.
The formatting logic (line counts, action messages, error handling) stays in EditTools. Only the disk I/O changes. The LLM keeps seeing the same write and edit tools with the same parameters and the same formatted output.
Architecture: where each responsibility belongs
ACP server (crates/goose-acp/src/server.rs):
- Parse
clientCapabilities.fsfrom the initialize request, store as booleans - Provide ACP-backed read/write closures to the developer extension via
PlatformExtensionContext - Send
ReadTextFileRequest/WriteTextFileRequestto client when called - Do NOT intercept tool results, parse URIs, or extract diffs — the server is transport
Developer extension (crates/goose/src/agents/platform_extensions/developer/edit.rs):
EditToolscalls read/write through an injected abstraction instead ofstd::fsdirectly- Default:
std::fs(when no ACP, or capabilities absent) - ACP mode: closures/trait that send JSON-RPC to client
- No knowledge of ACP protocol details — just "I have a read function and a write function"
Client (editor) — not goose code:
- Receives
fs/write_text_file(path, content), compares with buffer, shows diff - Receives
fs/read_text_file(path), returns buffer content
Editors compute diffs themselves. Zed compares with its buffer and shows native diff. codecompanion.nvim opens a split window. acp.nvim offers :AcpViewDiff. The agent sends full content; the editor handles the rest.
sequenceDiagram
participant Zed as Zed (ACP Client)
participant ACP as goose-acp
participant Dev as developer extension
Zed->>ACP: initialize {fs: {readTextFile: true, writeTextFile: true}}
ACP-->>Zed: initialize response
Note over ACP: stores capabilities, provides<br/>read/write closures to extension
Zed->>ACP: session/prompt "edit main.py"
Note over ACP,Dev: edit tool (read phase)
Dev->>ACP: read closure("main.py")
ACP->>Zed: fs/read_text_file {path: "main.py"}
Zed-->>ACP: buffer contents (may include unsaved changes)
ACP-->>Dev: file content
Note over Dev: does string replacement
Note over ACP,Dev: edit tool (write phase)
Dev->>ACP: write closure("main.py", new_content)
ACP->>Zed: fs/write_text_file {path: "main.py", content: new_content}
Zed-->>ACP: success
ACP-->>Zed: session/update {edit result}
ACP-->>Zed: prompt response
When the client doesn't advertise the capabilities, EditTools uses std::fs as it does today, no change.
Implementation steps
- Parse and store
clientCapabilities.fsfrom the initialize request - Add read/write closures or a trait to
EditTools, defaulting tostd::fs - Thread closures through
DeveloperClient::new()viaPlatformExtensionContext - When
readTextFile=true, provide a closure that sendsReadTextFileRequest - When
writeTextFile=true, provide a closure that sendsWriteTextFileRequest - Fall back to
std::fswhen capabilities are absent - Keep the existing
session/request_permissionflow for tool approval, no additional permission prompts
Test scenarios
The implementation should handle these protocol behaviors:
| scenario | what happens |
|---|---|
| Read delegation | edit tool triggers fs/read_text_file to client, no requestPermission for reads |
| Write with permission approved | write/edit sends requestPermission, then fs/write_text_file with path + full content, tool_call_update status completed |
| Write with permission rejected | fs/write_text_file NOT called, file NOT on disk, tool_call_update status failed |
| Agent-side write (fs disabled) | writeTextFile absent → agent writes via std::fs, permission flow still applies |
| Agent-side rejection (fs disabled) | writeTextFile absent + rejected → file NOT written |
Goose-specific values: allowOptionId: "allow_once", rejectOptionId: "reject_once", rejectedToolStatus: "failed".
Describe alternatives you've considered
Intercept purely at the ACP server layer without changing the developer extension. This doesn't work cleanly because edit does read-transform-write as an atomic operation inside EditTools. The server would have to either duplicate the replacement logic or let the extension read/write disk and then separately send the same content through ACP, which means double I/O for no reason.
Another alternative: smuggle file diffs through MCP embedded resources using a custom URI scheme, then have the server intercept and extract them. This adds two layers of indirection (extension creates fake resources, server parses custom URIs) for what should be a direct function call. The platform extension architecture makes this unnecessary — the extension receives closures via PlatformExtensionContext and calls them directly.
Additional context
How other agents implement this:
- claude-agent-acp: Always delegates regardless of capability (source)
- gemini-cli: Transparent fallback, delegates reads when capability is true, local fs otherwise. Writes always local (source)
- cursor-agent-acp: Only registers file tools when capability is true (source)
Transparent fallback is the natural fit for goose: same tools always available, delegate when the client supports it. No new CLI flags needed, capability negotiation is the feature gate.
How editors handle fs/write_text_file:
- Zed: Compares with buffer, shows native diff in assistant panel
- codecompanion.nvim: Opens split window with before/after using Neovim's native diff
- acp.nvim: Inline permission text,
:AcpViewDifffor manual review - vscode-acp: Delegates to registered callback for diff rendering
- deepchat: Writes directly, notifies via callback
The agent sends full content. Every editor computes diffs on its own. No diff logic in the agent.
- I have verified this does not duplicate an existing feature request