diff --git a/README.md b/README.md index e174f8d3..866c41f6 100644 --- a/README.md +++ b/README.md @@ -227,12 +227,35 @@ deepseek resume --last # resume the most recent sessio deepseek resume # resume a specific session by UUID deepseek fork # 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 # 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 | diff --git a/crates/tui/src/acp_server.rs b/crates/tui/src/acp_server.rs new file mode 100644 index 00000000..4cc4fdb3 --- /dev/null +++ b/crates/tui/src/acp_server.rs @@ -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, +} + +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( + &mut self, + method: &str, + params: Value, + writer: &mut W, + ) -> std::result::Result + 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 { + 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(&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 { + 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 { + 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) -> 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) -> Self { + Self { + code: -32603, + message: message.into(), + } + } +} + +fn initialize_result(client_protocol_version: Option) -> 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 { + match prompt? { + Value::String(text) => Some(text.clone()), + Value::Array(blocks) => { + let parts = blocks + .iter() + .filter_map(content_block_text) + .collect::>(); + (!parts.is_empty()).then(|| parts.join("\n\n")) + } + _ => None, + } +} + +fn content_block_text(block: &Value) -> Option { + 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 { + 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 { + let uri = block + .get("uri") + .or_else(|| block.pointer("/resource/uri")) + .and_then(Value::as_str)?; + Some(format!("@{uri}")) +} + +async fn write_session_update(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(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( + writer: &mut W, + id: Option, + code: i32, + message: impl Into, +) -> 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(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"); + } +} diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 33270850..bf633c15 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -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 } => { diff --git a/crates/tui/src/tui/osc8.rs b/crates/tui/src/tui/osc8.rs index b6a83523..156733be 100644 --- a/crates/tui/src/tui/osc8.rs +++ b/crates/tui/src/tui/osc8.rs @@ -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 diff --git a/docs/MCP.md b/docs/MCP.md index f0396adf..15a0d2c1 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -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 diff --git a/docs/RUNTIME_API.md b/docs/RUNTIME_API.md index a4a43859..eab38911 100644 --- a/docs/RUNTIME_API.md +++ b/docs/RUNTIME_API.md @@ -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.