Skip to content

feat(acp): Agent Client Protocol server (forge machine stdio), dogfooded from T3 Code - #3864

Open
JDLondon7 wants to merge 19 commits into
tailcallhq:mainfrom
JDLondon7:acp-t3
Open

JDLondon7 wants to merge 19 commits into
tailcallhq:mainfrom
JDLondon7:acp-t3

Conversation

@JDLondon7

Copy link
Copy Markdown

Summary

Revives the ACP stdio transport from #2858 (closed for inactivity; the maintainer asked for it behind a beta flag — happy to add that) and fixes what surfaced when driving it end-to-end from a real ACP client (T3 Code) rather than a test harness. Every item below was a silent hang or a broken picker in the client; each has a reproduction in the commit message.

  • forge machine stdio no longer has stdin consumed as a piped prompt before the server starts (the JSON-RPC stream was eaten; server exited silently).
  • Acknowledge ChatResponse::ToolCallStart's notifier: the orchestrator now waits on it before executing any tool, so every tool call hung over ACP.
  • User questions raised during tool execution (policy confirmations, MCP trust, follow-up choices) are forwarded as session/request_permission instead of opening a terminal picker on the pipe. The connection is single-threaded, so this crosses a channel into the connection task.
  • Stdout is the protocol: in machine mode the pipe moves to a private descriptor and fd 1 points at stderr, so the shell tool's live echo (or a panic hook) cannot corrupt the stream. The first git status in a turn used to kill the session.
  • session/set_config_option implemented for mode and model; current clients use it instead of session/set_mode, and the crate default answered "Method not found".
  • Commands: custom commands plus the built-ins forge can run for a client (compact, commit, commit-preview, config, info, tools, usage, workspace-*, and one per agent for switching) are advertised via available_commands_update and, because ACP can only advertise per session, also on the initialize response _meta so a client can populate its menu before the first message.
  • Models are advertised as SessionModelState and as a model session config option (clients apply a choice through the latter), also on initialize _meta.
  • Removed the 300 s whole-connection I/O timeout; logging is initialised in machine mode.
  • alibaba_token_plan reads its model list from the endpoint's /models instead of a hand-maintained list that had drifted.

Deliberately not exposed over ACP: terminal affordances (:exit, :copy, :edit), and things a client owns in an ACP session (:new, :model, :conversation*).

Notes for review

  • A built-in command's reply is queued like any other update. Awaiting delivery inside the handler (connection, oneshot, even a timer) leaves the turn without a response until the client sends more input, in the current-thread runtime + LocalSet inside spawn_blocking setup. Worth a look from whoever knows that runtime choice.
  • :skill is not advertised: listing skills lives on the infra (SkillRepository), not on the Services the adapter holds.

Test plan

  • Raw ACP over stdio: initialize → session/new → prompt; tool turn; permission grant and reject; set_config_option for mode and model.
  • T3 Code UI: plain turn, read and shell tool turns, @file mentions, Supervised approve/decline, stop mid-turn, slash commands, agent switching, model picker.

🤖 Generated with Claude Code

flazouh and others added 19 commits April 5, 2026 23:08
Expose a machine stdio entrypoint in Forge and route it through a real ACP stdio transport so Acepe can launch Forge as an installable provider instead of depending on an unpublished branch.

Co-Authored-By: ForgeCode <noreply@forgecode.dev>
- Replace unbounded notification channel with bounded (1024) to apply
  backpressure when the client stalls
- Add per-session model override to prevent concurrent sessions from
  interfering with each other
- Replace From<Error> impl with explicit into_acp_error() per project
  guidelines
- Extract classify_mcp_tool() and convert to free functions, removing
  the unnecessary ToolOutputConverter struct
- Validate MCP server names (length, charset) to prevent injection
- Add MAX_BLOB_SIZE (50 MB) guard on base64-decoded resources
- Add I/O timeout (5 min) and graceful shutdown drain (5 s) to prevent
  indefinite hangs
