feat(acp): add stdio adapter for editor agents (#782)

This commit is contained in:
Hunter Bown
2026-05-05 22:30:17 -05:00
committed by GitHub
parent 02456429ca
commit ece6b88e79
6 changed files with 535 additions and 18 deletions
+23
View File
@@ -227,12 +227,35 @@ deepseek resume --last # resume the most recent sessio
deepseek resume <SESSION_ID> # resume a specific session by UUID
deepseek fork <SESSION_ID> # fork a session at a chosen turn
deepseek serve --http # HTTP/SSE API server
deepseek serve --acp # ACP stdio adapter for Zed/custom agents
deepseek pr <N> # fetch PR and pre-seed review prompt
deepseek mcp list # list configured MCP servers
deepseek mcp validate # validate MCP config/connectivity
deepseek mcp-server # run dispatcher MCP stdio server
```
### Zed / ACP
DeepSeek can run as a custom Agent Client Protocol server for editors that
spawn local ACP agents over stdio. In Zed, add a custom agent server:
```json
{
"agent_servers": {
"DeepSeek": {
"type": "custom",
"command": "deepseek",
"args": ["serve", "--acp"],
"env": {}
}
}
}
```
The first ACP slice supports new sessions and prompt responses through your
existing DeepSeek config/API key. Tool-backed editing and checkpoint replay are
not exposed through ACP yet.
### Keyboard Shortcuts
| Key | Action |
+461
View File
@@ -0,0 +1,461 @@
//! Minimal Agent Client Protocol stdio adapter.
//!
//! This intentionally starts with the ACP baseline: initialize, new session,
//! prompt, and cancel. It keeps stdout protocol-clean for editor clients and
//! routes prompts through the same configured DeepSeek client as one-shot CLI
//! mode.
use std::collections::HashMap;
use std::path::PathBuf;
use anyhow::{Result, anyhow};
use serde_json::{Value, json};
use tokio::io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader};
use crate::client::DeepSeekClient;
use crate::config::Config;
use crate::llm_client::LlmClient;
use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt};
const ACP_PROTOCOL_VERSION: u64 = 1;
pub async fn run_acp_server(config: Config, model: String, default_cwd: PathBuf) -> Result<()> {
let stdin = tokio::io::stdin();
let stdout = tokio::io::stdout();
let mut reader = BufReader::new(stdin).lines();
let mut writer = tokio::io::BufWriter::new(stdout);
let mut server = AcpServer::new(config, model, default_cwd);
while let Some(line) = reader.next_line().await? {
if line.trim().is_empty() {
continue;
}
let message: Value = match serde_json::from_str(&line) {
Ok(value) => value,
Err(err) => {
write_jsonrpc_error(&mut writer, None, -32700, format!("invalid json: {err}"))
.await?;
continue;
}
};
if message.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
write_jsonrpc_error(
&mut writer,
message.get("id").cloned(),
-32600,
"jsonrpc version must be 2.0",
)
.await?;
continue;
}
let id = message.get("id").cloned();
let method = match message.get("method").and_then(Value::as_str) {
Some(method) => method,
None => {
write_jsonrpc_error(&mut writer, id, -32600, "missing method").await?;
continue;
}
};
let params = message.get("params").cloned().unwrap_or_else(|| json!({}));
match server.handle_request(method, params, &mut writer).await {
Ok(AcpDispatch::Response(result)) => {
if let Some(id) = id {
write_jsonrpc_result(&mut writer, id, result).await?;
}
}
Ok(AcpDispatch::Shutdown) => {
if let Some(id) = id {
write_jsonrpc_result(&mut writer, id, json!(null)).await?;
}
break;
}
Err(err) => {
write_jsonrpc_error(&mut writer, id, err.code, err.message).await?;
}
}
}
Ok(())
}
struct AcpServer {
config: Config,
model: String,
default_cwd: PathBuf,
sessions: HashMap<String, AcpSession>,
}
struct AcpSession {
cwd: PathBuf,
}
enum AcpDispatch {
Response(Value),
Shutdown,
}
struct AcpError {
code: i32,
message: String,
}
impl AcpServer {
fn new(config: Config, model: String, default_cwd: PathBuf) -> Self {
Self {
config,
model,
default_cwd,
sessions: HashMap::new(),
}
}
async fn handle_request<W>(
&mut self,
method: &str,
params: Value,
writer: &mut W,
) -> std::result::Result<AcpDispatch, AcpError>
where
W: AsyncWrite + Unpin,
{
match method {
"initialize" => Ok(AcpDispatch::Response(initialize_result(
params.get("protocolVersion").and_then(Value::as_u64),
))),
"session/new" => Ok(AcpDispatch::Response(self.new_session(params)?)),
"session/prompt" => {
self.prompt(params, writer).await?;
Ok(AcpDispatch::Response(json!({ "stopReason": "end_turn" })))
}
"session/cancel" => Ok(AcpDispatch::Response(json!(null))),
"shutdown" => Ok(AcpDispatch::Shutdown),
_ => Err(AcpError::method_not_found(method)),
}
}
fn new_session(&mut self, params: Value) -> std::result::Result<Value, AcpError> {
let cwd = params
.get("cwd")
.and_then(Value::as_str)
.map(PathBuf::from)
.unwrap_or_else(|| self.default_cwd.clone());
let session_id = format!("deepseek-{}", uuid::Uuid::new_v4());
self.sessions.insert(session_id.clone(), AcpSession { cwd });
Ok(json!({ "sessionId": session_id }))
}
async fn prompt<W>(&self, params: Value, writer: &mut W) -> std::result::Result<(), AcpError>
where
W: AsyncWrite + Unpin,
{
let session_id = params
.get("sessionId")
.and_then(Value::as_str)
.ok_or_else(|| AcpError::invalid_params("sessionId is required"))?;
let session = self
.sessions
.get(session_id)
.ok_or_else(|| AcpError::invalid_params("unknown sessionId"))?;
let prompt = extract_prompt_text(params.get("prompt"))
.filter(|text| !text.trim().is_empty())
.ok_or_else(|| AcpError::invalid_params("prompt must include text content"))?;
let output = self
.run_prompt(&prompt, &session.cwd)
.await
.map_err(|err| AcpError::internal(err.to_string()))?;
if !output.is_empty() {
write_session_update(writer, session_id, output)
.await
.map_err(|err| AcpError::internal(err.to_string()))?;
}
Ok(())
}
async fn run_prompt(&self, prompt: &str, cwd: &PathBuf) -> Result<String> {
let _cwd_guard = ScopedCurrentDir::new(cwd)?;
let client = DeepSeekClient::new(&self.config)?;
let route = crate::resolve_cli_auto_route(&self.config, &self.model, prompt).await;
let reasoning_effort = route
.reasoning_effort
.map(|effort| effort.as_setting().to_string());
let request = MessageRequest {
model: route.model,
messages: vec![Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: prompt.to_string(),
cache_control: None,
}],
}],
max_tokens: 4096,
system: Some(SystemPrompt::Text(
"You are a coding assistant inside an ACP-compatible editor. Give concise, actionable responses.".to_string(),
)),
tools: None,
tool_choice: None,
metadata: None,
thinking: None,
reasoning_effort,
stream: Some(false),
temperature: Some(0.2),
top_p: Some(0.9),
};
let response = client.create_message(request).await?;
let mut output = String::new();
for block in response.content {
if let ContentBlock::Text { text, .. } = block {
output.push_str(&text);
}
}
Ok(output)
}
}
struct ScopedCurrentDir {
prior: PathBuf,
}
impl ScopedCurrentDir {
fn new(cwd: &PathBuf) -> Result<Self> {
let prior = std::env::current_dir()?;
if cwd.as_os_str().is_empty() {
return Ok(Self { prior });
}
std::env::set_current_dir(cwd)
.map_err(|err| anyhow!("failed to enter ACP session cwd {}: {err}", cwd.display()))?;
Ok(Self { prior })
}
}
impl Drop for ScopedCurrentDir {
fn drop(&mut self) {
let _ = std::env::set_current_dir(&self.prior);
}
}
impl AcpError {
fn invalid_params(message: impl Into<String>) -> Self {
Self {
code: -32602,
message: message.into(),
}
}
fn method_not_found(method: &str) -> Self {
Self {
code: -32601,
message: format!("method not found: {method}"),
}
}
fn internal(message: impl Into<String>) -> Self {
Self {
code: -32603,
message: message.into(),
}
}
}
fn initialize_result(client_protocol_version: Option<u64>) -> Value {
json!({
"protocolVersion": client_protocol_version
.map(|version| version.min(ACP_PROTOCOL_VERSION))
.unwrap_or(ACP_PROTOCOL_VERSION),
"agentCapabilities": {
"loadSession": false,
"promptCapabilities": {
"image": false,
"audio": false,
"embeddedContext": true
},
"mcpCapabilities": {
"http": false,
"sse": false
},
"sessionCapabilities": {}
},
"agentInfo": {
"name": "deepseek",
"title": "DeepSeek TUI",
"version": env!("CARGO_PKG_VERSION")
},
"authMethods": []
})
}
fn extract_prompt_text(prompt: Option<&Value>) -> Option<String> {
match prompt? {
Value::String(text) => Some(text.clone()),
Value::Array(blocks) => {
let parts = blocks
.iter()
.filter_map(content_block_text)
.collect::<Vec<_>>();
(!parts.is_empty()).then(|| parts.join("\n\n"))
}
_ => None,
}
}
fn content_block_text(block: &Value) -> Option<String> {
match block.get("type").and_then(Value::as_str)? {
"text" => block
.get("text")
.and_then(Value::as_str)
.map(str::to_string),
"resource" => resource_text(block),
"resource_link" | "resourceLink" => resource_link_text(block),
_ => None,
}
}
fn resource_text(block: &Value) -> Option<String> {
let resource = block.get("resource").unwrap_or(block);
if let Some(text) = resource.get("text").and_then(Value::as_str) {
return Some(text.to_string());
}
resource_link_text(resource)
}
fn resource_link_text(block: &Value) -> Option<String> {
let uri = block
.get("uri")
.or_else(|| block.pointer("/resource/uri"))
.and_then(Value::as_str)?;
Some(format!("@{uri}"))
}
async fn write_session_update<W>(writer: &mut W, session_id: &str, text: String) -> Result<()>
where
W: AsyncWrite + Unpin,
{
let notification = json!({
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": session_id,
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {
"type": "text",
"text": text
}
}
}
});
write_json_line(writer, notification).await
}
async fn write_jsonrpc_result<W>(writer: &mut W, id: Value, result: Value) -> Result<()>
where
W: AsyncWrite + Unpin,
{
write_json_line(
writer,
json!({
"jsonrpc": "2.0",
"id": id,
"result": result
}),
)
.await
}
async fn write_jsonrpc_error<W>(
writer: &mut W,
id: Option<Value>,
code: i32,
message: impl Into<String>,
) -> Result<()>
where
W: AsyncWrite + Unpin,
{
write_json_line(
writer,
json!({
"jsonrpc": "2.0",
"id": id,
"error": {
"code": code,
"message": message.into()
}
}),
)
.await
}
async fn write_json_line<W>(writer: &mut W, value: Value) -> Result<()>
where
W: AsyncWrite + Unpin,
{
writer.write_all(value.to_string().as_bytes()).await?;
writer.write_all(b"\n").await?;
writer.flush().await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn initialize_advertises_baseline_acp_agent() {
let result = initialize_result(Some(1));
assert_eq!(result["protocolVersion"], 1);
assert_eq!(result["agentInfo"]["name"], "deepseek");
assert_eq!(result["agentCapabilities"]["loadSession"], false);
assert_eq!(
result["agentCapabilities"]["promptCapabilities"]["embeddedContext"],
true
);
assert_eq!(result["authMethods"], json!([]));
}
#[test]
fn extract_prompt_text_accepts_text_and_resource_blocks() {
let prompt = json!([
{ "type": "text", "text": "Review this file" },
{
"type": "resource",
"resource": {
"uri": "file:///tmp/app.rs",
"mimeType": "text/rust",
"text": "fn main() {}"
}
},
{ "type": "resource_link", "uri": "file:///tmp/lib.rs" }
]);
let text = extract_prompt_text(Some(&prompt)).expect("prompt text");
assert!(text.contains("Review this file"));
assert!(text.contains("fn main() {}"));
assert!(text.contains("@file:///tmp/lib.rs"));
}
#[tokio::test]
async fn session_update_is_protocol_clean_single_line_json() {
let mut out = Vec::new();
write_session_update(&mut out, "sess_1", "hello\nworld".to_string())
.await
.expect("write update");
let line = String::from_utf8(out).expect("utf8");
assert_eq!(line.lines().count(), 1);
let value: Value = serde_json::from_str(line.trim()).expect("json");
assert_eq!(value["method"], "session/update");
assert_eq!(value["params"]["sessionId"], "sess_1");
assert_eq!(value["params"]["update"]["content"]["text"], "hello\nworld");
}
}
+15 -3
View File
@@ -12,6 +12,7 @@ use dotenvy::dotenv;
use tempfile::NamedTempFile;
use wait_timeout::ChildExt;
mod acp_server;
mod audit;
mod auto_reasoning;
mod automation_manager;
@@ -387,6 +388,9 @@ struct ServeArgs {
/// Start runtime HTTP/SSE API server
#[arg(long)]
http: bool,
/// Start ACP server over stdio for editor clients such as Zed
#[arg(long)]
acp: bool,
/// Bind host for HTTP server (default localhost)
#[arg(long, default_value = "127.0.0.1")]
host: String,
@@ -689,8 +693,12 @@ async fn main() -> Result<()> {
let workspace = cli.workspace.clone().unwrap_or_else(|| {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
});
if args.mcp && args.http {
bail!("Choose exactly one server mode: --mcp or --http");
let selected_modes = [args.mcp, args.http, args.acp]
.into_iter()
.filter(|selected| *selected)
.count();
if selected_modes != 1 {
bail!("Choose exactly one server mode: --mcp, --http, or --acp");
}
if args.mcp {
mcp_server::run_mcp_server(workspace)
@@ -708,8 +716,12 @@ async fn main() -> Result<()> {
},
)
.await
} else if args.acp {
let config = load_config_from_cli(&cli)?;
let model = config.default_model();
acp_server::run_acp_server(config, model, workspace).await
} else {
bail!("No server mode specified. Use --mcp or --http.")
unreachable!("server mode count checked above")
}
}
Commands::Resume { session_id, last } => {
+1 -3
View File
@@ -139,9 +139,7 @@ pub fn strip_ansi_into(s: &str, out: &mut String) {
/// to `1` for continuation bytes / invalid leads so callers always make
/// forward progress.
fn utf8_seq_len(lead: u8) -> usize {
if lead < 0x80 {
1
} else if lead < 0xc0 {
if lead < 0xc0 {
1
} else if lead < 0xe0 {
2
+10 -8
View File
@@ -176,16 +176,18 @@ Tools from a self-hosted DeepSeek server follow the standard naming convention:
For example, the `shell` tool becomes `mcp_deepseek_shell`.
### MCP Server vs HTTP/SSE API
### MCP Server vs HTTP/SSE API vs ACP
| | `deepseek-tui serve --mcp` | `deepseek-tui serve --http` |
|---|---|---|
| **Protocol** | MCP stdio | HTTP/SSE JSON-RPC |
| **Use case** | Tool server for MCP clients | Runtime API for apps |
| **Config** | `~/.deepseek/mcp.json` entry | Direct URL connection |
| **Lifecycle** | Spawned per client session | Long-running daemon |
| | `deepseek-tui serve --mcp` | `deepseek-tui serve --http` | `deepseek-tui serve --acp` |
|---|---|---|---|
| **Protocol** | MCP stdio | HTTP/SSE JSON-RPC | ACP stdio |
| **Use case** | Tool server for MCP clients | Runtime API for apps | Editor agent for Zed/custom ACP clients |
| **Config** | `~/.deepseek/mcp.json` entry | Direct URL connection | Editor `agent_servers` custom command |
| **Lifecycle** | Spawned per client session | Long-running daemon | Spawned per editor agent session |
Use `mcp add-self` when you want DeepSeek tools available to other MCP clients. Use `serve --http` when building applications that consume the API directly.
Use `mcp add-self` when you want DeepSeek tools available to other MCP clients.
Use `serve --http` when building applications that consume the API directly.
Use `serve --acp` when an editor wants to talk to DeepSeek as an ACP agent.
### Verification
+25 -4
View File
@@ -1,10 +1,11 @@
# Runtime API & Integration Contract
DeepSeek TUI exposes a local runtime API through `deepseek serve --http` and
machine-readable health via `deepseek doctor --json`. This document is the
stable integration contract for native macOS workbench applications (and other
local supervisors) that embed the DeepSeek engine without screen-scraping
terminal output.
machine-readable health via `deepseek doctor --json`. It also exposes
`deepseek serve --acp` for editor clients that speak the Agent Client Protocol
over stdio. This document is the stable integration contract for native macOS
workbench applications (and other local supervisors) that embed the DeepSeek
engine without screen-scraping terminal output.
## Architecture
@@ -13,6 +14,7 @@ macOS workbench (or any local supervisor)
├─ deepseek doctor --json → machine-readable health & capability
├─ deepseek serve --http → HTTP/SSE runtime API
├─ deepseek serve --acp → ACP stdio agent for editors such as Zed
├─ deepseek serve --mcp → MCP stdio server
└─ deepseek [args] → interactive TUI session
```
@@ -20,6 +22,25 @@ macOS workbench (or any local supervisor)
The engine runs as a local-only process. All APIs bind to `localhost` by
default. No hosted relay, no provider-token custody, no secret leakage.
## ACP stdio adapter: `deepseek serve --acp`
`deepseek serve --acp` speaks JSON-RPC 2.0 over newline-delimited stdio for
ACP-compatible editor clients. The initial adapter implements the ACP baseline:
- `initialize`
- `session/new`
- `session/prompt`
- `session/cancel`
Prompt requests are routed through the configured DeepSeek client and current
default model. Responses are emitted as `session/update` agent message chunks
followed by a `session/prompt` response with `stopReason: "end_turn"`.
The adapter is intentionally conservative: it does not yet expose shell tools,
file-write tools, checkpoint replay, or session loading through ACP. Use
`deepseek serve --http` for the full local runtime API and `deepseek serve --mcp`
when another client needs DeepSeek's tools as MCP tools.
## Capability endpoint: `deepseek doctor --json`
Returns a JSON object describing the current installation's readiness state.