refactor(box): use single port with path-based routing for Box WS

Update connector to use ws://host:5410/rpc/ws instead of ws://host:5411.
Update review docs to reflect the single-port architecture.
This commit is contained in:
Junyan Qin
2026-04-17 23:52:27 +08:00
committed by WangCham
parent f4406cd972
commit aa40151964
3 changed files with 36 additions and 33 deletions
+7 -7
View File
@@ -99,8 +99,9 @@ BoxService
管理与 Box Runtime 的通信连接: 管理与 Box Runtime 的通信连接:
- **本地 stdio**: `runtime_url` 时,fork `python -m langbot_plugin.box.server --port {port}` 子进程 - **本地 stdio**: Unix/macOS 无特殊配置时,fork `python -m langbot_plugin.box.server --port {port}` 子进程
- **远程 WebSocket**: `runtime_url` 时,连接 `ws://{host}:{port+1}`+1 偏移,5410 是 relay5411 是 RPC - **远程 WebSocket**: Docker / `--standalone-box` / 显式 `runtime_url` 时,连接 `ws://{host}:{port}/rpc/ws`(同一端口,路径区分
- **Windows**: subprocess + WebSocketWindows 不支持 async stdio pipe
- **同步等待**: 使用 `asyncio.Event` + `wait_for(timeout=30s)` 模式确认连接 - **同步等待**: 使用 `asyncio.Event` + `wait_for(timeout=30s)` 模式确认连接
### 2.3 BoxWorkspaceSession (`pkg/box/workspace.py`, 404 行) ### 2.3 BoxWorkspaceSession (`pkg/box/workspace.py`, 404 行)
@@ -200,14 +201,13 @@ start_managed_process(session, spec):
### 3.3 Server (`box/server.py`, 268 行) ### 3.3 Server (`box/server.py`, 268 行)
两个服务共存 单端口 aiohttp 服务(默认 5410),通过路径区分
1. **Action RPC**: `BoxServerHandler` 处理 11 种 actionHEALTH/STATUS/EXEC/CREATE_SESSION/...),通过 stdio 或 WS 传输 1. **Action RPC** (`/rpc/ws`): `BoxServerHandler` 处理 11 种 actionHEALTH/STATUS/EXEC/CREATE_SESSION/...),通过 stdio 或 WS 传输。WS 模式使用 `AiohttpWSConnection` 适配层。
2. **WS Relay** (aiohttp, port 5410): `GET /v1/sessions/{id}/managed-process/ws`双向桥接 WebSocket ↔ managed process stdin/stdout 2. **WS Relay** (`/v1/sessions/{id}/managed-process/ws`): 双向桥接 WebSocket ↔ managed process stdin/stdout
端口分配: 端口分配:
- Port N (默认 5410): WS relaymanaged process I/O - Port N (默认 5410): 所有 WebSocket 端点(Action RPC + managed process relay
- Port N+1 (5411): Action RPC WebSocket(仅远程模式使用)
### 3.4 Client (`box/client.py`, 177 行) ### 3.4 Client (`box/client.py`, 177 行)
+14 -12
View File
@@ -35,27 +35,29 @@ else:
# Unix/Mac → StdioClientController(python -m langbot_plugin.cli rt -s) # Unix/Mac → StdioClientController(python -m langbot_plugin.cli rt -s)
``` ```
### Box: 2-路决策 ### Box: 3-路决策
```python ```python
# pkg/box/connector.py:56-60 # pkg/box/connector.py
if self.manages_local_runtime: # = not configured_runtime_url if self._uses_websocket():
await self._start_local_stdio() # StdioClientController if platform.get_platform() == 'win32' and not self.configured_runtime_url:
await self._start_subprocess_then_ws() # subprocess + ws://localhost:5410/rpc/ws
else:
await self._connect_remote_ws() # ws://{host}:5410/rpc/ws
else: else:
await self._connect_remote_ws() # ws://{host}:{port+1} await self._start_local_stdio() # StdioClientController
``` ```
### 决策矩阵 ### 决策矩阵
| 环境 | Plugin | Box | | 环境 | Plugin | Box |
|------|--------|-----| |------|--------|-----|
| Docker | WS → `:5400` | WS → `:{port+1}` (5411) | | Docker | WS → `:5400` | WS → `:5410/rpc/ws` |
| Windows 非 Docker | subprocess + WS (`:5400`) | **stdio (可能失败!)** | | `--standalone-box` | N/A | WS → `localhost:5410/rpc/ws` |
| Windows 非 Docker | subprocess + WS (`:5400`) | subprocess + WS (`localhost:5410/rpc/ws`) |
| Unix/Mac 非 Docker | stdio | stdio | | Unix/Mac 非 Docker | stdio | stdio |
| 手动配置 URL | 通过配置项 | WS → 用户配置的 URL | | 手动配置 URL | 通过配置项 | WS → 用户配置的 URL |
**Box 的 Windows 问题**: 无 Win32 分支,asyncio ProactorEventLoop 不支持 subprocess stdio pipe。Plugin 为此专门做了处理。
--- ---
## 3. 连接建立 ## 3. 连接建立
@@ -168,10 +170,10 @@ Controller ← ABC
| 服务 | Plugin | Box | | 服务 | Plugin | Box |
|------|--------|-----| |------|--------|-----|
| Action RPC (stdio) | stdin/stdout | stdin/stdout | | Action RPC (stdio) | stdin/stdout | stdin/stdout |
| Action RPC (WS) | `:5400` | `:{port+1}` (默认 5411) | | Action RPC (WS) | `:5400` | `:5410/rpc/ws` |
| 辅助服务 | debug WS `:5401` | managed process WS relay `:5410` | | 辅助服务 | debug WS `:5401` | managed process WS relay `:5410/v1/sessions/{id}/managed-process/ws` |
**Box 特点**: 即使在 stdio 模式,也额外`:5410` 启动 aiohttp WS 服务用于 managed process attach。Plugin 在 stdio 模式不开额外端口。 **Box 特点**: 单端口 aiohttp 服务(默认 5410),通过路径区分 Action RPC 和 managed process relay。即使在 stdio 模式,也在 `:5410` 启动 aiohttp 用于 managed process attach。Plugin 在 stdio 模式不开额外端口。
--- ---
+15 -14
View File
@@ -22,8 +22,7 @@ if TYPE_CHECKING:
# Default Docker Compose service name for the standalone Box container. # Default Docker Compose service name for the standalone Box container.
_DOCKER_BOX_HOST = 'langbot_box' _DOCKER_BOX_HOST = 'langbot_box'
_DEFAULT_RELAY_PORT = 5410 _DEFAULT_PORT = 5410
_DEFAULT_RPC_PORT = 5411 # relay_port + 1
def _get_box_config(ap) -> dict: def _get_box_config(ap) -> dict:
@@ -48,9 +47,9 @@ def resolve_box_ws_relay_url(ap: core_app.Application) -> str:
# In Docker, relay lives on the box runtime container. # In Docker, relay lives on the box runtime container.
if platform.get_platform() == 'docker': if platform.get_platform() == 'docker':
return f'http://{_DOCKER_BOX_HOST}:{_DEFAULT_RELAY_PORT}' return f'http://{_DOCKER_BOX_HOST}:{_DEFAULT_PORT}'
return f'http://127.0.0.1:{_DEFAULT_RELAY_PORT}' return f'http://127.0.0.1:{_DEFAULT_PORT}'
class BoxRuntimeConnector(ManagedRuntimeConnector): class BoxRuntimeConnector(ManagedRuntimeConnector):
@@ -72,10 +71,10 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
self._handler_task: asyncio.Task | None = None self._handler_task: asyncio.Task | None = None
self._ctrl_task: asyncio.Task | None = None self._ctrl_task: asyncio.Task | None = None
# Parse the relay URL once for reuse (relay port, not RPC port). # Parse the relay URL once for reuse.
parsed = urlparse(self.ws_relay_base_url) parsed = urlparse(self.ws_relay_base_url)
self._relay_host = parsed.hostname or '127.0.0.1' self._relay_host = parsed.hostname or '127.0.0.1'
self._relay_port = parsed.port or _DEFAULT_RELAY_PORT self._relay_port = parsed.port or _DEFAULT_PORT
def _uses_websocket(self) -> bool: def _uses_websocket(self) -> bool:
"""Whether the connector should use WebSocket to reach the Box runtime. """Whether the connector should use WebSocket to reach the Box runtime.
@@ -142,18 +141,18 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
"""Launch box server as detached subprocess, then connect via WS (Windows).""" """Launch box server as detached subprocess, then connect via WS (Windows)."""
self.ap.logger.info('(windows) Use cmd to launch box runtime and communicate via ws') self.ap.logger.info('(windows) Use cmd to launch box runtime and communicate via ws')
# Launch the box server subprocess (no stdio pipe). # Launch the box server subprocess in ws mode (no stdio pipe).
# The server will listen on _relay_port for the WS relay and
# _relay_port+1 for action-RPC WebSocket.
await self._start_runtime_subprocess( await self._start_runtime_subprocess(
'-m', '-m',
'langbot_plugin.box.server', 'langbot_plugin.box.server',
'--mode',
'ws',
'--port', '--port',
str(self._relay_port), str(self._relay_port),
) )
# Wait for the WS endpoint to become reachable, then connect. # Wait for the WS endpoint to become reachable, then connect.
ws_url = f'ws://localhost:{self._relay_port + 1}' ws_url = f'ws://localhost:{self._relay_port}/rpc/ws'
await self._connect_ws(ws_url, '(windows) WebSocket') await self._connect_ws(ws_url, '(windows) WebSocket')
async def _connect_remote_ws(self) -> None: async def _connect_remote_ws(self) -> None:
@@ -167,18 +166,20 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
def _resolve_rpc_ws_url(self) -> str: def _resolve_rpc_ws_url(self) -> str:
"""Determine the action-RPC WebSocket URL. """Determine the action-RPC WebSocket URL.
All endpoints share a single port; action RPC is at ``/rpc/ws``.
Priority: Priority:
1. Explicit ``box.runtime_url`` from config (user-supplied, used as-is) 1. Explicit ``box.runtime_url`` from config (user-supplied, used as-is)
2. Docker environment -> ``ws://langbot_box_runtime:5411`` 2. Docker environment -> ``ws://langbot_box:5410/rpc/ws``
3. --standalone-box / Windows fallback -> ``ws://localhost:5411`` 3. --standalone-box / Windows fallback -> ``ws://localhost:5410/rpc/ws``
""" """
if self.configured_runtime_url: if self.configured_runtime_url:
return self.configured_runtime_url return self.configured_runtime_url
if platform.get_platform() == 'docker': if platform.get_platform() == 'docker':
return f'ws://{_DOCKER_BOX_HOST}:{_DEFAULT_RPC_PORT}' return f'ws://{_DOCKER_BOX_HOST}:{_DEFAULT_PORT}/rpc/ws'
return f'ws://localhost:{self._relay_port + 1}' return f'ws://localhost:{self._relay_port}/rpc/ws'
async def _connect_ws(self, ws_url: str, transport_name: str) -> None: async def _connect_ws(self, ws_url: str, transport_name: str) -> None:
"""Shared WebSocket connection procedure.""" """Shared WebSocket connection procedure."""