From ebd1c7757e2d9b26fea38296a957437fa104567a Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Sat, 25 Apr 2026 11:24:10 +0800 Subject: [PATCH 1/3] refactor: move ACP integration to dedicated crate Use the official agent-client-protocol crate from a new bitfun-acp crate and keep bitfun-cli as the startup host only. Also move shared AgenticSystem assembly into core and fix UTF-8 cursor handling in the CLI startup workspace input. --- Cargo.toml | 1 + scripts/test-acp.js | 169 +++-- scripts/test-acp.sh | 72 +- src/apps/cli/Cargo.toml | 2 +- src/apps/cli/src/acp/handlers.rs | 711 ------------------ src/apps/cli/src/acp/mod.rs | 105 --- src/apps/cli/src/acp/protocol.rs | 436 ----------- src/apps/cli/src/acp/session.rs | 100 --- src/apps/cli/src/agent/mod.rs | 3 +- src/apps/cli/src/main.rs | 20 +- src/apps/cli/src/modes/chat.rs | 2 +- src/apps/cli/src/modes/exec.rs | 4 +- src/apps/cli/src/ui/startup.rs | 71 +- src/crates/acp/Cargo.toml | 22 + src/crates/acp/src/lib.rs | 11 + src/crates/acp/src/runtime.rs | 421 +++++++++++ src/crates/acp/src/server.rs | 225 ++++++ src/crates/core/src/agentic/mod.rs | 2 + .../core/src/agentic/system.rs} | 38 +- src/crates/transport/Cargo.toml | 1 - 20 files changed, 920 insertions(+), 1496 deletions(-) delete mode 100644 src/apps/cli/src/acp/handlers.rs delete mode 100644 src/apps/cli/src/acp/mod.rs delete mode 100644 src/apps/cli/src/acp/protocol.rs delete mode 100644 src/apps/cli/src/acp/session.rs create mode 100644 src/crates/acp/Cargo.toml create mode 100644 src/crates/acp/src/lib.rs create mode 100644 src/crates/acp/src/runtime.rs create mode 100644 src/crates/acp/src/server.rs rename src/{apps/cli/src/agent/agentic_system.rs => crates/core/src/agentic/system.rs} (72%) diff --git a/Cargo.toml b/Cargo.toml index 5f6785bb8..8225668d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ "src/crates/events", "src/crates/ai-adapters", + "src/crates/acp", "src/crates/core", "src/crates/transport", "src/crates/api-layer", diff --git a/scripts/test-acp.js b/scripts/test-acp.js index f6bfd5c09..afe8d0236 100644 --- a/scripts/test-acp.js +++ b/scripts/test-acp.js @@ -1,98 +1,101 @@ -// Simple test client for BitFun ACP server +// Simple test client for BitFun ACP server. // Run with: node scripts/test-acp.js -const { spawn } = require('child_process'); -const path = require('path'); +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; -// Check if bitfun-cli exists +const __dirname = path.dirname(fileURLToPath(import.meta.url)); const cliPath = path.join(__dirname, '..', 'target', 'debug', 'bitfun-cli'); const cliReleasePath = path.join(__dirname, '..', 'target', 'release', 'bitfun-cli'); +const usePath = fs.existsSync(cliPath) + ? cliPath + : fs.existsSync(cliReleasePath) + ? cliReleasePath + : 'bitfun-cli'; -const usePath = require('fs').existsSync(cliPath) ? cliPath : - require('fs').existsSync(cliReleasePath) ? cliReleasePath : - 'bitfun-cli'; +const cwd = '/tmp/test-acp-node'; +fs.mkdirSync(cwd, { recursive: true }); console.log('=== BitFun ACP Server Test (Node.js) ===\n'); -// Test requests -const testRequests = [ - { - name: 'Initialize', - request: { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: 1, - clientCapabilities: { - fs: { readTextFile: true, writeTextFile: true }, - terminal: true - }, - clientInfo: { name: 'NodeTestClient', version: '1.0' } - } - } - }, - { - name: 'Create Session', - request: { - jsonrpc: '2.0', - id: 2, - method: 'session/new', - params: { - cwd: '/tmp/test-acp-node' - } - } - }, - { - name: 'List Tools', - request: { - jsonrpc: '2.0', - id: 3, - method: 'tools/list' - } - } -]; +const child = spawn(usePath, ['acp'], { + stdio: ['pipe', 'pipe', 'inherit'], +}); + +let buffer = ''; +let sessionId = null; + +function send(request) { + child.stdin.write(`${JSON.stringify(request)}\n`); +} -// Run individual tests -async function runTest(test) { - console.log(`Test: ${test.name}`); - console.log('Request:', JSON.stringify(test.request, null, 2)); - - const child = spawn(usePath, ['acp'], { - stdio: ['pipe', 'pipe', 'inherit'] - }); - - let output = ''; - - child.stdout.on('data', (data) => { - output += data.toString(); - }); - - child.stdin.write(JSON.stringify(test.request) + '\n'); +function stopChild() { child.stdin.end(); - - return new Promise((resolve) => { - child.on('close', (code) => { - console.log('Response:', output); - try { - const response = JSON.parse(output); - console.log('Parsed:', JSON.stringify(response, null, 2)); - } catch (e) { - console.log('Parse error:', e.message); - } - console.log('\n'); - resolve(); - }); - }); + setTimeout(() => { + if (!child.killed) { + child.kill('SIGTERM'); + } + }, 500); } -// Run all tests sequentially -async function runAllTests() { - for (const test of testRequests) { - await runTest(test); +child.stdout.on('data', (data) => { + buffer += data.toString(); + const lines = buffer.split(/\n/); + buffer = lines.pop(); + + for (const line of lines) { + if (!line.trim()) continue; + const message = JSON.parse(line); + console.log(JSON.stringify(message, null, 2)); + + if (message.id === 2) { + sessionId = message.result.sessionId; + send({ + jsonrpc: '2.0', + id: 3, + method: 'session/list', + params: { cwd }, + }); + } else if (message.id === 3) { + send({ + jsonrpc: '2.0', + id: 4, + method: 'session/prompt', + params: { + sessionId, + prompt: [{ type: 'text', text: '你好' }], + }, + }); + } else if (message.id === 4) { + stopChild(); + } } - - console.log('=== Tests Complete ==='); -} +}); + +child.on('close', (code) => { + console.log(`\n=== Tests Complete: exit ${code} ===`); + process.exit(code); +}); + +send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + terminal: true, + }, + clientInfo: { name: 'NodeTestClient', version: '1.0' }, + }, +}); -runAllTests().catch(console.error); \ No newline at end of file +send({ + jsonrpc: '2.0', + id: 2, + method: 'session/new', + params: { cwd, mcpServers: [] }, +}); diff --git a/scripts/test-acp.sh b/scripts/test-acp.sh index ca57fd292..21780ab2c 100644 --- a/scripts/test-acp.sh +++ b/scripts/test-acp.sh @@ -5,34 +5,62 @@ echo "=== BitFun ACP Server Test ===" echo "" -# Check if bitfun-cli is built -if ! command -v bitfun-cli &> /dev/null; then - echo "Error: bitfun-cli not found in PATH" - echo "Please build the CLI first: cargo build --package bitfun-cli" - exit 1 -fi +BINARY="${BITFUN_CLI:-target/debug/bitfun-cli}" +WORKSPACE="/tmp/test-acp" +PIPE_DIR="$(mktemp -d /tmp/bitfun-acp-test-sh.XXXXXX)" +ACP_IN="$PIPE_DIR/in" +ACP_OUT="$PIPE_DIR/out" +mkdir -p "$WORKSPACE" +mkfifo "$ACP_IN" "$ACP_OUT" -echo "Test 1: Initialize" -echo "Sending: initialize request" -echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":true,"writeTextFile":true},"terminal":true},"clientInfo":{"name":"TestClient","version":"1.0"}}}' | bitfun-cli acp -echo "" +cleanup() { + exec 3>&- 2>/dev/null || true + exec 4<&- 2>/dev/null || true + if [[ -n "${ACP_PID:-}" ]]; then + kill "$ACP_PID" 2>/dev/null || true + wait "$ACP_PID" 2>/dev/null || true + fi + rm -rf "$PIPE_DIR" +} +trap cleanup EXIT +echo "Test 1: Initialize" echo "Test 2: Create Session" -echo "Sending: session/new request" -echo '{"jsonrpc":"2.0","id":2,"method":"session/new","params":{"cwd":"/tmp/test-acp"}}' | bitfun-cli acp -echo "" +echo "Test 3: List Sessions" +"$BINARY" acp <"$ACP_IN" >"$ACP_OUT" & +ACP_PID="$!" +exec 3>"$ACP_IN" +exec 4<"$ACP_OUT" -echo "Test 3: List Tools" -echo "Sending: tools/list request" -echo '{"jsonrpc":"2.0","id":3,"method":"tools/list"}' | bitfun-cli acp -echo "" +printf '%s\n' \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":true,"writeTextFile":true},"terminal":true},"clientInfo":{"name":"TestClient","version":"1.0"}}}' \ + >&3 + +responses=0 +while [[ "$responses" -lt 3 ]]; do + if ! IFS= read -r -t 15 line <&4; then + echo "Timed out waiting for ACP response" >&2 + exit 1 + fi + + echo "$line" + if [[ "$line" == *'"id":'* ]]; then + responses=$((responses + 1)) + fi -echo "Test 4: List Sessions" -echo "Sending: session/list request" -echo '{"jsonrpc":"2.0","id":4,"method":"session/list"}' | bitfun-cli acp + if [[ "$line" == *'"id":1'* ]]; then + printf '%s\n' \ + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"session/new\",\"params\":{\"cwd\":\"$WORKSPACE\",\"mcpServers\":[]}}" \ + >&3 + elif [[ "$line" == *'"id":2'* ]]; then + printf '%s\n' \ + "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"session/list\",\"params\":{\"cwd\":\"$WORKSPACE\"}}" \ + >&3 + fi +done +exec 3>&- echo "" echo "=== Tests Complete ===" echo "" -echo "Note: This is a basic test of the protocol layer." -echo "Full agentic workflow execution is not yet implemented." \ No newline at end of file +echo "Note: This is a basic test of the typed ACP protocol layer." diff --git a/src/apps/cli/Cargo.toml b/src/apps/cli/Cargo.toml index 5795fc02b..6dd42ccf0 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -13,6 +13,7 @@ path = "src/main.rs" # Internal crates bitfun-core = { path = "../../crates/core" } bitfun-events = { path = "../../crates/events" } +bitfun-acp = { path = "../../crates/acp" } # CLI framework clap = { version = "4", features = ["derive"] } @@ -49,4 +50,3 @@ tracing-subscriber = { workspace = true } [features] default = [] - diff --git a/src/apps/cli/src/acp/handlers.rs b/src/apps/cli/src/acp/handlers.rs deleted file mode 100644 index ae056dd29..000000000 --- a/src/apps/cli/src/acp/handlers.rs +++ /dev/null @@ -1,711 +0,0 @@ -//! ACP Request Handlers -//! -//! Implements handlers for all ACP methods. - -use anyhow::{anyhow, Context, Result}; -use std::sync::Arc; -use tokio::io::{AsyncWriteExt, Stdout}; - -use crate::acp::protocol::*; -use crate::acp::session::{AcpSession, AcpSessionManager}; -use crate::agent::AgenticSystem; -use bitfun_core::agentic::coordination::{DialogSubmissionPolicy, DialogTriggerSource}; -use bitfun_core::agentic::core::SessionConfig; -use bitfun_core::agentic::tools::framework::{ToolResult, ToolUseContext}; -use bitfun_events::{AgenticEvent as CoreEvent, ToolEventData}; - -/// Handle an ACP method call -pub async fn handle_method( - request: JsonRpcRequest, - agentic_system: &AgenticSystem, - session_manager: &Arc, -) -> Result> { - let method = request.method.as_str(); - - tracing::info!("Handling ACP method: {}", method); - - let result = match method { - // Lifecycle methods - "initialize" => handle_initialize(&request)?, - "authenticate" => handle_authenticate(&request)?, - - // Session methods - "session/new" => handle_session_new(&request, session_manager, agentic_system).await?, - "session/load" => handle_session_load(&request)?, - "session/prompt" => { - handle_session_prompt(&request, agentic_system, session_manager).await? - } - "session/cancel" => { - // Notification - no response - handle_session_cancel(&request, session_manager).await?; - return Ok(None); - } - "session/list" => handle_session_list(&request, session_manager)?, - - // Tools methods - "tools/list" => handle_tools_list(&request, agentic_system).await?, - "tools/call" => handle_tools_call(&request, agentic_system, session_manager).await?, - - // Config methods - "session/set_config_option" => handle_set_config_option(&request)?, - "session/set_mode" => handle_set_mode(&request)?, - - // Unknown method - _ => { - if let Some(id) = request.id { - return Ok(Some(JsonRpcResponse::error( - id, - -32601, - format!("Method not found: {}", method), - ))); - } else { - // Notification for unknown method, just ignore - return Ok(None); - } - } - }; - - if let Some(id) = request.id { - Ok(Some(JsonRpcResponse::success(id, result))) - } else { - // Notification - Ok(None) - } -} - -/// Handle initialize request -fn handle_initialize(request: &JsonRpcRequest) -> Result { - let params: InitializeParams = serde_json::from_value( - request - .params - .as_ref() - .ok_or_else(|| anyhow!("Missing params for initialize"))? - .clone(), - ) - .context("Failed to parse initialize params")?; - - tracing::info!( - "ACP initialization: protocol_version={}, client_name={:?}", - params.protocol_version, - params.client_info.as_ref().map(|i| &i.name) - ); - - let result = InitializeResult { - protocol_version: "0.1.0".to_string(), // ACP protocol version - agent_capabilities: AgentCapabilities { - load_session: true, - mcp_capabilities: McpCapabilities { - http: true, - sse: true, - }, - prompt_capabilities: PromptCapabilities { - audio: false, - embedded_context: true, - image: true, - }, - session_capabilities: SessionCapabilities { list: true }, - }, - agent_info: Some(AgentInfo { - name: "BitFun".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - }), - auth_methods: vec![], // No authentication required - }; - - Ok(serde_json::to_value(result)?) -} - -/// Handle authenticate request -fn handle_authenticate(_request: &JsonRpcRequest) -> Result { - // BitFun doesn't require authentication - Ok(serde_json::json!({ "success": true })) -} - -/// Handle session/new request -async fn handle_session_new( - request: &JsonRpcRequest, - session_manager: &Arc, - agentic_system: &AgenticSystem, -) -> Result { - let params: SessionNewParams = serde_json::from_value( - request - .params - .as_ref() - .ok_or_else(|| anyhow!("Missing params for session/new"))? - .clone(), - ) - .context("Failed to parse session/new params")?; - - tracing::info!("Creating new ACP session: cwd={}", params.cwd); - - // Create ACP session - let client_caps = ClientCapabilities::default(); // TODO: Get from previous initialize - let acp_session = session_manager.create_session(params.cwd.clone(), client_caps)?; - - // Create a BitFun session via ConversationCoordinator - let workspace_path = Some(params.cwd.clone()); - let session = agentic_system - .coordinator - .create_session( - format!( - "ACP Session - {}", - chrono::Local::now().format("%Y-%m-%d %H:%M:%S") - ), - "agentic".to_string(), - SessionConfig { - workspace_path, - ..Default::default() - }, - ) - .await?; - - // Update the ACP session with the real BitFun session ID - session_manager - .update_bitfun_session_id(&acp_session.acp_session_id, session.session_id.clone()); - - tracing::info!( - "Created BitFun session for ACP: acp_id={}, bitfun_id={}", - acp_session.acp_session_id, - session.session_id - ); - - let result = SessionNewResult { - session_id: acp_session.acp_session_id.clone(), - config_options: Some(vec![]), // No special config options - modes: Some(SessionModes { - available_modes: vec![ - ModeInfo { - id: "ask".to_string(), - name: Some("Ask".to_string()), - description: Some("Ask questions and get information".to_string()), - }, - ModeInfo { - id: "architect".to_string(), - name: Some("Architect".to_string()), - description: Some("Design and plan architecture".to_string()), - }, - ModeInfo { - id: "code".to_string(), - name: Some("Code".to_string()), - description: Some("Write and modify code".to_string()), - }, - ], - current_mode: Some("code".to_string()), - }), - }; - - Ok(serde_json::to_value(result)?) -} - -/// Handle session/load request -fn handle_session_load(_request: &JsonRpcRequest) -> Result { - // TODO: Implement session loading - Err(anyhow!("Session loading not yet implemented")) -} - -/// Handle session/prompt request - executes user message with BitFun's agentic system -async fn handle_session_prompt( - request: &JsonRpcRequest, - agentic_system: &AgenticSystem, - session_manager: &Arc, -) -> Result { - let params: SessionPromptParams = serde_json::from_value( - request - .params - .as_ref() - .ok_or_else(|| anyhow!("Missing params for session/prompt"))? - .clone(), - ) - .context("Failed to parse session/prompt params")?; - - tracing::info!( - "Processing session/prompt: session_id={}, prompt_blocks={}", - params.session_id, - params.prompt.len() - ); - - // Get ACP session - let acp_session = session_manager - .get_session(¶ms.session_id) - .ok_or_else(|| anyhow!("Session not found: {}", params.session_id))?; - - // Extract text from prompt content blocks - let user_message = params - .prompt - .iter() - .filter_map(|block| match block { - ContentBlock::Text { text } => Some(text.clone()), - _ => None, - }) - .collect::>() - .join("\n"); - - if user_message.is_empty() { - return Err(anyhow!("Empty user message")); - } - - tracing::info!( - "User message for session {}: {}", - acp_session.bitfun_session_id, - user_message.chars().take(100).collect::() - ); - - // Get stdout for sending notifications - let stdout = tokio::io::stdout(); - - // Execute the message through ConversationCoordinator - let result = execute_prompt_turn(agentic_system, &acp_session, user_message, stdout).await?; - - Ok(serde_json::to_value(result)?) -} - -/// Execute a prompt turn using BitFun's ConversationCoordinator -async fn execute_prompt_turn( - agentic_system: &AgenticSystem, - acp_session: &AcpSession, - user_message: String, - mut stdout: Stdout, -) -> Result { - let session_id = acp_session.bitfun_session_id.clone(); - let agent_type = "agentic".to_string(); - - tracing::info!("Starting dialog turn for session: {}", session_id); - - // Start the dialog turn - agentic_system - .coordinator - .start_dialog_turn( - session_id.clone(), - user_message.clone(), - None, - None, - agent_type.clone(), - Some(acp_session.cwd.clone()), - DialogSubmissionPolicy::for_source(DialogTriggerSource::Cli), - ) - .await?; - - let event_queue = agentic_system.event_queue.clone(); - let mut stop_reason: Option = None; - let mut accumulated_text = String::new(); - - loop { - let events = event_queue.dequeue_batch(10).await; - - if events.is_empty() { - if stop_reason.is_some() { - break; - } - - tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; - continue; - } - - for envelope in events { - let event = envelope.event; - - if event.session_id() != Some(&session_id) { - continue; - } - - match event { - CoreEvent::TextChunk { text, .. } => { - accumulated_text.push_str(&text); - let notification = SessionUpdateNotification { - session_id: acp_session.acp_session_id.clone(), - update: SessionUpdate::AgentMessageChunk { - content: ContentBlock::Text { text }, - }, - }; - send_notification(&mut stdout, "session/update", ¬ification).await?; - } - - CoreEvent::ToolEvent { tool_event, .. } => { - handle_tool_event(&mut stdout, &acp_session.acp_session_id, tool_event).await?; - } - - CoreEvent::DialogTurnCompleted { .. } => { - tracing::info!("Dialog turn completed in ACP handler"); - stop_reason = Some(StopReason::EndTurn); - break; - } - - CoreEvent::DialogTurnFailed { error, .. } => { - tracing::error!("Dialog turn failed: {}", error); - stop_reason = Some(StopReason::Error); - let notification = SessionUpdateNotification { - session_id: acp_session.acp_session_id.clone(), - update: SessionUpdate::AgentMessageChunk { - content: ContentBlock::Text { - text: format!("Error: {}", error), - }, - }, - }; - send_notification(&mut stdout, "session/update", ¬ification).await?; - break; - } - - CoreEvent::SystemError { error, .. } => { - tracing::error!("System error: {}", error); - stop_reason = Some(StopReason::Error); - break; - } - - _ => { - tracing::debug!("Ignoring event: {:?}", event); - } - } - } - } - - tracing::info!( - "Dialog turn finished: stop_reason={:?}, text_len={}", - stop_reason, - accumulated_text.len(), - ); - - Ok(SessionPromptResult { - stop_reason: stop_reason.unwrap_or(StopReason::EndTurn), - }) -} - -/// Handle tool event and send appropriate notification -async fn handle_tool_event( - stdout: &mut Stdout, - session_id: &str, - tool_event: ToolEventData, -) -> Result<()> { - match tool_event { - ToolEventData::Started { - tool_id, - tool_name, - params: _, - } => { - let notification = SessionUpdateNotification { - session_id: session_id.to_string(), - update: SessionUpdate::ToolCall { - tool_call_id: tool_id, - name: tool_name, - title: None, - kind: None, - status: Some(ToolCallStatus::Pending), - }, - }; - send_notification(stdout, "session/update", ¬ification).await?; - } - - ToolEventData::Progress { - tool_id, - tool_name, - message, - percentage: _, - } => { - let notification = SessionUpdateNotification { - session_id: session_id.to_string(), - update: SessionUpdate::ToolCall { - tool_call_id: tool_id.clone(), - name: tool_name.clone(), - title: None, - kind: None, - status: Some(ToolCallStatus::InProgress), - }, - }; - send_notification(stdout, "session/update", ¬ification).await?; - - // Send tool result with progress message - let result_notification = SessionUpdateNotification { - session_id: session_id.to_string(), - update: SessionUpdate::ToolResult { - tool_call_id: tool_id, - content: vec![ToolResultContent::Text { text: message }], - status: Some(ToolCallStatus::InProgress), - }, - }; - send_notification(stdout, "session/update", &result_notification).await?; - } - - ToolEventData::Completed { - tool_id, - tool_name, - result, - duration_ms: _, - .. - } => { - let result_text = - serde_json::to_string(&result).unwrap_or_else(|_| "Success".to_string()); - - let notification = SessionUpdateNotification { - session_id: session_id.to_string(), - update: SessionUpdate::ToolCall { - tool_call_id: tool_id.clone(), - name: tool_name.clone(), - title: None, - kind: None, - status: Some(ToolCallStatus::Completed), - }, - }; - send_notification(stdout, "session/update", ¬ification).await?; - - // Send tool result - let result_notification = SessionUpdateNotification { - session_id: session_id.to_string(), - update: SessionUpdate::ToolResult { - tool_call_id: tool_id, - content: vec![ToolResultContent::Text { text: result_text }], - status: Some(ToolCallStatus::Completed), - }, - }; - send_notification(stdout, "session/update", &result_notification).await?; - } - - ToolEventData::Failed { - tool_id, - tool_name, - error, - } => { - let notification = SessionUpdateNotification { - session_id: session_id.to_string(), - update: SessionUpdate::ToolCall { - tool_call_id: tool_id.clone(), - name: tool_name.clone(), - title: None, - kind: None, - status: None, // Failed - no specific status - }, - }; - send_notification(stdout, "session/update", ¬ification).await?; - - // Send tool result with error - let result_notification = SessionUpdateNotification { - session_id: session_id.to_string(), - update: SessionUpdate::ToolResult { - tool_call_id: tool_id, - content: vec![ToolResultContent::Text { text: error }], - status: None, // Failed - }, - }; - send_notification(stdout, "session/update", &result_notification).await?; - } - - ToolEventData::ConfirmationNeeded { - tool_id, - tool_name, - params: _, - } => { - let notification = SessionUpdateNotification { - session_id: session_id.to_string(), - update: SessionUpdate::ToolCall { - tool_call_id: tool_id, - name: tool_name, - title: None, - kind: None, - status: Some(ToolCallStatus::Pending), - }, - }; - send_notification(stdout, "session/update", ¬ification).await?; - } - - _ => { - // Ignore other tool events for now - } - } - - Ok(()) -} - -/// Send a JSON-RPC notification via stdout -async fn send_notification( - stdout: &mut Stdout, - method: &str, - params: &impl serde::Serialize, -) -> Result<()> { - let notification = JsonRpcRequest::new( - None, - method.to_string(), - Some(serde_json::to_value(params)?), - ); - - let notification_json = serde_json::to_string(¬ification)?; - tracing::debug!("Sending notification: {}", notification_json); - - stdout.write_all(notification_json.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - - Ok(()) -} - -/// Handle session/cancel notification -async fn handle_session_cancel( - _request: &JsonRpcRequest, - _session_manager: &Arc, -) -> Result<()> { - tracing::info!("Received session/cancel notification"); - - // TODO: Implement cancellation - // 1. Stop ongoing model requests - // 2. Abort tool invocations - // 3. Send pending session/update notifications - // 4. Mark session as cancelled - - Ok(()) -} - -/// Handle session/list request -fn handle_session_list( - _request: &JsonRpcRequest, - session_manager: &Arc, -) -> Result { - let sessions = session_manager.list_sessions(); - - let session_infos: Vec = sessions - .iter() - .map(|s| { - serde_json::json!({ - "sessionId": s.acp_session_id, - "cwd": s.cwd, - }) - }) - .collect(); - - Ok(serde_json::json!({ - "sessions": session_infos, - })) -} - -/// Handle tools/list request -async fn handle_tools_list( - _request: &JsonRpcRequest, - _agentic_system: &AgenticSystem, -) -> Result { - tracing::info!("Listing available tools"); - - // Get tools from BitFun's tool registry - let registry = bitfun_core::agentic::tools::registry::get_global_tool_registry(); - let registry_lock = registry.read().await; - let all_tools = registry_lock.get_all_tools(); - - // Build tool definitions (need to await description) - let mut tools: Vec = Vec::new(); - for tool in all_tools.iter() { - let desc = tool - .description() - .await - .map_err(|e| anyhow!("Failed to get tool description: {}", e))?; - tools.push(ToolDefinition { - name: tool.name().to_string(), - description: Some(desc), - input_schema: Some(tool.input_schema().clone()), - }); - } - - let result = ToolsListResult { tools }; - - Ok(serde_json::to_value(result)?) -} - -/// Handle tools/call request - executes a tool directly -async fn handle_tools_call( - request: &JsonRpcRequest, - agentic_system: &AgenticSystem, - session_manager: &Arc, -) -> Result { - let params: ToolsCallParams = serde_json::from_value( - request - .params - .as_ref() - .ok_or_else(|| anyhow!("Missing params for tools/call"))? - .clone(), - ) - .context("Failed to parse tools/call params")?; - - tracing::info!( - "Tool call request: session_id={}, tool_name={}", - params.session_id, - params.name - ); - - // Get ACP session - let acp_session = session_manager - .get_session(¶ms.session_id) - .ok_or_else(|| anyhow!("Session not found: {}", params.session_id))?; - - // Get tool from registry - let registry = bitfun_core::agentic::tools::registry::get_global_tool_registry(); - let registry_lock = registry.read().await; - let tool = registry_lock - .get_tool(¶ms.name) - .ok_or_else(|| anyhow!("Tool not found: {}", params.name))?; - - // Create tool use context - let context = ToolUseContext { - tool_call_id: None, - agent_type: Some("agentic".to_string()), - session_id: Some(acp_session.bitfun_session_id.clone()), - dialog_turn_id: None, - workspace: None, - custom_data: std::collections::HashMap::new(), - computer_use_host: None, - cancellation_token: None, - runtime_tool_restrictions: Default::default(), - workspace_services: None, - }; - - tracing::info!( - "Executing tool {} with arguments: {:?}", - params.name, - params.arguments - ); - - // Execute the tool - let tool_results = tool - .call(¶ms.arguments, &context) - .await - .map_err(|e| anyhow!("Tool execution failed: {}", e))?; - - // Convert tool results to ACP content blocks - let content: Vec = tool_results - .into_iter() - .filter_map(|result| { - match result { - ToolResult::Result { - data, - result_for_assistant, - .. - } => { - // Use result_for_assistant if available, otherwise serialize data - let text = result_for_assistant.unwrap_or_else(|| { - serde_json::to_string(&data).unwrap_or_else(|_| "Success".to_string()) - }); - Some(ToolResultContent::Text { text }) - } - ToolResult::Progress { content: data, .. } => { - let text = - serde_json::to_string(&data).unwrap_or_else(|_| "Progress".to_string()); - Some(ToolResultContent::Text { text }) - } - ToolResult::StreamChunk { data, .. } => { - let text = - serde_json::to_string(&data).unwrap_or_else(|_| "Stream chunk".to_string()); - Some(ToolResultContent::Text { text }) - } - } - }) - .collect(); - - let result = ToolsCallResult { content }; - - Ok(serde_json::to_value(result)?) -} - -/// Handle session/set_config_option request -fn handle_set_config_option(_request: &JsonRpcRequest) -> Result { - // TODO: Implement config options - Ok(serde_json::json!({ "success": true })) -} - -/// Handle session/set_mode request -fn handle_set_mode(_request: &JsonRpcRequest) -> Result { - // TODO: Implement mode switching - Ok(serde_json::json!({ "success": true })) -} diff --git a/src/apps/cli/src/acp/mod.rs b/src/apps/cli/src/acp/mod.rs deleted file mode 100644 index 3d1b42e9d..000000000 --- a/src/apps/cli/src/acp/mod.rs +++ /dev/null @@ -1,105 +0,0 @@ -//! Agent Client Protocol (ACP) Support -//! -//! This module implements the Agent Client Protocol for BitFun CLI, -//! enabling integration with ACP-compatible editors and IDEs. -//! -//! ACP is a JSON-RPC 2.0 based protocol for communication between -//! code editors/IDEs and AI coding agents. - -pub mod handlers; -pub mod protocol; -pub mod session; - -use anyhow::{Context, Result}; -use std::sync::Arc; - -use crate::agent::AgenticSystem; - -pub use protocol::*; -pub use session::*; - -/// ACP Server - handles JSON-RPC communication over stdio -pub struct AcpServer { - agentic_system: AgenticSystem, - session_manager: Arc, -} - -impl AcpServer { - /// Create a new ACP server - pub fn new(agentic_system: AgenticSystem) -> Self { - Self { - session_manager: Arc::new(AcpSessionManager::new()), - agentic_system, - } - } - - /// Run the ACP server - reads JSON-RPC from stdin, writes to stdout - pub async fn run(&self) -> Result<()> { - use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - - tracing::info!("Starting ACP server (JSON-RPC over stdio)"); - - let stdin = tokio::io::stdin(); - let mut stdout = tokio::io::stdout(); - let mut reader = BufReader::new(stdin); - let mut line = String::new(); - - loop { - line.clear(); - let bytes_read = reader.read_line(&mut line).await?; - - if bytes_read == 0 { - tracing::info!("EOF received, shutting down ACP server"); - break; - } - - let request = line.trim(); - if request.is_empty() { - continue; - } - - tracing::debug!("Received ACP request: {}", request); - - match self.handle_request(request).await { - Ok(Some(response)) => { - let response_json = serde_json::to_string(&response)?; - tracing::debug!("Sending ACP response: {}", response_json); - stdout.write_all(response_json.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - } - Ok(None) => { - // Notification, no response needed - tracing::debug!("ACP notification processed (no response needed)"); - } - Err(e) => { - tracing::error!("Error handling ACP request: {}", e); - // Send error response if we can parse the request ID - if let Ok(json_value) = serde_json::from_str::(request) { - if let Some(id) = json_value.get("id") { - let error_response = JsonRpcResponse::error( - id.clone(), - -32603, - format!("Internal error: {}", e), - ); - let error_json = serde_json::to_string(&error_response)?; - stdout.write_all(error_json.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - } - } - } - } - } - - Ok(()) - } - - /// Handle a single JSON-RPC request - async fn handle_request(&self, request: &str) -> Result> { - let rpc_request: JsonRpcRequest = - serde_json::from_str(request).context("Failed to parse JSON-RPC request")?; - - handlers::handle_method(rpc_request, &self.agentic_system, &self.session_manager).await - } -} diff --git a/src/apps/cli/src/acp/protocol.rs b/src/apps/cli/src/acp/protocol.rs deleted file mode 100644 index 3cc264fb3..000000000 --- a/src/apps/cli/src/acp/protocol.rs +++ /dev/null @@ -1,436 +0,0 @@ -//! JSON-RPC Protocol Types for ACP -//! -//! Defines the JSON-RPC 2.0 message structures used by ACP. - -use serde::{Deserialize, Serialize}; - -/// JSON-RPC 2.0 Request -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JsonRpcRequest { - pub jsonrpc: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - pub method: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub params: Option, -} - -impl JsonRpcRequest { - /// Create a new JSON-RPC request - pub fn new( - id: Option, - method: String, - params: Option, - ) -> Self { - Self { - jsonrpc: "2.0".to_string(), - id, - method, - params, - } - } - - /// Check if this is a notification (no response expected) - pub fn is_notification(&self) -> bool { - self.id.is_none() - } -} - -/// JSON-RPC 2.0 Response -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JsonRpcResponse { - pub jsonrpc: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -impl JsonRpcResponse { - /// Create a successful response - pub fn success(id: serde_json::Value, result: serde_json::Value) -> Self { - Self { - jsonrpc: "2.0".to_string(), - id: Some(id), - result: Some(result), - error: None, - } - } - - /// Create an error response - pub fn error(id: serde_json::Value, code: i32, message: String) -> Self { - Self { - jsonrpc: "2.0".to_string(), - id: Some(id), - result: None, - error: Some(JsonRpcError { code, message }), - } - } -} - -/// JSON-RPC Error -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JsonRpcError { - pub code: i32, - pub message: String, -} - -// ============================================================================ -// ACP Protocol Types -// ============================================================================ - -/// Initialize request parameters -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct InitializeParams { - /// Protocol version as string (e.g., "0.1.0") - #[serde(rename = "protocolVersion")] - pub protocol_version: String, - #[serde(default)] - pub client_capabilities: ClientCapabilities, - #[serde(skip_serializing_if = "Option::is_none")] - pub client_info: Option, -} - -/// Client capabilities -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct ClientCapabilities { - #[serde(default)] - pub fs: FsCapabilities, - #[serde(default)] - pub terminal: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct FsCapabilities { - #[serde(default)] - pub read_text_file: bool, - #[serde(default)] - pub write_text_file: bool, -} - -/// Client info -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ClientInfo { - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub version: Option, -} - -/// Initialize response -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct InitializeResult { - /// Protocol version as string (e.g., "0.1.0") - #[serde(rename = "protocolVersion")] - pub protocol_version: String, - #[serde(default)] - pub agent_capabilities: AgentCapabilities, - #[serde(default)] - pub agent_info: Option, - #[serde(default)] - pub auth_methods: Vec, -} - -/// Agent capabilities -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct AgentCapabilities { - #[serde(default)] - pub load_session: bool, - #[serde(default)] - pub mcp_capabilities: McpCapabilities, - #[serde(default)] - pub prompt_capabilities: PromptCapabilities, - #[serde(default)] - pub session_capabilities: SessionCapabilities, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct McpCapabilities { - #[serde(default)] - pub http: bool, - #[serde(default)] - pub sse: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct PromptCapabilities { - #[serde(default)] - pub audio: bool, - #[serde(default)] - pub embedded_context: bool, - #[serde(default)] - pub image: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct SessionCapabilities { - #[serde(default)] - pub list: bool, -} - -/// Agent info -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentInfo { - pub name: String, - pub version: String, -} - -/// Auth method -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AuthMethod { - pub method_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, -} - -// ============================================================================ -// Session Types -// ============================================================================ - -/// Session/new request parameters -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SessionNewParams { - /// Working directory for the session (optional, defaults to current directory) - #[serde(default = "default_cwd")] - pub cwd: String, - #[serde(default)] - pub mcp_servers: Vec, -} - -fn default_cwd() -> String { - std::env::current_dir() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|_| ".".to_string()) -} - -/// MCP server configuration -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct McpServerConfig { - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub transport: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum McpTransport { - Stdio { - command: String, - #[serde(default)] - args: Vec, - #[serde(default)] - env: std::collections::HashMap, - }, - Http { - url: String, - }, - Sse { - url: String, - }, -} - -/// Session/new response -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SessionNewResult { - pub session_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub config_options: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub modes: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConfigOption { - pub key: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub value: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SessionModes { - pub available_modes: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub current_mode: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ModeInfo { - pub id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, -} - -/// Session/prompt request parameters -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SessionPromptParams { - pub session_id: String, - pub prompt: Vec, -} - -/// Content block for messages -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum ContentBlock { - Text { - text: String, - }, - Image { - source: ImageSource, - }, - #[serde(rename = "embedded_context")] - EmbeddedContext { - resources: Vec, - }, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ImageSource { - #[serde(rename = "type")] - source_type: String, - media_type: String, - data: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Resource { - pub uri: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, -} - -/// Session/prompt response -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SessionPromptResult { - pub stop_reason: StopReason, -} - -/// Stop reason for prompt turn -/// See ACP spec: https://agentclientprotocol.com/protocol/prompt-turn#stop-reasons -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum StopReason { - #[serde(rename = "end_turn")] - EndTurn, - #[serde(rename = "cancelled")] - Cancelled, - #[serde(rename = "tool_use")] - ToolUse, - #[serde(rename = "tool_error")] - ToolError, - #[serde(rename = "error")] - Error, -} - -// ============================================================================ -// Tool Types -// ============================================================================ - -/// Tool definition -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolDefinition { - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub input_schema: Option, -} - -/// Tools/list response -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolsListResult { - pub tools: Vec, -} - -/// Tools/call request parameters -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ToolsCallParams { - pub session_id: String, - pub name: String, - #[serde(default)] - pub arguments: serde_json::Value, -} - -/// Tools/call response -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolsCallResult { - pub content: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum ToolResultContent { - Text { text: String }, - Image { source: ImageSource }, -} - -// ============================================================================ -// Notification Types -// ============================================================================ - -/// Session/update notification -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SessionUpdateNotification { - pub session_id: String, - pub update: SessionUpdate, -} - -/// Session update notification types -/// See ACP spec: https://agentclientprotocol.com/protocol/session-lifecycle -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "sessionUpdate", rename_all = "snake_case")] -pub enum SessionUpdate { - AgentMessageChunk { - content: ContentBlock, - }, - AgentThoughtChunk { - content: ContentBlock, - }, - ToolCall { - tool_call_id: String, - name: String, - #[serde(skip_serializing_if = "Option::is_none")] - title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - kind: Option, - #[serde(skip_serializing_if = "Option::is_none")] - status: Option, - }, - ToolResult { - tool_call_id: String, - content: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - status: Option, - }, -} - -/// Tool call status -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ToolCallStatus { - Pending, - InProgress, - Completed, -} diff --git a/src/apps/cli/src/acp/session.rs b/src/apps/cli/src/acp/session.rs deleted file mode 100644 index 247138863..000000000 --- a/src/apps/cli/src/acp/session.rs +++ /dev/null @@ -1,100 +0,0 @@ -//! ACP Session Management -//! -//! Manages ACP sessions and maps them to BitFun sessions. - -use anyhow::Result; -use dashmap::DashMap; -use std::sync::Arc; - -/// ACP Session Manager -/// -/// Maps ACP session IDs to BitFun session IDs -pub struct AcpSessionManager { - /// ACP session ID -> BitFun session ID mapping - sessions: Arc>, -} - -/// ACP Session metadata -#[derive(Debug, Clone)] -pub struct AcpSession { - /// ACP session ID (used in ACP protocol) - pub acp_session_id: String, - /// BitFun session ID - pub bitfun_session_id: String, - /// Working directory - pub cwd: String, - /// Client capabilities - pub client_capabilities: crate::acp::protocol::ClientCapabilities, -} - -impl AcpSessionManager { - /// Create a new session manager - pub fn new() -> Self { - Self { - sessions: Arc::new(DashMap::new()), - } - } - - /// Create a new ACP session - pub fn create_session( - &self, - cwd: String, - client_capabilities: crate::acp::protocol::ClientCapabilities, - ) -> Result { - let acp_session_id = uuid::Uuid::new_v4().to_string(); - let bitfun_session_id = uuid::Uuid::new_v4().to_string(); - - let session = AcpSession { - acp_session_id: acp_session_id.clone(), - bitfun_session_id, - cwd, - client_capabilities, - }; - - self.sessions - .insert(acp_session_id.clone(), session.clone()); - - tracing::info!( - "Created ACP session: acp_id={}, bitfun_id={}", - session.acp_session_id, - session.bitfun_session_id - ); - - Ok(session) - } - - /// Get an ACP session by ID - pub fn get_session(&self, acp_session_id: &str) -> Option { - self.sessions.get(acp_session_id).map(|s| s.clone()) - } - - /// Remove an ACP session - pub fn remove_session(&self, acp_session_id: &str) -> Option { - self.sessions - .remove(acp_session_id) - .map(|(_, session)| session) - } - - /// List all sessions - pub fn list_sessions(&self) -> Vec { - self.sessions.iter().map(|s| s.clone()).collect() - } - - /// Update the BitFun session ID for an ACP session - pub fn update_bitfun_session_id( - &self, - acp_session_id: &str, - bitfun_session_id: String, - ) -> Option { - self.sessions.get_mut(acp_session_id).map(|mut mut_ref| { - mut_ref.bitfun_session_id = bitfun_session_id; - mut_ref.clone() - }) - } -} - -impl Default for AcpSessionManager { - fn default() -> Self { - Self::new() - } -} diff --git a/src/apps/cli/src/agent/mod.rs b/src/apps/cli/src/agent/mod.rs index 9772f8c26..2d3851f73 100644 --- a/src/apps/cli/src/agent/mod.rs +++ b/src/apps/cli/src/agent/mod.rs @@ -1,11 +1,10 @@ /// Agent integration module /// /// Wraps interaction with bitfun-core's Agent system -pub mod agentic_system; pub mod core_adapter; // Re-export AgenticSystem for use in other modules -pub use agentic_system::AgenticSystem; +pub use bitfun_core::agentic::system::AgenticSystem; use anyhow::Result; use tokio::sync::mpsc; diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index f37f023c4..b9c476f0b 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -1,4 +1,3 @@ -mod acp; mod agent; /// BitFun CLI /// @@ -164,6 +163,7 @@ async fn main() -> Result<()> { }; let is_tui_mode = matches!(cli.command, None | Some(Commands::Chat { .. })); + let is_acp_mode = matches!(cli.command, Some(Commands::Acp { .. })); if is_tui_mode { use std::fs::OpenOptions; @@ -197,6 +197,13 @@ async fn main() -> Result<()> { .with_target(false) .init(); } + } else if is_acp_mode { + tracing_subscriber::fmt() + .with_max_level(log_level) + .with_writer(std::io::stderr) + .with_ansi(false) + .with_target(false) + .init(); } else { tracing_subscriber::fmt() .with_max_level(log_level) @@ -271,7 +278,7 @@ async fn main() -> Result<()> { .context("Failed to initialize global AIClientFactory")?; tracing::info!("Global AI client factory initialized"); - let agentic_system = agent::agentic_system::init_agentic_system() + let agentic_system = bitfun_core::agentic::system::init_agentic_system() .await .context("Failed to initialize agentic system")?; tracing::info!("Agentic system initialized"); @@ -338,7 +345,7 @@ async fn main() -> Result<()> { .context("Failed to initialize global AIClientFactory")?; tracing::info!("Global AI client factory initialized"); - let agentic_system = agent::agentic_system::init_agentic_system() + let agentic_system = bitfun_core::agentic::system::init_agentic_system() .await .context("Failed to initialize agentic system")?; tracing::info!("Agentic system initialized"); @@ -407,15 +414,14 @@ async fn main() -> Result<()> { .context("Failed to initialize global AIClientFactory")?; tracing::info!("Global AI client factory initialized"); - let agentic_system = agent::agentic_system::init_agentic_system() + let agentic_system = bitfun_core::agentic::system::init_agentic_system() .await .context("Failed to initialize agentic system")?; tracing::info!("Agentic system initialized"); // Start ACP server tracing::info!("Starting ACP server..."); - let acp_server = acp::AcpServer::new(agentic_system); - acp_server.run().await?; + bitfun_acp::BitfunAcpRuntime::serve_stdio(agentic_system).await?; } None => { @@ -463,7 +469,7 @@ async fn main() -> Result<()> { .context("Failed to initialize global AIClientFactory")?; tracing::info!("Global AI client factory initialized"); - let agentic_system = agent::agentic_system::init_agentic_system() + let agentic_system = bitfun_core::agentic::system::init_agentic_system() .await .context("Failed to initialize agentic system")?; tracing::info!("Agentic system initialized"); diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index cd20e4f0c..537b322fb 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -11,7 +11,7 @@ use std::sync::Arc; use std::time::Duration; use tokio::sync::mpsc; -use crate::agent::{agentic_system::AgenticSystem, core_adapter::CoreAgentAdapter, Agent}; +use crate::agent::{core_adapter::CoreAgentAdapter, Agent, AgenticSystem}; use crate::config::CliConfig; use crate::session::Session; use crate::ui::chat::ChatView; diff --git a/src/apps/cli/src/modes/exec.rs b/src/apps/cli/src/modes/exec.rs index eca043967..314d44c44 100644 --- a/src/apps/cli/src/modes/exec.rs +++ b/src/apps/cli/src/modes/exec.rs @@ -1,6 +1,4 @@ -use crate::agent::{ - agentic_system::AgenticSystem, core_adapter::CoreAgentAdapter, Agent, AgentEvent, -}; +use crate::agent::{core_adapter::CoreAgentAdapter, Agent, AgentEvent, AgenticSystem}; use crate::config::CliConfig; /// Exec mode implementation /// diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 408ec19c9..18070cfca 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -1032,6 +1032,8 @@ impl StartupPage { key: KeyEvent, page: &mut WorkspaceSelectPage, ) -> Result<()> { + page.custom_cursor = normalize_byte_cursor(&page.custom_input, page.custom_cursor); + match key.code { KeyCode::Enter => { // If input is empty, use current directory @@ -1051,8 +1053,10 @@ impl StartupPage { } KeyCode::Backspace => { if page.custom_cursor > 0 && page.custom_cursor <= page.custom_input.len() { - page.custom_input.remove(page.custom_cursor - 1); - page.custom_cursor -= 1; + let prev_cursor = + previous_char_boundary(&page.custom_input, page.custom_cursor); + page.custom_input.remove(prev_cursor); + page.custom_cursor = prev_cursor; } } KeyCode::Delete => { @@ -1062,12 +1066,13 @@ impl StartupPage { } KeyCode::Left => { if page.custom_cursor > 0 { - page.custom_cursor -= 1; + page.custom_cursor = + previous_char_boundary(&page.custom_input, page.custom_cursor); } } KeyCode::Right => { if page.custom_cursor < page.custom_input.len() { - page.custom_cursor += 1; + page.custom_cursor = next_char_boundary(&page.custom_input, page.custom_cursor); } } KeyCode::Home => { @@ -1078,7 +1083,7 @@ impl StartupPage { } KeyCode::Char(c) => { page.custom_input.insert(page.custom_cursor, c); - page.custom_cursor += 1; + page.custom_cursor += c.len_utf8(); } _ => {} } @@ -1380,3 +1385,59 @@ impl StartupPage { Ok(()) } } + +fn normalize_byte_cursor(input: &str, cursor: usize) -> usize { + let mut cursor = cursor.min(input.len()); + while cursor > 0 && !input.is_char_boundary(cursor) { + cursor -= 1; + } + cursor +} + +fn previous_char_boundary(input: &str, cursor: usize) -> usize { + let mut cursor = normalize_byte_cursor(input, cursor); + if cursor == 0 { + return 0; + } + cursor -= 1; + normalize_byte_cursor(input, cursor) +} + +fn next_char_boundary(input: &str, cursor: usize) -> usize { + let cursor = normalize_byte_cursor(input, cursor); + if cursor >= input.len() { + return input.len(); + } + + let mut next = cursor + 1; + while next < input.len() && !input.is_char_boundary(next) { + next += 1; + } + next +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn workspace_cursor_moves_on_utf8_boundaries() { + let input = "a你b"; + + assert_eq!(next_char_boundary(input, 0), 1); + assert_eq!(next_char_boundary(input, 1), 4); + assert_eq!(next_char_boundary(input, 4), 5); + assert_eq!(previous_char_boundary(input, 5), 4); + assert_eq!(previous_char_boundary(input, 4), 1); + assert_eq!(previous_char_boundary(input, 1), 0); + } + + #[test] + fn workspace_cursor_normalizes_invalid_byte_offsets() { + let input = "你"; + + assert_eq!(normalize_byte_cursor(input, 1), 0); + assert_eq!(normalize_byte_cursor(input, 2), 0); + assert_eq!(normalize_byte_cursor(input, 3), 3); + } +} diff --git a/src/crates/acp/Cargo.toml b/src/crates/acp/Cargo.toml new file mode 100644 index 000000000..1774a325b --- /dev/null +++ b/src/crates/acp/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "bitfun-acp" +version.workspace = true +authors.workspace = true +edition.workspace = true +description = "BitFun Agent Client Protocol integration" + +[lib] +name = "bitfun_acp" + +[dependencies] +bitfun-core = { path = "../core" } +bitfun-events = { path = "../events" } + +agent-client-protocol = { version = "=0.11.1", features = ["unstable"] } +tokio = { workspace = true } +tokio-util = { workspace = true, features = ["compat"] } +async-trait = { workspace = true } +serde_json = { workspace = true } +chrono = { workspace = true } +dashmap = { workspace = true } +log = { workspace = true } diff --git a/src/crates/acp/src/lib.rs b/src/crates/acp/src/lib.rs new file mode 100644 index 000000000..a7e9ab7f2 --- /dev/null +++ b/src/crates/acp/src/lib.rs @@ -0,0 +1,11 @@ +//! BitFun Agent Client Protocol integration. +//! +//! This crate owns the external ACP server surface and maps it onto BitFun's +//! core agentic runtime. CLI and other hosts should only start this crate. + +mod runtime; +mod server; + +pub use agent_client_protocol as protocol; +pub use runtime::BitfunAcpRuntime; +pub use server::AcpServer; diff --git a/src/crates/acp/src/runtime.rs b/src/crates/acp/src/runtime.rs new file mode 100644 index 000000000..76f252fe8 --- /dev/null +++ b/src/crates/acp/src/runtime.rs @@ -0,0 +1,421 @@ +use std::path::Path; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use agent_client_protocol::schema::{ + AgentCapabilities, CancelNotification, ContentBlock, ContentChunk, CurrentModeUpdate, + Implementation, InitializeRequest, InitializeResponse, ListSessionsRequest, + ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, McpCapabilities, + NewSessionRequest, NewSessionResponse, PromptCapabilities, PromptRequest, PromptResponse, + ProtocolVersion, SessionCapabilities, SessionId, SessionInfo, SessionListCapabilities, + SessionMode, SessionModeState, SessionNotification, SessionUpdate, SetSessionModeRequest, + SetSessionModeResponse, StopReason, +}; +use agent_client_protocol::{Client, ConnectionTo, Error, Result}; +use async_trait::async_trait; +use bitfun_core::agentic::agents::get_agent_registry; +use bitfun_core::agentic::coordination::{DialogSubmissionPolicy, DialogTriggerSource}; +use bitfun_core::agentic::core::SessionConfig; +use bitfun_core::agentic::system::AgenticSystem; +use bitfun_events::AgenticEvent as CoreEvent; +use chrono::{DateTime, Utc}; +use dashmap::DashMap; + +use crate::server::{AcpRuntime, AcpServer}; + +pub struct BitfunAcpRuntime { + agentic_system: AgenticSystem, + sessions: DashMap, + connections: DashMap>, +} + +#[derive(Clone)] +struct AcpSessionState { + acp_session_id: String, + bitfun_session_id: String, + cwd: String, + mode_id: String, +} + +impl BitfunAcpRuntime { + pub fn new(agentic_system: AgenticSystem) -> Self { + Self { + agentic_system, + sessions: DashMap::new(), + connections: DashMap::new(), + } + } + + pub async fn serve_stdio(agentic_system: AgenticSystem) -> Result<()> { + AcpServer::new(Arc::new(Self::new(agentic_system))) + .serve_stdio() + .await + } + + fn internal_error(error: impl std::fmt::Display) -> Error { + Error::internal_error().data(serde_json::json!(error.to_string())) + } +} + +#[async_trait] +impl AcpRuntime for BitfunAcpRuntime { + async fn initialize(&self, _request: InitializeRequest) -> Result { + Ok(InitializeResponse::new(ProtocolVersion::V1) + .agent_capabilities( + AgentCapabilities::new() + .load_session(true) + .prompt_capabilities(PromptCapabilities::new()) + .mcp_capabilities(McpCapabilities::new()) + .session_capabilities( + SessionCapabilities::new().list(SessionListCapabilities::new()), + ), + ) + .agent_info( + Implementation::new("bitfun-acp", env!("CARGO_PKG_VERSION")).title("BitFun"), + )) + } + + async fn new_session( + &self, + request: NewSessionRequest, + connection: ConnectionTo, + ) -> Result { + let cwd = request.cwd.to_string_lossy().to_string(); + let session = self + .agentic_system + .coordinator + .create_session( + format!( + "ACP Session - {}", + chrono::Local::now().format("%Y-%m-%d %H:%M:%S") + ), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(cwd.clone()), + ..Default::default() + }, + ) + .await + .map_err(Self::internal_error)?; + + let acp_session = AcpSessionState { + acp_session_id: session.session_id.clone(), + bitfun_session_id: session.session_id.clone(), + cwd, + mode_id: session.agent_type.clone(), + }; + self.sessions + .insert(acp_session.acp_session_id.clone(), acp_session.clone()); + + self.connections + .insert(acp_session.acp_session_id.clone(), connection); + + let modes = build_session_modes(Some(session.agent_type.as_str())).await; + Ok(NewSessionResponse::new(SessionId::new(acp_session.acp_session_id)).modes(modes)) + } + + async fn load_session( + &self, + request: LoadSessionRequest, + connection: ConnectionTo, + ) -> Result { + let cwd = request.cwd.to_string_lossy().to_string(); + let session_id = request.session_id.to_string(); + let session = self + .agentic_system + .coordinator + .restore_session(Path::new(&cwd), &session_id) + .await + .map_err(Self::internal_error)?; + + let acp_session = AcpSessionState { + acp_session_id: session.session_id.clone(), + bitfun_session_id: session.session_id.clone(), + cwd, + mode_id: session.agent_type.clone(), + }; + self.sessions + .insert(acp_session.acp_session_id.clone(), acp_session.clone()); + self.connections + .insert(acp_session.acp_session_id.clone(), connection); + + let modes = build_session_modes(Some(session.agent_type.as_str())).await; + Ok(LoadSessionResponse::new().modes(modes)) + } + + async fn list_sessions(&self, request: ListSessionsRequest) -> Result { + let cwd = request + .cwd + .or_else(|| std::env::current_dir().ok()) + .ok_or_else(|| Error::invalid_params().data("cwd is required"))?; + let cursor = request + .cursor + .as_deref() + .and_then(|value| value.parse::().ok()); + + let mut summaries = self + .agentic_system + .coordinator + .list_sessions(&cwd) + .await + .map_err(Self::internal_error)?; + summaries.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at)); + + let limit = 100usize; + let filtered = summaries + .into_iter() + .filter(|summary| { + cursor + .map(|cursor| system_time_to_unix_ms(summary.last_activity_at) < cursor) + .unwrap_or(true) + }) + .collect::>(); + + let sessions = filtered + .iter() + .take(limit) + .map(|summary| { + SessionInfo::new( + SessionId::new(summary.session_id.clone()), + Path::new(&cwd).to_path_buf(), + ) + .title(summary.session_name.clone()) + .updated_at(system_time_to_rfc3339(summary.last_activity_at)) + }) + .collect::>(); + + let next_cursor = if filtered.len() > limit { + filtered + .get(limit - 1) + .map(|summary| system_time_to_unix_ms(summary.last_activity_at).to_string()) + } else { + None + }; + + Ok(ListSessionsResponse::new(sessions).next_cursor(next_cursor)) + } + + async fn prompt(&self, request: PromptRequest) -> Result { + let session_id = request.session_id.to_string(); + let acp_session = self + .sessions + .get(&session_id) + .ok_or_else(|| Error::resource_not_found(Some(session_id.clone())))?; + let acp_session = acp_session.clone(); + let connection = self + .connections + .get(&session_id) + .ok_or_else(|| Error::resource_not_found(Some(session_id.clone())))? + .clone(); + + let user_message = request + .prompt + .into_iter() + .filter_map(|block| match block { + ContentBlock::Text(text) => Some(text.text), + _ => None, + }) + .collect::>() + .join("\n"); + + if user_message.trim().is_empty() { + return Err(Error::invalid_params().data("empty prompt")); + } + + self.agentic_system + .coordinator + .start_dialog_turn( + acp_session.bitfun_session_id.clone(), + user_message, + None, + None, + acp_session.mode_id.clone(), + Some(acp_session.cwd.clone()), + DialogSubmissionPolicy::for_source(DialogTriggerSource::Cli), + ) + .await + .map_err(Self::internal_error)?; + + let stop_reason = wait_for_prompt_completion( + &self.agentic_system, + &connection, + &acp_session.acp_session_id, + &acp_session.bitfun_session_id, + ) + .await?; + + Ok(PromptResponse::new(stop_reason)) + } + + async fn cancel(&self, notification: CancelNotification) -> Result<()> { + let session_id = notification.session_id.to_string(); + let acp_session = self + .sessions + .get(&session_id) + .ok_or_else(|| Error::resource_not_found(Some(session_id.clone())))?; + let acp_session = acp_session.clone(); + + self.agentic_system + .coordinator + .cancel_active_turn_for_session( + &acp_session.bitfun_session_id, + std::time::Duration::from_secs(5), + ) + .await + .map_err(Self::internal_error)?; + + Ok(()) + } + + async fn set_session_mode( + &self, + request: SetSessionModeRequest, + ) -> Result { + let session_id = request.session_id.to_string(); + let mode_id = request.mode_id.to_string(); + let acp_session = self + .sessions + .get(&session_id) + .ok_or_else(|| Error::resource_not_found(Some(session_id.clone())))?; + let bitfun_session_id = acp_session.bitfun_session_id.clone(); + drop(acp_session); + + validate_mode_id(&mode_id).await?; + + self.agentic_system + .coordinator + .update_session_agent_type(&bitfun_session_id, &mode_id) + .await + .map_err(Self::internal_error)?; + + if let Some(mut state) = self.sessions.get_mut(&session_id) { + state.mode_id = mode_id.clone(); + } + + if let Some(connection) = self.connections.get(&session_id) { + send_update( + &connection, + &session_id, + SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(mode_id)), + )?; + } + + Ok(SetSessionModeResponse::new()) + } +} + +async fn build_session_modes(preferred_mode_id: Option<&str>) -> SessionModeState { + let available_modes = get_agent_registry() + .get_modes_info() + .await + .into_iter() + .filter(|info| info.enabled) + .map(|info| SessionMode::new(info.id, info.name).description(info.description)) + .collect::>(); + + let current_mode_id = preferred_mode_id + .and_then(|preferred| { + available_modes + .iter() + .find(|mode| mode.id.to_string() == preferred) + .map(|mode| mode.id.clone()) + }) + .or_else(|| { + available_modes + .iter() + .find(|mode| mode.id.to_string() == "agentic") + .or_else(|| available_modes.first()) + .map(|mode| mode.id.clone()) + }) + .unwrap_or_else(|| "agentic".into()); + + SessionModeState::new(current_mode_id, available_modes) +} + +async fn validate_mode_id(mode_id: &str) -> Result<()> { + let mode_exists = get_agent_registry() + .get_modes_info() + .await + .into_iter() + .any(|info| info.enabled && info.id == mode_id); + + if mode_exists { + Ok(()) + } else { + Err(Error::invalid_params().data(format!("unknown session mode: {}", mode_id))) + } +} + +async fn wait_for_prompt_completion( + agentic_system: &AgenticSystem, + connection: &ConnectionTo, + acp_session_id: &str, + bitfun_session_id: &str, +) -> Result { + loop { + let events = agentic_system.event_queue.dequeue_batch(10).await; + if events.is_empty() { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + continue; + } + + for envelope in events { + let event = envelope.event; + if event.session_id() != Some(bitfun_session_id) { + continue; + } + + match event { + CoreEvent::TextChunk { text, .. } => { + send_update( + connection, + acp_session_id, + SessionUpdate::AgentMessageChunk(ContentChunk::new(text.into())), + )?; + } + CoreEvent::ThinkingChunk { content, .. } => { + send_update( + connection, + acp_session_id, + SessionUpdate::AgentThoughtChunk(ContentChunk::new(content.into())), + )?; + } + CoreEvent::DialogTurnCompleted { .. } => return Ok(StopReason::EndTurn), + CoreEvent::DialogTurnCancelled { .. } => return Ok(StopReason::Cancelled), + CoreEvent::DialogTurnFailed { error, .. } + | CoreEvent::SystemError { error, .. } => { + send_update( + connection, + acp_session_id, + SessionUpdate::AgentMessageChunk(ContentChunk::new( + format!("Error: {}", error).into(), + )), + )?; + return Err(Error::internal_error().data(serde_json::json!(error))); + } + _ => {} + } + } + } +} + +fn send_update( + connection: &ConnectionTo, + session_id: &str, + update: SessionUpdate, +) -> Result<()> { + connection.send_notification(SessionNotification::new( + SessionId::new(session_id.to_string()), + update, + )) +} + +fn system_time_to_unix_ms(time: SystemTime) -> u128 { + time.duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or_default() +} + +fn system_time_to_rfc3339(time: SystemTime) -> String { + DateTime::::from(time).to_rfc3339() +} diff --git a/src/crates/acp/src/server.rs b/src/crates/acp/src/server.rs new file mode 100644 index 000000000..71ed81260 --- /dev/null +++ b/src/crates/acp/src/server.rs @@ -0,0 +1,225 @@ +use std::sync::Arc; + +use agent_client_protocol::schema::{ + AuthenticateRequest, AuthenticateResponse, CancelNotification, InitializeRequest, + InitializeResponse, ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, + LoadSessionResponse, NewSessionRequest, NewSessionResponse, PromptRequest, PromptResponse, + SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, SetSessionModeRequest, + SetSessionModeResponse, +}; +use agent_client_protocol::{ + Agent, ByteStreams, Client, ConnectTo, ConnectionTo, Dispatch, Error, Result, +}; +use async_trait::async_trait; +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; + +/// Runtime operations needed by the ACP protocol layer. +#[async_trait] +pub trait AcpRuntime: Send + Sync + 'static { + async fn initialize(&self, request: InitializeRequest) -> Result; + + async fn authenticate(&self, _request: AuthenticateRequest) -> Result { + Ok(AuthenticateResponse::new()) + } + + async fn new_session( + &self, + request: NewSessionRequest, + connection: ConnectionTo, + ) -> Result; + + async fn load_session( + &self, + _request: LoadSessionRequest, + _connection: ConnectionTo, + ) -> Result { + Err(Error::method_not_found().data("session/load is not implemented")) + } + + async fn list_sessions(&self, request: ListSessionsRequest) -> Result; + + async fn prompt(&self, request: PromptRequest) -> Result; + + async fn cancel(&self, notification: CancelNotification) -> Result<()>; + + async fn set_session_mode( + &self, + _request: SetSessionModeRequest, + ) -> Result { + Err(Error::method_not_found().data("session/set_mode is not implemented")) + } + + async fn set_session_config_option( + &self, + _request: SetSessionConfigOptionRequest, + ) -> Result { + Err(Error::method_not_found().data("session/set_config_option is not implemented")) + } +} + +/// Typed ACP server backed by an injected BitFun runtime. +pub struct AcpServer { + runtime: Arc, +} + +impl AcpServer +where + R: AcpRuntime, +{ + pub fn new(runtime: Arc) -> Self { + Self { runtime } + } + + pub async fn serve_stdio(self) -> Result<()> { + let stdin = tokio::io::stdin().compat(); + let stdout = tokio::io::stdout().compat_write(); + self.serve(ByteStreams::new(stdout, stdin)).await + } + + pub async fn serve(self, transport: impl ConnectTo + 'static) -> Result<()> { + let runtime = self.runtime; + + Agent + .builder() + .name("bitfun-acp") + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: InitializeRequest, responder, _cx| { + responder.respond_with_result(runtime.initialize(request).await) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: AuthenticateRequest, + responder, + cx: ConnectionTo| { + let runtime = runtime.clone(); + cx.spawn(async move { + responder.respond_with_result(runtime.authenticate(request).await) + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: NewSessionRequest, responder, cx: ConnectionTo| { + let runtime = runtime.clone(); + let session_cx = cx.clone(); + cx.spawn(async move { + responder + .respond_with_result(runtime.new_session(request, session_cx).await) + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: LoadSessionRequest, responder, cx: ConnectionTo| { + let runtime = runtime.clone(); + let session_cx = cx.clone(); + cx.spawn(async move { + responder.respond_with_result( + runtime.load_session(request, session_cx).await, + ) + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: ListSessionsRequest, + responder, + cx: ConnectionTo| { + let runtime = runtime.clone(); + cx.spawn(async move { + responder.respond_with_result(runtime.list_sessions(request).await) + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: PromptRequest, responder, cx: ConnectionTo| { + let runtime = runtime.clone(); + cx.spawn(async move { + responder.respond_with_result(runtime.prompt(request).await) + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_notification( + { + let runtime = runtime.clone(); + async move |notification: CancelNotification, cx: ConnectionTo| { + let runtime = runtime.clone(); + cx.spawn(async move { + if let Err(error) = runtime.cancel(notification).await { + log::error!("Error handling ACP cancel notification: {:?}", error); + } + Ok(()) + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_notification!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: SetSessionModeRequest, + responder, + cx: ConnectionTo| { + let runtime = runtime.clone(); + cx.spawn(async move { + responder.respond_with_result(runtime.set_session_mode(request).await) + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: SetSessionConfigOptionRequest, + responder, + cx: ConnectionTo| { + let runtime = runtime.clone(); + cx.spawn(async move { + responder.respond_with_result( + runtime.set_session_config_option(request).await, + ) + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_dispatch( + async move |message: Dispatch, cx: ConnectionTo| { + message.respond_with_error(Error::method_not_found(), cx) + }, + agent_client_protocol::on_receive_dispatch!(), + ) + .connect_to(transport) + .await + } +} diff --git a/src/crates/core/src/agentic/mod.rs b/src/crates/core/src/agentic/mod.rs index f23889868..fd724e9d4 100644 --- a/src/crates/core/src/agentic/mod.rs +++ b/src/crates/core/src/agentic/mod.rs @@ -31,6 +31,7 @@ pub mod image_analysis; // Ephemeral side-question module (used by desktop /btw overlay) pub mod side_question; +pub mod system; // Agents module pub mod agents; @@ -54,4 +55,5 @@ pub use round_preempt::{ }; pub use session::*; pub use side_question::*; +pub use system::{init_agentic_system, AgenticSystem}; pub use workspace::{WorkspaceBackend, WorkspaceBinding}; diff --git a/src/apps/cli/src/agent/agentic_system.rs b/src/crates/core/src/agentic/system.rs similarity index 72% rename from src/apps/cli/src/agent/agentic_system.rs rename to src/crates/core/src/agentic/system.rs index d7184af44..c12948722 100644 --- a/src/apps/cli/src/agent/agentic_system.rs +++ b/src/crates/core/src/agentic/system.rs @@ -1,29 +1,29 @@ -//! Agentic System Initialization for CLI -//! -//! Initialize the complete agentic system, including coordinator, execution engine, session management, etc. +//! Agentic system assembly shared by CLI, ACP, and other hosts. -use anyhow::Result; -use bitfun_core::infrastructure::ai::AIClientFactory; use std::sync::Arc; -// Import all agentic system modules -use bitfun_core::agentic::coordination; -use bitfun_core::agentic::events; -use bitfun_core::agentic::execution; -use bitfun_core::agentic::persistence; -use bitfun_core::agentic::session; -use bitfun_core::agentic::tools; -use bitfun_core::infrastructure::try_get_path_manager_arc; +use anyhow::Result; +use log::info; + +use crate::agentic::coordination; +use crate::agentic::events; +use crate::agentic::execution; +use crate::agentic::persistence; +use crate::agentic::session; +use crate::agentic::tools; +use crate::infrastructure::ai::AIClientFactory; +use crate::infrastructure::try_get_path_manager_arc; -/// Agentic system state +/// Agentic runtime state shared by host adapters. +#[derive(Clone)] pub struct AgenticSystem { pub coordinator: Arc, pub event_queue: Arc, } -/// Initialize Agentic system +/// Initialize the agentic runtime and register the global coordinator. pub async fn init_agentic_system() -> Result { - tracing::info!("Initializing Agentic system"); + info!("Initializing agentic system"); let _ai_client_factory = AIClientFactory::get_global().await?; @@ -38,7 +38,7 @@ pub async fn init_agentic_system() -> Result { let session_manager = Arc::new(session::SessionManager::new( context_store, - persistence_manager.clone(), + persistence_manager, Default::default(), )); @@ -69,11 +69,11 @@ pub async fn init_agentic_system() -> Result { execution_engine, tool_pipeline, event_queue.clone(), - event_router.clone(), + event_router, )); coordination::ConversationCoordinator::set_global(coordinator.clone()); - tracing::info!("Agentic system initialization complete"); + info!("Agentic system initialization complete"); Ok(AgenticSystem { coordinator, diff --git a/src/crates/transport/Cargo.toml b/src/crates/transport/Cargo.toml index c7cff370a..77c158e27 100644 --- a/src/crates/transport/Cargo.toml +++ b/src/crates/transport/Cargo.toml @@ -30,4 +30,3 @@ default = [] tauri-adapter = ["tauri"] cli-adapter = [] websocket-adapter = [] - From b20265f3969afcb3bdc3c8e567aa1dedddd0eb9a Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Sun, 26 Apr 2026 16:21:29 +0800 Subject: [PATCH 2/3] feat: improve ACP server integration --- src/crates/acp/src/runtime.rs | 372 ++---------------- src/crates/acp/src/runtime/content.rs | 235 +++++++++++ src/crates/acp/src/runtime/events.rs | 372 ++++++++++++++++++ src/crates/acp/src/runtime/mcp.rs | 173 ++++++++ src/crates/acp/src/runtime/prompt.rs | 259 ++++++++++++ src/crates/acp/src/runtime/session.rs | 235 +++++++++++ .../src/agentic/coordination/coordinator.rs | 11 + src/crates/core/src/agentic/events/queue.rs | 18 +- .../service/mcp/server/manager/lifecycle.rs | 91 ++++- .../src/service/mcp/server/manager/mod.rs | 2 + 10 files changed, 1408 insertions(+), 360 deletions(-) create mode 100644 src/crates/acp/src/runtime/content.rs create mode 100644 src/crates/acp/src/runtime/events.rs create mode 100644 src/crates/acp/src/runtime/mcp.rs create mode 100644 src/crates/acp/src/runtime/prompt.rs create mode 100644 src/crates/acp/src/runtime/session.rs diff --git a/src/crates/acp/src/runtime.rs b/src/crates/acp/src/runtime.rs index 76f252fe8..e19927a23 100644 --- a/src/crates/acp/src/runtime.rs +++ b/src/crates/acp/src/runtime.rs @@ -1,40 +1,39 @@ -use std::path::Path; use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; use agent_client_protocol::schema::{ - AgentCapabilities, CancelNotification, ContentBlock, ContentChunk, CurrentModeUpdate, - Implementation, InitializeRequest, InitializeResponse, ListSessionsRequest, - ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, McpCapabilities, - NewSessionRequest, NewSessionResponse, PromptCapabilities, PromptRequest, PromptResponse, - ProtocolVersion, SessionCapabilities, SessionId, SessionInfo, SessionListCapabilities, - SessionMode, SessionModeState, SessionNotification, SessionUpdate, SetSessionModeRequest, - SetSessionModeResponse, StopReason, + AgentCapabilities, CancelNotification, Implementation, InitializeRequest, InitializeResponse, + ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, + McpCapabilities, NewSessionRequest, NewSessionResponse, PromptCapabilities, PromptRequest, + PromptResponse, ProtocolVersion, SessionCapabilities, SessionListCapabilities, + SetSessionModeRequest, SetSessionModeResponse, }; use agent_client_protocol::{Client, ConnectionTo, Error, Result}; use async_trait::async_trait; -use bitfun_core::agentic::agents::get_agent_registry; -use bitfun_core::agentic::coordination::{DialogSubmissionPolicy, DialogTriggerSource}; -use bitfun_core::agentic::core::SessionConfig; use bitfun_core::agentic::system::AgenticSystem; -use bitfun_events::AgenticEvent as CoreEvent; -use chrono::{DateTime, Utc}; use dashmap::DashMap; use crate::server::{AcpRuntime, AcpServer}; +mod content; +mod events; +mod mcp; +mod prompt; +mod session; + pub struct BitfunAcpRuntime { - agentic_system: AgenticSystem, - sessions: DashMap, - connections: DashMap>, + pub(crate) agentic_system: AgenticSystem, + pub(crate) sessions: DashMap, + pub(crate) connections: DashMap>, } #[derive(Clone)] -struct AcpSessionState { - acp_session_id: String, - bitfun_session_id: String, - cwd: String, - mode_id: String, +pub(crate) struct AcpSessionState { + pub(crate) acp_session_id: String, + pub(crate) bitfun_session_id: String, + pub(crate) cwd: String, + pub(crate) mode_id: String, + #[allow(dead_code)] + pub(crate) mcp_server_ids: Vec, } impl BitfunAcpRuntime { @@ -52,7 +51,7 @@ impl BitfunAcpRuntime { .await } - fn internal_error(error: impl std::fmt::Display) -> Error { + pub(crate) fn internal_error(error: impl std::fmt::Display) -> Error { Error::internal_error().data(serde_json::json!(error.to_string())) } } @@ -64,8 +63,10 @@ impl AcpRuntime for BitfunAcpRuntime { .agent_capabilities( AgentCapabilities::new() .load_session(true) - .prompt_capabilities(PromptCapabilities::new()) - .mcp_capabilities(McpCapabilities::new()) + .prompt_capabilities( + PromptCapabilities::new().image(true).embedded_context(true), + ) + .mcp_capabilities(McpCapabilities::new().http(true)) .session_capabilities( SessionCapabilities::new().list(SessionListCapabilities::new()), ), @@ -80,38 +81,7 @@ impl AcpRuntime for BitfunAcpRuntime { request: NewSessionRequest, connection: ConnectionTo, ) -> Result { - let cwd = request.cwd.to_string_lossy().to_string(); - let session = self - .agentic_system - .coordinator - .create_session( - format!( - "ACP Session - {}", - chrono::Local::now().format("%Y-%m-%d %H:%M:%S") - ), - "agentic".to_string(), - SessionConfig { - workspace_path: Some(cwd.clone()), - ..Default::default() - }, - ) - .await - .map_err(Self::internal_error)?; - - let acp_session = AcpSessionState { - acp_session_id: session.session_id.clone(), - bitfun_session_id: session.session_id.clone(), - cwd, - mode_id: session.agent_type.clone(), - }; - self.sessions - .insert(acp_session.acp_session_id.clone(), acp_session.clone()); - - self.connections - .insert(acp_session.acp_session_id.clone(), connection); - - let modes = build_session_modes(Some(session.agent_type.as_str())).await; - Ok(NewSessionResponse::new(SessionId::new(acp_session.acp_session_id)).modes(modes)) + self.create_session(request, connection).await } async fn load_session( @@ -119,303 +89,25 @@ impl AcpRuntime for BitfunAcpRuntime { request: LoadSessionRequest, connection: ConnectionTo, ) -> Result { - let cwd = request.cwd.to_string_lossy().to_string(); - let session_id = request.session_id.to_string(); - let session = self - .agentic_system - .coordinator - .restore_session(Path::new(&cwd), &session_id) - .await - .map_err(Self::internal_error)?; - - let acp_session = AcpSessionState { - acp_session_id: session.session_id.clone(), - bitfun_session_id: session.session_id.clone(), - cwd, - mode_id: session.agent_type.clone(), - }; - self.sessions - .insert(acp_session.acp_session_id.clone(), acp_session.clone()); - self.connections - .insert(acp_session.acp_session_id.clone(), connection); - - let modes = build_session_modes(Some(session.agent_type.as_str())).await; - Ok(LoadSessionResponse::new().modes(modes)) + self.restore_session(request, connection).await } async fn list_sessions(&self, request: ListSessionsRequest) -> Result { - let cwd = request - .cwd - .or_else(|| std::env::current_dir().ok()) - .ok_or_else(|| Error::invalid_params().data("cwd is required"))?; - let cursor = request - .cursor - .as_deref() - .and_then(|value| value.parse::().ok()); - - let mut summaries = self - .agentic_system - .coordinator - .list_sessions(&cwd) - .await - .map_err(Self::internal_error)?; - summaries.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at)); - - let limit = 100usize; - let filtered = summaries - .into_iter() - .filter(|summary| { - cursor - .map(|cursor| system_time_to_unix_ms(summary.last_activity_at) < cursor) - .unwrap_or(true) - }) - .collect::>(); - - let sessions = filtered - .iter() - .take(limit) - .map(|summary| { - SessionInfo::new( - SessionId::new(summary.session_id.clone()), - Path::new(&cwd).to_path_buf(), - ) - .title(summary.session_name.clone()) - .updated_at(system_time_to_rfc3339(summary.last_activity_at)) - }) - .collect::>(); - - let next_cursor = if filtered.len() > limit { - filtered - .get(limit - 1) - .map(|summary| system_time_to_unix_ms(summary.last_activity_at).to_string()) - } else { - None - }; - - Ok(ListSessionsResponse::new(sessions).next_cursor(next_cursor)) + self.list_sessions_for_cwd(request).await } async fn prompt(&self, request: PromptRequest) -> Result { - let session_id = request.session_id.to_string(); - let acp_session = self - .sessions - .get(&session_id) - .ok_or_else(|| Error::resource_not_found(Some(session_id.clone())))?; - let acp_session = acp_session.clone(); - let connection = self - .connections - .get(&session_id) - .ok_or_else(|| Error::resource_not_found(Some(session_id.clone())))? - .clone(); - - let user_message = request - .prompt - .into_iter() - .filter_map(|block| match block { - ContentBlock::Text(text) => Some(text.text), - _ => None, - }) - .collect::>() - .join("\n"); - - if user_message.trim().is_empty() { - return Err(Error::invalid_params().data("empty prompt")); - } - - self.agentic_system - .coordinator - .start_dialog_turn( - acp_session.bitfun_session_id.clone(), - user_message, - None, - None, - acp_session.mode_id.clone(), - Some(acp_session.cwd.clone()), - DialogSubmissionPolicy::for_source(DialogTriggerSource::Cli), - ) - .await - .map_err(Self::internal_error)?; - - let stop_reason = wait_for_prompt_completion( - &self.agentic_system, - &connection, - &acp_session.acp_session_id, - &acp_session.bitfun_session_id, - ) - .await?; - - Ok(PromptResponse::new(stop_reason)) + self.run_prompt(request).await } async fn cancel(&self, notification: CancelNotification) -> Result<()> { - let session_id = notification.session_id.to_string(); - let acp_session = self - .sessions - .get(&session_id) - .ok_or_else(|| Error::resource_not_found(Some(session_id.clone())))?; - let acp_session = acp_session.clone(); - - self.agentic_system - .coordinator - .cancel_active_turn_for_session( - &acp_session.bitfun_session_id, - std::time::Duration::from_secs(5), - ) - .await - .map_err(Self::internal_error)?; - - Ok(()) + self.cancel_prompt(notification).await } async fn set_session_mode( &self, request: SetSessionModeRequest, ) -> Result { - let session_id = request.session_id.to_string(); - let mode_id = request.mode_id.to_string(); - let acp_session = self - .sessions - .get(&session_id) - .ok_or_else(|| Error::resource_not_found(Some(session_id.clone())))?; - let bitfun_session_id = acp_session.bitfun_session_id.clone(); - drop(acp_session); - - validate_mode_id(&mode_id).await?; - - self.agentic_system - .coordinator - .update_session_agent_type(&bitfun_session_id, &mode_id) - .await - .map_err(Self::internal_error)?; - - if let Some(mut state) = self.sessions.get_mut(&session_id) { - state.mode_id = mode_id.clone(); - } - - if let Some(connection) = self.connections.get(&session_id) { - send_update( - &connection, - &session_id, - SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(mode_id)), - )?; - } - - Ok(SetSessionModeResponse::new()) - } -} - -async fn build_session_modes(preferred_mode_id: Option<&str>) -> SessionModeState { - let available_modes = get_agent_registry() - .get_modes_info() - .await - .into_iter() - .filter(|info| info.enabled) - .map(|info| SessionMode::new(info.id, info.name).description(info.description)) - .collect::>(); - - let current_mode_id = preferred_mode_id - .and_then(|preferred| { - available_modes - .iter() - .find(|mode| mode.id.to_string() == preferred) - .map(|mode| mode.id.clone()) - }) - .or_else(|| { - available_modes - .iter() - .find(|mode| mode.id.to_string() == "agentic") - .or_else(|| available_modes.first()) - .map(|mode| mode.id.clone()) - }) - .unwrap_or_else(|| "agentic".into()); - - SessionModeState::new(current_mode_id, available_modes) -} - -async fn validate_mode_id(mode_id: &str) -> Result<()> { - let mode_exists = get_agent_registry() - .get_modes_info() - .await - .into_iter() - .any(|info| info.enabled && info.id == mode_id); - - if mode_exists { - Ok(()) - } else { - Err(Error::invalid_params().data(format!("unknown session mode: {}", mode_id))) - } -} - -async fn wait_for_prompt_completion( - agentic_system: &AgenticSystem, - connection: &ConnectionTo, - acp_session_id: &str, - bitfun_session_id: &str, -) -> Result { - loop { - let events = agentic_system.event_queue.dequeue_batch(10).await; - if events.is_empty() { - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - continue; - } - - for envelope in events { - let event = envelope.event; - if event.session_id() != Some(bitfun_session_id) { - continue; - } - - match event { - CoreEvent::TextChunk { text, .. } => { - send_update( - connection, - acp_session_id, - SessionUpdate::AgentMessageChunk(ContentChunk::new(text.into())), - )?; - } - CoreEvent::ThinkingChunk { content, .. } => { - send_update( - connection, - acp_session_id, - SessionUpdate::AgentThoughtChunk(ContentChunk::new(content.into())), - )?; - } - CoreEvent::DialogTurnCompleted { .. } => return Ok(StopReason::EndTurn), - CoreEvent::DialogTurnCancelled { .. } => return Ok(StopReason::Cancelled), - CoreEvent::DialogTurnFailed { error, .. } - | CoreEvent::SystemError { error, .. } => { - send_update( - connection, - acp_session_id, - SessionUpdate::AgentMessageChunk(ContentChunk::new( - format!("Error: {}", error).into(), - )), - )?; - return Err(Error::internal_error().data(serde_json::json!(error))); - } - _ => {} - } - } + self.update_session_mode(request).await } } - -fn send_update( - connection: &ConnectionTo, - session_id: &str, - update: SessionUpdate, -) -> Result<()> { - connection.send_notification(SessionNotification::new( - SessionId::new(session_id.to_string()), - update, - )) -} - -fn system_time_to_unix_ms(time: SystemTime) -> u128 { - time.duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis()) - .unwrap_or_default() -} - -fn system_time_to_rfc3339(time: SystemTime) -> String { - DateTime::::from(time).to_rfc3339() -} diff --git a/src/crates/acp/src/runtime/content.rs b/src/crates/acp/src/runtime/content.rs new file mode 100644 index 000000000..96b98ff38 --- /dev/null +++ b/src/crates/acp/src/runtime/content.rs @@ -0,0 +1,235 @@ +use agent_client_protocol::schema::{ + Annotations, BlobResourceContents, ContentBlock, EmbeddedResourceResource, ImageContent, + ResourceLink, Role, TextResourceContents, +}; +use bitfun_core::agentic::image_analysis::ImageContextData; + +pub(super) struct ParsedPrompt { + pub(super) user_message: String, + pub(super) original_user_message: Option, + pub(super) image_contexts: Vec, +} + +pub(super) fn parse_prompt_blocks(session_id: &str, blocks: Vec) -> ParsedPrompt { + let mut text_parts = Vec::new(); + let mut original_text_parts = Vec::new(); + let mut image_contexts = Vec::new(); + + for (index, block) in blocks.into_iter().enumerate() { + match block { + ContentBlock::Text(text) => { + if is_user_only(text.annotations.as_ref()) { + continue; + } + original_text_parts.push(text.text.clone()); + text_parts.push(text.text); + } + ContentBlock::Image(image) => { + if is_user_only(image.annotations.as_ref()) { + continue; + } + if let Some(context) = image_to_context(session_id, index, image) { + text_parts.push(format!("[Attached image: {}]", context.id)); + image_contexts.push(context); + } + } + ContentBlock::ResourceLink(link) => { + if is_user_only(link.annotations.as_ref()) { + continue; + } + text_parts.push(resource_link_text(&link)); + } + ContentBlock::Resource(resource) => { + if is_user_only(resource.annotations.as_ref()) { + continue; + } + match resource.resource { + EmbeddedResourceResource::TextResourceContents(text) => { + text_parts.push(text_resource_text(&text)); + } + EmbeddedResourceResource::BlobResourceContents(blob) => { + if let Some(context) = + blob_resource_to_image_context(session_id, index, &blob) + { + text_parts.push(format!("[Attached image resource: {}]", context.id)); + image_contexts.push(context); + } else { + text_parts.push(blob_resource_text(&blob)); + } + } + _ => { + text_parts.push( + "[Embedded resource omitted: unsupported resource type]".to_string(), + ); + } + } + } + ContentBlock::Audio(audio) => { + if is_user_only(audio.annotations.as_ref()) { + continue; + } + text_parts.push(format!( + "[Audio attachment omitted: mime_type={}, bytes={}]", + audio.mime_type, + audio.data.len() + )); + } + _ => {} + } + } + + let user_message = join_prompt_parts(text_parts); + let original_user_message = if original_text_parts.is_empty() { + None + } else { + Some(join_prompt_parts(original_text_parts)) + }; + + ParsedPrompt { + user_message, + original_user_message, + image_contexts, + } +} + +fn is_user_only(annotations: Option<&Annotations>) -> bool { + matches!( + annotations.and_then(|a| a.audience.as_ref()), + Some(audience) if audience.len() == 1 && matches!(audience.first(), Some(Role::User)) + ) +} + +fn image_to_context( + session_id: &str, + index: usize, + image: ImageContent, +) -> Option { + if image.data.trim().is_empty() { + return image.uri.clone().map(|uri| ImageContextData { + id: prompt_context_id(session_id, "image", index), + image_path: file_uri_to_path(&uri).or(Some(uri)), + data_url: None, + mime_type: image.mime_type, + metadata: Some(serde_json::json!({ + "source": "acp", + "uri": image.uri, + })), + }); + } + + Some(ImageContextData { + id: prompt_context_id(session_id, "image", index), + image_path: None, + data_url: Some(format!("data:{};base64,{}", image.mime_type, image.data)), + mime_type: image.mime_type, + metadata: Some(serde_json::json!({ + "source": "acp", + "uri": image.uri, + })), + }) +} + +fn blob_resource_to_image_context( + session_id: &str, + index: usize, + blob: &BlobResourceContents, +) -> Option { + let mime_type = blob + .mime_type + .clone() + .unwrap_or_else(|| "application/octet-stream".to_string()); + if !mime_type.to_ascii_lowercase().starts_with("image/") { + return None; + } + + Some(ImageContextData { + id: prompt_context_id(session_id, "resource_image", index), + image_path: None, + data_url: Some(format!("data:{};base64,{}", mime_type, blob.blob)), + mime_type, + metadata: Some(serde_json::json!({ + "source": "acp_resource", + "uri": blob.uri, + })), + }) +} + +fn resource_link_text(link: &ResourceLink) -> String { + let mut lines = vec![ + "[Attached resource link]".to_string(), + format!("name: {}", link.name), + format!("uri: {}", link.uri), + ]; + if let Some(title) = &link.title { + lines.push(format!("title: {}", title)); + } + if let Some(description) = &link.description { + lines.push(format!("description: {}", description)); + } + if let Some(mime_type) = &link.mime_type { + lines.push(format!("mime_type: {}", mime_type)); + } + lines.join("\n") +} + +fn text_resource_text(resource: &TextResourceContents) -> String { + let language = resource + .mime_type + .as_deref() + .and_then(markdown_language_for_mime) + .unwrap_or(""); + format!( + "[Embedded resource]\nuri: {}\nmime_type: {}\n```{}\n{}\n```", + resource.uri, + resource.mime_type.as_deref().unwrap_or("text/plain"), + language, + resource.text + ) +} + +fn blob_resource_text(resource: &BlobResourceContents) -> String { + format!( + "[Embedded binary resource]\nuri: {}\nmime_type: {}\nbase64_bytes: {}", + resource.uri, + resource + .mime_type + .as_deref() + .unwrap_or("application/octet-stream"), + resource.blob.len() + ) +} + +fn markdown_language_for_mime(mime_type: &str) -> Option<&'static str> { + match mime_type.split(';').next()?.trim() { + "application/json" => Some("json"), + "application/javascript" | "text/javascript" => Some("javascript"), + "text/css" => Some("css"), + "text/html" => Some("html"), + "text/markdown" => Some("markdown"), + "text/x-python" => Some("python"), + "text/x-rust" => Some("rust"), + "text/x-typescript" => Some("typescript"), + _ => None, + } +} + +fn join_prompt_parts(parts: Vec) -> String { + parts + .into_iter() + .map(|part| part.trim().to_string()) + .filter(|part| !part.is_empty()) + .collect::>() + .join("\n\n") +} + +fn prompt_context_id(session_id: &str, kind: &str, index: usize) -> String { + let sanitized = session_id + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' }) + .collect::(); + format!("acp_{}_{}_{}", kind, sanitized, index) +} + +fn file_uri_to_path(uri: &str) -> Option { + uri.strip_prefix("file://").map(|path| path.to_string()) +} diff --git a/src/crates/acp/src/runtime/events.rs b/src/crates/acp/src/runtime/events.rs new file mode 100644 index 000000000..7e42c0ed2 --- /dev/null +++ b/src/crates/acp/src/runtime/events.rs @@ -0,0 +1,372 @@ +use std::collections::HashSet; + +use agent_client_protocol::schema::{ + PermissionOption, PermissionOptionKind, RequestPermissionRequest, SessionId, + SessionNotification, SessionUpdate, ToolCall, ToolCallContent, ToolCallStatus, ToolCallUpdate, + ToolCallUpdateFields, ToolKind, +}; +use agent_client_protocol::{Client, ConnectionTo, Result}; +use bitfun_events::ToolEventData; + +pub(super) const PERMISSION_ALLOW_ONCE: &str = "allow_once"; +pub(super) const PERMISSION_REJECT_ONCE: &str = "reject_once"; + +pub(super) fn send_update( + connection: &ConnectionTo, + session_id: &str, + update: SessionUpdate, +) -> Result<()> { + connection.send_notification(SessionNotification::new( + SessionId::new(session_id.to_string()), + update, + )) +} + +pub(super) fn tool_event_updates( + tool_event: &ToolEventData, + seen_tool_calls: &mut HashSet, +) -> Vec { + let tool_id = tool_event.tool_id(); + let mut updates = Vec::new(); + + if !seen_tool_calls.contains(tool_id) { + seen_tool_calls.insert(tool_id.to_string()); + updates.push(SessionUpdate::ToolCall(initial_tool_call(tool_event))); + } + + if let Some(update) = tool_call_update(tool_event) { + updates.push(SessionUpdate::ToolCallUpdate(update)); + } + + updates +} + +pub(super) fn permission_request( + session_id: &str, + tool_id: &str, + tool_name: &str, + params: &serde_json::Value, +) -> RequestPermissionRequest { + RequestPermissionRequest::new( + SessionId::new(session_id.to_string()), + ToolCallUpdate::new( + tool_id.to_string(), + ToolCallUpdateFields::new() + .title(format!("Allow {}?", tool_name)) + .status(ToolCallStatus::Pending) + .kind(tool_kind(tool_name)) + .raw_input(params.clone()) + .content(vec![text_content(format!( + "Permission required to run {}.", + tool_name + ))]), + ), + vec![ + PermissionOption::new( + PERMISSION_ALLOW_ONCE, + "Allow once", + PermissionOptionKind::AllowOnce, + ), + PermissionOption::new( + PERMISSION_REJECT_ONCE, + "Reject once", + PermissionOptionKind::RejectOnce, + ), + ], + ) +} + +fn initial_tool_call(tool_event: &ToolEventData) -> ToolCall { + let tool_id = tool_event.tool_id().to_string(); + let tool_name = tool_event.tool_name(); + let mut tool_call = ToolCall::new(tool_id, tool_title(tool_name)) + .kind(tool_kind(tool_name)) + .status(tool_status(tool_event)); + + if let Some(raw_input) = tool_event.raw_input() { + tool_call = tool_call.raw_input(raw_input); + } + + tool_call +} + +fn tool_call_update(tool_event: &ToolEventData) -> Option { + let tool_id = tool_event.tool_id().to_string(); + let fields = match tool_event { + ToolEventData::EarlyDetected { tool_name, .. } => ToolCallUpdateFields::new() + .title(tool_title(tool_name)) + .kind(tool_kind(tool_name)) + .status(ToolCallStatus::Pending), + ToolEventData::ParamsPartial { params, .. } => ToolCallUpdateFields::new() + .status(ToolCallStatus::Pending) + .content(vec![text_content(format!("Input: {}", params))]), + ToolEventData::Queued { position, .. } => ToolCallUpdateFields::new() + .status(ToolCallStatus::Pending) + .content(vec![text_content(format!( + "Queued at position {}.", + position + ))]), + ToolEventData::Waiting { dependencies, .. } => ToolCallUpdateFields::new() + .status(ToolCallStatus::Pending) + .content(vec![text_content(format!( + "Waiting for dependencies: {}.", + dependencies.join(", ") + ))]), + ToolEventData::Started { + tool_name, params, .. + } => ToolCallUpdateFields::new() + .title(tool_title(tool_name)) + .kind(tool_kind(tool_name)) + .status(ToolCallStatus::InProgress) + .raw_input(params.clone()), + ToolEventData::Progress { + message, + percentage, + .. + } => ToolCallUpdateFields::new() + .status(ToolCallStatus::InProgress) + .content(vec![text_content(format!( + "{} ({:.0}%)", + message, percentage + ))]), + ToolEventData::Streaming { + chunks_received, .. + } => ToolCallUpdateFields::new() + .status(ToolCallStatus::InProgress) + .content(vec![text_content(format!( + "Received {} streaming chunks.", + chunks_received + ))]), + ToolEventData::StreamChunk { data, .. } => ToolCallUpdateFields::new() + .status(ToolCallStatus::InProgress) + .content(vec![text_content(value_to_display_text(data))]), + ToolEventData::ConfirmationNeeded { + tool_name, params, .. + } => ToolCallUpdateFields::new() + .title(format!("Allow {}?", tool_name)) + .status(ToolCallStatus::Pending) + .raw_input(params.clone()) + .content(vec![text_content("Waiting for permission.")]), + ToolEventData::Confirmed { .. } => ToolCallUpdateFields::new() + .status(ToolCallStatus::InProgress) + .content(vec![text_content("Permission granted.")]), + ToolEventData::Rejected { .. } => ToolCallUpdateFields::new() + .status(ToolCallStatus::Failed) + .content(vec![text_content("Permission rejected.")]), + ToolEventData::Completed { + result, + result_for_assistant, + duration_ms, + .. + } => { + let display = result_for_assistant + .clone() + .unwrap_or_else(|| value_to_display_text(result)); + ToolCallUpdateFields::new() + .status(ToolCallStatus::Completed) + .raw_output(result.clone()) + .content(vec![text_content(format!( + "{}\nCompleted in {} ms.", + display, duration_ms + ))]) + } + ToolEventData::Failed { error, .. } => ToolCallUpdateFields::new() + .status(ToolCallStatus::Failed) + .raw_output(serde_json::json!({ "error": error })) + .content(vec![text_content(format!("Error: {}", error))]), + ToolEventData::Cancelled { reason, .. } => ToolCallUpdateFields::new() + .status(ToolCallStatus::Failed) + .raw_output(serde_json::json!({ "reason": reason })) + .content(vec![text_content(format!("Cancelled: {}", reason))]), + }; + + Some(ToolCallUpdate::new(tool_id, fields)) +} + +fn tool_title(tool_name: &str) -> String { + format!("Run {}", tool_name) +} + +fn tool_status(tool_event: &ToolEventData) -> ToolCallStatus { + match tool_event { + ToolEventData::Started { .. } + | ToolEventData::Progress { .. } + | ToolEventData::Streaming { .. } + | ToolEventData::StreamChunk { .. } + | ToolEventData::Confirmed { .. } => ToolCallStatus::InProgress, + ToolEventData::Completed { .. } => ToolCallStatus::Completed, + ToolEventData::Failed { .. } + | ToolEventData::Cancelled { .. } + | ToolEventData::Rejected { .. } => ToolCallStatus::Failed, + _ => ToolCallStatus::Pending, + } +} + +fn tool_kind(tool_name: &str) -> ToolKind { + let name = tool_name.to_ascii_lowercase(); + if name.contains("delete") || name.contains("remove") { + ToolKind::Delete + } else if name.contains("write") + || name.contains("edit") + || name.contains("patch") + || name.contains("replace") + { + ToolKind::Edit + } else if name.contains("move") || name.contains("rename") { + ToolKind::Move + } else if name.contains("grep") + || name.contains("glob") + || name.contains("search") + || name.contains("find") + { + ToolKind::Search + } else if name.contains("bash") + || name.contains("terminal") + || name.contains("command") + || name.contains("execute") + { + ToolKind::Execute + } else if name.contains("web") || name.contains("fetch") || name.contains("http") { + ToolKind::Fetch + } else if name.contains("think") || name.contains("plan") { + ToolKind::Think + } else if name.contains("read") || name == "ls" { + ToolKind::Read + } else { + ToolKind::Other + } +} + +fn text_content(text: impl Into) -> ToolCallContent { + ToolCallContent::from(text.into()) +} + +fn value_to_display_text(value: &serde_json::Value) -> String { + match value { + serde_json::Value::String(text) => text.clone(), + _ => serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string()), + } +} + +trait ToolEventExt { + fn tool_id(&self) -> &str; + fn tool_name(&self) -> &str; + fn raw_input(&self) -> Option; +} + +impl ToolEventExt for ToolEventData { + fn tool_id(&self) -> &str { + match self { + Self::EarlyDetected { tool_id, .. } + | Self::ParamsPartial { tool_id, .. } + | Self::Queued { tool_id, .. } + | Self::Waiting { tool_id, .. } + | Self::Started { tool_id, .. } + | Self::Progress { tool_id, .. } + | Self::Streaming { tool_id, .. } + | Self::StreamChunk { tool_id, .. } + | Self::ConfirmationNeeded { tool_id, .. } + | Self::Confirmed { tool_id, .. } + | Self::Rejected { tool_id, .. } + | Self::Completed { tool_id, .. } + | Self::Failed { tool_id, .. } + | Self::Cancelled { tool_id, .. } => tool_id, + } + } + + fn tool_name(&self) -> &str { + match self { + Self::EarlyDetected { tool_name, .. } + | Self::ParamsPartial { tool_name, .. } + | Self::Queued { tool_name, .. } + | Self::Waiting { tool_name, .. } + | Self::Started { tool_name, .. } + | Self::Progress { tool_name, .. } + | Self::Streaming { tool_name, .. } + | Self::StreamChunk { tool_name, .. } + | Self::ConfirmationNeeded { tool_name, .. } + | Self::Confirmed { tool_name, .. } + | Self::Rejected { tool_name, .. } + | Self::Completed { tool_name, .. } + | Self::Failed { tool_name, .. } + | Self::Cancelled { tool_name, .. } => tool_name, + } + } + + fn raw_input(&self) -> Option { + match self { + Self::Started { params, .. } | Self::ConfirmationNeeded { params, .. } => { + Some(params.clone()) + } + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn early_detected_creates_tool_call_once() { + let mut seen = HashSet::new(); + let event = ToolEventData::EarlyDetected { + tool_id: "tool-1".to_string(), + tool_name: "Read".to_string(), + }; + + let first = tool_event_updates(&event, &mut seen); + assert_eq!(first.len(), 2); + assert!(matches!(first[0], SessionUpdate::ToolCall(_))); + assert!(matches!(first[1], SessionUpdate::ToolCallUpdate(_))); + + let second = tool_event_updates(&event, &mut seen); + assert_eq!(second.len(), 1); + assert!(matches!(second[0], SessionUpdate::ToolCallUpdate(_))); + } + + #[test] + fn completed_event_maps_to_completed_update_with_output() { + let mut seen = HashSet::new(); + let event = ToolEventData::Completed { + tool_id: "tool-1".to_string(), + tool_name: "Bash".to_string(), + result: serde_json::json!({ "stdout": "ok" }), + result_for_assistant: Some("done".to_string()), + duration_ms: 42, + }; + + let updates = tool_event_updates(&event, &mut seen); + let SessionUpdate::ToolCallUpdate(update) = &updates[1] else { + panic!("expected tool call update"); + }; + + assert_eq!(update.fields.status, Some(ToolCallStatus::Completed)); + assert_eq!( + update.fields.raw_output, + Some(serde_json::json!({ "stdout": "ok" })) + ); + } + + #[test] + fn permission_request_exposes_allow_and_reject_once() { + let request = permission_request( + "session-1", + "tool-1", + "FileWrite", + &serde_json::json!({ "path": "a.txt" }), + ); + + assert_eq!(request.options.len(), 2); + assert_eq!( + request.options[0].option_id.to_string(), + PERMISSION_ALLOW_ONCE + ); + assert_eq!(request.options[0].kind, PermissionOptionKind::AllowOnce); + assert_eq!( + request.options[1].option_id.to_string(), + PERMISSION_REJECT_ONCE + ); + assert_eq!(request.options[1].kind, PermissionOptionKind::RejectOnce); + } +} diff --git a/src/crates/acp/src/runtime/mcp.rs b/src/crates/acp/src/runtime/mcp.rs new file mode 100644 index 000000000..a69810b63 --- /dev/null +++ b/src/crates/acp/src/runtime/mcp.rs @@ -0,0 +1,173 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use agent_client_protocol::schema::{McpServer, McpServerSse, McpServerStdio}; +use agent_client_protocol::{Error, Result}; +use bitfun_core::service::config::get_global_config_service; +use bitfun_core::service::mcp::{ + get_global_mcp_service, set_global_mcp_service, ConfigLocation, MCPServerConfig, + MCPServerManager, MCPServerTransport, MCPServerType, MCPService, +}; + +use super::BitfunAcpRuntime; + +impl BitfunAcpRuntime { + pub(super) async fn provision_mcp_servers( + &self, + acp_session_id: &str, + servers: Vec, + ) -> Result> { + if servers.is_empty() { + return Ok(Vec::new()); + } + + let manager = mcp_server_manager().await?; + let mut server_ids: Vec = Vec::with_capacity(servers.len()); + + for server in servers { + let config = acp_mcp_server_config(acp_session_id, server)?; + let server_id = config.id.clone(); + + if let Err(error) = manager.add_ephemeral_server(config).await { + for provisioned_id in &server_ids { + let _ = manager.remove_ephemeral_server(provisioned_id).await; + } + return Err(Self::internal_error(error)); + } + + server_ids.push(server_id); + } + + Ok(server_ids) + } +} + +async fn mcp_server_manager() -> Result> { + if let Some(service) = get_global_mcp_service() { + return Ok(service.server_manager()); + } + + let config_service = get_global_config_service() + .await + .map_err(BitfunAcpRuntime::internal_error)?; + let service = + Arc::new(MCPService::new(config_service).map_err(BitfunAcpRuntime::internal_error)?); + set_global_mcp_service(service.clone()); + Ok(service.server_manager()) +} + +fn acp_mcp_server_config(acp_session_id: &str, server: McpServer) -> Result { + match server { + McpServer::Stdio(server) => stdio_server_config(acp_session_id, server), + McpServer::Http(server) => remote_server_config( + acp_session_id, + server.name, + server.url, + header_map(server.headers), + MCPServerTransport::StreamableHttp, + ), + McpServer::Sse(server) => sse_server_config(acp_session_id, server), + _ => Err(Error::invalid_params().data("unsupported MCP server transport")), + } +} + +fn stdio_server_config(acp_session_id: &str, server: McpServerStdio) -> Result { + let name = clean_server_name(&server.name)?; + Ok(MCPServerConfig { + id: ephemeral_server_id(acp_session_id, &name), + name, + server_type: MCPServerType::Local, + transport: Some(MCPServerTransport::Stdio), + command: Some(server.command.to_string_lossy().to_string()), + args: server.args, + env: server + .env + .into_iter() + .map(|env| (env.name, env.value)) + .collect(), + headers: HashMap::new(), + url: None, + auto_start: true, + enabled: true, + location: ConfigLocation::Project, + capabilities: Vec::new(), + settings: HashMap::new(), + oauth: None, + xaa: None, + }) +} + +fn sse_server_config(acp_session_id: &str, server: McpServerSse) -> Result { + remote_server_config( + acp_session_id, + server.name, + server.url, + header_map(server.headers), + MCPServerTransport::Sse, + ) +} + +fn remote_server_config( + acp_session_id: &str, + name: String, + url: String, + headers: HashMap, + transport: MCPServerTransport, +) -> Result { + let name = clean_server_name(&name)?; + Ok(MCPServerConfig { + id: ephemeral_server_id(acp_session_id, &name), + name, + server_type: MCPServerType::Remote, + transport: Some(transport), + command: None, + args: Vec::new(), + env: HashMap::new(), + headers, + url: Some(url), + auto_start: true, + enabled: true, + location: ConfigLocation::Project, + capabilities: Vec::new(), + settings: HashMap::new(), + oauth: None, + xaa: None, + }) +} + +fn header_map(headers: Vec) -> HashMap { + headers + .into_iter() + .map(|header| (header.name, header.value)) + .collect() +} + +fn clean_server_name(name: &str) -> Result { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err(Error::invalid_params().data("MCP server name cannot be empty")); + } + Ok(trimmed.to_string()) +} + +fn ephemeral_server_id(acp_session_id: &str, server_name: &str) -> String { + format!( + "acp-{}-{}", + sanitize_id_part(acp_session_id), + sanitize_id_part(server_name) + ) +} + +fn sanitize_id_part(value: &str) -> String { + let sanitized = value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '-' + } + }) + .collect::(); + sanitized.trim_matches('-').to_string() +} diff --git a/src/crates/acp/src/runtime/prompt.rs b/src/crates/acp/src/runtime/prompt.rs new file mode 100644 index 000000000..5e9b8c5c9 --- /dev/null +++ b/src/crates/acp/src/runtime/prompt.rs @@ -0,0 +1,259 @@ +use std::collections::HashSet; + +use agent_client_protocol::schema::{ + CancelNotification, ContentChunk, PromptRequest, PromptResponse, RequestPermissionOutcome, + SessionUpdate, StopReason, +}; +use agent_client_protocol::{Client, ConnectionTo, Error, Result}; +use bitfun_core::agentic::coordination::{DialogSubmissionPolicy, DialogTriggerSource}; +use bitfun_core::agentic::events::EventEnvelope; +use bitfun_events::AgenticEvent as CoreEvent; +use log::warn; +use tokio::sync::broadcast; + +use super::content::parse_prompt_blocks; +use super::events::{ + permission_request, send_update, tool_event_updates, PERMISSION_ALLOW_ONCE, + PERMISSION_REJECT_ONCE, +}; +use super::BitfunAcpRuntime; + +impl BitfunAcpRuntime { + pub(super) async fn run_prompt(&self, request: PromptRequest) -> Result { + let session_id = request.session_id.to_string(); + let acp_session = self + .sessions + .get(&session_id) + .ok_or_else(|| Error::resource_not_found(Some(session_id.clone())))?; + let acp_session = acp_session.clone(); + let connection = self + .connections + .get(&session_id) + .ok_or_else(|| Error::resource_not_found(Some(session_id.clone())))? + .clone(); + + let parsed_prompt = parse_prompt_blocks(&session_id, request.prompt); + + if parsed_prompt.user_message.trim().is_empty() && parsed_prompt.image_contexts.is_empty() { + return Err(Error::invalid_params().data("empty prompt")); + } + + let mut event_rx = self.agentic_system.event_queue.subscribe(); + if parsed_prompt.image_contexts.is_empty() { + self.agentic_system + .coordinator + .start_dialog_turn( + acp_session.bitfun_session_id.clone(), + parsed_prompt.user_message, + parsed_prompt.original_user_message, + None, + acp_session.mode_id.clone(), + Some(acp_session.cwd.clone()), + DialogSubmissionPolicy::for_source(DialogTriggerSource::Cli), + ) + .await + .map_err(Self::internal_error)?; + } else { + self.agentic_system + .coordinator + .start_dialog_turn_with_image_contexts( + acp_session.bitfun_session_id.clone(), + parsed_prompt.user_message, + parsed_prompt.original_user_message, + parsed_prompt.image_contexts, + None, + acp_session.mode_id.clone(), + Some(acp_session.cwd.clone()), + DialogSubmissionPolicy::for_source(DialogTriggerSource::Cli), + ) + .await + .map_err(Self::internal_error)?; + } + + let stop_reason = wait_for_prompt_completion( + self, + &mut event_rx, + &connection, + &acp_session.acp_session_id, + &acp_session.bitfun_session_id, + ) + .await?; + + Ok(PromptResponse::new(stop_reason)) + } + + pub(super) async fn cancel_prompt(&self, notification: CancelNotification) -> Result<()> { + let session_id = notification.session_id.to_string(); + let acp_session = self + .sessions + .get(&session_id) + .ok_or_else(|| Error::resource_not_found(Some(session_id.clone())))?; + let acp_session = acp_session.clone(); + + self.agentic_system + .coordinator + .cancel_active_turn_for_session( + &acp_session.bitfun_session_id, + std::time::Duration::from_secs(5), + ) + .await + .map_err(Self::internal_error)?; + + Ok(()) + } +} + +async fn wait_for_prompt_completion( + runtime: &BitfunAcpRuntime, + event_rx: &mut broadcast::Receiver, + connection: &ConnectionTo, + acp_session_id: &str, + bitfun_session_id: &str, +) -> Result { + let mut seen_tool_calls = HashSet::new(); + + loop { + let event = match event_rx.recv().await { + Ok(envelope) => envelope.event, + Err(broadcast::error::RecvError::Lagged(count)) => { + warn!("ACP event receiver lagged: skipped {} events", count); + continue; + } + Err(broadcast::error::RecvError::Closed) => { + return Err(Error::internal_error().data("event stream closed")); + } + }; + + if event.session_id() != Some(bitfun_session_id) { + continue; + } + + match event { + CoreEvent::TextChunk { text, .. } => { + send_update( + connection, + acp_session_id, + SessionUpdate::AgentMessageChunk(ContentChunk::new(text.into())), + )?; + } + CoreEvent::ThinkingChunk { content, .. } => { + send_update( + connection, + acp_session_id, + SessionUpdate::AgentThoughtChunk(ContentChunk::new(content.into())), + )?; + } + CoreEvent::ToolEvent { tool_event, .. } => { + for update in tool_event_updates(&tool_event, &mut seen_tool_calls) { + send_update(connection, acp_session_id, update)?; + } + + if let bitfun_events::ToolEventData::ConfirmationNeeded { + tool_id, + tool_name, + params, + } = tool_event + { + handle_permission_request( + runtime, + connection, + acp_session_id, + &tool_id, + &tool_name, + ¶ms, + ) + .await?; + } + } + CoreEvent::DialogTurnCompleted { .. } => return Ok(StopReason::EndTurn), + CoreEvent::DialogTurnCancelled { .. } => return Ok(StopReason::Cancelled), + CoreEvent::DialogTurnFailed { error, .. } | CoreEvent::SystemError { error, .. } => { + send_update( + connection, + acp_session_id, + SessionUpdate::AgentMessageChunk(ContentChunk::new( + format!("Error: {}", error).into(), + )), + )?; + return Err(Error::internal_error().data(serde_json::json!(error))); + } + _ => {} + } + } +} + +async fn handle_permission_request( + runtime: &BitfunAcpRuntime, + connection: &ConnectionTo, + acp_session_id: &str, + tool_id: &str, + tool_name: &str, + params: &serde_json::Value, +) -> Result<()> { + let request = permission_request(acp_session_id, tool_id, tool_name, params); + let response = match connection.send_request(request).block_task().await { + Ok(response) => response, + Err(error) => { + let reason = format!("ACP permission request failed: {}", error); + let _ = runtime + .agentic_system + .coordinator + .reject_tool(tool_id, reason.clone()) + .await; + return Err(error); + } + }; + + match response.outcome { + RequestPermissionOutcome::Selected(selected) + if selected.option_id.to_string() == PERMISSION_ALLOW_ONCE => + { + runtime + .agentic_system + .coordinator + .confirm_tool(tool_id, None) + .await + .map_err(BitfunAcpRuntime::internal_error)?; + } + RequestPermissionOutcome::Selected(selected) + if selected.option_id.to_string() == PERMISSION_REJECT_ONCE => + { + runtime + .agentic_system + .coordinator + .reject_tool(tool_id, "Rejected by ACP client".to_string()) + .await + .map_err(BitfunAcpRuntime::internal_error)?; + } + RequestPermissionOutcome::Cancelled => { + runtime + .agentic_system + .coordinator + .reject_tool(tool_id, "ACP permission request cancelled".to_string()) + .await + .map_err(BitfunAcpRuntime::internal_error)?; + } + RequestPermissionOutcome::Selected(selected) => { + let reason = format!( + "Unknown ACP permission option selected: {}", + selected.option_id + ); + runtime + .agentic_system + .coordinator + .reject_tool(tool_id, reason) + .await + .map_err(BitfunAcpRuntime::internal_error)?; + } + _ => { + runtime + .agentic_system + .coordinator + .reject_tool(tool_id, "Unsupported ACP permission outcome".to_string()) + .await + .map_err(BitfunAcpRuntime::internal_error)?; + } + } + + Ok(()) +} diff --git a/src/crates/acp/src/runtime/session.rs b/src/crates/acp/src/runtime/session.rs new file mode 100644 index 000000000..c537d736d --- /dev/null +++ b/src/crates/acp/src/runtime/session.rs @@ -0,0 +1,235 @@ +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use agent_client_protocol::schema::{ + CurrentModeUpdate, ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, + LoadSessionResponse, NewSessionRequest, NewSessionResponse, SessionId, SessionInfo, + SessionMode, SessionModeState, SessionUpdate, SetSessionModeRequest, SetSessionModeResponse, +}; +use agent_client_protocol::{Client, ConnectionTo, Error, Result}; +use bitfun_core::agentic::agents::get_agent_registry; +use bitfun_core::agentic::core::SessionConfig; +use chrono::{DateTime, Utc}; + +use super::events::send_update; +use super::{AcpSessionState, BitfunAcpRuntime}; + +impl BitfunAcpRuntime { + pub(super) async fn create_session( + &self, + request: NewSessionRequest, + connection: ConnectionTo, + ) -> Result { + let cwd = request.cwd.to_string_lossy().to_string(); + let mcp_servers = request.mcp_servers; + let session = self + .agentic_system + .coordinator + .create_session( + format!( + "ACP Session - {}", + chrono::Local::now().format("%Y-%m-%d %H:%M:%S") + ), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(cwd.clone()), + ..Default::default() + }, + ) + .await + .map_err(Self::internal_error)?; + + let acp_session = AcpSessionState { + acp_session_id: session.session_id.clone(), + bitfun_session_id: session.session_id.clone(), + cwd, + mode_id: session.agent_type.clone(), + mcp_server_ids: self + .provision_mcp_servers(&session.session_id, mcp_servers) + .await?, + }; + self.sessions + .insert(acp_session.acp_session_id.clone(), acp_session.clone()); + self.connections + .insert(acp_session.acp_session_id.clone(), connection); + + let modes = build_session_modes(Some(session.agent_type.as_str())).await; + Ok(NewSessionResponse::new(SessionId::new(acp_session.acp_session_id)).modes(modes)) + } + + pub(super) async fn restore_session( + &self, + request: LoadSessionRequest, + connection: ConnectionTo, + ) -> Result { + let cwd = request.cwd.to_string_lossy().to_string(); + let session_id = request.session_id.to_string(); + let mcp_servers = request.mcp_servers; + let session = self + .agentic_system + .coordinator + .restore_session(Path::new(&cwd), &session_id) + .await + .map_err(Self::internal_error)?; + + let acp_session = AcpSessionState { + acp_session_id: session.session_id.clone(), + bitfun_session_id: session.session_id.clone(), + cwd, + mode_id: session.agent_type.clone(), + mcp_server_ids: self + .provision_mcp_servers(&session.session_id, mcp_servers) + .await?, + }; + self.sessions + .insert(acp_session.acp_session_id.clone(), acp_session.clone()); + self.connections + .insert(acp_session.acp_session_id.clone(), connection); + + let modes = build_session_modes(Some(session.agent_type.as_str())).await; + Ok(LoadSessionResponse::new().modes(modes)) + } + + pub(super) async fn list_sessions_for_cwd( + &self, + request: ListSessionsRequest, + ) -> Result { + let cwd = request + .cwd + .or_else(|| std::env::current_dir().ok()) + .ok_or_else(|| Error::invalid_params().data("cwd is required"))?; + let cursor = request + .cursor + .as_deref() + .and_then(|value| value.parse::().ok()); + + let mut summaries = self + .agentic_system + .coordinator + .list_sessions(&cwd) + .await + .map_err(Self::internal_error)?; + summaries.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at)); + + let limit = 100usize; + let filtered = summaries + .into_iter() + .filter(|summary| { + cursor + .map(|cursor| system_time_to_unix_ms(summary.last_activity_at) < cursor) + .unwrap_or(true) + }) + .collect::>(); + + let sessions = filtered + .iter() + .take(limit) + .map(|summary| { + SessionInfo::new( + SessionId::new(summary.session_id.clone()), + Path::new(&cwd).to_path_buf(), + ) + .title(summary.session_name.clone()) + .updated_at(system_time_to_rfc3339(summary.last_activity_at)) + }) + .collect::>(); + + let next_cursor = if filtered.len() > limit { + filtered + .get(limit - 1) + .map(|summary| system_time_to_unix_ms(summary.last_activity_at).to_string()) + } else { + None + }; + + Ok(ListSessionsResponse::new(sessions).next_cursor(next_cursor)) + } + + pub(super) async fn update_session_mode( + &self, + request: SetSessionModeRequest, + ) -> Result { + let session_id = request.session_id.to_string(); + let mode_id = request.mode_id.to_string(); + let acp_session = self + .sessions + .get(&session_id) + .ok_or_else(|| Error::resource_not_found(Some(session_id.clone())))?; + let bitfun_session_id = acp_session.bitfun_session_id.clone(); + drop(acp_session); + + validate_mode_id(&mode_id).await?; + + self.agentic_system + .coordinator + .update_session_agent_type(&bitfun_session_id, &mode_id) + .await + .map_err(Self::internal_error)?; + + if let Some(mut state) = self.sessions.get_mut(&session_id) { + state.mode_id = mode_id.clone(); + } + + if let Some(connection) = self.connections.get(&session_id) { + send_update( + &connection, + &session_id, + SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(mode_id)), + )?; + } + + Ok(SetSessionModeResponse::new()) + } +} + +async fn build_session_modes(preferred_mode_id: Option<&str>) -> SessionModeState { + let available_modes = get_agent_registry() + .get_modes_info() + .await + .into_iter() + .filter(|info| info.enabled) + .map(|info| SessionMode::new(info.id, info.name).description(info.description)) + .collect::>(); + + let current_mode_id = preferred_mode_id + .and_then(|preferred| { + available_modes + .iter() + .find(|mode| mode.id.to_string() == preferred) + .map(|mode| mode.id.clone()) + }) + .or_else(|| { + available_modes + .iter() + .find(|mode| mode.id.to_string() == "agentic") + .or_else(|| available_modes.first()) + .map(|mode| mode.id.clone()) + }) + .unwrap_or_else(|| "agentic".into()); + + SessionModeState::new(current_mode_id, available_modes) +} + +async fn validate_mode_id(mode_id: &str) -> Result<()> { + let mode_exists = get_agent_registry() + .get_modes_info() + .await + .into_iter() + .any(|info| info.enabled && info.id == mode_id); + + if mode_exists { + Ok(()) + } else { + Err(Error::invalid_params().data(format!("unknown session mode: {}", mode_id))) + } +} + +fn system_time_to_unix_ms(time: SystemTime) -> u128 { + time.duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or_default() +} + +fn system_time_to_rfc3339(time: SystemTime) -> String { + DateTime::::from(time).to_rfc3339() +} diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index 69ad48e74..a2591a93f 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -2867,6 +2867,17 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet Ok(normalized) } + pub async fn update_session_agent_type( + &self, + session_id: &str, + agent_type: &str, + ) -> BitFunResult<()> { + let normalized = Self::normalize_agent_type(agent_type); + self.session_manager + .update_session_agent_type(session_id, &normalized) + .await + } + /// Emit event async fn emit_event(&self, event: AgenticEvent) { let _ = self diff --git a/src/crates/core/src/agentic/events/queue.rs b/src/crates/core/src/agentic/events/queue.rs index 35f2ef7b7..2f93bd540 100644 --- a/src/crates/core/src/agentic/events/queue.rs +++ b/src/crates/core/src/agentic/events/queue.rs @@ -7,7 +7,9 @@ use crate::util::errors::BitFunResult; use log::{debug, trace, warn}; use std::collections::BinaryHeap; use std::sync::Arc; -use tokio::sync::{Mutex, Notify}; +use tokio::sync::{broadcast, Mutex, Notify}; + +const EVENT_BROADCAST_BUFFER: usize = 1024; /// Event queue configuration #[derive(Debug, Clone)] @@ -46,6 +48,9 @@ pub struct EventQueue { /// Notifier (used to wake up waiting consumers) notify: Arc, + /// Broadcast stream for non-consuming subscribers. + broadcast_tx: broadcast::Sender, + /// Configuration config: EventQueueConfig, @@ -55,9 +60,11 @@ pub struct EventQueue { impl EventQueue { pub fn new(config: EventQueueConfig) -> Self { + let (broadcast_tx, _) = broadcast::channel(EVENT_BROADCAST_BUFFER); Self { queue: Arc::new(Mutex::new(BinaryHeap::new())), notify: Arc::new(Notify::new()), + broadcast_tx, config, stats: Arc::new(Mutex::new(QueueStats::default())), } @@ -85,9 +92,11 @@ impl EventQueue { // Add to queue { let mut queue = self.queue.lock().await; - queue.push(std::cmp::Reverse(envelope)); + queue.push(std::cmp::Reverse(envelope.clone())); } + let _ = self.broadcast_tx.send(envelope); + // Update statistics: get queue size first, then update statistics (avoid getting queue lock while holding stats lock) let queue_len = self.queue.lock().await.len(); { @@ -136,6 +145,11 @@ impl EventQueue { self.dequeue_batch(self.config.batch_size).await } + /// Subscribe to events without consuming them from the queue. + pub fn subscribe(&self) -> broadcast::Receiver { + self.broadcast_tx.subscribe() + } + /// Clear all events for a session pub async fn clear_session(&self, session_id: &str) -> BitFunResult<()> { // Remove all events for this session from the queue diff --git a/src/crates/core/src/service/mcp/server/manager/lifecycle.rs b/src/crates/core/src/service/mcp/server/manager/lifecycle.rs index bcac98e4a..1d5949751 100644 --- a/src/crates/core/src/service/mcp/server/manager/lifecycle.rs +++ b/src/crates/core/src/service/mcp/server/manager/lifecycle.rs @@ -1,6 +1,21 @@ use super::*; impl MCPServerManager { + async fn runtime_server_config(&self, server_id: &str) -> BitFunResult { + if let Some(config) = self.config_service.get_server_config(server_id).await? { + return Ok(config); + } + + self.ephemeral_configs + .read() + .await + .get(server_id) + .cloned() + .ok_or_else(|| { + BitFunError::NotFound(format!("MCP server config not found: {}", server_id)) + }) + } + /// Initializes all servers. pub async fn initialize_all(&self) -> BitFunResult<()> { info!("Initializing all MCP servers"); @@ -134,12 +149,7 @@ impl MCPServerManager { return Ok(()); } - let Some(config) = self.config_service.get_server_config(server_id).await? else { - return Err(BitFunError::NotFound(format!( - "MCP server config not found: {}", - server_id - ))); - }; + let config = self.runtime_server_config(server_id).await?; if !config.enabled { return Ok(()); @@ -155,12 +165,11 @@ impl MCPServerManager { info!("Starting MCP server: id={}", server_id); let config = self - .config_service - .get_server_config(server_id) - .await? - .ok_or_else(|| { + .runtime_server_config(server_id) + .await + .map_err(|error| { error!("MCP server config not found: id={}", server_id); - BitFunError::NotFound(format!("MCP server config not found: {}", server_id)) + error })?; if !config.enabled { @@ -325,13 +334,7 @@ impl MCPServerManager { pub async fn restart_server(&self, server_id: &str) -> BitFunResult<()> { info!("Restarting MCP server: id={}", server_id); - let config = self - .config_service - .get_server_config(server_id) - .await? - .ok_or_else(|| { - BitFunError::NotFound(format!("MCP server config not found: {}", server_id)) - })?; + let config = self.runtime_server_config(server_id).await?; match config.server_type { super::super::MCPServerType::Local => { @@ -427,6 +430,58 @@ impl MCPServerManager { Ok(()) } + /// Adds a runtime-only MCP server without saving it to user or project config. + pub async fn add_ephemeral_server(&self, config: MCPServerConfig) -> BitFunResult<()> { + config.validate()?; + + let server_id = config.id.clone(); + if self.registry.contains(&server_id).await { + let _ = self.remove_ephemeral_server(&server_id).await; + } + + self.ephemeral_configs + .write() + .await + .insert(server_id.clone(), config.clone()); + self.registry.register(&config).await?; + + if config.enabled && config.auto_start { + if let Err(error) = self.start_server(&server_id).await { + let _ = self.remove_ephemeral_server(&server_id).await; + return Err(error); + } + } + + Ok(()) + } + + /// Removes a runtime-only MCP server and its registered tools without touching persisted config. + pub async fn remove_ephemeral_server(&self, server_id: &str) -> BitFunResult<()> { + info!("Removing ephemeral MCP server: id={}", server_id); + + let _ = self.stop_server(server_id).await; + self.stop_connection_event_listener(server_id).await; + + match self.registry.unregister(server_id).await { + Ok(_) => { + info!("Unregistered ephemeral MCP server: id={}", server_id); + } + Err(e) => { + warn!( + "Ephemeral MCP server was not registered, skipping unregister: id={} error={}", + server_id, e + ); + } + } + + self.ephemeral_configs.write().await.remove(server_id); + self.clear_reconnect_state(server_id).await; + self.resource_catalog_cache.write().await.remove(server_id); + self.prompt_catalog_cache.write().await.remove(server_id); + + Ok(()) + } + /// Removes a server. pub async fn remove_server(&self, server_id: &str) -> BitFunResult<()> { info!("Removing MCP server: id={}", server_id); diff --git a/src/crates/core/src/service/mcp/server/manager/mod.rs b/src/crates/core/src/service/mcp/server/manager/mod.rs index c093429fb..b2ca77bef 100644 --- a/src/crates/core/src/service/mcp/server/manager/mod.rs +++ b/src/crates/core/src/service/mcp/server/manager/mod.rs @@ -106,6 +106,7 @@ pub struct MCPServerManager { prompt_catalog_cache: Arc>>>, pending_interactions: Arc>>, oauth_sessions: Arc>>>, + ephemeral_configs: Arc>>, } impl MCPServerManager { @@ -123,6 +124,7 @@ impl MCPServerManager { prompt_catalog_cache: Arc::new(tokio::sync::RwLock::new(HashMap::new())), pending_interactions: Arc::new(tokio::sync::RwLock::new(HashMap::new())), oauth_sessions: Arc::new(tokio::sync::RwLock::new(HashMap::new())), + ephemeral_configs: Arc::new(tokio::sync::RwLock::new(HashMap::new())), } } } From 27fb90ecc22183ee757d5152ef63f80866519305 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Sun, 26 Apr 2026 16:57:32 +0800 Subject: [PATCH 3/3] feat: add ACP model selection support --- src/crates/acp/src/runtime.rs | 20 +- src/crates/acp/src/runtime/model.rs | 267 +++++++++++++++++++++++++ src/crates/acp/src/runtime/prompt.rs | 37 +++- src/crates/acp/src/runtime/session.rs | 57 ++++-- src/crates/acp/src/runtime/thinking.rs | 222 ++++++++++++++++++++ src/crates/acp/src/server.rs | 24 ++- 6 files changed, 608 insertions(+), 19 deletions(-) create mode 100644 src/crates/acp/src/runtime/model.rs create mode 100644 src/crates/acp/src/runtime/thinking.rs diff --git a/src/crates/acp/src/runtime.rs b/src/crates/acp/src/runtime.rs index e19927a23..0ec32fd1a 100644 --- a/src/crates/acp/src/runtime.rs +++ b/src/crates/acp/src/runtime.rs @@ -5,7 +5,8 @@ use agent_client_protocol::schema::{ ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, McpCapabilities, NewSessionRequest, NewSessionResponse, PromptCapabilities, PromptRequest, PromptResponse, ProtocolVersion, SessionCapabilities, SessionListCapabilities, - SetSessionModeRequest, SetSessionModeResponse, + SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, SetSessionModeRequest, + SetSessionModeResponse, SetSessionModelRequest, SetSessionModelResponse, }; use agent_client_protocol::{Client, ConnectionTo, Error, Result}; use async_trait::async_trait; @@ -17,8 +18,10 @@ use crate::server::{AcpRuntime, AcpServer}; mod content; mod events; mod mcp; +mod model; mod prompt; mod session; +mod thinking; pub struct BitfunAcpRuntime { pub(crate) agentic_system: AgenticSystem, @@ -32,6 +35,7 @@ pub(crate) struct AcpSessionState { pub(crate) bitfun_session_id: String, pub(crate) cwd: String, pub(crate) mode_id: String, + pub(crate) model_id: String, #[allow(dead_code)] pub(crate) mcp_server_ids: Vec, } @@ -110,4 +114,18 @@ impl AcpRuntime for BitfunAcpRuntime { ) -> Result { self.update_session_mode(request).await } + + async fn set_session_config_option( + &self, + request: SetSessionConfigOptionRequest, + ) -> Result { + self.update_session_config_option(request).await + } + + async fn set_session_model( + &self, + request: SetSessionModelRequest, + ) -> Result { + self.update_session_model(request).await + } } diff --git a/src/crates/acp/src/runtime/model.rs b/src/crates/acp/src/runtime/model.rs new file mode 100644 index 000000000..8bfa3d272 --- /dev/null +++ b/src/crates/acp/src/runtime/model.rs @@ -0,0 +1,267 @@ +use agent_client_protocol::schema::{ + ModelInfo, SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption, + SessionModelState, SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, + SetSessionModelRequest, SetSessionModelResponse, +}; +use agent_client_protocol::{Error, Result}; +use bitfun_core::agentic::agents::get_agent_registry; +use bitfun_core::service::config::types::AIConfig; +use bitfun_core::service::config::{GlobalConfig, GlobalConfigManager}; + +use super::BitfunAcpRuntime; + +const AUTO_MODEL_ID: &str = "auto"; +const MODEL_CONFIG_ID: &str = "model"; +const MODE_CONFIG_ID: &str = "mode"; + +impl BitfunAcpRuntime { + pub(super) async fn update_session_model( + &self, + request: SetSessionModelRequest, + ) -> Result { + let session_id = request.session_id.to_string(); + let model_id = request.model_id.to_string(); + self.set_session_model_id(&session_id, &model_id).await?; + Ok(SetSessionModelResponse::new()) + } + + pub(super) async fn update_session_config_option( + &self, + request: SetSessionConfigOptionRequest, + ) -> Result { + let session_id = request.session_id.to_string(); + let config_id = request.config_id.to_string(); + let value = request + .value + .as_value_id() + .ok_or_else(|| Error::invalid_params().data("config option value must be a string"))? + .to_string(); + + match config_id.as_str() { + MODEL_CONFIG_ID => { + self.set_session_model_id(&session_id, &value).await?; + } + MODE_CONFIG_ID => { + self.update_session_mode_inner(&session_id, &value).await?; + } + _ => { + return Err(Error::invalid_params() + .data(format!("unknown session config option: {}", config_id))); + } + } + + let state = self + .sessions + .get(&session_id) + .ok_or_else(|| Error::resource_not_found(Some(session_id.clone())))?; + let model_id = state.model_id.clone(); + let mode_id = state.mode_id.clone(); + drop(state); + + Ok(SetSessionConfigOptionResponse::new( + build_session_config_options(Some(&model_id), Some(&mode_id)).await?, + )) + } + + async fn set_session_model_id(&self, session_id: &str, model_id: &str) -> Result<()> { + let acp_session = self + .sessions + .get(session_id) + .ok_or_else(|| Error::resource_not_found(Some(session_id.to_string())))?; + let bitfun_session_id = acp_session.bitfun_session_id.clone(); + drop(acp_session); + + let normalized_model_id = normalize_model_selection(model_id).await?; + + self.agentic_system + .coordinator + .update_session_model(&bitfun_session_id, &normalized_model_id) + .await + .map_err(Self::internal_error)?; + + if let Some(mut state) = self.sessions.get_mut(session_id) { + state.model_id = normalized_model_id; + } + + Ok(()) + } +} + +pub(super) fn normalize_session_model_id(model_id: Option<&str>) -> String { + let model_id = model_id.unwrap_or(AUTO_MODEL_ID).trim(); + if model_id.is_empty() { + AUTO_MODEL_ID.to_string() + } else { + model_id.to_string() + } +} + +pub(super) async fn build_session_model_state( + preferred_model_id: Option<&str>, +) -> Result { + let ai_config = load_ai_config().await?; + let current_model_id = current_model_id(&ai_config, preferred_model_id); + let available_models = available_model_infos(&ai_config); + Ok(SessionModelState::new(current_model_id, available_models)) +} + +pub(super) async fn build_session_config_options( + preferred_model_id: Option<&str>, + preferred_mode_id: Option<&str>, +) -> Result> { + let ai_config = load_ai_config().await?; + let current_model_id = current_model_id(&ai_config, preferred_model_id); + let model_options = available_model_select_options(&ai_config); + + let mode_infos = get_agent_registry() + .get_modes_info() + .await + .into_iter() + .filter(|info| info.enabled) + .collect::>(); + let current_mode_id = preferred_mode_id + .and_then(|preferred| { + mode_infos + .iter() + .find(|mode| mode.id == preferred) + .map(|mode| mode.id.clone()) + }) + .or_else(|| { + mode_infos + .iter() + .find(|mode| mode.id == "agentic") + .or_else(|| mode_infos.first()) + .map(|mode| mode.id.clone()) + }) + .unwrap_or_else(|| "agentic".to_string()); + let mode_options = mode_infos + .into_iter() + .map(|mode| { + SessionConfigSelectOption::new(mode.id, mode.name).description(mode.description) + }) + .collect::>(); + + Ok(vec![ + SessionConfigOption::select(MODEL_CONFIG_ID, "Model", current_model_id, model_options) + .description("AI model used for this session") + .category(SessionConfigOptionCategory::Model), + SessionConfigOption::select(MODE_CONFIG_ID, "Mode", current_mode_id, mode_options) + .description("Agent mode used for this session") + .category(SessionConfigOptionCategory::Mode), + ]) +} + +async fn normalize_model_selection(model_id: &str) -> Result { + let model_id = normalize_session_model_id(Some(model_id)); + if model_id == AUTO_MODEL_ID { + return Ok(model_id); + } + + let ai_config = load_ai_config().await?; + ai_config.resolve_model_reference(&model_id).ok_or_else(|| { + Error::invalid_params().data(format!("unknown or disabled session model: {}", model_id)) + }) +} + +fn current_model_id(ai_config: &AIConfig, preferred_model_id: Option<&str>) -> String { + let preferred_model_id = normalize_session_model_id(preferred_model_id); + if preferred_model_id == AUTO_MODEL_ID { + return preferred_model_id; + } + + ai_config + .resolve_model_reference(&preferred_model_id) + .unwrap_or_else(|| AUTO_MODEL_ID.to_string()) +} + +fn available_model_infos(ai_config: &AIConfig) -> Vec { + let mut models = Vec::with_capacity(ai_config.models.len() + 1); + models.push(ModelInfo::new(AUTO_MODEL_ID, "Auto").description("Use the mode default model")); + models.extend( + ai_config + .models + .iter() + .filter(|model| model.enabled) + .map(|model| ModelInfo::new(model.id.clone(), model_display_name(model))), + ); + models +} + +fn available_model_select_options(ai_config: &AIConfig) -> Vec { + let mut options = Vec::with_capacity(ai_config.models.len() + 1); + options.push( + SessionConfigSelectOption::new(AUTO_MODEL_ID, "Auto") + .description("Use the mode default model"), + ); + options.extend( + ai_config + .models + .iter() + .filter(|model| model.enabled) + .map(|model| { + SessionConfigSelectOption::new(model.id.clone(), model_display_name(model)) + .description(format!("{} / {}", model.provider, model.model_name)) + }), + ); + options +} + +fn model_display_name(model: &bitfun_core::service::config::types::AIModelConfig) -> String { + if model.name.trim().is_empty() { + format!("{} / {}", model.provider, model.model_name) + } else { + model.name.clone() + } +} + +async fn load_ai_config() -> Result { + let config_service = GlobalConfigManager::get_service() + .await + .map_err(BitfunAcpRuntime::internal_error)?; + let global_config = config_service + .get_config::(None) + .await + .map_err(BitfunAcpRuntime::internal_error)?; + Ok(global_config.ai) +} + +#[cfg(test)] +mod tests { + use super::{current_model_id, normalize_session_model_id, AUTO_MODEL_ID}; + use bitfun_core::service::config::types::{AIConfig, AIModelConfig}; + + #[test] + fn normalize_session_model_defaults_to_auto() { + assert_eq!(normalize_session_model_id(None), AUTO_MODEL_ID); + assert_eq!(normalize_session_model_id(Some("")), AUTO_MODEL_ID); + assert_eq!(normalize_session_model_id(Some(" model-a ")), "model-a"); + } + + #[test] + fn current_model_falls_back_to_auto_for_disabled_model() { + let mut ai_config = AIConfig::default(); + ai_config.models.push(AIModelConfig { + id: "model-a".to_string(), + enabled: false, + ..Default::default() + }); + + assert_eq!(current_model_id(&ai_config, Some("model-a")), AUTO_MODEL_ID); + } + + #[test] + fn current_model_resolves_name_to_model_id() { + let mut ai_config = AIConfig::default(); + ai_config.models.push(AIModelConfig { + id: "model-a".to_string(), + name: "Readable Model".to_string(), + enabled: true, + ..Default::default() + }); + + assert_eq!( + current_model_id(&ai_config, Some("Readable Model")), + "model-a" + ); + } +} diff --git a/src/crates/acp/src/runtime/prompt.rs b/src/crates/acp/src/runtime/prompt.rs index 5e9b8c5c9..cdea66fa9 100644 --- a/src/crates/acp/src/runtime/prompt.rs +++ b/src/crates/acp/src/runtime/prompt.rs @@ -16,6 +16,7 @@ use super::events::{ permission_request, send_update, tool_event_updates, PERMISSION_ALLOW_ONCE, PERMISSION_REJECT_ONCE, }; +use super::thinking::{InlineThinkRouter, InlineThinkSegment}; use super::BitfunAcpRuntime; impl BitfunAcpRuntime { @@ -111,6 +112,7 @@ async fn wait_for_prompt_completion( bitfun_session_id: &str, ) -> Result { let mut seen_tool_calls = HashSet::new(); + let mut inline_think = InlineThinkRouter::new(); loop { let event = match event_rx.recv().await { @@ -130,10 +132,10 @@ async fn wait_for_prompt_completion( match event { CoreEvent::TextChunk { text, .. } => { - send_update( + send_inline_think_segments( connection, acp_session_id, - SessionUpdate::AgentMessageChunk(ContentChunk::new(text.into())), + inline_think.route_text(text), )?; } CoreEvent::ThinkingChunk { content, .. } => { @@ -165,9 +167,16 @@ async fn wait_for_prompt_completion( .await?; } } - CoreEvent::DialogTurnCompleted { .. } => return Ok(StopReason::EndTurn), - CoreEvent::DialogTurnCancelled { .. } => return Ok(StopReason::Cancelled), + CoreEvent::DialogTurnCompleted { .. } => { + send_inline_think_segments(connection, acp_session_id, inline_think.flush())?; + return Ok(StopReason::EndTurn); + } + CoreEvent::DialogTurnCancelled { .. } => { + send_inline_think_segments(connection, acp_session_id, inline_think.flush())?; + return Ok(StopReason::Cancelled); + } CoreEvent::DialogTurnFailed { error, .. } | CoreEvent::SystemError { error, .. } => { + send_inline_think_segments(connection, acp_session_id, inline_think.flush())?; send_update( connection, acp_session_id, @@ -182,6 +191,26 @@ async fn wait_for_prompt_completion( } } +fn send_inline_think_segments( + connection: &ConnectionTo, + acp_session_id: &str, + segments: Vec, +) -> Result<()> { + for segment in segments { + let update = match segment { + InlineThinkSegment::Text(text) => { + SessionUpdate::AgentMessageChunk(ContentChunk::new(text.into())) + } + InlineThinkSegment::Thinking(content) => { + SessionUpdate::AgentThoughtChunk(ContentChunk::new(content.into())) + } + }; + send_update(connection, acp_session_id, update)?; + } + + Ok(()) +} + async fn handle_permission_request( runtime: &BitfunAcpRuntime, connection: &ConnectionTo, diff --git a/src/crates/acp/src/runtime/session.rs b/src/crates/acp/src/runtime/session.rs index c537d736d..3a74b9d85 100644 --- a/src/crates/acp/src/runtime/session.rs +++ b/src/crates/acp/src/runtime/session.rs @@ -12,6 +12,9 @@ use bitfun_core::agentic::core::SessionConfig; use chrono::{DateTime, Utc}; use super::events::send_update; +use super::model::{ + build_session_config_options, build_session_model_state, normalize_session_model_id, +}; use super::{AcpSessionState, BitfunAcpRuntime}; impl BitfunAcpRuntime { @@ -44,6 +47,7 @@ impl BitfunAcpRuntime { bitfun_session_id: session.session_id.clone(), cwd, mode_id: session.agent_type.clone(), + model_id: normalize_session_model_id(session.config.model_id.as_deref()), mcp_server_ids: self .provision_mcp_servers(&session.session_id, mcp_servers) .await?, @@ -54,7 +58,16 @@ impl BitfunAcpRuntime { .insert(acp_session.acp_session_id.clone(), connection); let modes = build_session_modes(Some(session.agent_type.as_str())).await; - Ok(NewSessionResponse::new(SessionId::new(acp_session.acp_session_id)).modes(modes)) + let models = build_session_model_state(Some(&acp_session.model_id)).await?; + let config_options = + build_session_config_options(Some(&acp_session.model_id), Some(&acp_session.mode_id)) + .await?; + Ok( + NewSessionResponse::new(SessionId::new(acp_session.acp_session_id)) + .modes(modes) + .models(models) + .config_options(config_options), + ) } pub(super) async fn restore_session( @@ -77,6 +90,7 @@ impl BitfunAcpRuntime { bitfun_session_id: session.session_id.clone(), cwd, mode_id: session.agent_type.clone(), + model_id: normalize_session_model_id(session.config.model_id.as_deref()), mcp_server_ids: self .provision_mcp_servers(&session.session_id, mcp_servers) .await?, @@ -87,7 +101,14 @@ impl BitfunAcpRuntime { .insert(acp_session.acp_session_id.clone(), connection); let modes = build_session_modes(Some(session.agent_type.as_str())).await; - Ok(LoadSessionResponse::new().modes(modes)) + let models = build_session_model_state(Some(&acp_session.model_id)).await?; + let config_options = + build_session_config_options(Some(&acp_session.model_id), Some(&acp_session.mode_id)) + .await?; + Ok(LoadSessionResponse::new() + .modes(modes) + .models(models) + .config_options(config_options)) } pub(super) async fn list_sessions_for_cwd( @@ -149,36 +170,46 @@ impl BitfunAcpRuntime { &self, request: SetSessionModeRequest, ) -> Result { - let session_id = request.session_id.to_string(); let mode_id = request.mode_id.to_string(); + self.update_session_mode_inner(&request.session_id.to_string(), &mode_id) + .await?; + + Ok(SetSessionModeResponse::new()) + } + + pub(super) async fn update_session_mode_inner( + &self, + session_id: &str, + mode_id: &str, + ) -> Result<()> { let acp_session = self .sessions - .get(&session_id) - .ok_or_else(|| Error::resource_not_found(Some(session_id.clone())))?; + .get(session_id) + .ok_or_else(|| Error::resource_not_found(Some(session_id.to_string())))?; let bitfun_session_id = acp_session.bitfun_session_id.clone(); drop(acp_session); - validate_mode_id(&mode_id).await?; + validate_mode_id(mode_id).await?; self.agentic_system .coordinator - .update_session_agent_type(&bitfun_session_id, &mode_id) + .update_session_agent_type(&bitfun_session_id, mode_id) .await .map_err(Self::internal_error)?; - if let Some(mut state) = self.sessions.get_mut(&session_id) { - state.mode_id = mode_id.clone(); + if let Some(mut state) = self.sessions.get_mut(session_id) { + state.mode_id = mode_id.to_string(); } - if let Some(connection) = self.connections.get(&session_id) { + if let Some(connection) = self.connections.get(session_id) { send_update( &connection, - &session_id, - SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(mode_id)), + session_id, + SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(mode_id.to_string())), )?; } - Ok(SetSessionModeResponse::new()) + Ok(()) } } diff --git a/src/crates/acp/src/runtime/thinking.rs b/src/crates/acp/src/runtime/thinking.rs new file mode 100644 index 000000000..dd1d61821 --- /dev/null +++ b/src/crates/acp/src/runtime/thinking.rs @@ -0,0 +1,222 @@ +use std::mem; + +const INLINE_THINK_OPEN_TAG: &str = ""; +const INLINE_THINK_CLOSE_TAG: &str = ""; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Activation { + Unknown, + Enabled, + Disabled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Mode { + Text, + Thinking, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum InlineThinkSegment { + Text(String), + Thinking(String), +} + +#[derive(Debug)] +pub(crate) struct InlineThinkRouter { + activation: Activation, + mode: Mode, + pending_tail: String, + initial_probe: String, +} + +impl InlineThinkRouter { + pub(crate) fn new() -> Self { + Self { + activation: Activation::Unknown, + mode: Mode::Text, + pending_tail: String::new(), + initial_probe: String::new(), + } + } + + pub(crate) fn route_text(&mut self, text: String) -> Vec { + match self.activation { + Activation::Unknown => self.consume_unknown_text(text), + Activation::Enabled => self.parse_enabled_text(text), + Activation::Disabled => vec![InlineThinkSegment::Text(text)], + } + } + + pub(crate) fn flush(&mut self) -> Vec { + match self.activation { + Activation::Unknown => { + let pending = mem::take(&mut self.initial_probe); + if pending.is_empty() { + Vec::new() + } else { + vec![InlineThinkSegment::Text(pending)] + } + } + Activation::Enabled => { + let pending = mem::take(&mut self.pending_tail); + if pending.is_empty() { + Vec::new() + } else if self.mode == Mode::Thinking { + vec![InlineThinkSegment::Thinking(pending)] + } else { + vec![InlineThinkSegment::Text(pending)] + } + } + Activation::Disabled => Vec::new(), + } + } + + fn consume_unknown_text(&mut self, text: String) -> Vec { + self.initial_probe.push_str(&text); + + let trimmed = self.initial_probe.trim_start_matches(char::is_whitespace); + if trimmed.is_empty() { + return Vec::new(); + } + + if trimmed.starts_with(INLINE_THINK_OPEN_TAG) { + self.activation = Activation::Enabled; + let buffered = mem::take(&mut self.initial_probe); + return self.parse_enabled_text(buffered); + } + + if INLINE_THINK_OPEN_TAG.starts_with(trimmed) { + return Vec::new(); + } + + self.activation = Activation::Disabled; + vec![InlineThinkSegment::Text(mem::take(&mut self.initial_probe))] + } + + fn parse_enabled_text(&mut self, text: String) -> Vec { + let mut data = mem::take(&mut self.pending_tail); + data.push_str(&text); + + let mut segments = Vec::new(); + + loop { + let marker = match self.mode { + Mode::Text => INLINE_THINK_OPEN_TAG, + Mode::Thinking => INLINE_THINK_CLOSE_TAG, + }; + + if let Some(marker_idx) = data.find(marker) { + let before_marker = data[..marker_idx].to_string(); + self.push_segment(&mut segments, before_marker); + + data = data[marker_idx + marker.len()..].to_string(); + self.mode = match self.mode { + Mode::Text => Mode::Thinking, + Mode::Thinking => Mode::Text, + }; + continue; + } + + let tail_len = longest_suffix_prefix_len(&data, marker); + let flush_len = data.len() - tail_len; + let ready = data[..flush_len].to_string(); + self.push_segment(&mut segments, ready); + self.pending_tail = data[flush_len..].to_string(); + break; + } + + segments + } + + fn push_segment(&self, segments: &mut Vec, content: String) { + if content.is_empty() { + return; + } + + match self.mode { + Mode::Text => segments.push(InlineThinkSegment::Text(content)), + Mode::Thinking => segments.push(InlineThinkSegment::Thinking(content)), + } + } +} + +fn longest_suffix_prefix_len(value: &str, marker: &str) -> usize { + let max_len = value.len().min(marker.len().saturating_sub(1)); + (1..=max_len) + .rev() + .find(|&len| value.ends_with(&marker[..len])) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::{InlineThinkRouter, InlineThinkSegment}; + + #[test] + fn routes_initial_inline_thinking_to_thought_segments() { + let mut router = InlineThinkRouter::new(); + + let first = router.route_text("abc".to_string()); + let second = router.route_text("defghi".to_string()); + + assert_eq!(first, vec![InlineThinkSegment::Thinking("abc".to_string())]); + assert_eq!( + second, + vec![ + InlineThinkSegment::Thinking("def".to_string()), + InlineThinkSegment::Text("ghi".to_string()) + ] + ); + } + + #[test] + fn handles_split_opening_tag() { + let mut router = InlineThinkRouter::new(); + + assert!(router.route_text("hiddenvisible".to_string()), + vec![ + InlineThinkSegment::Thinking("hidden".to_string()), + InlineThinkSegment::Text("visible".to_string()) + ] + ); + } + + #[test] + fn leaves_non_initial_tags_as_message_text() { + let mut router = InlineThinkRouter::new(); + + assert_eq!( + router.route_text("hello literal".to_string()), + vec![InlineThinkSegment::Text("hello literal".to_string())] + ); + assert_eq!( + router.route_text(" world".to_string()), + vec![InlineThinkSegment::Text(" world".to_string())] + ); + } + + #[test] + fn flushes_unclosed_thinking_without_tags() { + let mut router = InlineThinkRouter::new(); + + assert_eq!( + router.route_text("abc".to_string()), + vec![InlineThinkSegment::Thinking("abc".to_string())] + ); + assert!(router.flush().is_empty()); + } + + #[test] + fn flushes_unknown_probe_as_text() { + let mut router = InlineThinkRouter::new(); + + assert!(router.route_text(" ".to_string()).is_empty()); + assert_eq!( + router.flush(), + vec![InlineThinkSegment::Text(" ".to_string())] + ); + } +} diff --git a/src/crates/acp/src/server.rs b/src/crates/acp/src/server.rs index 71ed81260..23a16a28b 100644 --- a/src/crates/acp/src/server.rs +++ b/src/crates/acp/src/server.rs @@ -5,7 +5,7 @@ use agent_client_protocol::schema::{ InitializeResponse, ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, NewSessionRequest, NewSessionResponse, PromptRequest, PromptResponse, SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, SetSessionModeRequest, - SetSessionModeResponse, + SetSessionModeResponse, SetSessionModelRequest, SetSessionModelResponse, }; use agent_client_protocol::{ Agent, ByteStreams, Client, ConnectTo, ConnectionTo, Dispatch, Error, Result, @@ -55,6 +55,13 @@ pub trait AcpRuntime: Send + Sync + 'static { ) -> Result { Err(Error::method_not_found().data("session/set_config_option is not implemented")) } + + async fn set_session_model( + &self, + _request: SetSessionModelRequest, + ) -> Result { + Err(Error::method_not_found().data("session/set_model is not implemented")) + } } /// Typed ACP server backed by an injected BitFun runtime. @@ -213,6 +220,21 @@ where }, agent_client_protocol::on_receive_request!(), ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: SetSessionModelRequest, + responder, + cx: ConnectionTo| { + let runtime = runtime.clone(); + cx.spawn(async move { + responder.respond_with_result(runtime.set_session_model(request).await) + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ) .on_receive_dispatch( async move |message: Dispatch, cx: ConnectionTo| { message.respond_with_error(Error::method_not_found(), cx)