- Track cancellation via AtomicBool across loop iterations
- Log warnings instead of silently ignoring reload/config errors
- Add tests for tool kind mapping, file extraction, and edge cases

Co-Authored-By: ForgeCode <noreply@forgecode.dev>
The store ran eagerly on function call, not when the future was
awaited. Move it into the async block so the test actually verifies
that the caller awaits the returned future.

Co-Authored-By: ForgeCode <noreply@forgecode.dev>
Co-Authored-By: ForgeCode <noreply@forgecode.dev>
…l start, log in machine mode

- Policy confirmations, MCP trust and follow-up choices raised during tool
  execution are forwarded as session/request_permission instead of opening a
  terminal picker on the protocol pipe (previously hung forever).
- Acknowledge ChatResponse::ToolCallStart's notifier; the orchestrator waits
  on it before executing any tool.
- Dispatch 'forge machine stdio' in main before the interactive UI and
  initialise file logging there; remove the acp_runner shim.
- Drop the blanket 300s I/O timeout on the connection; the pipe closing ends it.
…h custom commands

- set_config_option maps 'mode' and 'model' onto the existing handlers
  (newer clients no longer call session/set_mode).
- Advertise custom commands via available_commands_update at session start
  and at each prompt; a '/name args' prompt runs the command with the
  terminal's semantics.
- Nine built-ins forge can run for a client (compact, commit, commit-preview,
  info, tools, usage, workspace-{info,status,sync}), each calling the same
  service the terminal calls. Terminal-only and client-owned commands are
  deliberately not advertised.
- Publish the command list on the initialize response's _meta as well as the
  session update, so a client can populate its menu before the first message.
- Await delivery of a built-in's output so it lands before the turn response.
A built-in can still raise a user question (MCP trust, a policy
confirmation). Without the marker the bridge had no session to attribute
it to, answered nothing, and /tools hung in a directory with an untrusted
.mcp.json.
Awaiting anything after a built-in runs — the connection, a delivery
signal, or a timer — leaves the turn without a response until the client
sends more input. Queue the message like every other session update.
The terminal lists 45 commands behind ':'; a client had no way to switch
agents at all. Advertise one command per agent (forge, muse, sage) that
switches the session agent through the same handler as session/set_mode,
plus /config and /workspace-init.
- alibaba_token_plan: qwen3.8-max-preview becomes the qwen3.8-max-0902
  snapshot, and qwen3.8-flash is added.
- Publish the active agent's model list on the initialize response _meta,
  so a client can show models before a session exists.
- Declare the models as a 'model' session config option on session/new and
  session/load: clients apply a model with session/set_config_option, so
  SessionModelState alone left the picker unable to choose.
A client only sets a session model when it differs from forge's current
one, so an unset session model meant '/info' claimed 'agent default' for a
session that was using the configured model.
The hard-coded list had drifted from what the token-plan endpoint serves
(qwen3.8-max, not qwen3.8-max-preview or a -0902 snapshot; deepseek-v4-flash
is deepseek-v4-flash-0731 there). The endpoint publishes /models, and forge
already reads OpenAI-compatible model lists from a URL, so use that.
The shell tool echoes command output through the console writer, which
under 'forge machine stdio' was the JSON-RPC pipe: the first shell command
in a turn corrupted the stream and the client dropped the session. Move
the pipe to a private descriptor and point fd 1 at stderr before the ACP
server starts, so nothing in the process can write into the protocol.
@CLAassistant

CLAassistant commented Sep 2, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@github-actions github-actions Bot added type: feature Brand new functionality, features, pages, workflows, endpoints, etc. type: provider Updates provider.json configuration. labels Sep 2, 2026
@JDLondon7
JDLondon7 marked this pull request as ready for review September 2, 2026 16:51
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

Action required: PR inactive for 5 days.
Status update or closure in 10 days.

@github-actions github-actions Bot added the state: inactive No current action needed/possible; issue fixed, out of scope, or superseded. label Sep 7, 2026
@amitksingh1490

Copy link
Copy Markdown
Contributor

