openclaw-acp-bridge 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,27 @@
1
+ Copyright (c) 2026, sunshinejnjn@github
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without
5
+ modification, are permitted provided that the following conditions are met:
6
+
7
+ 1. Redistributions of source code must retain the above copyright notice, this
8
+ list of conditions and the following disclaimer.
9
+
10
+ 2. Redistributions in binary form must reproduce the above copyright notice,
11
+ this list of conditions and the following disclaimer in the documentation
12
+ and/or other materials provided with the distribution.
13
+
14
+ 3. Neither the name of the copyright holder nor the names of its
15
+ contributors may be used to endorse or promote products derived from
16
+ this software without specific prior written permission.
17
+
18
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
19
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
20
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FORDaily A PARTICULAR PURPOSE ARE
21
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
22
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
23
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
24
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
25
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
26
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,137 @@
1
+ Metadata-Version: 2.4
2
+ Name: openclaw-acp-bridge
3
+ Version: 0.3.2
4
+ Summary: A high-performance persistent TCP bridge and async client for OpenClaw ACP
5
+ Author: sunshinejnjn@github
6
+ License: BSD-3-Clause
7
+ Project-URL: Homepage, https://github.com/sunshinejnjn/openclaw-acp-bridge
8
+ Project-URL: Bug Tracker, https://github.com/sunshinejnjn/openclaw-acp-bridge/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: BSD License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Topic :: Communications :: Chat
13
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
14
+ Requires-Python: >=3.8
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: httpx>=0.24.0
18
+ Dynamic: license-file
19
+
20
+ # OpenClaw ACP Bridge
21
+
22
+ A high-performance, persistent TCP bridge and client for the OpenClaw Agent Control Protocol (ACP).
23
+
24
+ The **OpenClaw ACP Bridge** solves the challenge of maintaining persistent agent sessions over standard TCP while providing a high-speed "side-channel" for large-scale file transfers. It is designed for high-performance agentic workflows where large binary assets (images, videos, datasets) need to be moved efficiently between remote agents and local clients.
25
+
26
+ ## ๐Ÿš€ Key Features
27
+
28
+ - **Persistent Agent Session**: Unlike standard ACP tools that may restart agents per request, the bridge maintains a single persistent agent process across multiple client turns.
29
+ - **High-Speed HTTP Side-Channel**: Automatically switches to HTTP streaming for large files (GB-sized), bypassing JSON-RPC/Base64 overhead and memory bloat.
30
+ - **Explicit File Interception**: Use `/filerequest <path>` to instantly fetch any file from the remote agent's filesystem.
31
+ - **Auto-File Retrieval**: Intelligently detects the `[FILEPATH: /path/to/file]` pattern in agent responses and automatically initiates a high-speed transfer.
32
+ - **Async/Non-Blocking**: Built from the ground up for `asyncio`, utilizing `httpx` for reliable binary streaming.
33
+ - **Environment Consistency**: Server-side agent launching utilizes interactive shells (`bash -i`) to ensure `.bashrc`, NVM, and local paths are correctly resolved.
34
+
35
+ ## ๐Ÿ“ฆ Installation
36
+ On the server (host where your openclaw gateway runs on) side, as the acp_server_bridge.
37
+ And the client side if you want to run with our client interface. Or you can use acp directly to connect to the acp_server_bridge using TCP directly (pip install agent-client-protocol).
38
+ ```bash
39
+ pip install openclaw-acp-bridge
40
+ ```
41
+
42
+ ## ๐Ÿ› ๏ธ Usage
43
+
44
+ ### 1. Launch the Bridge Server
45
+ On your remote server (where OpenClaw is installed), start the bridge using the provided helper script:
46
+
47
+ ```bash
48
+ chmod +x run_acp_server.sh
49
+ ./run_acp_server.sh
50
+ ```
51
+
52
+ Or run the module directly:
53
+
54
+ ```bash
55
+ python -m openclaw_acp_bridge --host 0.0.0.0 --port 18781 --debug
56
+ ```
57
+
58
+ *Note: The bridge will also open a side-channel HTTP server on `port + 1` (default 18782).*
59
+
60
+ ### 2. Connect the Async Client
61
+ On your local machine, use the `OpenClaw` client to interact with the remote agent:
62
+
63
+ ```python
64
+ import asyncio
65
+ from openclaw_acp_bridge import OpenClaw
66
+
67
+ async def main():
68
+ # Connect to the remote bridge
69
+ async with OpenClaw(host="10.71.253.132", download_dir="my_assets") as client:
70
+ # 1. Standard Chat
71
+ response = await client.chat("Hello, who are you?")
72
+ print(f"Agent: {response.text}")
73
+
74
+ # 2. Explicit File Request
75
+ # This uses the high-speed side-channel automatically
76
+ response = await client.chat("/filerequest /path/to/large_dataset.zip")
77
+ if response.files:
78
+ print(f"Received file: {response.files[0]}")
79
+
80
+ # 3. Auto-Retrieval Pattern
81
+ # Ask the agent to generate something and return the path
82
+ response = await client.chat("Generate a report and return path in [FILEPATH: /path] format.")
83
+ # The client automatically detects the pattern and fetches the file!
84
+ for file in response.files:
85
+ print(f"Auto-downloaded: {file}")
86
+
87
+ if __name__ == "__main__":
88
+ asyncio.run(main())
89
+ ```
90
+
91
+ ## ๐Ÿ”Œ Using with Standard ACP Clients
92
+
93
+ The bridge is fully compatible with any standard ACP-compliant client or SDK. To use it, simply point your client to the bridge's TCP address (default port `18781`).
94
+
95
+ ### Why use the Bridge with standard clients?
96
+ 1. **Persistence**: Even with a standard client, the bridge keeps your remote agent process alive across sessions.
97
+ 2. **Environment**: The bridge handles the complex `bash -i` shell environment setup for you.
98
+ 3. **Special Commands**: You can still use `/filerequest <path>` in your prompts. The bridge will intercept these and return a standard ACP `resource` block.
99
+
100
+ *Note: When using a standard client, the high-speed HTTP side-channel will return a `resource` block with a `uri` starting with `http://`. Ensure your client can handle HTTP-based resources or use our provided `openclaw_acp_bridge` client for automatic handling.*
101
+
102
+ ## โš™๏ธ Advanced Configuration
103
+
104
+ ### Bridge Server Options
105
+ You can customize the server behavior using CLI arguments:
106
+
107
+ ```bash
108
+ python -m openclaw_acp_bridge --port 18781 --token "my-secret-key" --openclaw-path "/usr/local/bin/openclaw"
109
+ ```
110
+
111
+ | Parameter | Description |
112
+ | :--- | :--- |
113
+ | `--host` | Host to bind the TCP server to (default: `0.0.0.0`) |
114
+ | `--port` | Port to listen on (default: `18781`) |
115
+ | `--token` | Optional authentication token. If set, clients must provide this token to connect. |
116
+ | `--openclaw-path` | Path to the `openclaw` binary on the server (default: `openclaw`) |
117
+ | `--no-http` | Disable the high-speed HTTP side-channel. When set, the bridge uses standard Base64 blobs for all file transfers. |
118
+ | `--debug` | Enable verbose logging for debugging. |
119
+
120
+ ### Authentication
121
+ The bridge supports simple token-based authentication. You can either pass the token via the `--token` CLI argument or place a `token.txt` file in the server's working directory. The `--token` argument takes precedence.
122
+
123
+ ### Download Directory
124
+ The client allows you to specify where downloaded assets should be stored:
125
+ ```python
126
+ client = OpenClaw(host="...", download_dir="./downloads")
127
+ ```
128
+
129
+ ## ๐Ÿงช Testing
130
+ The package includes a comprehensive test suite `test_bridge.py` that demonstrates chat, small file blobs, and large-scale (100MB+) HTTP streaming.
131
+
132
+ ```bash
133
+ python test_bridge.py --tests 1,2,3,4
134
+ ```
135
+
136
+ ## ๐Ÿ“œ License
137
+ BSD 3-Clause
@@ -0,0 +1,118 @@
1
+ # OpenClaw ACP Bridge
2
+
3
+ A high-performance, persistent TCP bridge and client for the OpenClaw Agent Control Protocol (ACP).
4
+
5
+ The **OpenClaw ACP Bridge** solves the challenge of maintaining persistent agent sessions over standard TCP while providing a high-speed "side-channel" for large-scale file transfers. It is designed for high-performance agentic workflows where large binary assets (images, videos, datasets) need to be moved efficiently between remote agents and local clients.
6
+
7
+ ## ๐Ÿš€ Key Features
8
+
9
+ - **Persistent Agent Session**: Unlike standard ACP tools that may restart agents per request, the bridge maintains a single persistent agent process across multiple client turns.
10
+ - **High-Speed HTTP Side-Channel**: Automatically switches to HTTP streaming for large files (GB-sized), bypassing JSON-RPC/Base64 overhead and memory bloat.
11
+ - **Explicit File Interception**: Use `/filerequest <path>` to instantly fetch any file from the remote agent's filesystem.
12
+ - **Auto-File Retrieval**: Intelligently detects the `[FILEPATH: /path/to/file]` pattern in agent responses and automatically initiates a high-speed transfer.
13
+ - **Async/Non-Blocking**: Built from the ground up for `asyncio`, utilizing `httpx` for reliable binary streaming.
14
+ - **Environment Consistency**: Server-side agent launching utilizes interactive shells (`bash -i`) to ensure `.bashrc`, NVM, and local paths are correctly resolved.
15
+
16
+ ## ๐Ÿ“ฆ Installation
17
+ On the server (host where your openclaw gateway runs on) side, as the acp_server_bridge.
18
+ And the client side if you want to run with our client interface. Or you can use acp directly to connect to the acp_server_bridge using TCP directly (pip install agent-client-protocol).
19
+ ```bash
20
+ pip install openclaw-acp-bridge
21
+ ```
22
+
23
+ ## ๐Ÿ› ๏ธ Usage
24
+
25
+ ### 1. Launch the Bridge Server
26
+ On your remote server (where OpenClaw is installed), start the bridge using the provided helper script:
27
+
28
+ ```bash
29
+ chmod +x run_acp_server.sh
30
+ ./run_acp_server.sh
31
+ ```
32
+
33
+ Or run the module directly:
34
+
35
+ ```bash
36
+ python -m openclaw_acp_bridge --host 0.0.0.0 --port 18781 --debug
37
+ ```
38
+
39
+ *Note: The bridge will also open a side-channel HTTP server on `port + 1` (default 18782).*
40
+
41
+ ### 2. Connect the Async Client
42
+ On your local machine, use the `OpenClaw` client to interact with the remote agent:
43
+
44
+ ```python
45
+ import asyncio
46
+ from openclaw_acp_bridge import OpenClaw
47
+
48
+ async def main():
49
+ # Connect to the remote bridge
50
+ async with OpenClaw(host="10.71.253.132", download_dir="my_assets") as client:
51
+ # 1. Standard Chat
52
+ response = await client.chat("Hello, who are you?")
53
+ print(f"Agent: {response.text}")
54
+
55
+ # 2. Explicit File Request
56
+ # This uses the high-speed side-channel automatically
57
+ response = await client.chat("/filerequest /path/to/large_dataset.zip")
58
+ if response.files:
59
+ print(f"Received file: {response.files[0]}")
60
+
61
+ # 3. Auto-Retrieval Pattern
62
+ # Ask the agent to generate something and return the path
63
+ response = await client.chat("Generate a report and return path in [FILEPATH: /path] format.")
64
+ # The client automatically detects the pattern and fetches the file!
65
+ for file in response.files:
66
+ print(f"Auto-downloaded: {file}")
67
+
68
+ if __name__ == "__main__":
69
+ asyncio.run(main())
70
+ ```
71
+
72
+ ## ๐Ÿ”Œ Using with Standard ACP Clients
73
+
74
+ The bridge is fully compatible with any standard ACP-compliant client or SDK. To use it, simply point your client to the bridge's TCP address (default port `18781`).
75
+
76
+ ### Why use the Bridge with standard clients?
77
+ 1. **Persistence**: Even with a standard client, the bridge keeps your remote agent process alive across sessions.
78
+ 2. **Environment**: The bridge handles the complex `bash -i` shell environment setup for you.
79
+ 3. **Special Commands**: You can still use `/filerequest <path>` in your prompts. The bridge will intercept these and return a standard ACP `resource` block.
80
+
81
+ *Note: When using a standard client, the high-speed HTTP side-channel will return a `resource` block with a `uri` starting with `http://`. Ensure your client can handle HTTP-based resources or use our provided `openclaw_acp_bridge` client for automatic handling.*
82
+
83
+ ## โš™๏ธ Advanced Configuration
84
+
85
+ ### Bridge Server Options
86
+ You can customize the server behavior using CLI arguments:
87
+
88
+ ```bash
89
+ python -m openclaw_acp_bridge --port 18781 --token "my-secret-key" --openclaw-path "/usr/local/bin/openclaw"
90
+ ```
91
+
92
+ | Parameter | Description |
93
+ | :--- | :--- |
94
+ | `--host` | Host to bind the TCP server to (default: `0.0.0.0`) |
95
+ | `--port` | Port to listen on (default: `18781`) |
96
+ | `--token` | Optional authentication token. If set, clients must provide this token to connect. |
97
+ | `--openclaw-path` | Path to the `openclaw` binary on the server (default: `openclaw`) |
98
+ | `--no-http` | Disable the high-speed HTTP side-channel. When set, the bridge uses standard Base64 blobs for all file transfers. |
99
+ | `--debug` | Enable verbose logging for debugging. |
100
+
101
+ ### Authentication
102
+ The bridge supports simple token-based authentication. You can either pass the token via the `--token` CLI argument or place a `token.txt` file in the server's working directory. The `--token` argument takes precedence.
103
+
104
+ ### Download Directory
105
+ The client allows you to specify where downloaded assets should be stored:
106
+ ```python
107
+ client = OpenClaw(host="...", download_dir="./downloads")
108
+ ```
109
+
110
+ ## ๐Ÿงช Testing
111
+ The package includes a comprehensive test suite `test_bridge.py` that demonstrates chat, small file blobs, and large-scale (100MB+) HTTP streaming.
112
+
113
+ ```bash
114
+ python test_bridge.py --tests 1,2,3,4
115
+ ```
116
+
117
+ ## ๐Ÿ“œ License
118
+ BSD 3-Clause
@@ -0,0 +1,5 @@
1
+ from .client import OpenClaw
2
+ from .server import run_server
3
+
4
+ __version__ = "0.3.2"
5
+ __all__ = ["OpenClaw", "run_server"]
@@ -0,0 +1,18 @@
1
+ import argparse
2
+ import asyncio
3
+ from .server import run_server
4
+
5
+ def main():
6
+ parser = argparse.ArgumentParser(description="OpenClaw ACP TCP Bridge")
7
+ parser.add_argument("--host", default="0.0.0.0", help="Host to bind to")
8
+ parser.add_argument("--port", type=int, default=18781, help="Port to listen on")
9
+ parser.add_argument("--debug", action="store_true", help="Enable verbose logging")
10
+ parser.add_argument("--token", help="Authentication token required for clients")
11
+ parser.add_argument("--openclaw-path", default="openclaw", help="Path to the openclaw binary")
12
+ parser.add_argument("--no-http", action="store_true", help="Disable the high-speed HTTP side-channel and use Base64 blobs instead")
13
+ args = parser.parse_args()
14
+
15
+ asyncio.run(run_server(args.host, args.port, args.debug, args.token, args.openclaw_path, use_http=(not args.no_http)))
16
+
17
+ if __name__ == "__main__":
18
+ main()
@@ -0,0 +1,249 @@
1
+ import asyncio
2
+ import os
3
+ import base64
4
+ import shutil
5
+ import httpx
6
+ from uuid import uuid4
7
+ from typing import Optional, Callable, Any
8
+ from acp import PROTOCOL_VERSION, text_block
9
+ from acp.client import ClientSideConnection
10
+ from acp.interfaces import Client
11
+
12
+ class ChatResponse:
13
+ """A structured response from OpenClaw, containing text and any received files."""
14
+ def __init__(self, text: str, files: list[str]):
15
+ self.text = text
16
+ self.files = files
17
+
18
+ def __str__(self):
19
+ return self.text
20
+
21
+ def __repr__(self):
22
+ return self.text
23
+
24
+ class _InternalACPClient(Client):
25
+ """Internal handler for ACP session updates."""
26
+ def __init__(self, on_update: Optional[Callable[[str, Any], Any]] = None, download_dir: str = "downloads"):
27
+ self.on_update = on_update
28
+ self.download_dir = download_dir
29
+ self.current_response_chunks = []
30
+ self.received_files = []
31
+ self.active_tasks = [] # Track background downloads
32
+
33
+ # Ensure download directory exists
34
+ if not os.path.exists(self.download_dir):
35
+ os.makedirs(self.download_dir)
36
+
37
+ async def _download_stream(self, uri: str, save_path: str, filename: str):
38
+ try:
39
+ async with httpx.AsyncClient(timeout=300.0) as http_client:
40
+ async with http_client.stream("GET", uri) as r:
41
+ with open(save_path, "wb") as f:
42
+ chunk_count = 0
43
+ async for chunk in r.aiter_bytes():
44
+ f.write(chunk)
45
+ chunk_count += len(chunk)
46
+ if chunk_count > 0 and chunk_count % (10 * 1024 * 1024) == 0:
47
+ print(f"--- [Download Progress: {chunk_count // (1024*1024)}MB] ---")
48
+
49
+ self.received_files.append(save_path)
50
+ self.current_response_chunks.append(f"\n[File Received: {save_path}]\n")
51
+ except Exception as e:
52
+ self.current_response_chunks.append(f"\n[Stream Error: {e}]\n")
53
+
54
+
55
+ async def request_permission(self, options, session_id, tool_call, **kwargs):
56
+ return {"outcome": {"outcome": "approved"}}
57
+
58
+ async def session_update(self, session_id, update, **kwargs):
59
+ # Forward to custom callback if provided
60
+ if self.on_update:
61
+ if asyncio.iscoroutinefunction(self.on_update):
62
+ await self.on_update(session_id, update)
63
+ else:
64
+ self.on_update(session_id, update)
65
+
66
+ # Helper to get attributes from either an object or a dict
67
+ def get_attr(obj, name, default=None):
68
+ if isinstance(obj, dict):
69
+ val = obj.get(name)
70
+ if val is not None: return val
71
+ # Try alternate case
72
+ alt_name = name.replace("_", "") if "_" in name else name
73
+ for k, v in obj.items():
74
+ if k.lower() == name.lower() or k.lower() == alt_name.lower():
75
+ return v
76
+ return default
77
+ return getattr(obj, name, default)
78
+
79
+ # Robust type detection
80
+ update_type = get_attr(update, 'session_update', get_attr(update, 'sessionUpdate'))
81
+
82
+ if update_type == 'agent_message_chunk':
83
+ content = get_attr(update, 'content')
84
+ if content:
85
+ content_type = get_attr(content, 'type')
86
+
87
+ if content_type == 'text':
88
+ text = get_attr(content, 'text')
89
+ if text: self.current_response_chunks.append(text)
90
+ elif content_type == 'resource':
91
+ # Handle file/blob resources
92
+ res_info = get_attr(content, 'resource')
93
+ if res_info:
94
+ uri = get_attr(res_info, 'uri', get_attr(res_info, 'URI', 'unknown_file'))
95
+ blob = get_attr(res_info, 'blob')
96
+
97
+ # Extract filename from URI
98
+ filename = os.path.basename(uri.replace("file://", ""))
99
+ save_path = os.path.join(self.download_dir, filename)
100
+
101
+ # Priority 1: High-speed HTTP Side-Channel (for large files)
102
+ if uri and str(uri).startswith("http"):
103
+ task = asyncio.create_task(self._download_stream(uri, save_path, filename))
104
+ self.active_tasks.append(task)
105
+ # Priority 2: Standard Base64 Blob (for small files)
106
+ elif blob and blob != "AAA=":
107
+ with open(save_path, "wb") as f:
108
+ f.write(base64.b64decode(blob))
109
+ self.received_files.append(save_path)
110
+ self.current_response_chunks.append(f"\n[File Saved: {save_path}]\n")
111
+ else:
112
+ self.current_response_chunks.append(f"\n[File Received: {uri}]\n")
113
+ elif content_type == 'image':
114
+ # Handle direct image data
115
+ data = get_attr(content, 'data')
116
+ uri = get_attr(content, 'uri')
117
+
118
+ if data:
119
+ mime_type = get_attr(content, 'mimeType', 'image/png')
120
+ ext = mime_type.split('/')[-1]
121
+ uri = uri or f"image_{uuid4().hex[:8]}.{ext}"
122
+ filename = os.path.basename(uri)
123
+ save_path = os.path.join(self.download_dir, filename)
124
+
125
+ with open(save_path, "wb") as f:
126
+ f.write(base64.b64decode(data))
127
+
128
+ self.received_files.append(save_path)
129
+ self.current_response_chunks.append(f"\n[Image Saved: {save_path}]\n")
130
+ elif uri and uri.startswith("http"):
131
+ # Handle images served via HTTP as a background task
132
+ task = asyncio.create_task(self._download_stream(uri, os.path.join(self.download_dir, os.path.basename(uri)), os.path.basename(uri)))
133
+ self.active_tasks.append(task)
134
+
135
+ class OpenClaw:
136
+ """
137
+ A high-level client for interacting with OpenClaw via the ACP TCP Bridge.
138
+
139
+ Usage:
140
+ async with OpenClaw(host="10.71.253.132", token="...") as client:
141
+ response = await client.chat("Hello!")
142
+ print(response)
143
+ """
144
+ def __init__(self, host: str, port: int = 18781, token: Optional[str] = None, download_dir: str = "downloads"):
145
+ self.host = host
146
+ self.port = port
147
+ self.token = token
148
+ self.download_dir = download_dir
149
+ self._conn = None
150
+ self._writer = None
151
+ self._reader = None
152
+ self.session = None
153
+ self._internal_client = None
154
+
155
+ async def connect(self, on_update: Optional[Callable[[str, Any], Any]] = None):
156
+ """Connects to the remote OpenClaw server and initializes a session."""
157
+ # Increase limit to 10MB to handle large file transfers
158
+ self._reader, self._writer = await asyncio.open_connection(self.host, self.port, limit=10*1024*1024)
159
+
160
+ # Handle token authentication
161
+ auth_token = self.token
162
+ if not auth_token:
163
+ try:
164
+ with open("token.txt", "r") as f:
165
+ auth_token = f.read().strip()
166
+ except FileNotFoundError:
167
+ pass
168
+
169
+ if auth_token:
170
+ self._writer.write(auth_token.encode('utf-8') + b'\n')
171
+ await self._writer.drain()
172
+
173
+ # Setup internal ACP infrastructure
174
+ self._internal_client = _InternalACPClient(on_update=on_update, download_dir=self.download_dir)
175
+ self._conn = ClientSideConnection(self._internal_client, self._writer, self._reader)
176
+
177
+ # Initialize ACP protocol
178
+ await self._conn.initialize(protocol_version=PROTOCOL_VERSION)
179
+ self.session = await self._conn.new_session(cwd="/", mcp_servers=[])
180
+
181
+ return self
182
+
183
+ async def chat(self, message: str) -> ChatResponse:
184
+ """
185
+ Sends a message to OpenClaw and returns a ChatResponse object.
186
+ The response object can be printed as a string, but also contains a .files list.
187
+ """
188
+ if not self._conn or not self.session:
189
+ raise RuntimeError("Client is not connected. Call connect() or use 'async with'.")
190
+
191
+ self._internal_client.current_response_chunks = []
192
+ self._internal_client.received_files = []
193
+
194
+ # Sends the prompt. The library blocks here until the 'turn' is finished.
195
+ await self._conn.prompt(
196
+ session_id=self.session.session_id,
197
+ prompt=[text_block(message)],
198
+ message_id=str(uuid4())
199
+ )
200
+
201
+ # Wait for any background downloads to finish
202
+ if self._internal_client.active_tasks:
203
+ await asyncio.gather(*self._internal_client.active_tasks)
204
+ self._internal_client.active_tasks = []
205
+
206
+ full_text = "".join(self._internal_client.current_response_chunks)
207
+ full_files = list(self._internal_client.received_files)
208
+
209
+ # New: Auto-request files marked with [FILEPATH: ...]
210
+ import re
211
+ explicit_paths = re.findall(r'\[FILEPATH:\s*<?([a-zA-Z0-9\._\-/]+)>?\]', full_text)
212
+
213
+ for path in explicit_paths:
214
+ # We call the underlying logic to fetch the file without resetting the whole session state
215
+ self._internal_client.current_response_chunks = []
216
+ self._internal_client.received_files = []
217
+
218
+ await self._conn.prompt(
219
+ session_id=self.session.session_id,
220
+ prompt=[text_block(f"/filerequest {path}")],
221
+ message_id=str(uuid4())
222
+ )
223
+
224
+ # Wait for the streaming download
225
+ if self._internal_client.active_tasks:
226
+ await asyncio.gather(*self._internal_client.active_tasks)
227
+ self._internal_client.active_tasks = []
228
+
229
+ full_files.extend(self._internal_client.received_files)
230
+ # Note: We don't necessarily need to append the "/filerequest" confirmation text to full_text
231
+
232
+ return ChatResponse(text=full_text, files=full_files)
233
+
234
+ async def close(self):
235
+ """Gracefully closes the connection."""
236
+ if self._conn:
237
+ await self._conn.close()
238
+ if self._writer:
239
+ self._writer.close()
240
+ try:
241
+ await self._writer.wait_closed()
242
+ except:
243
+ pass
244
+
245
+ async def __aenter__(self):
246
+ return await self.connect()
247
+
248
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
249
+ await self.close()
@@ -0,0 +1,305 @@
1
+ import asyncio
2
+ import sys
3
+ import json
4
+ import os
5
+ import base64
6
+ import io
7
+ import zipfile
8
+ import mimetypes
9
+ import argparse
10
+ import threading
11
+ from http.server import HTTPServer, SimpleHTTPRequestHandler
12
+
13
+ # Global state to manage the single persistent process
14
+ process = None
15
+ active_writer = None
16
+ # Track files for the side-channel HTTP server
17
+ served_files = {} # {uuid: full_path}
18
+
19
+ class FileServerHandler(SimpleHTTPRequestHandler):
20
+ def do_GET(self):
21
+ # Path is /<file_id> or /<file_id>/<filename>
22
+ parts = self.path.strip("/").split("/")
23
+ file_id = parts[0]
24
+ if file_id in served_files:
25
+ full_path = served_files[file_id]
26
+ filename = os.path.basename(full_path)
27
+ with open(full_path, 'rb') as f:
28
+ self.send_response(200)
29
+ self.send_header("Content-Type", "application/octet-stream")
30
+ self.send_header("Content-Disposition", f'attachment; filename="{filename}"')
31
+ self.send_header("Content-Length", str(os.path.getsize(full_path)))
32
+ self.end_headers()
33
+ self.wfile.write(f.read())
34
+ # Optional: remove from served_files after one download
35
+ # del served_files[file_id]
36
+ else:
37
+ self.send_response(404)
38
+ self.end_headers()
39
+ def log_message(self, format, *args): pass # Silence logs
40
+
41
+ def start_http_server(port):
42
+ httpd = HTTPServer(('0.0.0.0', port), FileServerHandler)
43
+ threading.Thread(target=httpd.serve_forever, daemon=True).start()
44
+ return port
45
+
46
+ async def run_server(host="0.0.0.0", port=18781, is_debug=False, token=None, openclaw_path="openclaw", use_http=True):
47
+ global process, active_writer
48
+
49
+ # Start side-channel HTTP server on port + 1
50
+ http_port = port + 1
51
+ if use_http:
52
+ start_http_server(http_port)
53
+ print(f"Side-channel HTTP server listening on {host}:{http_port}")
54
+ else:
55
+ print("Side-channel HTTP server disabled. Using Base64 blobs for all transfers.")
56
+
57
+ # Launch OpenClaw ACP as a persistent subprocess
58
+ # Using bash -i -c to ensure the user's .bashrc (and NVM paths) are loaded
59
+ process = await asyncio.create_subprocess_exec(
60
+ "bash", "-i", "-c", f"{openclaw_path} acp",
61
+ stdin=asyncio.subprocess.PIPE,
62
+ stdout=asyncio.subprocess.PIPE,
63
+ stderr=sys.stderr # Forward stderr to see agent logs
64
+ )
65
+ print(f"TCP Bridge listening on {host}:{port}")
66
+
67
+ # Start a background task to forward Agent stdout to all active clients
68
+ async def agent_to_client():
69
+ try:
70
+ async for line in process.stdout:
71
+ line_str = line.decode('utf-8', errors='ignore')
72
+ if is_debug:
73
+ print(f"Agent -> Clients: {line_str.strip()}", file=sys.stderr)
74
+
75
+ if active_writer:
76
+ try:
77
+ active_writer.write(line)
78
+ await active_writer.drain()
79
+ except:
80
+ pass
81
+ except Exception as e:
82
+ print(f"Agent stream error: {e}", file=sys.stderr)
83
+ except Exception as e:
84
+ print(f"Agent stream error: {e}", file=sys.stderr)
85
+
86
+ asyncio.create_task(agent_to_client())
87
+
88
+ async def handle_client(reader, writer):
89
+ global active_writer
90
+ addr = writer.get_extra_info('peername')
91
+ local_ip = writer.get_extra_info('sockname')[0]
92
+
93
+ # Simple token-based authentication
94
+ expected_token = token
95
+ if not expected_token:
96
+ token_path = os.path.join(os.getcwd(), "token.txt")
97
+ if os.path.exists(token_path):
98
+ with open(token_path, "r") as f:
99
+ expected_token = f.read().strip()
100
+
101
+ if expected_token:
102
+ try:
103
+ auth_line = await reader.readline()
104
+ if auth_line.decode('utf-8').strip() != expected_token:
105
+ print(f"Failed authentication from {addr}", file=sys.stderr)
106
+ writer.close()
107
+ return
108
+ except Exception as e:
109
+ if is_debug: print(f"Auth Error: {e}", file=sys.stderr)
110
+ writer.close()
111
+ return
112
+
113
+ if active_writer is not None:
114
+ print(f"Preempting existing connection from {active_writer.get_extra_info('peername')}", file=sys.stderr)
115
+ active_writer.close()
116
+
117
+ print(f"Client {addr} authenticated and connected successfully", file=sys.stderr)
118
+ active_writer = writer
119
+
120
+ try:
121
+ while True:
122
+ if process.returncode is not None:
123
+ print("Background process died, closing connection", file=sys.stderr)
124
+ break
125
+
126
+ line = await reader.readline()
127
+ if not line:
128
+ break
129
+
130
+ # Debug logging
131
+ if is_debug:
132
+ try:
133
+ tmp_data = json.loads(line)
134
+ p = tmp_data.get("params", {})
135
+ tmp_sid = p.get("sessionId", p.get("session_id", "no-session"))
136
+ print(f"[{addr[0]}] [Session: {tmp_sid}] Client -> Agent: {line.decode('utf-8').strip()}", file=sys.stderr)
137
+ except:
138
+ print(f"[{addr[0]}] Client -> Agent: {line.decode('utf-8').strip()}", file=sys.stderr)
139
+
140
+ # Interception Logic for Special Mode
141
+ intercepted = False
142
+ try:
143
+ data = json.loads(line)
144
+ method = data.get("method", "")
145
+
146
+ if method in ["prompt", "session/prompt"]:
147
+ params = data.get("params", {})
148
+ prompt_list = params.get("prompt", [])
149
+ session_id = params.get("sessionId", params.get("session_id", "unknown"))
150
+ request_id = data.get("id")
151
+
152
+ for block in prompt_list:
153
+ if block.get("type") == "text":
154
+ text = block.get("text", "").strip()
155
+ if "/filerequest" in text:
156
+ intercepted = True
157
+ print(f"[{addr[0]}] [Session: {session_id}] [Special Mode] Intercepted: {text}", file=sys.stderr)
158
+
159
+ path_part = text.split("/filerequest")[-1].strip()
160
+ full_path = os.path.abspath(os.path.expanduser(path_part))
161
+
162
+ if not os.path.exists(full_path):
163
+ msg = {
164
+ "jsonrpc": "2.0", "method": "session/update",
165
+ "params": {
166
+ "sessionId": session_id,
167
+ "update": {
168
+ "sessionUpdate": "agent_message_chunk",
169
+ "content": {"type": "text", "text": f"\nError: {path_part} not found.\n"}
170
+ }
171
+ }
172
+ }
173
+ writer.write(json.dumps(msg).encode() + b"\n")
174
+ else:
175
+ # Prepare file data
176
+ if os.path.isdir(full_path):
177
+ buf = io.BytesIO()
178
+ with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
179
+ for r, d, fs in os.walk(full_path):
180
+ for f in fs:
181
+ fp = os.path.join(r, f)
182
+ zf.write(fp, os.path.relpath(fp, full_path))
183
+ file_bytes = buf.getvalue()
184
+ m_type = "application/zip"
185
+ fname = os.path.basename(full_path.rstrip("/\\")) + ".zip"
186
+
187
+ b64 = base64.b64encode(file_bytes).decode('utf-8')
188
+
189
+ # Send file chunk
190
+ chunk = {
191
+ "jsonrpc": "2.0", "method": "session/update",
192
+ "params": {
193
+ "sessionId": session_id,
194
+ "update": {
195
+ "sessionUpdate": "agent_message_chunk",
196
+ "content": {
197
+ "type": "resource",
198
+ "resource": {"blob": b64, "mimeType": m_type, "uri": f"file://{fname}"}
199
+ }
200
+ }
201
+ }
202
+ }
203
+ writer.write(json.dumps(chunk).encode() + b"\n")
204
+ await writer.drain()
205
+ info_text = f"\n[System]: Sent {fname} ({len(file_bytes)} bytes)\n"
206
+ else:
207
+ m_type, _ = mimetypes.guess_type(full_path)
208
+ m_type = m_type or "application/octet-stream"
209
+ fname = os.path.basename(full_path)
210
+ file_size = os.path.getsize(full_path)
211
+
212
+ if file_size > 10 * 1024 * 1024 and use_http: # > 10MB and HTTP enabled
213
+ import uuid
214
+ file_id = str(uuid.uuid4())
215
+ served_files[file_id] = full_path
216
+ # Include filename in URL so client can extract it easily
217
+ url = f"http://{local_ip}:{http_port}/{file_id}/{fname}"
218
+
219
+ chunk = {
220
+ "jsonrpc": "2.0", "method": "session/update",
221
+ "params": {
222
+ "sessionId": session_id,
223
+ "update": {
224
+ "sessionUpdate": "agent_message_chunk",
225
+ "content": {
226
+ "type": "resource",
227
+ "resource": {"blob": "AAA=", "mimeType": m_type, "uri": url}
228
+ }
229
+ }
230
+ }
231
+ }
232
+ writer.write(json.dumps(chunk).encode() + b"\n")
233
+ await writer.drain()
234
+
235
+ info_text = f"\n[System]: Serving large file via HTTP stream: {url} ({file_size} bytes)\n"
236
+ else:
237
+ # Standard Base64 blob (used if small OR if HTTP is disabled)
238
+ with open(full_path, 'rb') as f:
239
+ file_bytes = f.read()
240
+ b64 = base64.b64encode(file_bytes).decode('utf-8')
241
+
242
+ chunk = {
243
+ "jsonrpc": "2.0", "method": "session/update",
244
+ "params": {
245
+ "sessionId": session_id,
246
+ "update": {
247
+ "sessionUpdate": "agent_message_chunk",
248
+ "content": {
249
+ "type": "resource",
250
+ "resource": {"blob": b64, "mimeType": m_type, "uri": f"file://{fname}"}
251
+ }
252
+ }
253
+ }
254
+ }
255
+ writer.write(json.dumps(chunk).encode() + b"\n")
256
+ await writer.drain()
257
+ info_text = f"\n[System]: Sent {fname} ({len(file_bytes)} bytes)\n"
258
+
259
+ # Send confirmation text
260
+ info = {
261
+ "jsonrpc": "2.0", "method": "session/update",
262
+ "params": {
263
+ "sessionId": session_id,
264
+ "update": {
265
+ "sessionUpdate": "agent_message_chunk",
266
+ "content": {"type": "text", "text": info_text}
267
+ }
268
+ }
269
+ }
270
+ writer.write(json.dumps(info).encode() + b"\n")
271
+ await writer.drain()
272
+ print(f"[{addr[0]}] [Session: {session_id}] [Special Mode] Initiated transfer of {fname}.", file=sys.stderr)
273
+
274
+ # Increased delay for perfect sync with large files
275
+ await asyncio.sleep(0.5)
276
+
277
+ # End the turn for this request
278
+ res = {"jsonrpc": "2.0", "id": request_id, "result": {"stop_reason": "end_turn"}}
279
+ writer.write(json.dumps(res).encode() + b"\n")
280
+ await writer.drain()
281
+ break
282
+ except Exception as e:
283
+ if is_debug: print(f"Processing error: {e}", file=sys.stderr)
284
+
285
+ if intercepted:
286
+ continue
287
+
288
+ # Forward standard request to the agent
289
+ if is_debug and method in ["prompt", "session/prompt"]:
290
+ print(f"[{addr[0]}] [Session: {session_id}] Forwarding to Agent...", file=sys.stderr)
291
+
292
+ process.stdin.write(line)
293
+ await process.stdin.drain()
294
+
295
+ except Exception as e:
296
+ if is_debug: print(f"Connection error: {e}", file=sys.stderr)
297
+ finally:
298
+ print(f"Client {addr} disconnected", file=sys.stderr)
299
+ if active_writer == writer:
300
+ active_writer = None
301
+ writer.close()
302
+
303
+ server = await asyncio.start_server(handle_client, host, port)
304
+ async with server:
305
+ await server.serve_forever()
@@ -0,0 +1,137 @@
1
+ Metadata-Version: 2.4
2
+ Name: openclaw-acp-bridge
3
+ Version: 0.3.2
4
+ Summary: A high-performance persistent TCP bridge and async client for OpenClaw ACP
5
+ Author: sunshinejnjn@github
6
+ License: BSD-3-Clause
7
+ Project-URL: Homepage, https://github.com/sunshinejnjn/openclaw-acp-bridge
8
+ Project-URL: Bug Tracker, https://github.com/sunshinejnjn/openclaw-acp-bridge/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: BSD License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Topic :: Communications :: Chat
13
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
14
+ Requires-Python: >=3.8
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: httpx>=0.24.0
18
+ Dynamic: license-file
19
+
20
+ # OpenClaw ACP Bridge
21
+
22
+ A high-performance, persistent TCP bridge and client for the OpenClaw Agent Control Protocol (ACP).
23
+
24
+ The **OpenClaw ACP Bridge** solves the challenge of maintaining persistent agent sessions over standard TCP while providing a high-speed "side-channel" for large-scale file transfers. It is designed for high-performance agentic workflows where large binary assets (images, videos, datasets) need to be moved efficiently between remote agents and local clients.
25
+
26
+ ## ๐Ÿš€ Key Features
27
+
28
+ - **Persistent Agent Session**: Unlike standard ACP tools that may restart agents per request, the bridge maintains a single persistent agent process across multiple client turns.
29
+ - **High-Speed HTTP Side-Channel**: Automatically switches to HTTP streaming for large files (GB-sized), bypassing JSON-RPC/Base64 overhead and memory bloat.
30
+ - **Explicit File Interception**: Use `/filerequest <path>` to instantly fetch any file from the remote agent's filesystem.
31
+ - **Auto-File Retrieval**: Intelligently detects the `[FILEPATH: /path/to/file]` pattern in agent responses and automatically initiates a high-speed transfer.
32
+ - **Async/Non-Blocking**: Built from the ground up for `asyncio`, utilizing `httpx` for reliable binary streaming.
33
+ - **Environment Consistency**: Server-side agent launching utilizes interactive shells (`bash -i`) to ensure `.bashrc`, NVM, and local paths are correctly resolved.
34
+
35
+ ## ๐Ÿ“ฆ Installation
36
+ On the server (host where your openclaw gateway runs on) side, as the acp_server_bridge.
37
+ And the client side if you want to run with our client interface. Or you can use acp directly to connect to the acp_server_bridge using TCP directly (pip install agent-client-protocol).
38
+ ```bash
39
+ pip install openclaw-acp-bridge
40
+ ```
41
+
42
+ ## ๐Ÿ› ๏ธ Usage
43
+
44
+ ### 1. Launch the Bridge Server
45
+ On your remote server (where OpenClaw is installed), start the bridge using the provided helper script:
46
+
47
+ ```bash
48
+ chmod +x run_acp_server.sh
49
+ ./run_acp_server.sh
50
+ ```
51
+
52
+ Or run the module directly:
53
+
54
+ ```bash
55
+ python -m openclaw_acp_bridge --host 0.0.0.0 --port 18781 --debug
56
+ ```
57
+
58
+ *Note: The bridge will also open a side-channel HTTP server on `port + 1` (default 18782).*
59
+
60
+ ### 2. Connect the Async Client
61
+ On your local machine, use the `OpenClaw` client to interact with the remote agent:
62
+
63
+ ```python
64
+ import asyncio
65
+ from openclaw_acp_bridge import OpenClaw
66
+
67
+ async def main():
68
+ # Connect to the remote bridge
69
+ async with OpenClaw(host="10.71.253.132", download_dir="my_assets") as client:
70
+ # 1. Standard Chat
71
+ response = await client.chat("Hello, who are you?")
72
+ print(f"Agent: {response.text}")
73
+
74
+ # 2. Explicit File Request
75
+ # This uses the high-speed side-channel automatically
76
+ response = await client.chat("/filerequest /path/to/large_dataset.zip")
77
+ if response.files:
78
+ print(f"Received file: {response.files[0]}")
79
+
80
+ # 3. Auto-Retrieval Pattern
81
+ # Ask the agent to generate something and return the path
82
+ response = await client.chat("Generate a report and return path in [FILEPATH: /path] format.")
83
+ # The client automatically detects the pattern and fetches the file!
84
+ for file in response.files:
85
+ print(f"Auto-downloaded: {file}")
86
+
87
+ if __name__ == "__main__":
88
+ asyncio.run(main())
89
+ ```
90
+
91
+ ## ๐Ÿ”Œ Using with Standard ACP Clients
92
+
93
+ The bridge is fully compatible with any standard ACP-compliant client or SDK. To use it, simply point your client to the bridge's TCP address (default port `18781`).
94
+
95
+ ### Why use the Bridge with standard clients?
96
+ 1. **Persistence**: Even with a standard client, the bridge keeps your remote agent process alive across sessions.
97
+ 2. **Environment**: The bridge handles the complex `bash -i` shell environment setup for you.
98
+ 3. **Special Commands**: You can still use `/filerequest <path>` in your prompts. The bridge will intercept these and return a standard ACP `resource` block.
99
+
100
+ *Note: When using a standard client, the high-speed HTTP side-channel will return a `resource` block with a `uri` starting with `http://`. Ensure your client can handle HTTP-based resources or use our provided `openclaw_acp_bridge` client for automatic handling.*
101
+
102
+ ## โš™๏ธ Advanced Configuration
103
+
104
+ ### Bridge Server Options
105
+ You can customize the server behavior using CLI arguments:
106
+
107
+ ```bash
108
+ python -m openclaw_acp_bridge --port 18781 --token "my-secret-key" --openclaw-path "/usr/local/bin/openclaw"
109
+ ```
110
+
111
+ | Parameter | Description |
112
+ | :--- | :--- |
113
+ | `--host` | Host to bind the TCP server to (default: `0.0.0.0`) |
114
+ | `--port` | Port to listen on (default: `18781`) |
115
+ | `--token` | Optional authentication token. If set, clients must provide this token to connect. |
116
+ | `--openclaw-path` | Path to the `openclaw` binary on the server (default: `openclaw`) |
117
+ | `--no-http` | Disable the high-speed HTTP side-channel. When set, the bridge uses standard Base64 blobs for all file transfers. |
118
+ | `--debug` | Enable verbose logging for debugging. |
119
+
120
+ ### Authentication
121
+ The bridge supports simple token-based authentication. You can either pass the token via the `--token` CLI argument or place a `token.txt` file in the server's working directory. The `--token` argument takes precedence.
122
+
123
+ ### Download Directory
124
+ The client allows you to specify where downloaded assets should be stored:
125
+ ```python
126
+ client = OpenClaw(host="...", download_dir="./downloads")
127
+ ```
128
+
129
+ ## ๐Ÿงช Testing
130
+ The package includes a comprehensive test suite `test_bridge.py` that demonstrates chat, small file blobs, and large-scale (100MB+) HTTP streaming.
131
+
132
+ ```bash
133
+ python test_bridge.py --tests 1,2,3,4
134
+ ```
135
+
136
+ ## ๐Ÿ“œ License
137
+ BSD 3-Clause
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ openclaw_acp_bridge/__init__.py
5
+ openclaw_acp_bridge/__main__.py
6
+ openclaw_acp_bridge/client.py
7
+ openclaw_acp_bridge/server.py
8
+ openclaw_acp_bridge.egg-info/PKG-INFO
9
+ openclaw_acp_bridge.egg-info/SOURCES.txt
10
+ openclaw_acp_bridge.egg-info/dependency_links.txt
11
+ openclaw_acp_bridge.egg-info/requires.txt
12
+ openclaw_acp_bridge.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ openclaw_acp_bridge
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "openclaw-acp-bridge"
7
+ version = "0.3.2"
8
+ authors = [
9
+ { name = "sunshinejnjn@github" },
10
+ ]
11
+ license = { text = "BSD-3-Clause" }
12
+ description = "A high-performance persistent TCP bridge and async client for OpenClaw ACP"
13
+ readme = "README.md"
14
+ requires-python = ">=3.8"
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "License :: OSI Approved :: BSD License",
18
+ "Operating System :: OS Independent",
19
+ "Topic :: Communications :: Chat",
20
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
21
+ ]
22
+ dependencies = [
23
+ "httpx>=0.24.0",
24
+ ]
25
+
26
+ [project.urls]
27
+ "Homepage" = "https://github.com/sunshinejnjn/openclaw-acp-bridge"
28
+ "Bug Tracker" = "https://github.com/sunshinejnjn/openclaw-acp-bridge/issues"
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["."]
32
+ include = ["openclaw_acp_bridge*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+