feat(acp): delegate developer file I/O through ACP fs methods#6940
feat(acp): delegate developer file I/O through ACP fs methods#6940rabi wants to merge 1 commit intoblock:mainfrom
Conversation
ef56f3e to
dca8b91
Compare
6b0d256 to
5be3269
Compare
|
hrm - yeah this is not a small change, although pretty cool. Main thing is this changes the developer tool to have a new mode, that is main thing that concerns, but otherwise would make editor experience nicer, wonder if there is a way to package this to not be as cross cutting, as you will rarely want "write mode" etc? |
It's based on client capability to write. If you think there could be scenarios where clients won't need it irrespective of them advertising their capabilities, we can probably make it configurable, but we would still need the new developer mode. |
fb56604 to
ffd60ce
Compare
|
@rabi yeah - I think that we really want this only available when actually used from visual editors don't you think? otherwise it is adding more work for the LLM to do? if could polish that off I think is a great idea |
OK, Thanks @michaelneale, I'll expose the feature behind |
|
Hey @rabi, I filed #7451 to capture what I think the minimal approach looks like here. The core idea is the same (route file I/O through ACP), but the implementation can be a lot simpler if we lean on what the spec already defines:
The |
|
@codefromthecrypt Thanks for checking this and creating the issue as well.
We can implement these incrementally right? I kept this limited to write ad read as a followup. As you can see I've added
Yeah, that's a good point. I'll change.
It was not in my original PR. Added after @michaelneale asked for it.
I observed cases where approval didn’t trigger as expected in my testing, so I added a temporary guard. I’ll re-verify and remove if unnecessary.
Right, that would be larger refactor of developer extension with broader touch points I wanted to avoid, but I can include it if we want. |
|
thanks for accepting the feedback so far ;) ack that there are a couple design points going on. Here's some unsolicited feedback with TL;DR; maybe chop off a targeted smaller PR from this, with eventually landing this as the goal. The first, is probably just tuning the developer extension (builtin) so that the impl of reads are possible to adapt in with an ACP client., This is like a wiring concern and shouldn't be huge. with that in, other things are simpler to progress maybe. Something that can help is that when we look at this change, probably the easiest one is the read file not the write. One of the reasons it takes me so long to get changes together is that I make a PR (such as this one) then hack it to bits until all the pieces needed are in. This is why #6605 has taken so long, for example. For example, the infra carve-out for MCP came in this, and in some ways it is similar to the design issue you are hinting at #6972 Basically, you have a chicken-egg, right? you are looking to have a way to control which impl is used for editing tools that Goose tells the LLM it has. So, how do you get ACP to be the "impl" of the developer plugin, especially considering the chicken-egg is you don't know if ACP will be supported by the client until after it connects. So, one way to think about it is the "builtin" are already added to ACP via #6284 I mean that's how I was testing code mode, right? So, developer is a builtin.. so one way to chop this up is to choose the easiest thing, which may be read, not write, and do only that.. surgically, in a PR. It isn't required to do both read and write in the same one.. this would prove you've solved the bootstrapping concern in the right abstraction. The other thing that might help, is while we have hints everywhere somethings are not very explicit. I force the agent to re-read this every few minutes to cut down on cruft.. |
I think I've addressed all the feedback in the last update, including developer extension changes. So it should be good now. |
|
@rabi just today (I think?) there is a new developer tool, does this work with that? otherwise LGTM I think |
@michaelneale If you mean #7466, does not look like it's merged yet. |
codefromthecrypt
left a comment
There was a problem hiding this comment.
I'm lightly available, so only had 2 hours to figure out the best advice to give on this PR, so here goes.
Thanks for addressing point comments like the _meta.old_text leak, the application/x-goose-file-diff MIME type, the --acp-editor flag, and the double permission prompt.
The biggest thing is that the archecture itself looks the same. I mentioned I wrote #7451 for you, but maybe it got lost in comments.
So, architecture: the ACP server shouldn't parse custom URI schemes or extract diffs from tool results. That whole goose-internal://file-diff/ flow exists because the old developer extension (developer__text_editor) couldn't call back to the server. But #7466 merged and rewrote the developer extension as a platform extension with flat tools (write, edit, shell, tree). You need to rebase anyway, and the new code makes the injection point obvious — edit.rs has three fs:: calls and that's it.
I updated #7451 with the new architecture, exact file paths on main, and what the spec requires.
Below is a prompt you can iterate on to help your coding agent implement this cleanly.
Details
Prompt: Implement ACP file I/O delegation for goose
You are implementing #7451 — ACP file system delegation for the goose agent. This lets editors (Zed, VS Code, Neovim) provide buffer contents on reads and track modifications on writes, instead of goose hitting disk directly.
Read the issue first: #7451 — it has the full spec, architecture diagram, implementation checklist, and test scenarios.
Study these PRs for style:
- stakpak/agent#389: ACP fs capability gating in another agent (6 files, +170/-38 — small, focused)
- block/goose#7115: per-session Agent refactor (7 files, balanced refactor)
- block/goose#7466: the developer extension rewrite your PR must rebase onto
Phase 1: Research (read before coding)
Read the ACP file system spec:
https://agentclientprotocol.com/protocol/file-system
Key points:
fs/read_text_file: client-side method. Agent calls client, client returns buffer content. Params:sessionId,path, optionalline(1-based) +limit.fs/write_text_file: client-side method. Agent calls client withsessionId,path,content. That's it — no oldText, no diff, no metadata.- Capability negotiation: client sends
clientCapabilities.fs.readTextFile/.writeTextFileininitialize. Agent checks before calling. - The LLM never sees these methods. It sees
write,edit. The agent translates internally.
Read the tracking issue: #7451 — it has the architecture diagram, implementation checklist, and test scenarios.
Understand how editors handle diffs:
The agent sends full file content via one JSON-RPC call. The editor already has the old content (from its buffer or disk). The editor computes and displays the diff on its side:
- Zed: receives
fs/write_text_file(path, content), compares with its buffer, shows native diff in assistant panel (source) - codecompanion.nvim: same call, opens split window showing before/after using Neovim's native diff
- acp.nvim: same call, shows inline permission text,
:AcpViewDifffor review - vscode-acp: same call, delegates to registered callback for diff rendering
No diff logic belongs in the agent or server. The agent sends content; the editor diffs it.
Read the new developer extension on main (post-#7466):
The old DeveloperServer (MCP builtin, spawned via duplex pipe) is gone. The new code is a platform extension:
DeveloperClient(in-process):DeveloperClient::new()receivesPlatformExtensionContext(line 33)- Tools:
write,edit,shell,tree(lines 96-147)
EditToolsdoes file I/O:file_write_with_cwd: callsfs::write(path, content)at line 56file_edit_with_cwd: callsfs::read_to_string(&path)at line 85, thenfs::write(&path, &new_content)at line 112
These three fs:: calls are your injection points. No TextFileIOStrategy enum, no SpawnServerFn changes, no builtin_context threading needed.
Phase 2: Architecture — where each responsibility belongs
Three layers:
ACP server (server.rs):
- Parse
clientCapabilities.fsfromInitializeRequest(already done in your PR, keep this) - Store booleans, expose
supports_fs_read()/supports_fs_write()(already done, keep this) - Provide ACP-backed read/write closures or trait impls to the developer extension via
PlatformExtensionContext - Send
ReadTextFileRequest/WriteTextFileRequestto client when called - Do NOT intercept tool results, parse URIs, or extract diffs
Developer platform extension (edit.rs):
EditToolscalls read/write through an injected abstraction instead ofstd::fsdirectly- Default:
std::fs(when no ACP, or capabilities absent) - ACP-provided: closures/trait that send JSON-RPC to client
- No knowledge of ACP protocol,
goose-internal://URIs, or JSON-RPC - No
TextFileIOStrategyenum — just "I have a read function and a write function"
Client (editor) — not goose code:
- Receives
fs/write_text_file, compares with buffer, shows diff - Receives
fs/read_text_file, returns buffer content
Phase 3: Implementation steps
Step 0: Rebase on main (prerequisite)
developer__text_editoris gone.TextFileIOStrategyis gone.DeveloperServeris gone.- Start fresh from
edit.rson main.
Step 1: Make EditTools pluggable (developer extension only, ~50 lines)
- Add read/write closures or a trait to
EditTools - Default to
std::fs::read_to_string/std::fs::write - Thread through
DeveloperClient::new()viaPlatformExtensionContext - Files:
edit.rs,mod.rs - Test: existing developer tests still pass with default impls
Step 2: Wire ACP server to provide closures (server only, ~50 lines)
- Parse capabilities (already works from your PR)
- When
readTextFile=true: provide a closure that sendsReadTextFileRequest - When
writeTextFile=true: provide a closure that sendsWriteTextFileRequest - Pass closures to developer extension via context
- Files:
server.rs, maybePlatformExtensionContext - No changes to
goose-mcp,extension_manager,builtin_extension, orSpawnServerFn
Step 3: Write in-process ACP tests
Goose already has an in-process test framework for ACP. Tests use OpenAiFixture (mocked LLM with pattern-matched SSE responses) and ClientToAgentConnection (in-process duplex transport). Read #6969 for context on how tests work and recording tips.
The existing test fixture at fixtures/server.rs already handles:
RequestPermissionRequestviaPermissionDecisionenum (AllowOnce, RejectOnce, Cancel, etc.)SessionNotificationcollection for assertingtool_call_updatestatusOpenAiFixturefor canned LLM responses without hitting a real API
You need to extend the fixture to also handle ReadTextFileRequest and WriteTextFileRequest (add handlers in the on_receive_request builder, similar to how RequestPermissionRequest is handled). Then add these test scenarios:
Scenario 1: Read delegation without permission
- Initialize with
clientCapabilities.fs.readTextFile = true - Prompt agent to read a file
- Assert:
ReadTextFileRequestreceived by client with correct path - Assert:
RequestPermissionRequestNOT sent (reads don't need permission) - Assert: response contains file content
Scenario 2: Write delegation with permission approved
- Initialize with
clientCapabilities.fs.writeTextFile = true - Set
PermissionDecision::AllowOnce - Prompt agent to write content to a file
- Assert:
RequestPermissionRequestsent first - Assert:
WriteTextFileRequestreceived with correct path and content - Assert:
tool_call_updatestatus iscompleted - Assert: file exists on disk (client-side handler wrote it)
Scenario 3: Write delegation with permission rejected
- Initialize with
clientCapabilities.fs.writeTextFile = true - Set
PermissionDecision::RejectOnce - Prompt agent to write content to a file
- Assert:
RequestPermissionRequestsent - Assert:
WriteTextFileRequestNOT called - Assert:
tool_call_updatestatus isfailed - Assert: file does NOT exist on disk
Scenario 4: Agent-side write (fs disabled)
- Initialize WITHOUT
writeTextFilecapability - Set
PermissionDecision::AllowOnce - Prompt agent to write content to a file
- Assert:
WriteTextFileRequestNOT sent (agent handles it locally) - Assert:
RequestPermissionRequeststill sent (permission still applies) - Assert: file exists on disk (written by agent via
fs::write)
Scenario 5: Agent-side rejection (fs disabled)
- Initialize WITHOUT
writeTextFilecapability - Set
PermissionDecision::RejectOnce - Prompt agent to write content to a file
- Assert: file does NOT exist on disk
To record the OpenAI fixture data for these tests, follow the steps in #6969: run goose acp --with-builtin developer with a real provider, capture the SSE responses, and extract request patterns from llm_request.*.jsonl.
Goose-specific protocol values:
allowOptionId: "allow_once",rejectOptionId: "reject_once"rejectedToolStatus: "failed"- Spawn:
goose acp --with-builtin developer
Phase 4: What NOT to recreate
Everything in this list exists only because of the smuggle-diffs-through-MCP approach. Do not recreate any of it:
ManagedFileWritestructdecode_uri_component/encode_uri_componenthand-rolled URI codectry_extract_file_diff— parsinggoose-internal://file-diff/URIs from embedded resourcesToolCallContentWithDiffs— separating content from diffsbuild_tool_call_content— intercepting tool results to extract diffs- Two-phase InProgress→write→Completed notification for diffs
create_file_diff_content— wrapping content in fake MCP resourcesTextFileIOStrategyenum ingoose-mcpReadStrategyFn,TextFileWriteResulttypes ingoose-mcpSpawnServerFn4-parameter signatureadd_extension_with_contexton extension managerspawn_developercustom function
Why: the server's job is transport. It should not parse custom URI schemes or process file diffs. The agent sends full content via fs/write_text_file; the editor computes the diff. Every editor listed above already does this.
Phase 5: Editors to test with
Don't just test with Zed. Test with multiple editors to validate spec compliance:
- Neovim via acp.nvim, avante.nvim, or codecompanion.nvim (all implement both read + write)
- VS Code via vscode-acp
- deepchat (desktop app)
crates/goose-acp/src/server.rs
Outdated
| diffs: Vec<ManagedFileWrite>, | ||
| } | ||
|
|
||
| struct ToolPermissionRequestArgs { |
There was a problem hiding this comment.
this is a bit strange.. I think if an agent created this to avoid clippy warnings it is probably best to undo it. as fr as I can tell, we create this structure then destructure it immediately.
crates/goose-acp/src/server.rs
Outdated
| let supports_write = self.supports_fs_write(); | ||
| let mut apply_failed = false; | ||
|
|
||
| if !diffs.is_empty() { |
There was a problem hiding this comment.
this is mixing abstraction concepts, the server code shouldn't be handling diffs, even if some agents aren't designed perfectly. I raised #7451 to suggest designing first before implementing. In there are some hints and source bases to explore.
The issue with doing things this way is that it is tech debt that takes a lot more attention to undo vs just do cleanly in the first place.
crates/goose-acp/src/server.rs
Outdated
| new_text: String, | ||
| } | ||
|
|
||
| fn decode_uri_component(value: &str) -> Option<String> { |
There was a problem hiding this comment.
server should not have the responsibility for being a URI encoder/decoder. look for existing crates or a utility, or in any case mention why custom decoding is an ACP concern required to implement this.
crates/goose-acp/src/server.rs
Outdated
| .is_some_and(|c| c.fs.read_text_file) | ||
| } | ||
|
|
||
| fn build_io_strategy( |
There was a problem hiding this comment.
this is a broken abstraction. The server's job is only to push the file capability to our dev extension which consumes it optionally as a trait. When that's missing, it uses the default implementation. What you can see happening here, is server inheriting a mix of concerns which ends up with having tight coupling and confusing code, where the core server handler is now also a diff machine.
There was a problem hiding this comment.
Thanks @codefromthecrypt , I think most of the complexity came from bridging ACP through the old goose-mcp developer path, which mixed transport and file-diff concerns across layers.
Now that developer is a platform extension after #7466, ACP can now inject capability-gated read/write delegates directly as you mentioned, keeping server transport-only and letting clients render diffs natively.
I still think developer_file_io delegate on PlatformExtenstionContext would be extension specific leakage.
I'll rebase the PR make it aligned with the new developer platform extension. If there are still concerns from your side around the design, feel free to take over.
Route developer write/edit through ACP fs read/write requests when client capabilities are available, with local fs fallback as the default behavior. Keep the server focused on capability-gated transport wiring and verify delegation and permission behavior with ACP integration tests. Change-Id: I2ea8d50fd8c5ba2a3daa1f0c9148a321063820e9 Signed-off-by: rabi <ramishra@redhat.com>
Summary
When an ACP client (e.g. Zed) advertises fs.writeTextFile and fs.readTextFile capabilities, the developer extension routes file reads/writes through the client instead of hitting disk directly. This lets the editor track modifications and return unsaved buffer contents.
Type of Change
AI Assistance
Testing
Tested locally with zed.
Related Issues
Relates to #6894
Screenshots/Demos (for UX changes)
After:
