From aa4015196468eb88db53ea8cf38f41c865d333fc Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Fri, 17 Apr 2026 23:52:27 +0800 Subject: [PATCH] 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. --- docs/review/box-architecture.md | 14 +++++++------- docs/review/box-vs-plugin-runtime.md | 26 +++++++++++++------------ src/langbot/pkg/box/connector.py | 29 ++++++++++++++-------------- 3 files changed, 36 insertions(+), 33 deletions(-) diff --git a/docs/review/box-architecture.md b/docs/review/box-architecture.md index 13f7ce10..25c63857 100644 --- a/docs/review/box-architecture.md +++ b/docs/review/box-architecture.md @@ -99,8 +99,9 @@ BoxService 管理与 Box Runtime 的通信连接: -- **本地 stdio**: 无 `runtime_url` 时,fork `python -m langbot_plugin.box.server --port {port}` 子进程 -- **远程 WebSocket**: 有 `runtime_url` 时,连接 `ws://{host}:{port+1}`(+1 偏移,5410 是 relay,5411 是 RPC) +- **本地 stdio**: Unix/macOS 无特殊配置时,fork `python -m langbot_plugin.box.server --port {port}` 子进程 +- **远程 WebSocket**: Docker / `--standalone-box` / 显式 `runtime_url` 时,连接 `ws://{host}:{port}/rpc/ws`(同一端口,路径区分) +- **Windows**: subprocess + WebSocket(Windows 不支持 async stdio pipe) - **同步等待**: 使用 `asyncio.Event` + `wait_for(timeout=30s)` 模式确认连接 ### 2.3 BoxWorkspaceSession (`pkg/box/workspace.py`, 404 行) @@ -200,14 +201,13 @@ start_managed_process(session, spec): ### 3.3 Server (`box/server.py`, 268 行) -两个服务共存: +单端口 aiohttp 服务(默认 5410),通过路径区分: -1. **Action RPC**: `BoxServerHandler` 处理 11 种 action(HEALTH/STATUS/EXEC/CREATE_SESSION/...),通过 stdio 或 WS 传输 -2. **WS Relay** (aiohttp, port 5410): `GET /v1/sessions/{id}/managed-process/ws`,双向桥接 WebSocket ↔ managed process stdin/stdout +1. **Action RPC** (`/rpc/ws`): `BoxServerHandler` 处理 11 种 action(HEALTH/STATUS/EXEC/CREATE_SESSION/...),通过 stdio 或 WS 传输。WS 模式使用 `AiohttpWSConnection` 适配层。 +2. **WS Relay** (`/v1/sessions/{id}/managed-process/ws`): 双向桥接 WebSocket ↔ managed process stdin/stdout 端口分配: -- Port N (默认 5410): WS relay(managed process I/O) -- Port N+1 (5411): Action RPC WebSocket(仅远程模式使用) +- Port N (默认 5410): 所有 WebSocket 端点(Action RPC + managed process relay) ### 3.4 Client (`box/client.py`, 177 行) diff --git a/docs/review/box-vs-plugin-runtime.md b/docs/review/box-vs-plugin-runtime.md index 093a46c5..4bdda1e6 100644 --- a/docs/review/box-vs-plugin-runtime.md +++ b/docs/review/box-vs-plugin-runtime.md @@ -35,27 +35,29 @@ else: # Unix/Mac → StdioClientController(python -m langbot_plugin.cli rt -s) ``` -### Box: 2-路决策 +### Box: 3-路决策 ```python -# pkg/box/connector.py:56-60 -if self.manages_local_runtime: # = not configured_runtime_url - await self._start_local_stdio() # StdioClientController +# pkg/box/connector.py +if self._uses_websocket(): + 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: - await self._connect_remote_ws() # ws://{host}:{port+1} + await self._start_local_stdio() # StdioClientController ``` ### 决策矩阵 | 环境 | Plugin | Box | |------|--------|-----| -| Docker | WS → `:5400` | WS → `:{port+1}` (5411) | -| Windows 非 Docker | subprocess + WS (`:5400`) | **stdio (可能失败!)** | +| Docker | WS → `:5400` | WS → `:5410/rpc/ws` | +| `--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 | | 手动配置 URL | 通过配置项 | WS → 用户配置的 URL | -**Box 的 Windows 问题**: 无 Win32 分支,asyncio ProactorEventLoop 不支持 subprocess stdio pipe。Plugin 为此专门做了处理。 - --- ## 3. 连接建立 @@ -168,10 +170,10 @@ Controller ← ABC | 服务 | Plugin | Box | |------|--------|-----| | Action RPC (stdio) | stdin/stdout | stdin/stdout | -| Action RPC (WS) | `:5400` | `:{port+1}` (默认 5411) | -| 辅助服务 | debug WS `:5401` | managed process WS relay `:5410` | +| Action RPC (WS) | `:5400` | `:5410/rpc/ws` | +| 辅助服务 | 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 模式不开额外端口。 --- diff --git a/src/langbot/pkg/box/connector.py b/src/langbot/pkg/box/connector.py index 118e4748..6784309b 100644 --- a/src/langbot/pkg/box/connector.py +++ b/src/langbot/pkg/box/connector.py @@ -22,8 +22,7 @@ if TYPE_CHECKING: # Default Docker Compose service name for the standalone Box container. _DOCKER_BOX_HOST = 'langbot_box' -_DEFAULT_RELAY_PORT = 5410 -_DEFAULT_RPC_PORT = 5411 # relay_port + 1 +_DEFAULT_PORT = 5410 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. 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): @@ -72,10 +71,10 @@ class BoxRuntimeConnector(ManagedRuntimeConnector): self._handler_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) 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: """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).""" self.ap.logger.info('(windows) Use cmd to launch box runtime and communicate via ws') - # Launch the box server subprocess (no stdio pipe). - # The server will listen on _relay_port for the WS relay and - # _relay_port+1 for action-RPC WebSocket. + # Launch the box server subprocess in ws mode (no stdio pipe). await self._start_runtime_subprocess( '-m', 'langbot_plugin.box.server', + '--mode', + 'ws', '--port', str(self._relay_port), ) # 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') async def _connect_remote_ws(self) -> None: @@ -167,18 +166,20 @@ class BoxRuntimeConnector(ManagedRuntimeConnector): def _resolve_rpc_ws_url(self) -> str: """Determine the action-RPC WebSocket URL. + All endpoints share a single port; action RPC is at ``/rpc/ws``. + Priority: 1. Explicit ``box.runtime_url`` from config (user-supplied, used as-is) - 2. Docker environment -> ``ws://langbot_box_runtime:5411`` - 3. --standalone-box / Windows fallback -> ``ws://localhost:5411`` + 2. Docker environment -> ``ws://langbot_box:5410/rpc/ws`` + 3. --standalone-box / Windows fallback -> ``ws://localhost:5410/rpc/ws`` """ if self.configured_runtime_url: return self.configured_runtime_url 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: """Shared WebSocket connection procedure."""