Independent review for #2968 at head c3cec9728 (main 6ed5d37b6). This substantial contributor PR should remain the integration vehicle; I have not opened a duplicate PR or pushed to this branch.

The stdin exclusion, tool-start acknowledgement and permission forwarding address real historical problems. I do not think this head meets finite beta acceptance yet. Main findings below are static review, not independently reproduced ACP/editor integration failures:

  1. Beta/platform/startup: the maintainer beta gate request is still unimplemented. Stdout redirection occurs after sandbox creation / invalid-directory panic, both of which can write non-JSON to stdout. The new unconditional Unix fd API also breaks the Windows compilation path. Local source: /home/forge/worktrees/2968/crates/forge_main/src/main.rs:75-149.
  2. Session filesystem/MCP boundaries: new/load ignore the requested cwd. Client MCP config (including env/header values) is merged into Scope::Local and actually written to disk, contrary to the temporary-state comment; load does this even before validating the session. See /home/forge/worktrees/2968/crates/forge_app/src/acp/session_handlers.rs:80-149, /home/forge/worktrees/2968/crates/forge_app/src/acp/session_handlers.rs:211-270, /home/forge/worktrees/2968/crates/forge_app/src/acp/state_builders.rs:146-189, /home/forge/worktrees/2968/crates/forge_services/src/mcp/manager.rs:168-181. Please use session-local context/config rather than silently persisting client values.
  3. Capabilities: loadSession=true, but load does not replay conversation history before responding, which ACP v1 requires. Implement replay or stop advertising it. SSE is mapped to the same HTTP config; please verify legacy SSE transport before advertising it. See /home/forge/worktrees/2968/crates/forge_app/src/acp/session_handlers.rs:65-77 and /home/forge/worktrees/2968/crates/forge_app/src/acp/session_handlers.rs:211-270.
  4. Cancellation/ownership: notify_waiters loses cancellation before a waiter exists. Chat setup, built-ins and interrupt-permission waits are outside the cancellation select; some early error exits skip active-session cleanup. A single permission-attribution slot assumes sequential prompts without enforcing it. See /home/forge/worktrees/2968/crates/forge_app/src/acp/adapter.rs:125-139, /home/forge/worktrees/2968/crates/forge_app/src/acp/prompt_handler.rs:61-114, /home/forge/worktrees/2968/crates/forge_app/src/acp/prompt_handler.rs:159-200, /home/forge/worktrees/2968/crates/forge_app/src/acp/prompt_handler.rs:314-360. Please test early/permission cancellation, overlapping sessions and recovery after errors.
  5. Model/config isolation and delivery: per-session model state is recorded but chat uses the shared reloaded agent config; set_config_option returns an empty options list. Notification try_send fails rather than applying backpressure, and built-ins explicitly permit updates after the prompt response. See /home/forge/worktrees/2968/crates/forge_app/src/acp/session_handlers.rs:273-331, /home/forge/worktrees/2968/crates/forge_app/src/acp/mod.rs:94-124, /home/forge/worktrees/2968/crates/forge_app/src/acp/adapter.rs:159-163, /home/forge/worktrees/2968/crates/forge_app/src/acp/prompt_handler.rs:75-86.

Small optional fix offered below: the private protocol fd created by dup is inheritable across exec. Use Rust's owned-fd cloning (close-on-exec) and retain RAII if redirection fails. Only this narrow fix and its regression test were prepared locally; it does not solve the pre-existing Windows/startup-order issues above. Please adopt it in this PR if useful.

Validation actually performed: metadata-only offline Cargo check passed; contributor diff whitespace check passed; workspace cargo fmt --all -- --check failed including ACP files; touched main.rs rustfmt and local patch whitespace checks passed. An isolated Linux rustc harness extracted the exact new helper/test body, using a minimal libc FFI shim and std assertions: fixed helper passes one test; original dup negative control fails (fd flags 0 vs close-on-exec 1). This is not a full crate/CLI test. cargo test --offline --locked -p forge_app acp:: --lib and targeted Clippy both stopped before compilation because agent-client-protocol is not cached.

