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 resume <SESSION_ID> # resume a specific session by UUID
deepseek fork <SESSION_ID> # fork a session at a chosen turn deepseek fork <SESSION_ID> # fork a session at a chosen turn
deepseek serve --http # HTTP/SSE API server 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 pr <N> # fetch PR and pre-seed review prompt
deepseek mcp list # list configured MCP servers deepseek mcp list # list configured MCP servers
deepseek mcp validate # validate MCP config/connectivity deepseek mcp validate # validate MCP config/connectivity
deepseek mcp-server # run dispatcher MCP stdio server 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 ### Keyboard Shortcuts
| Key | Action | | 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 tempfile::NamedTempFile;
use wait_timeout::ChildExt; use wait_timeout::ChildExt;
mod acp_server;
mod audit; mod audit;
mod auto_reasoning; mod auto_reasoning;
mod automation_manager; mod automation_manager;
@@ -387,6 +388,9 @@ struct ServeArgs {
/// Start runtime HTTP/SSE API server /// Start runtime HTTP/SSE API server
#[arg(long)] #[arg(long)]
http: bool, http: bool,
/// Start ACP server over stdio for editor clients such as Zed
#[arg(long)]
acp: bool,
/// Bind host for HTTP server (default localhost) /// Bind host for HTTP server (default localhost)
#[arg(long, default_value = "127.0.0.1")] #[arg(long, default_value = "127.0.0.1")]
host: String, host: String,
@@ -689,8 +693,12 @@ async fn main() -> Result<()> {
let workspace = cli.workspace.clone().unwrap_or_else(|| { let workspace = cli.workspace.clone().unwrap_or_else(|| {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}); });
if args.mcp && args.http { let selected_modes = [args.mcp, args.http, args.acp]
bail!("Choose exactly one server mode: --mcp or --http"); .into_iter()
.filter(|selected| *selected)
.count();
if selected_modes != 1 {
bail!("Choose exactly one server mode: --mcp, --http, or --acp");
} }
if args.mcp { if args.mcp {
mcp_server::run_mcp_server(workspace) mcp_server::run_mcp_server(workspace)
@@ -708,8 +716,12 @@ async fn main() -> Result<()> {
}, },
) )
.await .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 { } else {
bail!("No server mode specified. Use --mcp or --http.") unreachable!("server mode count checked above")
} }
} }
Commands::Resume { session_id, last } => { 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 /// to `1` for continuation bytes / invalid leads so callers always make
/// forward progress. /// forward progress.
fn utf8_seq_len(lead: u8) -> usize { fn utf8_seq_len(lead: u8) -> usize {
if lead < 0x80 { if lead < 0xc0 {
1
} else if lead < 0xc0 {
1 1
} else if lead < 0xe0 { } else if lead < 0xe0 {
2 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`. 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` | | | `deepseek-tui serve --mcp` | `deepseek-tui serve --http` | `deepseek-tui serve --acp` |
|---|---|---| |---|---|---|---|
| **Protocol** | MCP stdio | HTTP/SSE JSON-RPC | | **Protocol** | MCP stdio | HTTP/SSE JSON-RPC | ACP stdio |
| **Use case** | Tool server for MCP clients | Runtime API for apps | | **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 | | **Config** | `~/.deepseek/mcp.json` entry | Direct URL connection | Editor `agent_servers` custom command |
| **Lifecycle** | Spawned per client session | Long-running daemon | | **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 ### Verification
+25 -4
View File
@@ -1,10 +1,11 @@
# Runtime API & Integration Contract # Runtime API & Integration Contract
DeepSeek TUI exposes a local runtime API through `deepseek serve --http` and DeepSeek TUI exposes a local runtime API through `deepseek serve --http` and
machine-readable health via `deepseek doctor --json`. This document is the machine-readable health via `deepseek doctor --json`. It also exposes
stable integration contract for native macOS workbench applications (and other `deepseek serve --acp` for editor clients that speak the Agent Client Protocol
local supervisors) that embed the DeepSeek engine without screen-scraping over stdio. This document is the stable integration contract for native macOS
terminal output. workbench applications (and other local supervisors) that embed the DeepSeek
engine without screen-scraping terminal output.
## Architecture ## Architecture
@@ -13,6 +14,7 @@ macOS workbench (or any local supervisor)
├─ deepseek doctor --json → machine-readable health & capability ├─ deepseek doctor --json → machine-readable health & capability
├─ deepseek serve --http → HTTP/SSE runtime API ├─ 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 serve --mcp → MCP stdio server
└─ deepseek [args] → interactive TUI session └─ 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 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. 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` ## Capability endpoint: `deepseek doctor --json`
Returns a JSON object describing the current installation's readiness state. Returns a JSON object describing the current installation's readiness state.