1414from collections .abc import Generator
1515from typing import TYPE_CHECKING , Any
1616
17- from pydantic import Field , PrivateAttr
17+ from pydantic import Field , PrivateAttr , model_validator
1818
1919from openhands .sdk .agent .base import AgentBase
2020from openhands .sdk .conversation .state import ConversationExecutionStatus
@@ -263,20 +263,21 @@ def on_connect(self, conn: Any) -> None: # noqa: ARG002
263263class ACPAgent (AgentBase ):
264264 """Agent that delegates to an ACP (Agent Client Protocol) server.
265265
266- Instead of calling an LLM directly, this agent spawns an ACP-compatible
267- server (e.g. ``claude-code-acp``) as a subprocess and communicates with
268- it via the ACP protocol. The server manages its own LLM, tools, and
269- execution lifecycle.
266+ Instead of calling an LLM directly, this agent communicates with an
267+ ACP-compatible server (e.g. ``claude-code-acp``) via the ACP protocol.
268+ The server manages its own LLM, tools, and execution lifecycle.
270269
271- Example: :
270+ Two transport modes are supported (mutually exclusive) :
272271
273- from openhands.sdk. agent import ACPAgent
274- from openhands.sdk.conversation import Conversation
272+ **Subprocess mode** — the agent spawns the ACP server as a child process
273+ and communicates via stdin/stdout JSON-RPC::
275274
276275 agent = ACPAgent(acp_command=["npx", "-y", "claude-code-acp"])
277- conversation = Conversation(agent=agent, workspace="./workspace")
278- conversation.send_message("Hello! What is 2+2?")
279- conversation.run()
276+
277+ **TCP mode** — the agent connects to an already-running ACP server over
278+ the network::
279+
280+ agent = ACPAgent(acp_host="acp-server.internal", acp_port=4001)
280281 """
281282
282283 # Override required fields with ACP-appropriate defaults
@@ -285,12 +286,39 @@ class ACPAgent(AgentBase):
285286 include_default_tools : list [str ] = Field (default_factory = list )
286287
287288 # ACP-specific configuration
288- acp_command : list [str ] = Field (
289- ... ,
289+ acp_command : list [str ] | None = Field (
290+ default = None ,
290291 description = (
291- "Command to start the ACP server, e.g. ['npx', '-y', 'claude-code-acp']"
292+ "Command to start the ACP server, e.g. ['npx', '-y', 'claude-code-acp']. "
293+ "Mutually exclusive with acp_host."
292294 ),
293295 )
296+ acp_host : str | None = Field (
297+ default = None ,
298+ description = (
299+ "Hostname of a remote ACP server. Mutually exclusive with acp_command."
300+ ),
301+ )
302+ acp_port : int | None = Field (
303+ default = None ,
304+ description = "Port of the remote ACP server. Required when acp_host is set." ,
305+ )
306+
307+ @model_validator (mode = "before" )
308+ @classmethod
309+ def _validate_transport (cls , data ):
310+ if not isinstance (data , dict ):
311+ return data
312+ has_command = data .get ("acp_command" ) is not None
313+ has_host = data .get ("acp_host" ) is not None
314+ if has_command and has_host :
315+ raise ValueError ("acp_command and acp_host are mutually exclusive" )
316+ if not has_command and not has_host :
317+ raise ValueError ("Either acp_command or acp_host must be provided" )
318+ if has_host and data .get ("acp_port" ) is None :
319+ raise ValueError ("acp_port is required when acp_host is set" )
320+ return data
321+
294322 acp_args : list [str ] = Field (
295323 default_factory = list ,
296324 description = "Additional arguments for the ACP server command" ,
@@ -307,6 +335,7 @@ class ACPAgent(AgentBase):
307335 _process : Any = PrivateAttr (default = None ) # asyncio subprocess
308336 _client : Any = PrivateAttr (default = None ) # _OpenHandsACPClient
309337 _filtered_reader : Any = PrivateAttr (default = None ) # StreamReader
338+ _tcp_writer : Any = PrivateAttr (default = None ) # asyncio.StreamWriter (TCP mode)
310339 _closed : bool = PrivateAttr (default = False )
311340 _working_dir : str = PrivateAttr (default = "" )
312341
@@ -375,70 +404,86 @@ def init_state(
375404 self ._initialized = True
376405
377406 def _start_acp_server (self , state : ConversationState ) -> None :
378- """Start the ACP subprocess and initialize the session."""
407+ """Start the ACP connection and initialize the session.
408+
409+ In subprocess mode (``acp_command``), spawns the server as a child
410+ process and communicates via stdin/stdout. In TCP mode
411+ (``acp_host``/``acp_port``), connects to an already-running server
412+ over the network.
413+ """
379414 import asyncio
380415
381416 from acp .client .connection import (
382417 ClientSideConnection ,
383418 )
384- from acp .transports import (
385- default_environment ,
386- )
387419
388420 client = _OpenHandsACPClient ()
389421 client ._llm_ref = self .llm
390422 self ._client = client
391423
392- # Build environment: inherit current env + ACP extras
393- env = default_environment ()
394- env .update (os .environ )
395- env .update (self .acp_env )
396-
397- command = self .acp_command [0 ]
398- args = list (self .acp_command [1 :]) + list (self .acp_args )
399-
400424 working_dir = str (state .workspace .working_dir )
401425
402- async def _init () -> tuple [Any , Any , Any , str ]:
403- # Spawn the subprocess directly so we can install a
404- # filtering reader that skips non-JSON-RPC lines some
405- # ACP servers (e.g. claude-code-acp v0.1.x) write to
406- # stdout.
407- process = await asyncio .create_subprocess_exec (
408- command ,
409- * args ,
410- stdin = asyncio .subprocess .PIPE ,
411- stdout = asyncio .subprocess .PIPE ,
412- stderr = asyncio .subprocess .PIPE ,
413- env = env ,
414- )
415- assert process .stdin is not None
416- assert process .stdout is not None
417-
418- # Wrap the subprocess stdout in a filtering reader that
419- # only passes lines starting with '{' (JSON-RPC messages).
420- filtered_reader = asyncio .StreamReader ()
421- asyncio .get_event_loop ().create_task (
422- _filter_jsonrpc_lines (process .stdout , filtered_reader )
423- )
426+ if self .acp_host is not None :
427+ # --- TCP mode ---
428+ async def _init_tcp () -> tuple [Any , str , Any ]:
429+ reader , writer = await asyncio .open_connection (
430+ self .acp_host , self .acp_port
431+ )
432+ conn = ClientSideConnection (
433+ client ,
434+ writer , # input_stream (write to server)
435+ reader , # output_stream (read from server)
436+ )
437+ await conn .initialize (protocol_version = 1 )
438+ response = await conn .new_session (cwd = working_dir )
439+ return conn , response .session_id , writer
424440
425- conn = ClientSideConnection (
426- client ,
427- process .stdin , # write to subprocess
428- filtered_reader , # read filtered output
441+ result = self ._executor .run_async (_init_tcp )
442+ self ._conn , self ._session_id , self ._tcp_writer = result
443+ else :
444+ # --- Subprocess mode ---
445+ from acp .transports import (
446+ default_environment ,
429447 )
430448
431- # Initialize the protocol
432- await conn .initialize (protocol_version = 1 )
449+ env = default_environment ()
450+ env .update (os .environ )
451+ env .update (self .acp_env )
452+
453+ assert self .acp_command is not None
454+ command = self .acp_command [0 ]
455+ args = list (self .acp_command [1 :]) + list (self .acp_args )
456+
457+ async def _init_subprocess () -> tuple [Any , Any , Any , str ]:
458+ process = await asyncio .create_subprocess_exec (
459+ command ,
460+ * args ,
461+ stdin = asyncio .subprocess .PIPE ,
462+ stdout = asyncio .subprocess .PIPE ,
463+ stderr = asyncio .subprocess .PIPE ,
464+ env = env ,
465+ )
466+ assert process .stdin is not None
467+ assert process .stdout is not None
433468
434- # Create a new session
435- response = await conn .new_session (cwd = working_dir )
436- session_id = response .session_id
469+ filtered_reader = asyncio .StreamReader ()
470+ asyncio .get_event_loop ().create_task (
471+ _filter_jsonrpc_lines (process .stdout , filtered_reader )
472+ )
437473
438- return conn , process , filtered_reader , session_id
474+ conn = ClientSideConnection (
475+ client ,
476+ process .stdin ,
477+ filtered_reader ,
478+ )
479+
480+ await conn .initialize (protocol_version = 1 )
481+ response = await conn .new_session (cwd = working_dir )
482+ return conn , process , filtered_reader , response .session_id
483+
484+ result = self ._executor .run_async (_init_subprocess )
485+ self ._conn , self ._process , self ._filtered_reader , self ._session_id = result
439486
440- result = self ._executor .run_async (_init )
441- self ._conn , self ._process , self ._filtered_reader , self ._session_id = result
442487 self ._working_dir = working_dir
443488
444489 def step (
@@ -640,6 +685,14 @@ def _cleanup(self) -> None:
640685 logger .debug ("Error closing ACP connection: %s" , e )
641686 self ._conn = None
642687
688+ # Close TCP writer if in network mode
689+ if self ._tcp_writer is not None :
690+ try :
691+ self ._tcp_writer .close ()
692+ except Exception as e :
693+ logger .debug ("Error closing TCP writer: %s" , e )
694+ self ._tcp_writer = None
695+
643696 # Terminate the subprocess
644697 if self ._process is not None :
645698 try :
0 commit comments