Skip to content

Delegate developer extension file I/O through ACP fs methods #7451

@codefromthecrypt

Description

@codefromthecrypt

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.fs from the initialize request, store as booleans
  • Provide ACP-backed read/write closures to the developer extension via PlatformExtensionContext
  • Send ReadTextFileRequest / WriteTextFileRequest to 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):

  • EditTools calls read/write through an injected abstraction instead of std::fs directly
  • 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
Loading

When the client doesn't advertise the capabilities, EditTools uses std::fs as it does today, no change.

Implementation steps

  • Parse and store clientCapabilities.fs from the initialize request
  • Add read/write closures or a trait to EditTools, defaulting to std::fs
  • Thread closures through DeveloperClient::new() via PlatformExtensionContext
  • When readTextFile=true, provide a closure that sends ReadTextFileRequest
  • When writeTextFile=true, provide a closure that sends WriteTextFileRequest
  • Fall back to std::fs when capabilities are absent
  • Keep the existing session/request_permission flow 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, :AcpViewDiff for 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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions