Skip to content

Commit 87e026c

Browse files
phodalcursoragent
andcommitted
feat(acp): add bidirectional ACP support
- Add JVM-side ACP client/server helpers in mpp-core (ACP has no Kotlin/Native variants) - Add CLI ACP agent mode (xiuper acp-agent) plus TS ACP client utilities - Upgrade IDEA ACP dependency to 0.15.3 - Align CLI config defaults and update related tests Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent d386c0e commit 87e026c

File tree

17 files changed

+1755
-6
lines changed

17 files changed

+1755
-6
lines changed

gradle/libs.versions.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ filekit = "0.12.0"
105105
plantuml = "1.2025.10"
106106
mynlp = "4.0.0"
107107
a2aSdk = "0.3.0.Beta1"
108+
acpKotlinSdk = "0.15.3"
108109

109110
[libraries]
110111
# Kotlinx
@@ -250,6 +251,8 @@ plantuml = { module = "net.sourceforge.plantuml:plantuml-epl", version.ref = "pl
250251
mynlp = { module = "com.mayabot.mynlp:mynlp", version.ref = "mynlp" }
251252
mynlp-all = { module = "com.mayabot.mynlp:mynlp-all", version.ref = "mynlp" }
252253
a2a-sdk = { module = "io.github.a2asdk:a2a-java-sdk-client", version.ref = "a2aSdk" }
254+
acp-sdk = { module = "com.agentclientprotocol:acp", version.ref = "acpKotlinSdk" }
255+
acp-model = { module = "com.agentclientprotocol:acp-model", version.ref = "acpKotlinSdk" }
253256

254257
[plugins]
255258
# Kotlin

mpp-core/build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,10 @@ kotlin {
173173
}
174174

175175
dependencies {
176+
// ACP (Agent Client Protocol) - JVM-only (no Kotlin/Native variants)
177+
implementation(libs.acp.sdk)
178+
implementation(libs.acp.model)
179+
176180
// Ktor CIO engine for JVM
177181
implementation(libs.ktor.client.cio)
178182
// Ktor content negotiation - required by ai.koog:prompt-executor-llms-all
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
package cc.unitmesh.agent.acp
2+
3+
import com.agentclientprotocol.agent.Agent
4+
import com.agentclientprotocol.agent.AgentInfo
5+
import com.agentclientprotocol.agent.AgentSession
6+
import com.agentclientprotocol.agent.AgentSupport
7+
import com.agentclientprotocol.client.ClientInfo
8+
import com.agentclientprotocol.common.Event
9+
import com.agentclientprotocol.common.SessionCreationParameters
10+
import com.agentclientprotocol.model.*
11+
import com.agentclientprotocol.protocol.Protocol
12+
import com.agentclientprotocol.transport.StdioTransport
13+
import io.github.oshai.kotlinlogging.KotlinLogging
14+
import kotlinx.coroutines.CoroutineScope
15+
import kotlinx.coroutines.Dispatchers
16+
import kotlinx.coroutines.flow.Flow
17+
import kotlinx.coroutines.flow.flow
18+
import kotlinx.io.RawSink
19+
import kotlinx.io.RawSource
20+
import kotlinx.io.buffered
21+
import kotlinx.serialization.json.JsonElement
22+
23+
private val logger = KotlinLogging.logger("AcpAgentServer")
24+
25+
/**
26+
* Callback interface for handling ACP agent prompts.
27+
*
28+
* Implementations should process the user's prompt and emit session update events.
29+
* This bridges the ACP protocol to the internal CodingAgent execution system.
30+
*/
31+
interface AcpPromptHandler {
32+
/**
33+
* Handle an incoming prompt from the ACP client.
34+
*
35+
* @param sessionId The ACP session ID
36+
* @param content The content blocks from the prompt
37+
* @param updateEmitter Use this to emit session update events back to the client
38+
* @return The stop reason for this prompt turn
39+
*/
40+
suspend fun handlePrompt(
41+
sessionId: String,
42+
content: List<ContentBlock>,
43+
updateEmitter: AcpUpdateEmitter,
44+
): StopReason
45+
46+
/**
47+
* Cancel any running task for the given session.
48+
*/
49+
suspend fun cancel(sessionId: String)
50+
}
51+
52+
/**
53+
* Emitter for sending ACP session updates back to the client.
54+
*/
55+
interface AcpUpdateEmitter {
56+
/**
57+
* Emit a text message chunk to the client.
58+
*/
59+
suspend fun emitTextChunk(text: String)
60+
61+
/**
62+
* Emit a thinking/thought chunk.
63+
*/
64+
suspend fun emitThoughtChunk(text: String)
65+
66+
/**
67+
* Emit a tool call update.
68+
*/
69+
suspend fun emitToolCall(
70+
toolCallId: String,
71+
title: String,
72+
status: ToolCallStatus,
73+
kind: ToolKind? = null,
74+
input: String? = null,
75+
output: String? = null,
76+
)
77+
78+
/**
79+
* Emit a plan update.
80+
*/
81+
suspend fun emitPlanUpdate(entries: List<PlanEntry>)
82+
}
83+
84+
/**
85+
* ACP Agent Server that exposes our CodingAgent via the Agent Client Protocol.
86+
*
87+
* Other editors (VSCode, Zed, etc.) can connect to this server and use our agent
88+
* through the standardized ACP protocol. Communication happens over STDIO (JSON-RPC).
89+
*
90+
* Note: ACP Kotlin SDK currently does not provide Kotlin/Native variants, so this server is JVM-only.
91+
*/
92+
class AcpAgentServer(
93+
private val coroutineScope: CoroutineScope,
94+
private val input: RawSource,
95+
private val output: RawSink,
96+
private val agentName: String = "autodev-xiuper",
97+
private val agentVersion: String = "dev",
98+
) {
99+
private var protocol: Protocol? = null
100+
private var agent: Agent? = null
101+
102+
/**
103+
* The handler that processes incoming prompts.
104+
* Must be set before calling [start].
105+
*/
106+
var promptHandler: AcpPromptHandler? = null
107+
108+
/**
109+
* Start the ACP agent server. This will begin listening for messages.
110+
*/
111+
suspend fun start() {
112+
val handler = promptHandler ?: throw IllegalStateException("promptHandler must be set before starting")
113+
114+
val transport = StdioTransport(
115+
parentScope = coroutineScope,
116+
ioDispatcher = Dispatchers.Default,
117+
input = input.buffered(),
118+
output = output.buffered(),
119+
name = agentName
120+
)
121+
val proto = Protocol(coroutineScope, transport)
122+
123+
val agentSupport = AutoDevAgentSupport(
124+
agentName = agentName,
125+
agentVersion = agentVersion,
126+
promptHandler = handler
127+
)
128+
129+
val acpAgent = Agent(
130+
protocol = proto,
131+
agentSupport = agentSupport
132+
)
133+
134+
this.protocol = proto
135+
this.agent = acpAgent
136+
137+
logger.info { "ACP agent server starting ($agentName v$agentVersion)..." }
138+
proto.start()
139+
logger.info { "ACP agent server started and listening for connections." }
140+
}
141+
142+
/**
143+
* Stop the server and clean up resources.
144+
*/
145+
suspend fun stop() {
146+
try {
147+
protocol?.close()
148+
} catch (_: Exception) {
149+
}
150+
protocol = null
151+
agent = null
152+
logger.info { "ACP agent server stopped." }
153+
}
154+
}
155+
156+
/**
157+
* Internal AgentSupport implementation for AutoDev.
158+
*/
159+
internal class AutoDevAgentSupport(
160+
private val agentName: String,
161+
private val agentVersion: String,
162+
private val promptHandler: AcpPromptHandler,
163+
) : AgentSupport {
164+
165+
override suspend fun initialize(clientInfo: ClientInfo): AgentInfo {
166+
logger.info { "ACP client connected: ${clientInfo.implementation?.name} v${clientInfo.implementation?.version}" }
167+
return AgentInfo(
168+
protocolVersion = LATEST_PROTOCOL_VERSION,
169+
capabilities = AgentCapabilities(loadSession = false, _meta = null),
170+
implementation = Implementation(
171+
name = agentName,
172+
version = agentVersion,
173+
title = "AutoDev Xiuper (ACP Agent)",
174+
_meta = null
175+
),
176+
)
177+
}
178+
179+
override suspend fun createSession(sessionParameters: SessionCreationParameters): AgentSession {
180+
val sessionId = SessionId("autodev-${kotlinx.datetime.Clock.System.now().toEpochMilliseconds()}")
181+
logger.info { "Creating ACP session: $sessionId (cwd=${sessionParameters.cwd})" }
182+
return AutoDevAgentSession(sessionId, promptHandler)
183+
}
184+
185+
override suspend fun loadSession(
186+
sessionId: SessionId,
187+
sessionParameters: SessionCreationParameters,
188+
): AgentSession {
189+
logger.info { "Loading ACP session (not fully supported): $sessionId" }
190+
return AutoDevAgentSession(sessionId, promptHandler)
191+
}
192+
}
193+
194+
/**
195+
* Internal AgentSession implementation for AutoDev.
196+
* Each session maps to one conversation with the client.
197+
*/
198+
internal class AutoDevAgentSession(
199+
override val sessionId: SessionId,
200+
private val promptHandler: AcpPromptHandler,
201+
) : AgentSession {
202+
203+
override suspend fun prompt(
204+
content: List<ContentBlock>,
205+
_meta: JsonElement?,
206+
): Flow<Event> = flow {
207+
val emitter = FlowAcpUpdateEmitter { event -> emit(event) }
208+
209+
val stopReason = try {
210+
promptHandler.handlePrompt(sessionId.value, content, emitter)
211+
} catch (e: Exception) {
212+
logger.warn(e) { "Error handling ACP prompt" }
213+
emit(
214+
Event.SessionUpdateEvent(
215+
SessionUpdate.AgentMessageChunk(
216+
ContentBlock.Text("Error: ${e.message}", Annotations(), null)
217+
)
218+
)
219+
)
220+
StopReason.END_TURN
221+
}
222+
223+
emit(Event.PromptResponseEvent(PromptResponse(stopReason)))
224+
}
225+
226+
override suspend fun cancel() {
227+
promptHandler.cancel(sessionId.value)
228+
}
229+
}
230+
231+
/**
232+
* AcpUpdateEmitter that emits events into a Flow.
233+
*/
234+
internal class FlowAcpUpdateEmitter(
235+
private val emit: suspend (Event) -> Unit,
236+
) : AcpUpdateEmitter {
237+
238+
override suspend fun emitTextChunk(text: String) {
239+
emit(
240+
Event.SessionUpdateEvent(
241+
SessionUpdate.AgentMessageChunk(
242+
ContentBlock.Text(text, Annotations(), null)
243+
)
244+
)
245+
)
246+
}
247+
248+
override suspend fun emitThoughtChunk(text: String) {
249+
emit(
250+
Event.SessionUpdateEvent(
251+
SessionUpdate.AgentThoughtChunk(
252+
ContentBlock.Text(text, Annotations(), null)
253+
)
254+
)
255+
)
256+
}
257+
258+
override suspend fun emitToolCall(
259+
toolCallId: String,
260+
title: String,
261+
status: ToolCallStatus,
262+
kind: ToolKind?,
263+
input: String?,
264+
output: String?,
265+
) {
266+
emit(
267+
Event.SessionUpdateEvent(
268+
SessionUpdate.ToolCallUpdate(
269+
toolCallId = ToolCallId(toolCallId),
270+
title = title,
271+
status = status,
272+
kind = kind,
273+
rawInput = null,
274+
rawOutput = null,
275+
_meta = null
276+
)
277+
)
278+
)
279+
}
280+
281+
override suspend fun emitPlanUpdate(entries: List<PlanEntry>) {
282+
emit(
283+
Event.SessionUpdateEvent(
284+
SessionUpdate.PlanUpdate(
285+
entries = entries,
286+
_meta = null
287+
)
288+
)
289+
)
290+
}
291+
}
292+

0 commit comments

Comments
 (0)