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.
- openclaw_acp_bridge-0.3.2/LICENSE +27 -0
- openclaw_acp_bridge-0.3.2/PKG-INFO +137 -0
- openclaw_acp_bridge-0.3.2/README.md +118 -0
- openclaw_acp_bridge-0.3.2/openclaw_acp_bridge/__init__.py +5 -0
- openclaw_acp_bridge-0.3.2/openclaw_acp_bridge/__main__.py +18 -0
- openclaw_acp_bridge-0.3.2/openclaw_acp_bridge/client.py +249 -0
- openclaw_acp_bridge-0.3.2/openclaw_acp_bridge/server.py +305 -0
- openclaw_acp_bridge-0.3.2/openclaw_acp_bridge.egg-info/PKG-INFO +137 -0
- openclaw_acp_bridge-0.3.2/openclaw_acp_bridge.egg-info/SOURCES.txt +12 -0
- openclaw_acp_bridge-0.3.2/openclaw_acp_bridge.egg-info/dependency_links.txt +1 -0
- openclaw_acp_bridge-0.3.2/openclaw_acp_bridge.egg-info/requires.txt +1 -0
- openclaw_acp_bridge-0.3.2/openclaw_acp_bridge.egg-info/top_level.txt +1 -0
- openclaw_acp_bridge-0.3.2/pyproject.toml +32 -0
- openclaw_acp_bridge-0.3.2/setup.cfg +4 -0
|
@@ -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,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
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
httpx>=0.24.0
|
|
@@ -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*"]
|