Full crate tests, debug CLI --help/-p, raw stdio protocol and editor/platform verification remain unexecuted. Only ~1.5 GB disk headroom (95% used), so I avoided a large build/download and did not delete other work. Latest-head CI and autofix are action_required; Release Drafter alone is not verification. I did not approve workflows.

Scope proposal / maintainer decision: default-off beta; explicit supported OS/client matrix (the issue mentions Windows and Zed); JSON-only startup; honored session cwd and non-persistent MCP config; correct advertised capabilities; tested permission/cancellation/error/session-isolation paths; passing crate/lint/actual CI plus one agreed editor smoke. Is the prior request to build ACP above rather than inside the API still required? Please also split out or justify the unrelated Alibaba model-list change.

Durable local evidence: /home/forge/issue-patches/2968/review.txt, logs, source-extracted regression harness and fd-hardening.patch; isolated branch review/2968-acp at /home/forge/worktrees/2968. Keeping #2968 blocked pending these scope/code/verification items, not claiming PR readiness.

Optional close-on-exec patch and unit regression (not full-workspace verified)
diff --git a/crates/forge_main/src/main.rs b/crates/forge_main/src/main.rs
index 150c1a765..9c3405759 100644
--- a/crates/forge_main/src/main.rs
+++ b/crates/forge_main/src/main.rs
@@ -134,15 +134,14 @@ async fn run() -> Result<()> {
         // it, so move the pipe to a private descriptor and point fd 1 at
         // stderr before anything else runs.
         let protocol_out = {
-            use std::os::fd::FromRawFd;
-            // SAFETY: fd 1 and 2 are open for the life of the process; dup
-            // returns a fresh descriptor this File then owns exclusively.
+            use std::os::fd::AsFd;
+
+            let pipe = duplicate_protocol_output(std::io::stdout().as_fd())?;
+            // SAFETY: fd 1 and 2 are open for the life of the process.
             unsafe {
-                let pipe = libc::dup(1);
-                anyhow::ensure!(pipe >= 0, "failed to duplicate stdout");
                 anyhow::ensure!(libc::dup2(2, 1) >= 0, "failed to redirect stdout to stderr");
-                std::fs::File::from_raw_fd(pipe)
             }
+            pipe
         };
         let (api, user_choices) = ForgeAPI::init_acp(cwd, config);
         let _guard = tracker::init_tracing(api.environment().log_path())?;
@@ -157,6 +156,12 @@ async fn run() -> Result<()> {
     Ok(())
 }
 
+/// Duplicates the protocol output with close-on-exec so tools cannot inherit it.
+#[cfg(unix)]
+fn duplicate_protocol_output(fd: std::os::fd::BorrowedFd<'_>) -> std::io::Result<std::fs::File> {
+    fd.try_clone_to_owned().map(std::fs::File::from)
+}
+
 #[cfg(test)]
 mod tests {
     use forge_main::TopLevelCommand;
@@ -164,6 +169,21 @@ mod tests {
 
     use super::*;
 
+    #[cfg(unix)]
+    #[test]
+    fn test_protocol_output_is_close_on_exec() {
+        use std::os::fd::{AsFd, AsRawFd};
+
+        let fixture = std::fs::File::open("/dev/null").unwrap();
+        let protocol_out = duplicate_protocol_output(fixture.as_fd()).unwrap();
+
+        // SAFETY: protocol_out owns this descriptor for the duration of the call.
+        let actual = unsafe { libc::fcntl(protocol_out.as_raw_fd(), libc::F_GETFD) };
+
+        let expected = libc::FD_CLOEXEC;
+        assert_eq!(actual, expected);
+    }
+
     #[test]
     fn test_stdin_detection_logic() {
         // This test verifies that the logic for detecting stdin is correct

Co-Authored-By: ForgeCode noreply@forgecode.dev

@github-actions github-actions Bot removed the state: inactive No current action needed/possible; issue fixed, out of scope, or superseded. label Sep 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: feature Brand new functionality, features, pages, workflows, endpoints, etc. type: provider Updates provider.json configuration.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants