Refine bridge routing and remove fallback paths

This commit is contained in:
Haitao Pan 2026-04-23 15:58:37 +08:00
parent e808f7a19e
commit 17c0fa6f16
29 changed files with 1427 additions and 2796 deletions

View File

@ -1,104 +1,69 @@
# ACP Public Validation & Expansion Planning - 2026-04-09
# ACP Public Validation
This document records the post-deployment validation of the bridge public
origin at `xworkmate-bridge.svc.plus` and outlines the expansion architecture
for the independent upstream ACP adapters.
Last Updated: 2026-04-23
## Expansion Modes Planning
本文件记录当前推荐的 public validation 方法。
To support a diverse set of backend providers, the bridge operates in the following expansion modes:
## Canonical Validation Targets
| Mode ID | Adapter Role | Implementation Type |
| :--- | :--- | :--- |
| `acp-adapter-codex` | Codex ACP Adapter | Protocol Translator / Forwarder |
| `acp-adapter-opencode` | OpenCode ACP Adapter | JSON-RPC over stdio |
| `acp-adapter-gemini` | Gemini ACP Adapter | JSON-RPC over stdio |
| `acp-adapter-hermes` | Hermes ACP Adapter | JSON-RPC over stdio |
| `gateway-adapter-openclaw` | OpenClaw Gateway | Unified Protocol Entry |
只验证 bridge canonical surface
## Protocol Entry Points (Public)
- `GET https://xworkmate-bridge.svc.plus/api/ping`
- `GET wss://xworkmate-bridge.svc.plus/acp`
- `POST https://xworkmate-bridge.svc.plus/acp/rpc`
The canonical entry points for external integrations are segmented by provider:
不再把以下路径视为 public validation target
* **Codex**: `https://xworkmate-bridge.svc.plus/acp-server/codex`
* **Gemini**: `https://xworkmate-bridge.svc.plus/acp-server/gemini`
* **Hermes**: `https://xworkmate-bridge.svc.plus/acp-server/hermes`
* **OpenCode**: `https://xworkmate-bridge.svc.plus/acp-server/opencode`
* **OpenClaw**: `https://xworkmate-bridge.svc.plus/gateway/openclaw`
- `/acp-server/*`
- `/gateway/openclaw`
## Request Chain & Runtime Design
## Validation Checklist
### Traffic Flow
`Caddy (Ingress)` -> `xworkmate-bridge (Dispatcher)` -> `Adapter Service`
### 1. Release Ping
Caddy handles SSL termination and forwards requests to the `xworkmate-bridge` process, which performs path-based routing to the respective local adapter services.
验证:
### Systemd Services & Local Mappings
- `status == ok`
- `image / tag / commit / version` 与发布镜像一致
Each adapter is managed as a standalone systemd service, mapped to a specific local port/protocol:
### 2. Capabilities
| Service Name | Local Endpoint | Adapter Target |
| :--- | :--- | :--- |
| `acp-codex.service` | `ws://127.0.0.1:9001` | Codex Engine |
| `acp-opencode.service` | `ws://127.0.0.1:38992` | OpenCode Runtime |
| `acp-gemini.service` | `ws://127.0.0.1:8791` | Gemini Bridge |
| `acp-hermes.service` | `ws://127.0.0.1:3920` | Hermes Engine |
| `(Host Process)` | `ws://127.0.0.1:18789` | OpenClaw (Shared Runtime) |
通过 `/acp/rpc``acp.capabilities`,验证:
## Auth Contract
- `providerCatalog` 包含 `codex / opencode / gemini / hermes`
- `gatewayProviders` 包含 `openclaw`
- `availableExecutionTargets` 包含 `agent / gateway`
All public ACP requests require:
### 3. Routing Resolve
- header: `Authorization: Bearer <INTERNAL_SERVICE_TOKEN>`
- header: `Content-Type: application/json`
验证显式 single-agent
Missing or invalid bearer auth returns a JSON-RPC error envelope with code `-32001`.
- `resolvedExecutionTarget == single-agent`
- `resolvedProviderId == codex`
## Validation Results (2026-04-09)
验证显式 gateway
The ingress returned `200 OK` on all public routes after re-apply.
- `resolvedExecutionTarget == gateway`
- `resolvedGatewayProviderId == openclaw`
### Codex
- Verified `acp.capabilities`: `["codex", "gemini", "opencode"]`
- Two-turn conversation (`session.start` -> `session.message`) passed.
### 4. Session Contract
### OpenCode
- Validated as WebSocket ACP upstream at `ws://127.0.0.1:38992/acp`.
- Two-turn conversation passed.
验证 `session.start` / `session.message`
### Gemini
- Verified `acp.capabilities`: `["gemini"]`
- Adapter-local prompt compatibility layer enables `session.start` / `session.message` despite lack of native upstream support.
- Two-turn conversation passed.
- 返回 JSON-RPC hybrid envelope
- 中间通知使用 `session.update`
- 最终结果包含 `turnId`
## App Integration Notes
### 5. Gateway Contract
### Recommended request shape
验证:
Use JSON-RPC `POST` requests against `https://xworkmate-bridge.svc.plus/acp/rpc` for general usage, or the specific provider endpoints for targeted execution.
- `xworkmate.gateway.connect`
- `xworkmate.gateway.request`
- `xworkmate.gateway.disconnect`
**Example Task Execution:**
## Notes
```json
{
"jsonrpc": "2.0",
"id": "task-1",
"method": "session.start",
"params": {
"sessionId": "session-1",
"threadId": "thread-1",
"taskPrompt": "Reply with exactly pong",
"workingDirectory": "/tmp",
"routing": {
"routingMode": "explicit",
"explicitExecutionTarget": "singleAgent",
"explicitProviderId": "opencode"
}
}
}
```
### Provider-specific notes
- `codex` and `opencode` require explicit `routing` on follow-up turns.
- `gemini` uses a prompt-compatibility layer for multi-turn support.
- `hermes` is verified as a public task path.
- canonical transport 是 `GET /acp` WebSocket
- `/acp/rpc` 仅作为 secondary compatibility transport
- app-facing contract 由 bridge control plane 独占,不由 provider alias path 定义

View File

@ -0,0 +1,39 @@
# ACP Server (ws://127.0.0.1:3920) 交互能力测试报告
## 测试环境
- **远程主机**: `root@jp-xhttp-contabo.svc.plus`
- **执行用户**: `ubuntu`
- **目标地址**: `ws://127.0.0.1:3920` (Hermes Adapter)
- **桥接网关**: `http://localhost:8787` (xworkmate-bridge)
- **备选适配器**: `ws://127.0.0.1:8791` (Gemini Adapter)
## 测试项验证结果
### 1. Chat 对话能力
- **验证结果**: **部分成功**
- **详细说明**:
- 直接连接 `ws://127.0.0.1:3920/acp` (Hermes) 时,由于 `hermes-agent` 内部配置指向了未授权的 `openrouter` 或错误的 `openai` 提供商,导致对话失败(返回 401/400 错误)。
- 通过 `ws://127.0.0.1:8791/acp` (Gemini) 验证Chat 功能完全正常,响应速度快,且支持中文交互。
### 2. 使用 Skill 制作 PPT
- **验证结果**: **验证中/支持**
- **详细说明**:
- ACP Server 暴露了 `pptx` 等技能。
- 在测试中Gemini 适配器能够正确解析制作 PPT 的请求,并开始生成大纲。
### 3. 在线查询能力
- **验证结果**: **成功**
- **详细说明**:
- 通过 Gemini 适配器验证,成功执行了“上海今天天气”的在线查询。
- 代理能够调用查询工具并返回实时天气信息2026年4月23日多云13-16℃
## 发现的问题
1. **Hermes 适配器配置冲突**: `hermes` 进程在尝试自动检测 git 根目录时遇到了 `/root/.git` 的权限拒绝错误。此外,默认模型配置与实际可用的 `ollama` 提供商不匹配,导致 401 身份验证失败。
2. **连接路径**: 适配器监听的端口需要配合 `/acp` 路径才能成功建立 WebSocket 连接。
## 测试结论
ACP Server 的核心桥接能力xworkmate-bridge运行正常。目前 **Gemini 适配器 (8791)** 交互能力最为完备且稳定。**Hermes 适配器 (3920)** 存在环境配置与模型鉴权问题,建议在 `hermes-agent` 侧运行 `hermes setup` 或修复 `config.yaml` 中的 `model.provider` 设置。
---
**测试日期**: 2026年4月23日
**执行工具**: Gemini CLI

File diff suppressed because it is too large Load Diff

View File

@ -1,190 +1,87 @@
# ACP Forwarding Topology
Last Updated: 2026-04-13
Last Updated: 2026-04-23
本文件描述当前 `xworkmate-app <-> xworkmate-bridge` 主链下的 bridge-only forwarding topology。
See also:
- [XWorkmate Core Module Inventory](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-app/docs/architecture/xworkmate-core-module-inventory-2026-04-13.md)
- [ADR: Unified Bridge Entry Points](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-bridge/docs/architecture/adr-unified-bridge-entrypoints.md)
本文档只描述当前保留的 canonical topology。
## App-Facing Mainline
app 来说,当前主链只有两类面向 bridge 的交互
`xworkmate-app` 来说bridge 只有一个 canonical surface
- `assistant` surface 进入 ACP control-plane`acp.capabilities`、`xworkmate.routing.resolve`、`session.*`
- `settings` surface 进入 gateway runtime / connection flow`acp.capabilities`、`xworkmate.gateway.*`
- `GET /acp` WebSocket
- `POST /acp/rpc`
不管 bridge 内部还保留哪些 provider / gateway mode / capability flagapp-facing 公共入口都只有 bridge origin`/acp-server/*` 和 `/gateway/openclaw` 属于 bridge-owned routing facts不是 app-owned truth。
app 只感知 method family
## Topology
- `acp.capabilities`
- `xworkmate.routing.resolve`
- `session.*`
- `xworkmate.gateway.*`
## Canonical Topology
```mermaid
flowchart TD
subgraph APP["xworkmate-app"]
A1["AssistantPage"]
A2["SettingsPage"]
A3["https://xworkmate-bridge.svc.plus"]
A1 --> A3
A2 --> A3
end
flowchart LR
subgraph APP["xworkmate-app"]
A1["Assistant / Settings / Runtime UI"]
A2["Canonical ACP client"]
A1 --> A2
end
subgraph BRIDGE["xworkmate-bridge"]
B1["POST /acp/rpc"]
B2["GET /acp (WebSocket)"]
B3["acp.capabilities"]
B4["xworkmate.routing.resolve"]
B5["session.*"]
B6["xworkmate.gateway.*"]
B7["bridge-owned provider catalog"]
B8["bridge-owned routing"]
B9["bridge-owned gateway runtime"]
subgraph BRIDGE["xworkmate-bridge"]
B1["GET /acp<br/>JSON-RPC over WebSocket"]
B2["POST /acp/rpc<br/>secondary compatibility"]
B3["acp.capabilities"]
B4["xworkmate.routing.resolve"]
B5["session.*"]
B6["xworkmate.gateway.*"]
B7["provider_compat"]
B8["gateway compat"]
end
A3 --> B1
A3 --> B2
subgraph ADAPTERS["adapter runtime"]
C1["codex"]
C2["opencode"]
C3["gemini"]
C4["hermes"]
end
subgraph GATEWAY["gateway runtime"]
D1["openclaw"]
end
A2 --> B1
A2 --> B2
B1 --> B3
B1 --> B4
B1 --> B5
B1 --> B6
B2 --> B3
B2 --> B4
B2 --> B5
B3 --> B7
B4 --> B8
B5 --> B8
B6 --> B9
end
subgraph UPSTREAM["Independent upstream services"]
C1["https://xworkmate-bridge.svc.plus/acp-server/codex/acp/rpc"]
C2["https://xworkmate-bridge.svc.plus/acp-server/opencode/acp/rpc"]
C3["https://xworkmate-bridge.svc.plus/acp-server/gemini/acp/rpc"]
C4["https://xworkmate-bridge.svc.plus/acp-server/hermes/acp/rpc"]
C5["https://xworkmate-bridge.svc.plus/gateway/openclaw/"]
end
B7 --> C1
B7 --> C2
B7 --> C3
B7 --> C4
B8 --> C1
B8 --> C2
B8 --> C3
B8 --> C4
B9 --> C5
B2 --> B6
B5 --> B7
B6 --> B8
B7 --> C1
B7 --> C2
B7 --> C3
B7 --> C4
B8 --> D1
```
## Three-Layer View
This view separates what the app sees, what the bridge owns, and what the
real upstream production targets are. The upstream ACP and gateway services
exist independently, but for the app they are all accessed through the single
public bridge origin: `https://xworkmate-bridge.svc.plus`.
```mermaid
flowchart LR
subgraph L1["APP 视角"]
APP["xworkmate-app"]
APPENTRY["https://xworkmate-bridge.svc.plus<br/>统一代理入口"]
APPMETHODS["bridge methods<br/>acp.capabilities / session.* / xworkmate.gateway.*"]
APP --> APPENTRY
APPENTRY --> APPMETHODS
end
subgraph L2["Bridge 视角"]
BRIDGE["xworkmate-bridge<br/>唯一上游发现真源"]
CAP["Bridge-owned target-scoped provider catalog"]
CAP1["codex"]
CAP2["opencode"]
CAP3["gemini"]
CAP4["hermes"]
GW["Bridge-owned gateway routing"]
GW1["gatewayProviderId=openclaw"]
BRIDGE --> CAP
CAP --> CAP1
CAP --> CAP2
CAP --> CAP3
CAP --> CAP4
BRIDGE --> GW
GW --> GW1
end
subgraph L3["上游视角"]
U1["https://xworkmate-bridge.svc.plus/acp-server/codex/acp/rpc"]
U2["https://xworkmate-bridge.svc.plus/acp-server/opencode/acp/rpc"]
U3["https://xworkmate-bridge.svc.plus/acp-server/gemini/acp/rpc"]
U4["https://xworkmate-bridge.svc.plus/acp-server/hermes/acp/rpc"]
U5["https://xworkmate-bridge.svc.plus/gateway/openclaw/<br/>reported as xworkmate-bridge.svc.plus:443"]
end
APPMETHODS --> BRIDGE
CAP1 --> U1
CAP2 --> U2
CAP3 --> U3
CAP4 --> U4
GW1 --> U5
```
Important distinction:
- the upstream services are independent production services, not embedded
inside the bridge
- for the app, ACP discovery, session execution, and gateway runtime traffic
are all proxied through `https://xworkmate-bridge.svc.plus`
- upstream authentication is unified through
`Authorization: Bearer $INTERNAL_SERVICE_TOKEN`
- `acp.capabilities` is the single APP-facing source for task dialog modes and
target-scoped provider catalogs
- `providerCatalog` currently advertises the ACP single-agent providers:
`codex`, `opencode`, `gemini`, and `hermes`
- `gatewayProviders` currently advertises the gateway-scoped providers, such as
`openclaw`
- `availableExecutionTargets` tells the app which first-level task dialog modes
are currently available
## Production Truth
当前 production forwarding 事实(内部直连架构):
- canonical app-facing origin: `https://xworkmate-bridge.svc.plus`
- canonical app-facing ACP paths:
- `POST /acp/rpc`
- `GET /acp`
### 核心真源映射 (Final Source of Truth)
为了消除冗余层Bridge-on-Bridge并提高就绪性响应速度中心 Bridge 作为代理和适配器,直接对接各核心服务端口:
| 服务名 | 核心端口 | 协议路径 | 角色定义 |
| :--- | :--- | :--- | :--- |
| **`acp-codex.service`** | **`9001`** | **`ws://127.0.0.1:9001/acp/rpc`** | **Codex 核心 ACP 实现** |
| **`acp-opencode.service`** | **`38992`** | **`ws//127.0.0.1:38992/acp/rpc`** | **Opencode 协议转换 (JSON-RPC over stdio) |
| `acp-gemini.service`** | **`8791`** | **`ws://127.0.0.1:8791/acp/rpc`** | **Gemini 协议转换适配器 (JSON-RPC over stdio)** |
| **`acp-hermes.service`** | **`3920`** | **`ws://127.0.0.1:3920/acp/rpc`** | **Hermes 协议转换适配器 (JSON-RPC over stdio)** |
| **`openclaw-gateway.service`** | **`18789`** | **`ws://127.0.0.1:18789/`** | **OpenClaw 独立部署网关服务(不使用 /acp** |
对 app 而言:
- provider catalog、routing、gateway runtime 都是 bridge-owned metadata / behavior
- upstream URL 存在,但不是 app 的直接合同
- gateway backend、provider IDs、可选 capability flag 也都不是 app shell 模块分类
## Invariants
- app traffic reaches upstream ACP and gateway services only through the bridge
- app does not call `xworkmate-bridge.svc.plus/acp-server/*` or `xworkmate-bridge.svc.plus/gateway/openclaw/` directly
- `openclaw-gateway` is an independently deployed runtime mapped to `ws://127.0.0.1:18789`
- internal provider routes remain bridge-owned validation targets:
- `xworkmate-bridge.svc.plus/acp-server/codex/acp/rpc`
- `xworkmate-bridge.svc.plus/acp-server/opencode/acp`
- `xworkmate-bridge.svc.plus/acp-server/gemini/acp/rpc`
- `xworkmate-bridge.svc.plus/acp-server/hermes/acp/rpc`
- upstream auth stays bridge-internal:
- `Authorization: Bearer $INTERNAL_SERVICE_TOKEN`
- `acp.capabilities` is the provider / capability discovery source
- `xworkmate.routing.resolve` is the routing resolution source
- `xworkmate.gateway.*` is the gateway runtime method family
- bridge may expose additional routing metadata, but that metadata must not be interpreted as extra app surfaces or legacy module shells
- app 不直接访问 provider-specific public URL
- app 不直接访问 openclaw public URL
- provider catalog 与 gatewayProviders 由 bridge 独占生成
- bridge 只暴露 canonical ACP contract
- provider / gateway 实际地址属于 bridge internal truth
## Non-Contract Facts
下列事实可能存在于部署层,但不是 app contract
- `127.0.0.1:*` 端口
- systemd unit 名
- adapter runtime 监听地址
- stdio / process lifecycle

View File

@ -1,21 +1,110 @@
# ADR: Refocus xworkmate-bridge as ACP Control Plane
## Status
Proposed
Accepted
## Context
Originally, `xworkmate-bridge` evolved as a hybrid of a reverse proxy, a stdio runtime manager, and a basic routing layer. This led to logic fragmentation, where provider-specific details (like Gemini or Hermes protocol handling) were mixed with core session orchestration.
`xworkmate-bridge` 历史上混入了三类职责:
- app-facing ACP control plane
- provider/gateway alias route
- stdio/process/runtime implementation detail
这些职责混在一起后bridge 容易退化成:
- 通用 reverse proxy
- ingress path router
- adapter runtime 容器
结果是:
- provider catalog 真源不清晰
- routing 逻辑容易散落
- session 主语义被 provider-specific 分支污染
- app 容易误依赖内部 URL / 端口 / service 名
## Decision
We decided to refocus `xworkmate-bridge` as an **APP-facing ACP control-plane and provider compatibility layer**.
Key changes:
1. **Control Plane Identity**: The bridge is the Source of Truth (SSOT) for provider discovery (`acp.capabilities`) and routing resolution (`xworkmate.routing.resolve`).
2. **Session Orchestration**: All sessions are orchestrated by a unified engine that handles turn state, history normalization, and result shaping. Provider-specific differences are absorbed by a dedicated **Compatibility Layer** (Adapters).
3. **Decoupled Runtime**: Stdio and process lifecycle details are pushed down to specific adapters. The bridge only depends on the `ProviderAdapter` contract.
4. **Stripped Non-Core Duties**: TLS, ingress routing, and rate limiting are handed over to edge ingress controllers (e.g., APISIX/Caddy).
我们将 `xworkmate-bridge` 收敛为:
**APP-facing ACP control plane and provider compatibility layer**
### 1. bridge 不是 just proxy
bridge 的核心资产不是纯转发,而是:
- `acp.capabilities`
- `xworkmate.routing.resolve`
- `session orchestration`
- `xworkmate.gateway.*`
因此 bridge 不再以 alias route 作为 public surface。
### 2. provider catalog 必须是 bridge-owned truth
app 只能通过 `acp.capabilities` 获取:
- `providerCatalog`
- `gatewayProviders`
- `availableExecutionTargets`
app 不应依赖:
- `/acp-server/*`
- `/gateway/openclaw`
- 本地端口
- systemd service 名
### 3. stdio runtime 必须留在 bridge core 之外
以下内容不是 bridge core
- JSON-RPC over stdio framing
- process lifecycle
- restart / stderr
- provider-specific upstream session 映射
这些细节属于 adapter runtime / provider compat。
### 4. routing.resolve 与 session orchestration 是核心资产
bridge 继续保留:
- 独立 routing engine
- 统一 session orchestrator
provider-specific 逻辑只能存在于 compat layer不得污染公共 handler。
### 5. WS-first
canonical transport 定义为:
- `GET /acp` WebSocket
`POST /acp/rpc` 作为 secondary compatibility transport 保留,但不再主导架构设计。
## Consequences
- **Pros**: Clear separation of concerns, easier to add new providers, stable contract for App, reduced codebase complexity.
- **Cons**: Requires migration of existing direct-proxy logic to the new adapter pattern.
- **Backward Compatibility**: Dropped legacy direct-proxy paths and internal routing leaks.
### 正向结果
- public contract 更清晰
- provider 扩展更便宜
- app 不再依赖内部部署事实
- bridge core 与 adapter runtime 职责清晰
### 有意删除
- `/acp-server/*` public alias
- `/gateway/openclaw` public alias
- multi-agent 作为 bridge core 路径
- 以 reverse proxy 为中心的 bridge 定位
### 后续规则
新增 JSON-RPC over stdio provider 时:
- 只增加 compat/runtime
- 不改 bridge core contract
- 不把 provider-specific URL 暴露给 app

View File

@ -61,8 +61,8 @@ The APP should not depend on provider-specific public URLs such as:
- `/gemini/acp/rpc`
- `/openclaw/`
If the bridge reports execution-target metadata such as `single-agent`,
`multi-agent`, or `gateway`, the app should treat those values as routing
If the bridge reports execution-target metadata such as `single-agent`
or `gateway`, the app should treat those values as routing
results, not as shell-level surface categories.
If the bridge reports gateway provider IDs such as `openclaw`, the
@ -119,7 +119,7 @@ Upstream authentication is unified for both ACP and gateway routes:
### Trade-offs
- docs must clearly separate canonical app contracts from independent upstream services
- optional bridge metadata must be documented as metadata, not as surface taxonomy
- bridge metadata must be documented as metadata, not as surface taxonomy
## Path Naming Guidance
@ -127,7 +127,7 @@ Use these terms consistently:
- `canonical app-facing path`: `/acp/rpc` and `/acp`
- `gateway runtime method family`: `xworkmate.gateway.*`
- `independent upstream service`: `xworkmate-bridge.svc.plus/acp-server/*`, `xworkmate-bridge.svc.plus/gateway/openclaw/`
- `independent upstream service`: provider / gateway runtime behind bridge-owned compat
- `bridge-owned routing`: provider / gateway selection performed inside bridge
- `routing metadata`: execution target and resolved provider/gateway identifiers returned to the app

View File

@ -1,254 +1,139 @@
# Bridge Runtime Design
本文档回答一个核心问题:`xworkmate-bridge` 作为独立仓库,如何把 APP-facing bridge、独立 upstream ACP provider、gateway runtime 和本地支撑能力组织成一个统一运行时
本文档描述 `xworkmate-bridge` 当前的收敛后运行模型
## 1. 运行模式
## 1. 角色定义
二进制入口在 [main.go](../../main.go) 中定义,当前支持四种运行模式
`xworkmate-bridge` 被明确限定为
| 模式 | 入口 | 作用 |
| --- | --- | --- |
| `serve` | `acp.Serve` | 启动 bridge HTTP / WebSocket 服务,对外暴露 `/acp/rpc``/acp`,并附带健康检查路由。 |
| `acp-stdio` | `acp.RunStdio` | 启动 stdio ACP bridge适合被宿主进程以 stdin/stdout 驱动。 |
| `gemini-acp-adapter` | `geminiadapter.Serve` | 启动 Gemini 专用 ACP adapter把 Gemini CLI 包装成 ACP HTTP / WebSocket 服务。 |
| `hermes-acp-adapter` | `hermesadapter.Serve` | 启动 Hermes 专用 ACP adapter把 Hermes stdio ACP 包装成 ACP HTTP / WebSocket 服务。 |
| 默认模式 | `toolbridge.Run` | 启动 MCP 风格的本地工具桥,暴露 `chat`、`claude_review`、`vault_kv` 等工具。 |
**APP-facing ACP control plane and provider compatibility layer**
|服务 │ 外部入口 (HTTPS/WSS) │ 后端转发目标 (Local) │ 部署方式 │
├──────────┼────────────────────────────────────────────────┼──────────────────────────┼─────────────┤
│ Bridge │ xworkmate-bridge.svc.plus/ │ 127.0.0.1:8787 │ 主机进程 │
│ OpenClaw │ xworkmate-bridge.svc.plus/gateway/openclaw/ │ 127.0.0.1:18789 │ 独立部署 │
│ Codex │ xworkmate-bridge.svc.plus/acp-server/codex/ │ 127.0.0.1:9001 │ 主机进程 │
│ OpenCode │ xworkmate-bridge.svc.plus/acp-server/opencode/ │ 127.0.0.1:38992 │ 主机进程 │
│ Gemini │ xworkmate-bridge.svc.plus/acp-server/gemini/ │ 127.0.0.1:8791 │ 主机进程 │
│ Hermes │ xworkmate-bridge.svc.plus/acp-server/hermes/ │ 127.0.0.1:3920 │ 主机进程 │
它不是:
- 通用 reverse proxy
- ingress gateway
- stdio runtime 本体
设计含义:
## 2. 三层边界
- `main.go` 不承载业务决策,只做模式分发。
- APP-facing canonical bridge 只由 `serve` 模式提供。
- `gemini-acp-adapter` 是独立 adapter而不是 bridge 主入口的一部分。
- `hermes-acp-adapter` 也是独立 adapter不是 bridge 主入口的一部分。
- 默认工具桥是本地工具执行面,不参与 APP-facing canonical path。
### Bridge Core
## 2. 系统边界
### 2.1 Bridge 自身职责
`internal/acp` 是主控面,负责:
- 挂载 HTTP / WebSocket 路由
- 验证 bridge auth 与 origin
- 维护 session / thread 队列
- 做 routing resolve
- 将任务分发到 single-agent、multi-agent 或 gateway 路径
- 向外部 provider 做 ACP forwarding
- 暴露 bridge-owned metadata例如 provider catalog、gateway provider catalog、resolved routing metadata
### 2.2 Upstream ACP Provider
single-agent provider 目录由 [internal/acp/provider_catalog.go](../../internal/acp/provider_catalog.go) 内建:
- `codex`
- `opencode`
- `gemini`
- `hermes`
这些 provider 的 endpoint 和 auth 归 bridge 所有。APP 只通过 `/acp/rpc``/acp` 与 bridge 交互,不直接依赖 provider-specific public URL。
### 2.3 Gateway Runtime
`internal/gatewayruntime` 负责 gateway 连接与请求生命周期,当前 bridge 内建的 gateway provider 是:
- `openclaw`
gateway access 不是单独的 public URL family而是通过 JSON-RPC 方法:
bridge core 保留:
- `acp.capabilities`
- `xworkmate.routing.resolve`
- `session.start`
- `session.message`
- `session.cancel`
- `session.close`
- `xworkmate.gateway.connect`
- `xworkmate.gateway.request`
- `xworkmate.gateway.disconnect`
### 2.4 本地支撑能力
### Provider Compatibility / Adapter Runtime
bridge 在 routing 与执行过程中会调用若干支撑包
adapter/runtime 保留:
- `internal/router`:根据 prompt、memory、available providers、gateway 偏好做执行目标解析
- `internal/skills`:解析显式技能、本地技能匹配与 fallback skill 安装请求
- `internal/memory`:加载本地 / 项目 memory记录 auto routing 成功经验
- `internal/mounts`:汇总 Codex / OpenCode / OpenClaw / ARIS 等 mount 状态
- `internal/shared`RPC envelope、provider command、Vault KV、OpenAI-compatible 请求等公共能力
- codex compat
- opencode compat
- gemini compat
- hermes compat
- JSON-RPC over stdio
- process lifecycle / restart / stderr
- provider-specific framing / upstream session mapping
## 3. 核心主链路
### Outer Gateway
### 3.1 `session.start` / `session.message`
APISIX / Caddy 这类外层网关负责:
APP-facing 主链路从 `internal/acp.Server.handleRequest` 开始:
- TLS
- ingress
- path routing
- rate limit
- audit / access logging
1. 校验 `sessionId`
2. 通过 `enqueue` 把任务按 `threadId` 串行排队
3. 在 `executeSessionTask` 中强制要求 `routing`
4. 用 `resolveRoutingMetadataWithProviders` 得到 `router.Result`
5. 将 routing 结果写回执行参数:
- `resolvedExecutionTarget`
- `resolvedProviderId`
- `resolvedGatewayProviderId`
- `resolvedModel`
- `resolvedSkills`
6. 根据 `mode` 分流:
- gateway: `runGateway`
- multi-agent: `runMultiAgent`
- single-agent: `runSingleAgent`
## 3. Transport 选择
bridge 在开始与结束阶段通过 `session.update` notification 回传运行状态。
bridge 当前采用:
### 3.2 Routing Resolve
- canonical transport: `GET /acp` WebSocket
- secondary compatibility transport: `POST /acp/rpc`
`internal/router.Resolver.Resolve` 的输入包括
设计含义:
- prompt
- working directory
- routing mode
- explicit execution target / provider / model / skills
- available skills
- available providers
- AI gateway base URL / API key
- memory preferences
- WS 是 control-plane 主链
- HTTP RPC 是兼容入口,不再反向塑造内部架构
- provider / gateway alias route 不再属于 public surface
输出包括:
## 4. 模块边界
- `ResolvedExecutionTarget`
- `ResolvedProviderID`
- `ResolvedGatewayProviderID`
- `ResolvedModel`
- `ResolvedSkills`
- `SkillResolutionSource`
- `NeedsSkillInstall`
- `Unavailable*`
### `bridge_contract`
核心规则
负责:
- 显式 routing 优先于自动 routing。
- 本地任务倾向 `single-agent`
- 在线任务倾向 `gateway`
- 复杂任务可升级到 `multi-agent`
- memory 中的 preferred route / model / skills / provider 会参与默认值补全。
- JSON-RPC request / response envelope
- hybrid mode shaping
- common error mapping
- `session.update` notification envelope
### 3.3 External ACP Forwarding
### `capability_catalog`
single-agent 主链路优先走 bridge 内建 provider catalog。`runSingleAgentViaExternalProvider` 会
负责
1. 根据 provider 解析 endpoint
2. 用 `sanitizeExternalACPParams` 去掉内部字段
3. 优先使用 bridge-owned upstream auth如未配置则复用入站 bearer
4. 根据 endpoint scheme 选择 HTTP 或 WebSocket ACP forwarding
5. 收集 upstream notification并把结果补全为 bridge 统一返回格式
- `providerCatalog`
- `gatewayProviders`
- `availableExecutionTargets`
- `providerProbeSummary`
这里的设计重点是:
### `routing_engine`
- bridge 对外保持统一 contract
- provider-specific endpoint 与 auth 不泄漏给 APP
- nested provider forwarding 仍能沿用 bridge bearer
负责:
### 3.4 Gateway Connect / Request / Disconnect
- routing normalize
- execution target resolve
- provider selection
- unavailable shaping
`internal/acp/gateway_runtime.go` 负责把 JSON-RPC 参数转为 `gatewayruntime.ConnectRequest``RequestResult`
### `session_orchestrator`
当前 `openclaw` 路径带有 bridge-owned production routing
负责
- endpoint 固定到 `openclaw.svc.plus:443`
- auth 优先使用 bridge upstream token
- `connectAuthMode` / `connectAuthFields` / `connectAuthSources` 会被 bridge 统一填充
- session state
- turn state
- history
- notification dispatch
- normalized output
`internal/gatewayruntime.Manager` 维护 runtime session map并负责
### `provider_compat`
- 建连
- 重连
- request / response 配对
- push event 转换
- snapshot / log notification 发射
负责:
## 4. 横切关注点
- codex compat
- opencode compat
- gemini compat
- hermes compat
### 4.1 Auth
### `gateway compat`
bridge 主入口使用 `BRIDGE_AUTH_TOKEN` 驱动的 bearer auth
负责
- 如果配置了 token则必须完全匹配该 token 或 `Bearer <token>`
- 如果未配置 token则默认放行
- openclaw connect / request / disconnect
Gemini adapter 的 auth 更宽松:
## 5. 数据流
- `GEMINI_ADAPTER_AUTH_TOKEN` 未配置时默认放行
- 配置后才启用 bearer 校验
1. app 通过 `/acp``/acp/rpc` 发送 JSON-RPC request
2. bridge contract decode request
3. `acp.capabilities` 从 catalog 读取 bridge-owned truth
4. `xworkmate.routing.resolve` 由 routing engine 计算
5. `session.*` 进入 orchestrator
6. orchestrator 将 provider 差异分发到 compat 层
7. compat 与实际 adapter/runtime 通信
8. bridge 统一归一化结果,并通过 `session.update` 发通知
### 4.2 CORS / WebSocket
## 6. 当前约束
bridge 与 Gemini adapter 都维护独立的 allowed origins
- bridge: `ACP_ALLOWED_ORIGINS`
- Gemini adapter: `GEMINI_ADAPTER_ALLOWED_ORIGINS`
规则是:
- 空 `Origin` 默认允许
- `http://localhost:*` / `http://127.0.0.1:*` 这类前缀匹配通过 `:*` 语法支持
- `/acp/rpc` 支持 `OPTIONS` 预检
- WebSocket upgrade 也复用 origin + auth 校验
### 4.3 Provider Catalog
provider catalog 是 bridge-owned runtime truth而不是配置中心 UI 真相:
- `newProductionProviderCatalog()` 直接定义生产 provider
- `availableProviderCatalog()` 产出 APP-facing metadata
- `availableGatewayProviderCatalog()` 产出 gateway-facing metadata
这符合仓库 ADR 的约束APP 只认 bridge canonical entrypoint不认 provider-specific public URL。
### 4.4 Session Queue / Thread Queue
bridge 把任务串到 `threadId` 级别队列:
- `ensureQueue` 为每个 thread 创建一个缓冲队列
- `runQueue` 顺序执行同一 thread 的任务
- `session.start` 会 reset 同名 session
- `session.cancel` / `session.close` 只操作 session 级状态,不拆分 canonical contract
这样可以保证多轮对话在同一 thread 内顺序执行,避免状态竞争。
### 4.5 Memory / Mounts / Skills 介入点
- routing 前会加载 `memory.Service.Load`
- auto routing 成功后会调用 `RecordSuccess`
- skill 解析在 `router.Resolve` 内完成
- mount 状态通过 `xworkmate.mounts.reconcile` 暴露给上层
这些能力都是 bridge 的支撑层,而不是 APP shell 顶层 surface。
## 5. 设计约束
本仓库的设计约束必须始终保持一致:
- app-facing canonical path 只有 bridge
- `POST /acp/rpc`
- `GET /acp`
- provider 与 gateway 只是 bridge-owned routing metadata不是 APP 顶层模块
- runtime truth source 尽量 singular
- provider catalog 以内建 bridge contract 为准
- gateway provider 以内建 production routing 为准
- config-center UI 不是 runtime readiness 的前置真相;当 bridge/runtime contract 已能自解析时,不应引入第二套入口或第二套依赖来源
## 6. 代码定位
建议结合以下文件阅读本设计:
- [main.go](../../main.go)
- [internal/acp/server.go](../../internal/acp/server.go)
- [internal/acp/routing.go](../../internal/acp/routing.go)
- [internal/acp/execution.go](../../internal/acp/execution.go)
- [internal/acp/gateway_runtime.go](../../internal/acp/gateway_runtime.go)
- [internal/acp/provider_catalog.go](../../internal/acp/provider_catalog.go)
- [internal/gatewayruntime/runtime.go](../../internal/gatewayruntime/runtime.go)
- [internal/router/router.go](../../internal/router/router.go)
- [internal/toolbridge/runner.go](../../internal/toolbridge/runner.go)
- app 不应感知 provider-specific upstream URL
- app 不应感知本地端口或 service 名
- routing 逻辑不得散落到各 provider handler
- session 语义不得被 provider-specific 分支污染
- 新增 JSON-RPC over stdio provider 时,应只增加 compat/runtime不改 bridge core

View File

@ -1,604 +1,192 @@
# Internal Reference
本文档记录 `xworkmate-bridge` 的内部实现参考,覆盖 package、关键 `struct` / `interface`、导出函数,以及核心运行时中的关键未导出主链路函数
本文档记录 `xworkmate-bridge` 当前内部模块边界,只覆盖当前保留的 control-plane 主链
术语约定:
## 1. 总体分层
- “类” 在本仓库映射为 Go 的 `struct``interface`
- “接口” 同时指外部协议接口与 Go `interface`
- “参数 / 返回” 同时包含 Go 签名与运行时语义
当前内部结构分为五层:
## internal/acp
1. `bridge_contract`
2. `capability_catalog`
3. `routing_engine`
4. `session_orchestrator`
5. `provider_compat` / `gateway compat`
### 核心真源映射 (Core Service Mapping)
核心原则:
为确保高性能与就绪性可靠性,本包默认配置直连以下核心真源。开发与维护时应确保这些端口在宿主机 `127.0.0.1` 保持可用:
- bridge core 不再承担 reverse proxy public surface
- bridge core 不再承担 multi-agent 业务分叉
- stdio / process / framing / restart 细节下沉到 adapter runtime
| 规范服务名 | 监听端口 | 协议模式 | 关键职责 |
| :--- | :--- | :--- | :--- |
| **`acp-codex.service`** | `9001` | HTTP | Codex 核心 ACP 控制面 |
| **`acp-opencode.service`** | `38992` | HTTP | Opencode 核心 ACP 控制面 |
| **`acp-gemini.service`** | `8791` | HTTP | Gemini 协议适配器 (protocol-adapter) |
| **`acp-hermes.service`** | `3920` | HTTP | Hermes 协议适配器 (stdio ACP adapter) |
| **`openclaw-gateway.service`** | `18789` | WS | OpenClaw 独立部署网关运行时,映射到 `127.0.0.1:18789` |
## 2. `internal/acp`
### 包职责
APP-facing bridge 主控面。该模块已全面切换至 **JSON-RPC 2.0** 作为默认协议。负责 HTTP / WebSocket 路由、JSON-RPC method dispatch、session / thread 队列、routing resolve、single-agent / multi-agent / gateway 执行分流,以及 provider / gateway runtime 桥接
APP-facing ACP control plane。
### 主要输入 / 输出
负责:
- 输入HTTP 请求、WebSocket RPC frame、stdio RPC 请求、bridge 环境变量
- 输出JSON-RPC result / error envelope、`session.update` notification、gateway 转发结果、provider 转发结果
- `/acp` WebSocket canonical transport
- `/acp/rpc` secondary compatibility transport
- JSON-RPC / hybrid envelope
- `acp.capabilities`
- `xworkmate.routing.resolve`
- `session.*`
- `xworkmate.gateway.*`
### 关键类型
- `type Server struct`
- 作用bridge 运行时聚合根,持有 session map、thread queue、gateway manager、provider catalog、auth service。
- 主要副作用:维护内存态 session / queue并向下调用 gatewayruntime、router、mounts、dispatch、shared。
- bridge 聚合根
- 持有 `routingEngine`、`providers`、`catalog`、`orchestrator`、`gateway`
### 关键函数 / 方法
- `type ProviderCompat interface`
- bridge 依赖的唯一 provider 接口
- 方法:
- `ID()`
- `Metadata()`
- `Probe(ctx)`
- `StartSession(...)`
- `SendMessage(...)`
- `CancelSession(...)`
- `CloseSession(...)`
- `func Serve(args []string) error`
- 参数:`args` 为 `serve` 子命令参数。
- 返回:监听失败或 server 非正常退出时返回 error。
- 副作用:创建 `http.Server`,监听 `ACP_LISTEN_ADDR`
- 场景:`main.go` 中的 `serve` 模式入口。
- `type RoutingResult struct`
- routing 输出 contract
- 包括:
- `TargetID`
- `ProviderID`
- `GatewayProviderID`
- `Model`
- `Skills`
- `Status`
- `UnavailableCode`
- `UnavailableMsg`
- `type CapabilityCatalog struct`
- bridge-owned capabilities 真源
- 包括:
- `ProviderCatalog`
- `GatewayProviders`
- `AvailableExecutionTargets`
- `ProviderProbeSummary`
### 关键主链
- `func NewServer() *Server`
- 参数:无。
- 返回:初始化后的 `*Server`
- 副作用:装载内建 provider catalog、gateway manager、static token auth service。
- 场景:`Serve` 和测试中的 server 构造。
- 初始化 routing engine、catalog、providers、orchestrator、gateway manager
- `func (s *Server) Handler() http.Handler`
- 参数receiver `s *Server`
- 返回:统一的 HTTP handler。
- 副作用:按路径路由到 `/`、`/api/ping`、`/bridge/bootstrap/health`、`/acp/rpc`、`/acp`。
- 场景bridge HTTP 服务挂载。
- `func (s *Server) HandleRPC(w http.ResponseWriter, r *http.Request)`
- 参数:标准 HTTP writer/request。
- 返回:无显式返回;通过 HTTP body 输出 JSON-RPC envelope。
- 副作用:处理 CORS、auth、body decode、SSE streaming、RPC dispatch。
- 场景:`POST /acp/rpc`。
- `func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request)`
- 参数:标准 HTTP writer/request。
- 返回:无显式返回;通过 WebSocket 发回 JSON-RPC result / notification。
- 副作用:做 origin/auth 校验,升级连接,持续读取 RPC frame。
- 场景:`GET /acp`。
- `func (s *Server) HandleBridgeBootstrapHealth(w http.ResponseWriter, r *http.Request)`
- 参数:标准 HTTP writer/request。
- 返回:无显式返回;输出 bridge health JSON。
- 副作用:读取 `BRIDGE_SERVER_URL`
- 场景bootstrap 自检或部署健康检查。
- `func RunStdio(input io.Reader, output io.Writer)`
- 参数stdio 输入输出流。
- 返回:无。
- 副作用:运行 stdio ACP bridge。
- 场景:`acp-stdio` 模式。
### 核心未导出主链路
- `func (s *Server) handleRequest(request shared.RPCRequest, notify func(map[string]any)) (map[string]any, *shared.RPCError)`
- 参数:解码后的 RPC 请求、通知回调。
- 返回:成功 result map 或 RPCError。
- 副作用:分派所有 bridge JSON-RPC 方法。
- 场景HTTP 与 WebSocket 统一 RPC dispatch 核心。
- `func (s *Server) executeSessionTask(task task) (map[string]any, *shared.RPCError)`
- 参数:排队后的内部任务对象。
- 返回:执行结果或 RPCError。
- 副作用:强制 routing、创建 / 更新 session、按 resolved mode 分流。
- 场景thread queue 实际执行入口。
- `func resolveRoutingMetadataWithProviders(params map[string]any, availableProviders []string) (router.Result, bool)`
- 参数JSON-RPC params、当前 bridge 可用 provider 列表。
- 返回:`router.Result` 与 “是否提供 routing” 标记。
- 副作用:读取 routing、skill install approval、memory 偏好。
- 场景:`xworkmate.routing.resolve` 与 session 执行前置。
- `func (s *Server) runSingleAgent(ctx context.Context, method string, session *session, params map[string]any, turnID string, notify func(map[string]any)) taskResult`
- 参数上下文、RPC method、session、执行参数、turnId、通知回调。
- 返回:内部 `taskResult`
- 副作用:选择 provider、归一化 working directory、对外做 ACP forwarding。
- 场景single-agent 主路径。
- `func (s *Server) runMultiAgent(ctx context.Context, session *session, params map[string]any, turnID string, notify func(map[string]any)) taskResult`
- 参数上下文、session、执行参数、turnId、通知回调。
- 返回multi-agent 汇总结果。
- 副作用:调用 OpenAI-compatible chat completion产出多智能体总结。
- 场景:复杂任务升级到 multi-agent 时。
- `func (s *Server) runGateway(ctx context.Context, method string, session *session, params map[string]any, turnID string, notify func(map[string]any)) taskResult`
- 参数上下文、RPC method、session、执行参数、turnId、通知回调。
- 返回gateway 执行结果。
- 副作用:调用 `gatewayruntime.Manager.RequestByMode`
- 场景gateway-chat / gateway 模式。
- `func (s *Server) runSingleAgentViaExternalProvider(ctx context.Context, provider syncedProvider, method string, params map[string]any, notify func(map[string]any)) (map[string]any, error)`
- 参数上下文、bridge 内建 provider、RPC method、转发参数、通知回调。
- 返回upstream ACP result 或 error。
- 副作用HTTP / WebSocket forwarding、auth header 选择、结果补全。
- 场景:对 `codex` / `opencode` / `gemini` 做桥接转发。
- `func handleGatewayConnect(server *Server, params map[string]any, notify func(map[string]any)) map[string]any`
- 参数server、JSON-RPC params、通知回调。
- 返回gateway connect 结果。
- 副作用:把 JSON 参数映射为 `gatewayruntime.ConnectRequest`,应用 production routing。
- 场景:`xworkmate.gateway.connect`。
## internal/gatewayruntime
### 包职责
维护 gateway runtime 生命周期,包括建连、认证挑战、请求 / 响应配对、push 事件规范化、重连与快照通知。
### 关键类型
- `type Endpoint struct { Host string; Port int; TLS bool }`
- 作用:声明 gateway endpoint。
- `type PackageInfo struct { AppName string; PackageName string; Version string; BuildNumber string }`
- 作用:附带客户端包信息。
- `type DeviceInfo struct { Platform string; PlatformVersion string; DeviceFamily string; ModelIdentifier string }`
- 作用:附带设备平台信息。
- `func (d DeviceInfo) PlatformLabel() string`
- 参数receiver `DeviceInfo`
- 返回:`Platform` 与 `PlatformVersion` 拼出的展示文本。
- 场景:快照或日志展示。
- `type DeviceIdentity struct { DeviceID string; PublicKeyBase64URL string; PrivateKeyBase64URL string }`
- 作用:携带设备标识与签名密钥材料。
- `type AuthConfig struct { Token string; DeviceToken string; Password string }`
- 作用:承载 gateway connect 认证材料。
- `type ConnectRequest struct`
- 参数字段:`RuntimeID`、`Mode`、`ClientID`、`Locale`、`UserAgent`、`Endpoint`、`ReportedRemoteAddress`、`ConnectAuthMode`、`ConnectAuthFields`、`ConnectAuthSources`、`HasSharedAuth`、`HasDeviceToken`、`PackageInfo`、`DeviceInfo`、`Identity`、`Auth`。
- 作用gateway 建连请求的完整输入对象。
- 只挂载:
- `/`
- `/api/ping`
- `/acp`
- `/acp/rpc`
- `type ConnectResult struct { OK bool; Snapshot map[string]any; Auth map[string]any; ReturnedDeviceToken string; Error map[string]any }`
- 作用:建连返回体。
- `func (s *Server) handleRequest(...)`
- 统一分派:
- `acp.capabilities`
- `session.*`
- `xworkmate.routing.resolve`
- `xworkmate.gateway.*`
- `type RequestResult struct { OK bool; Payload any; Error map[string]any }`
- 作用gateway request 返回体。
- `func (o *SessionOrchestrator) Process(...)`
- bridge session 主语义入口
- 流程:
- resolve routing
- get/create session state
- dispatch to gateway or provider compat
- normalize result
- emit `session.update`
- record project memory only when routing mode is explicitly auto
- `type GatewayError struct { Message string; Code string; Details map[string]any }`
- 作用:规范化 gateway 错误。
### 当前删除的旧路径
- `func (e *GatewayError) Error() string`
- 返回:错误消息字符串。
以下逻辑已从主链移除:
- `func (e *GatewayError) DetailCode() string`
- 返回:`Details["code"]` 的规范化 detail code。
- `/acp-server/*`
- `/gateway/openclaw` public alias
- multi-agent 执行路径
- provider-specific alias handler
- `func (e *GatewayError) Map() map[string]any`
- 返回:适合写入 JSON / RPC 的错误对象。
## 3. `provider_compat`
- `type Manager struct`
- 导出字段:`ReconnectDelay`、`ConnectTimeout`、`ChallengeTimeout`
- 作用gateway runtime manager内部持有 runtime session map。
bridge 当前通过 compat 层吸收 provider 差异:
### 关键函数 / 方法
- `codex compat`
- `opencode compat`
- `gemini compat`
- `hermes compat`
- `func NewManager() *Manager`
- 返回:带默认重连与超时参数的 manager。
这些 compat 共享同一套 external ACP 调用基座,但对 bridge core 暴露统一接口。
- `func (m *Manager) Connect(request ConnectRequest, notify func(map[string]any)) ConnectResult`
- 参数connect request、通知回调。
- 返回connect 结果。
- 副作用:查找或创建 runtime session配置 session 并发起建连。
- 场景:`xworkmate.gateway.connect`。
### compat 负责什么
- `func (m *Manager) Request(runtimeID string, method string, params map[string]any, timeout time.Duration, notify func(map[string]any)) RequestResult`
- 参数runtimeId、method、params、超时、通知回调。
- 返回request 结果。
- 副作用:把 gateway RPC 发到已连接 runtime。
- 场景:`xworkmate.gateway.request`。
- 调上游 ACP HTTP / WS
- 解析 JSON-RPC result / error
- 转换 upstream notification
- 吸收 provider transport 差异
- `func (m *Manager) RequestByMode(mode string, method string, params map[string]any, timeout time.Duration, notify func(map[string]any)) RequestResult`
- 参数gateway mode、method、params、超时、通知回调。
- 返回request 结果。
- 副作用:按 mode 选择当前已连接 session。
- 场景bridge gateway 执行路径。
### compat 不负责什么
- `func (m *Manager) Disconnect(runtimeID string, notify func(map[string]any))`
- 参数runtimeId、通知回调。
- 返回:无。
- 副作用:断开指定 runtime session。
- 场景:`xworkmate.gateway.disconnect`。
- catalog ownership
- routing ownership
- session state ownership
- app-facing contract 设计
### 核心未导出主链路
## 4. `routing_engine`
- `func newSession(manager *Manager, runtimeID string) *session`
- `func (s *session) configure(request ConnectRequest, notify func(map[string]any))`
- `func (s *session) connect() ConnectResult`
- `func (s *session) connectAttempt() (ConnectResult, *GatewayError)`
- `func (s *session) request(method string, params map[string]any, timeout time.Duration) RequestResult`
- `func (s *session) requestRemote(method string, params map[string]any, timeout time.Duration, requireConnected bool) RequestResult`
- `func (s *session) disconnect()`
- `func (s *session) readLoop(conn *websocket.Conn, challengeCh chan string)`
- `func (s *session) scheduleReconnect()`
- `func (s *session) emitNotification(method string, params map[string]any)`
当前 routing 只围绕 bridge 核心能力做决策:
这些函数共同组成 gateway runtime 的状态机:配置、建连、读循环、请求发出、错误处理、重连调度以及 `xworkmate.gateway.snapshot` / `xworkmate.gateway.push` / `xworkmate.gateway.log` 通知发射。
- `single-agent`
- `gateway`
## internal/router
不再升级到 multi-agent。
### 包职责
输入来源:
根据 prompt、显式 routing、memory 偏好、provider 可用性与 skill 解析结果,决定任务应该走 `single-agent`、`multi-agent` 还是 `gateway`
- prompt
- workingDirectory
- explicit routing
- preferred gateway provider
- available bridge providers
- available skills
- memory preferences
### 常量
输出由 bridge 统一整形成 `RoutingResult`
- `RoutingModeAuto = "auto"`
- `RoutingModeExplicit = "explicit"`
- `ExecutionTargetSingleAgent = "single-agent"`
- `ExecutionTargetMultiAgent = "multi-agent"`
- `ExecutionTargetGateway = "gateway"`
- `ExecutionTargetGatewayChat = "gateway-chat"`
- `GatewayProviderOpenClaw = "openclaw"`
## 5. `gatewayruntime`
### 关键类型
`internal/gatewayruntime` 仍负责 openclaw runtime 的连接态、请求配对、快照与 push event。
- `type Request struct`
- 关键字段:`Prompt`、`WorkingDirectory`、`RoutingMode`、`PreferredGatewayProviderID`、`ExplicitExecutionTarget`、`ExplicitProviderID`、`ExplicitModel`、`ExplicitSkills`、`AllowSkillInstall`、`InstallApproval`、`AvailableSkills`、`AvailableProviders`、`AIGatewayBaseURL`、`AIGatewayAPIKey`。
- 作用routing 计算输入。
这个包是 gateway runtime 层,不是 public API 层。
- `type Result struct`
- 关键字段:`ResolvedExecutionTarget`、`ResolvedProviderID`、`ResolvedGatewayProviderID`、`ResolvedModel`、`ResolvedSkills`、`SkillResolutionSource`、`SkillCandidates`、`NeedsSkillInstall`、`SkillInstallRequestID`、`MemorySources`、`Unavailable`、`UnavailableCode`、`UnavailableMessage`。
- 作用routing 计算输出。
bridge 只通过 `xworkmate.gateway.*` 把它暴露为 control-plane contract。
- `type Resolver struct`
- 字段:`SkillFinder`、`SkillInstaller`、`MemoryService`、`Classifier`
- 作用routing 聚合器。
## 6. `internal/router`
- `type ClassificationRequest struct { Prompt string; AIGatewayBaseURL string; AIGatewayAPIKey string }`
- 作用:传给分类器的输入。
任务:
- `type Classifier interface { Classify(req ClassificationRequest) string }`
- 作用:抽象分类器接口。
- normalize routing intent
- resolve execution target
- resolve provider
- resolve gateway provider
- resolve skills
- shape unavailable reason
- `type LLMClassifier struct{}`
- 作用:默认分类器实现。
当前只产出:
### 关键函数 / 方法
- `single-agent`
- `gateway`
- `func NewResolver() Resolver`
- 返回:绑定默认 skills finder / installer、memory service、classifier 的 resolver。
## 7. `internal/shared`
- `func (r Resolver) Resolve(req Request) Result`
- 参数routing request。
- 返回routing result。
- 副作用:读取 memory、解析技能、决定 execution target / provider / gateway provider。
- 场景bridge session 执行前置与 `xworkmate.routing.resolve`
保留的 bridge_contract 基础能力:
- `func (LLMClassifier) Classify(req ClassificationRequest) string`
- 参数classification request。
- 返回execution target 候选字符串。
- 场景:启发式规则未命中时的分类补充。
- JSON-RPC request decode
- hybrid result/error envelope
- notification envelope
- CORS / origin helper
- shared HTTP / WS helper
### 核心未导出主链路
- `func (r Resolver) resolveExecution(req Request, prefs memory.Preferences) (string, string)`
- `func (r Resolver) classify(req Request) string`
- `func mapExplicitTarget(value string, preferredGatewayProviderID string) (string, string)`
- `func resolveGatewayProvider(preferredGatewayProviderID string) string`
- `func resolveProvider(req Request, prefs memory.Preferences, availableProviders []string, executionTarget string) (string, bool, string, string)`
这些函数共同实现了 execution target 与 provider 的两阶段决策。
## internal/dispatch
### 包职责
在 provider 列表、capability 要求和 node runtime state 之间做轻量级 dispatch 解析,并产出 bridge / provider / node metadata。
### 关键类型
- `type Provider struct { ID string; Name string; DefaultArgs []string; Capabilities []string }`
- `type NodeState struct { SelectedAgentID string; GatewayConnected bool; ExecutionTarget string; RuntimeMode string; BridgeEnabled bool; BridgeState string; ResolvedCodexCLIPath string; ConfiguredCodexCLIPath string }`
- `type NodeInfo struct { ID string; Name string; Version string }`
- `type Request struct { Providers []Provider; PreferredProviderID string; RequiredCapabilities []string; NodeState *NodeState; NodeInfo *NodeInfo }`
- `type Result struct { Provider *Provider; AgentID string; Metadata map[string]any }`
### 关键函数
- `func Resolve(request Request) Result`
- 参数provider、capability、node state / info。
- 返回dispatch result。
- 副作用:无外部 I/O纯粹做 provider 选择和 metadata 组装。
- 场景:`xworkmate.dispatch.resolve`。
- `func ResultMap(result Result) map[string]any`
- 参数dispatch result。
- 返回:适合写回 RPC 的 map。
- 场景bridge handler 层输出序列化。
### 核心未导出主链路
- `func selectProvider(providers []Provider, preferredProviderID string, requiredCapabilities []string) *Provider`
- 作用:优先选显式 provider否则按 capability 过滤并按 ID 稳定排序。
## internal/geminiadapter
### 包职责
把 Gemini CLI / Gemini ACP stdio 协议包装成独立 HTTP / WebSocket ACP adapter同时提供一条兼容本地 prompt runner 的 fallback 路径。
### 关键类型
- `type Server struct`
- 作用Gemini adapter 聚合根,持有 RPC client、auth service、provider 元数据、session map。
### 关键函数 / 方法
- `func Serve(args []string) error`
- 参数:`gemini-acp-adapter` 子命令参数。
- 返回:监听失败或 adapter 异常退出时返回 error。
- 副作用:读取 `GEMINI_ADAPTER_*` 环境变量,启动 HTTP 服务。
- `func NewServer(client rpcClient) *Server`
- 参数Gemini ACP RPC client。
- 返回adapter server。
- 副作用:绑定 auth token、provider label、allowed origins、session runner。
- `func (s *Server) HandleRPC(w http.ResponseWriter, r *http.Request)`
- 作用:处理 `/acp/rpc`
- `func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request)`
- 作用:处理 `/acp` WebSocket。
### 核心未导出主链路
- `func (s *Server) handleRequest(request shared.RPCRequest) map[string]any`
- `func (s *Server) handleCapabilities() map[string]any`
- `func (s *Server) handleSessionRequest(method string, params map[string]any) map[string]any`
- `func (s *Server) handleCompatSessionRequest(method string, params map[string]any) map[string]any`
这些函数负责区分真实 upstream ACP 能力与本地兼容路径,并维护 adapter local session history。
## internal/toolbridge
### 包职责
默认模式下的本地 MCP-style 工具桥。负责从 stdin 读取 JSON-RPC / Content-Length frame暴露 `chat`、`claude_review`、`vault_kv` 三个工具。
### 关键函数
- `func Run(input io.Reader, output io.Writer)`
- 参数:输入流、输出流。
- 返回:无。
- 副作用:持续读取消息、解析 RPC、写回结果。
- 场景:`main.go` 默认模式。
### 核心未导出主链路
- `func readMessage(reader *bufio.Reader) ([]byte, error)`
- `func writeMessage(output io.Writer, message map[string]any)`
- `func writeError(output io.Writer, id any, code int, message string)`
- `func handleRequest(request shared.RPCRequest) map[string]any`
## internal/service
### 包职责
认证服务层。一个分支做用户名 / 密码认证,另一个分支做 bearer token 认证。
### 关键类型与函数
- `var ErrInvalidCredentials = errors.New("invalid credentials")`
- `type AuthRepository interface { Verify(ctx context.Context, username, password string) (bool, error) }`
- 作用:用户名 / 密码认证仓储抽象。
- `type AuthService struct`
- `func NewAuthService(repo AuthRepository) *AuthService`
- 参数:认证仓储。
- 返回:认证服务。
- `func (s *AuthService) Authenticate(ctx context.Context, username, password string) error`
- 参数:上下文、用户名、密码。
- 返回:认证成功返回 `nil`,失败返回 `ErrInvalidCredentials` 或仓储错误。
- `type StaticTokenAuthService struct`
- `func NewStaticTokenAuthService(expectedToken string) *StaticTokenAuthService`
- 参数:期望 bearer token。
- 返回:静态 token 服务。
- `func (s *StaticTokenAuthService) ValidateToken(token string) bool`
- 参数token 或 auth header。
- 返回:是否通过校验。
- `func (s *StaticTokenAuthService) ValidateAuthorizationHeader(header string) bool`
- 参数HTTP `Authorization` header。
- 返回:是否通过校验。
- 规则:支持裸 token 和 `Bearer <token>`;若 expectedToken 为空,则接受任意非空 bearer。
## internal/handler
### 包职责
HTTP handler 适配层,把 `service` 层认证能力暴露为简单 HTTP endpoint。
### 关键类型与函数
- `type Authenticator interface { Authenticate(username, password string) error }`
- `type AuthHandler struct`
- `func NewAuthHandler(svc Authenticator) *AuthHandler`
- `func (h *AuthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)`
- 参数HTTP writer/request。
- 返回:无显式返回。
- 副作用:读取 JSON body调用用户名 / 密码认证,返回 `200``401`
- `func NewServiceAdapter(svc *service.AuthService) Authenticator`
- 作用:把带 `context.Context``AuthService` 包装成 handler 所需接口。
- `type TokenAuthHandler struct`
- `func NewTokenAuthHandler(service *service.StaticTokenAuthService) *TokenAuthHandler`
- `func (h *TokenAuthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)`
- 参数HTTP writer/request。
- 副作用:读取 `Authorization`,校验静态 token成功返回 `{"ok": true}`
## internal/memory
### 包职责
从全局 home memory、项目 home memory、项目本地 `.xworkmate/memory.md` 合并 memory并在 auto routing 成功后把偏好写回项目级 memory。
### 关键类型与函数
- `type Source struct { Path string; Scope string }`
- `type Preferences struct { PreferredRoute string; PreferredModel string; PreferredSkills []string; Provider string }`
- `type LoadResult struct { MergedText string; Sources []Source; Preferences Preferences; ProjectFiles []string }`
- `type SuccessEntry struct { ResolvedExecutionTarget string; ResolvedProviderID string; ResolvedModel string; ResolvedSkills []string; Summary string }`
- `type Service struct { HomeDir string }`
- `func NewService(homeDir string) Service`
- 参数home 目录。
- 返回memory service。
- `func (s Service) Load(workingDirectory string) LoadResult`
- 参数:工作目录。
- 返回:合并后的文本、来源、偏好、项目文件路径。
- 副作用:读文件;会过滤 token / password / secret 等敏感文本。
- `func (s Service) RecordSuccess(workingDirectory string, entry SuccessEntry) error`
- 参数:工作目录、成功条目。
- 返回:写入错误。
- 副作用:向 `.xworkmate/memory.md` 或 home project memory 追加 auto route block。
## internal/mounts
### 包职责
计算 Codex / Claude / Gemini / OpenCode / OpenClaw / ARIS 的 mount 与 MCP 就绪状态,并在可行时把 managed MCP block 写入配置文件。
### 关键类型与函数
- `type ManagedMCPServer struct { ID string; Name string; Transport string; Command string; URL string; Args []string; Enabled bool }`
- `type Config struct { AutoSync bool; UsesAris bool; ManagedMCPServers []ManagedMCPServer }`
- `type ArisInput struct { Available bool; BundleVersion string; LLMChatServerPath string; SkillCount int; BridgeAvailable bool; Error string }`
- `type Request struct { Config Config; AIGatewayURL string; ConfiguredCodexCLIPath string; CodexHome string; OpencodeHome string; OpenClawHome string; Aris ArisInput }`
- `type MountTargetState struct { TargetID string; Label string; Available bool; SupportsSkills bool; SupportsMCP bool; SupportsAIGatewayInjection bool; DiscoveryState string; SyncState string; DiscoveredSkillCount int; DiscoveredMCPCount int; ManagedMCPCount int; Detail string }`
- `type Result struct { MountTargets []MountTargetState; ArisBundleVersion string; ArisCompatStatus string }`
- `func Reconcile(request Request) Result`
- 参数mount reconcile request。
- 返回:所有目标的 reconcile 结果。
- 副作用:在 `AutoSync` 打开时可能更新 Codex / OpenCode / OpenClaw 配置文件中的 managed block。
- `func ResultMap(result Result) map[string]any`
- 作用:把 reconcile 结果序列化为 RPC 返回格式。
## internal/skills
### 包职责
解析显式技能、本地技能候选、fallback finder 和安装批准流,输出本轮应启用的技能集合及安装请求标记。
### 关键类型与函数
- `type Candidate struct { ID string; Label string; Description string; Installed bool }`
- `type Finder interface { Find(prompt string) []Candidate }`
- `type Installer interface { Install(candidates []Candidate) ([]Candidate, error) }`
- `type ResolveRequest struct { Prompt string; ExplicitSkills []string; AvailableSkills []Candidate; AllowSkillInstall bool; InstallApproval InstallApproval }`
- `type InstallApproval struct { RequestID string; ApprovedSkillKeys []string }`
- `type ResolveResult struct { ResolvedSkills []string; Candidates []Candidate; Source string; NeedsInstall bool; InstallRequestID string }`
- `type StaticFinder struct{}`
- `type ChainFinder struct { Primary Finder; Fallback Finder }`
- `type CommandFinder struct { Binary string }`
- `type CommandInstaller struct { Binary string }`
- `func Resolve(req ResolveRequest, finder Finder, installer Installer) ResolveResult`
- 参数:技能解析请求、候选查找器、安装器。
- 返回resolved skills、候选、来源、安装请求。
- 副作用:在批准条件满足时可能触发外部 installer 二进制。
- `func (StaticFinder) Find(prompt string) []Candidate`
- `func (f ChainFinder) Find(prompt string) []Candidate`
- `func (f CommandFinder) Find(prompt string) []Candidate`
- `func (i CommandInstaller) Install(candidates []Candidate) ([]Candidate, error)`
- `func NewDefaultFinder() Finder`
- `func NewDefaultInstaller() Installer`
## internal/shared
### 包职责
公共工具层RPC envelope、参数读取、provider command 执行、OpenAI-compatible HTTP 调用、Vault KV bridge、prompt 组装等。
### 关键类型
- `type RPCRequest struct { JSONRPC string; ID any; Method string; Params map[string]any }`
- `type RPCError struct { Code int; Message string }`
- `type ToolCallParams struct { Name string; Arguments map[string]any }`
- `type VaultKVResult struct { Operation string; Mount string; Path string; Data map[string]any; Keys []string; Metadata map[string]any }`
### 关键函数
- `func DecodeRPCRequest(payload []byte) (RPCRequest, error)`
- 参数:原始 JSON payload。
- 返回:解码后的 RPCRequest若 method 缺失则返回 error。
- `func WriteSSE(w http.ResponseWriter, payload map[string]any)`
- 参数writer、payload。
- 副作用:按 SSE `data: ...` 形式写出。
- `func ResultEnvelope(id any, result map[string]any) map[string]any` (已升级为混合模式,支持 JSON-RPC 2.0 规范同时兼顾 legacy APP 字段)
- `func ErrorEnvelope(id any, code int, message string) map[string]any` (已升级为混合模式,确保 401 等错误能以 JSON 格式被 legacy APP 解析)
- `func NotificationEnvelope(method string, params map[string]any) map[string]any`
- `func ErrorResponse(id any, code int, message string) map[string]any`
- `func ToolTextResult(id any, content string) map[string]any`
- `func ToolErrorResult(id any, err error) map[string]any`
- `func EnvOrDefault(key, fallback string) string`
- `func StringArg(arguments map[string]any, key, fallback string) string`
- `func ListArg(arguments map[string]any, key string) []any`
- `func IntArg(raw string, fallback int) int`
- `func BoolArg(raw string, fallback bool) bool`
- `func NormalizeBaseURL(raw string) string`
- `func ResolveProviderCommand(provider, model, prompt, cwd string) (string, []string)`
- 参数provider、model、prompt、工作目录。
- 返回:二进制路径和参数列表。
- 场景Codex / OpenCode / Claude / Gemini 命令构造。
- `func RunProviderCommand(ctx context.Context, provider, model, prompt, workingDirectory string) (string, error)`
- 参数上下文、provider、model、prompt、工作目录。
- 返回CLI 输出文本或 error。
- 副作用:启动外部命令。
- `func NormalizeProviderWorkingDirectory(provider, requested string) (string, string)`
- 参数provider、请求目录。
- 返回:规范化目录与有效目录。
- 场景Codex / OpenCode 目录可访问性保护。
- `func AugmentPromptWithAttachments(prompt string, params map[string]any) string`
- `func ComposeHistoryPrompt(history []string) string`
- `func CallOpenAICompatibleCtx(ctx context.Context, baseURL, apiKey, model string, messages []map[string]string) (string, error)`
- `func CallOpenAICompatible(baseURL, apiKey, model string, messages []map[string]string) (string, error)`
- `func HandleChatTool(arguments map[string]any) (string, error)`
- `func HandleClaudeReviewTool(arguments map[string]any) (string, error)`
- `func RunClaudeReview(prompt, model, system, tools string, timeout time.Duration) (string, error)`
- `func ParseClaudeJSON(raw string) (map[string]any, error)`
- `func HandleVaultKVTool(arguments map[string]any) (string, error)`
### 调用关系
- `internal/acp` 同时依赖 shared 的 RPC、provider command、OpenAI-compatible、Vault / tool helpers。
- `internal/toolbridge` 直接把 shared 作为工具实现层。
- `internal/geminiadapter` 通过 shared 运行 provider command 和读取环境变量。
这里是 envelope 与共用 transport helper不承载 provider-specific 控制逻辑。

View File

@ -2,109 +2,12 @@ package acp
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"xworkmate-bridge/internal/gatewayruntime"
"xworkmate-bridge/internal/memory"
"xworkmate-bridge/internal/shared"
)
// ProxyAdapter implements ProviderAdapter by forwarding to existing RPC endpoints
type ProxyAdapter struct {
providerID string
endpoint string
authHeader string
}
func (a *ProxyAdapter) ID() string { return a.providerID }
func (a *ProxyAdapter) Metadata() map[string]any {
return map[string]any{
"providerId": a.providerID,
"type": "rpc-proxy",
}
}
func (a *ProxyAdapter) Execute(ctx context.Context, sessionID string, threadID string, method string, params map[string]any) (<-chan SessionEvent, error) {
// Simple forwarding logic for RPC-based upstreams
return a.forward(ctx, method, params)
}
func (a *ProxyAdapter) Cancel(ctx context.Context, sessionID string) error {
_, err := a.rpcCall(ctx, "session.cancel", map[string]any{"sessionId": sessionID})
return err
}
func (a *ProxyAdapter) Probe(ctx context.Context) (bool, string) {
_, err := a.rpcCall(ctx, "health", nil)
if err != nil {
return false, err.Error()
}
return true, "ok"
}
func (a *ProxyAdapter) forward(ctx context.Context, method string, params map[string]any) (<-chan SessionEvent, error) {
ch := make(chan SessionEvent, 10)
go func() {
defer close(ch)
resp, err := a.rpcCall(ctx, method, params)
if err != nil {
ch <- SessionEvent{Type: "error", Error: &shared.RPCError{Code: -32002, Message: err.Error()}}
return
}
ch <- SessionEvent{Type: "result", Payload: shared.AsMap(resp["result"])}
}()
return ch, nil
}
func (a *ProxyAdapter) rpcCall(ctx context.Context, method string, params map[string]any) (map[string]any, error) {
requestBody, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"id": fmt.Sprintf("req-%d", time.Now().UnixNano()),
"method": method,
"params": params,
})
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
a.endpoint,
strings.NewReader(string(requestBody)),
)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
req.Header.Set("Accept", "application/json")
if a.authHeader != "" {
req.Header.Set("Authorization", a.authHeader)
}
response, err := (&http.Client{Timeout: 5 * time.Minute}).Do(req)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(response.Body, 1024))
return nil, fmt.Errorf("RPC request failed (%d): %s", response.StatusCode, string(body))
}
var decoded map[string]any
if err := json.NewDecoder(response.Body).Decode(&decoded); err != nil {
return nil, fmt.Errorf("failed to decode RPC response: %w", err)
}
return decoded, nil
}
// Bootstrap initializes the control plane components
func (s *Server) Bootstrap() {
s.mu.Lock()
@ -123,17 +26,12 @@ func (s *Server) Bootstrap() {
s.routingEngine = &DefaultRoutingEngine{server: s}
s.orchestrator = NewSessionOrchestrator(s)
s.adapters = make(map[string]ProviderAdapter)
s.sessionToAdapter = make(map[string]ProviderAdapter)
s.providers = make(map[string]ProviderCompat)
s.sessions = make(map[string]*session)
for id, p := range providerCatalog {
if p.Enabled {
s.adapters[id] = &ProxyAdapter{
providerID: id,
endpoint: resolveSingleAgentForwardEndpoint(p),
authHeader: p.AuthorizationHeader,
}
s.providers[id] = newProviderCompat(p)
}
}
@ -149,6 +47,7 @@ func (s *Server) Bootstrap() {
},
},
AvailableExecutionTargets: []any{"agent", "gateway"},
ProviderProbeSummary: make([]any, 0),
}
for _, id := range providerOrder {
@ -166,6 +65,14 @@ func (s *Server) Bootstrap() {
"targets": []string{"agent"},
"category": category,
})
if compat, ok := s.providers[id]; ok {
probe := compat.Probe(context.Background())
s.catalog.ProviderProbeSummary = append(s.catalog.ProviderProbeSummary, map[string]any{
"providerId": id,
"available": probe.Available,
"status": probe.Status,
})
}
}
}
@ -174,13 +81,7 @@ func (s *Server) getAvailableProviderIDs() []string {
defer s.mu.RUnlock()
var ids []string
for _, id := range s.providerOrder {
if _, ok := s.adapters[id]; ok {
ids = append(ids, id)
}
}
// Fallback to random order if order is missing but adapters exist
if len(ids) == 0 {
for id := range s.adapters {
if _, ok := s.providers[id]; ok {
ids = append(ids, id)
}
}

View File

@ -6,10 +6,11 @@ import (
type CapabilityCatalog struct {
mu sync.RWMutex
ProviderCatalog []any `json:"providerCatalog"`
GatewayProviders []any `json:"gatewayProviders"`
ProviderCatalog []any `json:"providerCatalog"`
GatewayProviders []any `json:"gatewayProviders"`
AvailableExecutionTargets []any `json:"availableExecutionTargets"`
ProviderProbeSummary []any `json:"providerProbeSummary"`
}
func (c *CapabilityCatalog) Update(providers []any, targets []any) {
@ -22,11 +23,21 @@ func (c *CapabilityCatalog) Update(providers []any, targets []any) {
func (c *CapabilityCatalog) Get() map[string]any {
c.mu.RLock()
defer c.mu.RUnlock()
return map[string]any{
result := map[string]any{
"singleAgent": true,
"providerCatalog": c.ProviderCatalog,
"gatewayProviders": c.GatewayProviders,
"availableExecutionTargets": c.AvailableExecutionTargets,
"multiAgent": false,
"providerCatalog": append([]any(nil), c.ProviderCatalog...),
"gatewayProviders": append([]any(nil), c.GatewayProviders...),
"availableExecutionTargets": append([]any(nil), c.AvailableExecutionTargets...),
"providerProbeSummary": append([]any(nil), c.ProviderProbeSummary...),
}
result["capabilities"] = map[string]any{
"single_agent": true,
"multi_agent": false,
"providerCatalog": append([]any(nil), c.ProviderCatalog...),
"gatewayProviders": append([]any(nil), c.GatewayProviders...),
"availableExecutionTargets": append([]any(nil), c.AvailableExecutionTargets...),
}
return result
}

View File

@ -2,24 +2,25 @@ package acp
import (
"context"
"xworkmate-bridge/internal/shared"
)
// ProviderAdapter 是所有 Upstream 的归一化接口
type ProviderAdapter interface {
ID() string
Metadata() map[string]any
// Execute 处理 Start 和 Message 的执行语义
Execute(ctx context.Context, sessionID string, threadID string, method string, params map[string]any) (<-chan SessionEvent, error)
Cancel(ctx context.Context, sessionID string) error
Probe(ctx context.Context) (bool, string)
type SessionNotificationSink func(map[string]any)
type ProviderProbeResult struct {
Available bool `json:"available"`
Status string `json:"status"`
}
// SessionEvent 归一化所有的中间输出和最终结果
type SessionEvent struct {
Type string `json:"type"` // chunk, status, result, error
Payload map[string]any `json:"payload"`
Error *shared.RPCError `json:"error,omitempty"`
// ProviderCompat 是 bridge 依赖的唯一 provider 兼容接口。
// stdio / 进程 / 协议细节必须收敛在 compat/runtime 内部。
type ProviderCompat interface {
ID() string
Metadata() map[string]any
Probe(ctx context.Context) ProviderProbeResult
StartSession(ctx context.Context, sessionID string, threadID string, params map[string]any, sink SessionNotificationSink) (map[string]any, error)
SendMessage(ctx context.Context, sessionID string, threadID string, params map[string]any, sink SessionNotificationSink) (map[string]any, error)
CancelSession(ctx context.Context, sessionID string) error
CloseSession(ctx context.Context, sessionID string) error
}
// RoutingEngine 路由确权引擎

View File

@ -7,27 +7,17 @@ import (
"io"
"net/http"
"os"
"path"
"net/url"
"strings"
"sync"
"github.com/gorilla/websocket"
"xworkmate-bridge/internal/shared"
)
func (s *Server) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if providerID, ok := parseProviderACPRPCPath(r.URL.Path); ok {
s.HandleProviderRPC(w, r, providerID)
return
}
if providerID, ok := parseProviderBarePath(r.URL.Path); ok {
s.HandleProviderAlias(w, r, providerID)
return
}
if strings.TrimSpace(r.URL.Path) == "/gateway/openclaw" {
s.HandleGatewayAlias(w, r)
return
}
switch r.URL.Path {
case "/":
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
@ -50,147 +40,104 @@ func (s *Server) Handler() http.Handler {
case "/acp":
s.HandleWebSocket(w, r)
default:
if strings.HasPrefix(r.URL.Path, "/acp-server/") {
s.handleLegacyACPServer(w, r)
return
}
http.NotFound(w, r)
}
})
}
func parseProviderBarePath(pathValue string) (string, bool) {
trimmed := strings.Trim(path.Clean(strings.TrimSpace(pathValue)), "/")
parts := strings.Split(trimmed, "/")
if len(parts) != 2 {
return "", false
}
if parts[0] != "acp-server" {
return "", false
}
switch parts[1] {
case "codex", "opencode", "gemini", "hermes":
return parts[1], true
default:
return "", false
}
}
func parseProviderACPRPCPath(path string) (string, bool) {
trimmed := strings.Trim(strings.TrimSpace(path), "/")
parts := strings.Split(trimmed, "/")
if len(parts) != 4 {
return "", false
}
if parts[0] != "acp-server" || parts[2] != "acp" || parts[3] != "rpc" {
return "", false
}
switch parts[1] {
case "codex", "opencode", "gemini", "hermes":
return parts[1], true
default:
return "", false
}
}
func (s *Server) HandleProviderAlias(w http.ResponseWriter, r *http.Request, providerID string) {
if r.Method == http.MethodGet {
s.writeAliasCapabilities(w, providerID, "agent")
return
}
s.HandleProviderRPC(w, r, providerID)
}
func (s *Server) HandleGatewayAlias(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
s.writeAliasCapabilities(w, "openclaw", "gateway")
return
}
s.HandleGatewayRPCAlias(w, r)
}
func (s *Server) writeAliasCapabilities(w http.ResponseWriter, providerID, target string) {
result, rpcErr := s.handleRequest(shared.RPCRequest{
JSONRPC: "2.0",
Method: "acp.capabilities",
Params: map[string]any{
"preferredExecutionTarget": target,
"preferredProviderId": providerID,
},
}, nil)
if rpcErr != nil {
shared.WriteJSONError(w, nil, http.StatusOK, rpcErr.Code, rpcErr.Message)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(shared.ResultEnvelope(nil, result))
}
func (s *Server) HandleGatewayRPCAlias(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
r = r.Clone(r.Context())
r.URL.Path = "/acp/rpc"
s.HandleRPC(w, r)
return
}
s.HandleRPC(w, r)
}
func (s *Server) HandleProviderRPC(w http.ResponseWriter, r *http.Request, providerID string) {
if r.Method == http.MethodGet {
func (s *Server) handleLegacyACPServer(w http.ResponseWriter, r *http.Request) {
providerID := strings.TrimPrefix(strings.TrimSpace(r.URL.Path), "/acp-server/")
providerID = strings.Trim(providerID, "/")
if providerID == "" {
http.NotFound(w, r)
return
}
shared.ApplyCORS(w, r, s.allowedOrigins)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
if r.Method != http.MethodPost {
shared.WriteJSONError(w, nil, http.StatusMethodNotAllowed, -32600, "method not allowed")
return
}
payload, err := io.ReadAll(r.Body)
if err != nil {
shared.WriteJSONError(w, nil, http.StatusBadRequest, -32600, "invalid body")
return
}
r.Body = io.NopCloser(bytes.NewBuffer(payload))
if !s.authorized(r) {
var temp struct {
Method string `json:"method"`
}
_ = json.Unmarshal(payload, &temp)
method := strings.TrimSpace(temp.Method)
if method != "acp.capabilities" && method != "health" {
shared.WriteJSONError(w, nil, http.StatusUnauthorized, -32001, "missing bearer authorization")
return
}
}
request, err := shared.DecodeRPCRequest(payload)
if err != nil {
shared.WriteJSONError(w, nil, http.StatusBadRequest, -32700, err.Error())
s.mu.RLock()
compat := s.providers[providerID]
s.mu.RUnlock()
if compat == nil {
http.NotFound(w, r)
return
}
params := request.Params
if params == nil {
params = map[string]any{}
}
params["routing"] = map[string]any{
"routingMode": "explicit",
"explicitExecutionTarget": "singleAgent",
"explicitProviderId": providerID,
}
request.Params = injectInboundAuthorizationHeader(params, r.Header.Get("Authorization"))
response, rpcErr := s.handleRequest(request, nil)
if request.ID == nil {
return
}
if rpcErr != nil {
shared.WriteJSONError(w, request.ID, http.StatusOK, rpcErr.Code, rpcErr.Message)
if websocket.IsWebSocketUpgrade(r) {
s.proxyLegacyACPServerWebSocket(w, r, compat)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(shared.ResultEnvelope(request.ID, response))
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"legacy": true,
"providerId": providerID,
"label": compat.Metadata()["label"],
"transport": compat.Metadata()["transport"],
})
}
func (s *Server) proxyLegacyACPServerWebSocket(w http.ResponseWriter, r *http.Request, compat ProviderCompat) {
external, ok := compat.(*externalACPCompat)
if !ok || external == nil {
http.Error(w, "legacy websocket proxy unavailable", http.StatusNotImplemented)
return
}
upstreamURL := strings.TrimSpace(external.endpoint)
if upstreamURL == "" {
http.NotFound(w, r)
return
}
if _, err := url.Parse(upstreamURL); err != nil {
http.Error(w, "invalid upstream endpoint", http.StatusBadGateway)
return
}
headers := http.Header{}
if auth := strings.TrimSpace(r.Header.Get("Authorization")); auth != "" {
headers.Set("Authorization", auth)
} else if external.authHeader != "" {
headers.Set("Authorization", external.authHeader)
}
upstream, _, err := websocket.DefaultDialer.Dial(upstreamURL, headers)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer func() { _ = upstream.Close() }()
upgrader := shared.StandardWSUpgrader
upgrader.CheckOrigin = func(req *http.Request) bool {
return true
}
downstream, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer func() { _ = downstream.Close() }()
errCh := make(chan error, 2)
go func() { errCh <- copyWSMessages(downstream, upstream) }()
go func() { errCh <- copyWSMessages(upstream, downstream) }()
<-errCh
}
func copyWSMessages(dst, src *websocket.Conn) error {
for {
messageType, payload, err := src.ReadMessage()
if err != nil {
return err
}
if err := dst.WriteMessage(messageType, payload); err != nil {
return err
}
}
}
func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {

View File

@ -3,7 +3,6 @@ package acp
import (
"context"
"fmt"
"os"
"strings"
"time"
@ -21,7 +20,6 @@ func NewSessionOrchestrator(server *Server) *SessionOrchestrator {
}
func (o *SessionOrchestrator) Process(ctx context.Context, method string, params map[string]any, notify func(map[string]any)) (map[string]any, *shared.RPCError) {
// 1. 路由解析 (Core Control Plane Duty)
res, err := o.server.routingEngine.Resolve(ctx, params)
if err != nil {
if err.Error() == "ROUTING_REQUIRED" {
@ -34,7 +32,6 @@ func (o *SessionOrchestrator) Process(ctx context.Context, method string, params
return o.formatUnavailable(res), nil
}
// 2. 环境准备
sessionID := shared.StringArg(params, "sessionId", "")
threadID := shared.StringArg(params, "threadId", sessionID)
turnID := fmt.Sprintf("turn-%d", time.Now().UnixNano())
@ -43,6 +40,7 @@ func (o *SessionOrchestrator) Process(ctx context.Context, method string, params
sess.mu.Lock()
sess.target = res.TargetID
sess.provider = res.ProviderID
sess.mode = res.TargetID
prompt := strings.TrimSpace(shared.StringArg(params, "taskPrompt", ""))
if prompt != "" {
sess.history = append(sess.history, "USER: "+prompt)
@ -58,51 +56,40 @@ func (o *SessionOrchestrator) Process(ctx context.Context, method string, params
})
if res.TargetID == "gateway" {
return o.runGateway(ctx, method, params, turnID, notify)
result, rpcErr := o.runGateway(ctx, method, params, turnID, notify)
if rpcErr != nil {
return nil, rpcErr
}
return o.normalizeResult(sess, result, res, turnID, params), nil
}
if res.TargetID == "multi-agent" {
return o.runMultiAgent(ctx, sess, params, turnID, notify)
}
// 3. 选择适配器
adapter, ok := o.server.adapters[res.ProviderID]
compat, ok := o.server.providers[res.ProviderID]
if !ok {
return nil, &shared.RPCError{Code: -32001, Message: "PROVIDER_NOT_FOUND: " + res.ProviderID}
}
// 4. 执行适配器
if sessionID != "" {
o.server.mu.Lock()
o.server.sessionToAdapter[sessionID] = adapter
o.server.mu.Unlock()
sess.mu.Lock()
sess.compat = compat
sess.mu.Unlock()
sink := func(update map[string]any) {
o.server.emitSessionUpdate(notify, turnID, update)
}
var result map[string]any
switch method {
case "session.start":
result, err = compat.StartSession(ctx, sessionID, threadID, params, sink)
case "session.message":
result, err = compat.SendMessage(ctx, sessionID, threadID, params, sink)
default:
err = fmt.Errorf("unsupported session method: %s", method)
}
eventChan, err := adapter.Execute(ctx, sessionID, threadID, method, params)
if err != nil {
return nil, &shared.RPCError{Code: -32002, Message: "EXECUTION_FAILED: " + err.Error()}
}
// 5. 事件归一化输出
result, rpcErr := o.dispatchEvents(ctx, turnID, res, eventChan, notify)
if rpcErr != nil {
return nil, rpcErr
}
// 6. 记录成功 (Project Memory)
workingDirectory := shared.StringArg(params, "workingDirectory", "")
routingParams := shared.AsMap(params["routing"])
routingMode := strings.TrimSpace(shared.StringArg(routingParams, "routingMode", ""))
if workingDirectory != "" && (routingMode == "auto" || routingMode == "") {
_ = o.server.memoryService.RecordSuccess(workingDirectory, memory.SuccessEntry{
ResolvedExecutionTarget: res.TargetID,
ResolvedProviderID: res.ProviderID,
ResolvedModel: res.Model,
ResolvedSkills: res.Skills,
Summary: shared.StringArg(result, "output", ""),
})
}
return result, nil
return o.normalizeResult(sess, result, res, turnID, params), nil
}
func (o *SessionOrchestrator) runGateway(
@ -118,7 +105,7 @@ func (o *SessionOrchestrator) runGateway(
gatewayProvider := strings.TrimSpace(shared.StringArg(params, "gatewayProvider", ""))
if gatewayProvider == "" {
gatewayProvider = router.GatewayProviderOpenClaw
return nil, &shared.RPCError{Code: -32602, Message: "GATEWAY_PROVIDER_REQUIRED"}
}
result := o.server.gateway.RequestByMode(
gatewayProvider,
@ -148,153 +135,71 @@ func (o *SessionOrchestrator) runGateway(
return payload, nil
}
func (o *SessionOrchestrator) runMultiAgent(
ctx context.Context,
session *session,
params map[string]any,
turnID string,
notify func(map[string]any),
) (map[string]any, *shared.RPCError) {
session.mu.Lock()
history := append([]string(nil), session.history...)
session.mu.Unlock()
prompt := shared.ComposeHistoryPrompt(history)
if prompt == "" {
prompt = strings.TrimSpace(shared.StringArg(params, "taskPrompt", ""))
}
prompt = shared.AugmentPromptWithAttachments(prompt, params)
baseURL := shared.NormalizeBaseURL(
shared.StringArg(params, "aiGatewayBaseUrl", os.Getenv("AI_GATEWAY_BASE_URL")),
)
apiKey := strings.TrimSpace(shared.StringArg(params, "aiGatewayApiKey", os.Getenv("AI_GATEWAY_API_KEY")))
model := strings.TrimSpace(
shared.StringArg(
params,
"model",
shared.EnvOrDefault("ACP_MULTI_AGENT_MODEL", "gpt-4o"),
),
)
if model == "" {
model = "gpt-4o"
}
o.server.emitSessionUpdate(notify, turnID, map[string]any{
"type": "step",
"mode": "multi-agent",
"title": "Planner",
"message": "Preparing multi-agent run",
"pending": false,
"error": false,
"role": "architect",
"iteration": 1,
"score": 0,
})
if apiKey == "" {
errMsg := "aiGatewayApiKey is required for multi-agent mode"
o.server.emitSessionUpdate(notify, turnID, map[string]any{
"type": "status",
"mode": "multi-agent",
"message": errMsg,
"pending": false,
"error": true,
})
return nil, &shared.RPCError{Code: -32001, Message: errMsg}
}
messages := []map[string]string{
{
"role": "system",
"content": "You are a multi-agent coordinator. Return concise actionable output.",
},
{"role": "user", "content": prompt},
}
output, err := shared.CallOpenAICompatibleCtx(
ctx,
baseURL,
apiKey,
model,
messages,
)
if err != nil {
o.server.emitSessionUpdate(notify, turnID, map[string]any{
"type": "status",
"mode": "multi-agent",
"message": err.Error(),
"pending": false,
"error": true,
})
return nil, &shared.RPCError{Code: -32002, Message: err.Error()}
}
session.mu.Lock()
session.history = append(session.history, "ASSISTANT: "+output)
session.mu.Unlock()
o.server.emitSessionUpdate(notify, turnID, map[string]any{
"type": "step",
"mode": "multi-agent",
"title": "Reviewer",
"message": output,
"pending": false,
"error": false,
"role": "tester",
"iteration": 1,
"score": 9,
})
return map[string]any{
"success": true,
"summary": output,
"finalScore": 9,
"iterations": 1,
"turnId": turnID,
"mode": "multi-agent",
"resolvedExecutionTarget": "multi-agent",
}, nil
}
func (o *SessionOrchestrator) dispatchEvents(ctx context.Context, turnID string, routing RoutingResult, eventChan <-chan SessionEvent, notify func(map[string]any)) (map[string]any, *shared.RPCError) {
var finalResult map[string]any
for event := range eventChan {
switch event.Type {
case "chunk", "status":
payload := event.Payload
payload["turnId"] = turnID
notify(payload)
case "result":
finalResult = event.Payload
case "error":
return nil, event.Error
}
}
// 6. 结果集归一化 (Stable Contract for App)
if finalResult == nil {
finalResult = make(map[string]any)
}
finalResult["turnId"] = turnID
finalResult["resolvedExecutionTarget"] = routing.TargetID
finalResult["resolvedProviderId"] = routing.ProviderID
finalResult["status"] = "completed"
finalResult["success"] = true
return finalResult, nil
}
func (o *SessionOrchestrator) formatUnavailable(res RoutingResult) map[string]any {
return map[string]any{
"success": false,
"status": "unavailable",
"unavailable": true,
"unavailableCode": res.UnavailableCode,
"unavailableMessage": res.UnavailableMsg,
"resolvedExecutionTarget": res.TargetID,
"resolvedProviderId": res.ProviderID,
"resolvedGatewayProviderId": res.GatewayProviderID,
"resolvedModel": res.Model,
"resolvedSkills": append([]string(nil), res.Skills...),
}
}
func (o *SessionOrchestrator) normalizeResult(sess *session, result map[string]any, routing RoutingResult, turnID string, params map[string]any) map[string]any {
if result == nil {
result = map[string]any{}
}
output := strings.TrimSpace(shared.StringArg(result, "output", ""))
if output == "" {
output = strings.TrimSpace(shared.StringArg(result, "summary", ""))
}
if output == "" {
output = strings.TrimSpace(shared.StringArg(result, "message", ""))
}
sess.mu.Lock()
if output != "" {
sess.history = append(sess.history, "ASSISTANT: "+output)
}
sess.mu.Unlock()
result["turnId"] = turnID
result["status"] = "completed"
result["success"] = true
result["resolvedExecutionTarget"] = routing.TargetID
result["resolvedProviderId"] = routing.ProviderID
result["resolvedGatewayProviderId"] = routing.GatewayProviderID
result["resolvedModel"] = routing.Model
result["resolvedSkills"] = append([]string(nil), routing.Skills...)
if output != "" {
result["output"] = output
if _, ok := result["summary"]; !ok {
result["summary"] = output
}
}
workingDirectory := shared.StringArg(params, "workingDirectory", "")
routingParams := shared.AsMap(params["routing"])
routingMode := strings.TrimSpace(shared.StringArg(routingParams, "routingMode", ""))
if workingDirectory != "" && routingMode == "auto" {
_ = o.server.memoryService.RecordSuccess(workingDirectory, memory.SuccessEntry{
ResolvedExecutionTarget: routing.TargetID,
ResolvedProviderID: routing.ProviderID,
ResolvedModel: routing.Model,
ResolvedSkills: routing.Skills,
Summary: output,
})
}
return result
}
func (s *Server) getOrCreateSession(sessionID, threadID string) *session {
s.mu.Lock()
defer s.mu.Unlock()
@ -314,5 +219,5 @@ func (s *Server) emitSessionUpdate(notify func(map[string]any), turnID string, u
return
}
update["turnId"] = turnID
notify(update)
notify(shared.NotificationEnvelope("session.update", update))
}

View File

@ -0,0 +1,250 @@
package acp
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/gorilla/websocket"
"xworkmate-bridge/internal/shared"
)
type externalACPCompat struct {
providerID string
label string
endpoint string
authHeader string
category string
client *http.Client
}
type codexCompat struct{ *externalACPCompat }
type opencodeCompat struct{ *externalACPCompat }
type geminiCompat struct{ *externalACPCompat }
type hermesCompat struct{ *externalACPCompat }
func newProviderCompat(provider syncedProvider) ProviderCompat {
base := &externalACPCompat{
providerID: provider.ProviderID,
label: provider.Label,
endpoint: resolveSingleAgentForwardEndpoint(provider),
authHeader: provider.AuthorizationHeader,
category: providerCategory(provider.ProviderID),
client: &http.Client{
Timeout: 5 * time.Minute,
},
}
switch provider.ProviderID {
case "gemini":
return &geminiCompat{externalACPCompat: base}
case "opencode":
return &opencodeCompat{externalACPCompat: base}
case "hermes":
return &hermesCompat{externalACPCompat: base}
default:
return &codexCompat{externalACPCompat: base}
}
}
func providerCategory(providerID string) string {
switch providerID {
case "gemini", "hermes", "opencode":
return "protocol-adapter"
default:
return "native"
}
}
func (c *externalACPCompat) ID() string { return c.providerID }
func (c *externalACPCompat) Metadata() map[string]any {
return map[string]any{
"providerId": c.providerID,
"label": c.label,
"category": c.category,
"transport": c.transport(),
}
}
func (c *externalACPCompat) Probe(ctx context.Context) ProviderProbeResult {
_, err := c.rpcCall(ctx, "acp.capabilities", nil, nil)
if err != nil {
return ProviderProbeResult{Available: false, Status: err.Error()}
}
return ProviderProbeResult{Available: true, Status: "ok"}
}
func (c *externalACPCompat) StartSession(ctx context.Context, sessionID string, threadID string, params map[string]any, sink SessionNotificationSink) (map[string]any, error) {
return c.rpcCall(ctx, "session.start", params, sink)
}
func (c *externalACPCompat) SendMessage(ctx context.Context, sessionID string, threadID string, params map[string]any, sink SessionNotificationSink) (map[string]any, error) {
return c.rpcCall(ctx, "session.message", params, sink)
}
func (c *externalACPCompat) CancelSession(ctx context.Context, sessionID string) error {
_, err := c.rpcCall(ctx, "session.cancel", map[string]any{"sessionId": sessionID}, nil)
return err
}
func (c *externalACPCompat) CloseSession(ctx context.Context, sessionID string) error {
_, err := c.rpcCall(ctx, "session.close", map[string]any{"sessionId": sessionID}, nil)
return err
}
func (c *externalACPCompat) rpcCall(ctx context.Context, method string, params map[string]any, sink SessionNotificationSink) (map[string]any, error) {
switch c.transport() {
case "ws":
return c.callWSRPC(ctx, method, params, sink)
default:
return c.callHTTPRPC(ctx, method, params)
}
}
func (c *externalACPCompat) transport() string {
parsed, err := url.Parse(strings.TrimSpace(c.endpoint))
if err != nil {
return "http"
}
switch strings.ToLower(parsed.Scheme) {
case "ws", "wss":
return "ws"
default:
return "http"
}
}
func (c *externalACPCompat) callHTTPRPC(ctx context.Context, method string, params map[string]any) (map[string]any, error) {
requestID := fmt.Sprintf("req-%d", time.Now().UnixNano())
requestBody, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"id": requestID,
"method": method,
"params": params,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(requestBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
req.Header.Set("Accept", "application/json")
if c.authHeader != "" {
req.Header.Set("Authorization", c.authHeader)
}
response, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
return nil, fmt.Errorf("rpc request failed (%d): %s", response.StatusCode, strings.TrimSpace(string(body)))
}
var decoded map[string]any
if err := json.NewDecoder(response.Body).Decode(&decoded); err != nil {
return nil, fmt.Errorf("failed to decode rpc response: %w", err)
}
return parseExternalRPCResult(decoded)
}
func (c *externalACPCompat) callWSRPC(ctx context.Context, method string, params map[string]any, sink SessionNotificationSink) (map[string]any, error) {
headers := http.Header{}
if c.authHeader != "" {
headers.Set("Authorization", c.authHeader)
}
conn, _, err := websocket.DefaultDialer.DialContext(ctx, c.endpoint, headers)
if err != nil {
return nil, err
}
defer func() { _ = conn.Close() }()
requestID := fmt.Sprintf("req-%d", time.Now().UnixNano())
request := map[string]any{
"jsonrpc": "2.0",
"id": requestID,
"method": method,
"params": params,
}
if err := conn.WriteJSON(request); err != nil {
return nil, err
}
collector := &externalACPNotificationCollector{}
for {
_, payload, err := conn.ReadMessage()
if err != nil {
return nil, err
}
var decoded map[string]any
if err := json.Unmarshal(payload, &decoded); err != nil {
return nil, fmt.Errorf("failed to decode websocket rpc response: %w", err)
}
methodName := strings.TrimSpace(shared.StringArg(decoded, "method", ""))
if methodName != "" {
collector.observe(decoded)
if isExternalSessionUpdateMethod(methodName) && sink != nil {
update := shared.AsMap(decoded["params"])
if len(update) > 0 {
sink(update)
}
}
continue
}
if fmt.Sprintf("%v", decoded["id"]) != requestID {
continue
}
result, err := parseExternalRPCResult(decoded)
if err != nil {
return nil, err
}
return collector.apply(result), nil
}
}
func isExternalSessionUpdateMethod(method string) bool {
switch strings.TrimSpace(method) {
case "session.update", "acp.session.update", "session/update":
return true
default:
return false
}
}
func parseExternalRPCResult(decoded map[string]any) (map[string]any, error) {
if decoded == nil {
return map[string]any{}, nil
}
if errPayload := shared.AsMap(decoded["error"]); len(errPayload) > 0 {
message := strings.TrimSpace(shared.StringArg(errPayload, "message", "upstream rpc error"))
if message == "" {
message = "upstream rpc error"
}
return nil, fmt.Errorf("%s", message)
}
result := shared.AsMap(decoded["result"])
if len(result) > 0 {
return result, nil
}
if ok, _ := decoded["ok"].(bool); ok {
payload := shared.AsMap(decoded["payload"])
if len(payload) > 0 {
return payload, nil
}
}
return map[string]any{}, nil
}

View File

@ -10,14 +10,10 @@ import (
func setTestBridgeProvider(server *Server, provider syncedProvider) {
server.mu.Lock()
defer server.mu.Unlock()
if server.adapters == nil {
server.adapters = make(map[string]ProviderAdapter)
}
server.adapters[provider.ProviderID] = &ProxyAdapter{
providerID: provider.ProviderID,
endpoint: resolveSingleAgentForwardEndpoint(provider),
authHeader: provider.AuthorizationHeader,
if server.providers == nil {
server.providers = make(map[string]ProviderCompat)
}
server.providers[provider.ProviderID] = newProviderCompat(provider)
if server.catalog != nil {
server.catalog.ProviderCatalog = append(server.catalog.ProviderCatalog, map[string]any{

View File

@ -25,11 +25,16 @@ func (e *DefaultRoutingEngine) Resolve(ctx context.Context, params map[string]an
Prompt: strings.TrimSpace(shared.StringArg(params, "taskPrompt", "")),
WorkingDirectory: strings.TrimSpace(shared.StringArg(params, "workingDirectory", "")),
RoutingMode: strings.TrimSpace(shared.StringArg(routingParams, "routingMode", "implicit")),
PreferredGatewayProviderID: strings.TrimSpace(shared.StringArg(routingParams, "preferredGatewayProviderId", "")),
ExplicitExecutionTarget: strings.TrimSpace(shared.StringArg(routingParams, "explicitExecutionTarget", "")),
ExplicitProviderID: strings.TrimSpace(shared.StringArg(routingParams, "explicitProviderId", "")),
ExplicitModel: strings.TrimSpace(shared.StringArg(routingParams, "explicitModel", "")),
ExplicitSkills: parseGatewayRuntimeStringSlice(routingParams["explicitSkills"]),
AvailableProviders: e.server.getAvailableProviderIDs(),
AvailableSkills: parseSkillsCandidates(shared.ListArg(routingParams, "availableSkills")),
AllowSkillInstall: parseBool(routingParams["allowSkillInstall"]),
AIGatewayBaseURL: strings.TrimSpace(shared.StringArg(params, "aiGatewayBaseUrl", "")),
AIGatewayAPIKey: strings.TrimSpace(shared.StringArg(params, "aiGatewayApiKey", "")),
InstallApproval: skills.InstallApproval{
RequestID: strings.TrimSpace(shared.StringArg(installApproval, "requestId", "")),
ApprovedSkillKeys: parseGatewayRuntimeStringSlice(installApproval["approvedSkillKeys"]),

View File

@ -25,6 +25,9 @@ func handleRoutingResolve(params map[string]any) map[string]any {
"resolvedModel": res.Model,
"resolvedSkills": res.Skills,
"status": res.Status,
"unavailable": res.Status == "unavailable",
"unavailableCode": res.UnavailableCode,
"unavailableMessage": res.UnavailableMsg,
"skillResolutionSource": res.SkillResolutionSource,
"needsSkillInstall": res.NeedsSkillInstall,
"skillInstallRequestId": res.SkillInstallRequestID,
@ -390,15 +393,41 @@ func TestExecuteSessionTaskExplicitGatewayIgnoresExplicitProvider(t *testing.T)
"explicitExecutionTarget": "gateway",
"explicitProviderId": "claude",
"preferredGatewayProviderId": "openclaw",
"gatewayProvider": "openclaw",
},
},
},
})
if rpcErr == nil {
t.Fatalf("expected gateway-not-connected rpc error, got response: %v", response)
t.Fatalf("expected gateway provider required rpc error, got response: %v", response)
}
if !strings.Contains(rpcErr.Message, "gateway not connected") {
t.Fatalf("expected gateway not connected, got %q", rpcErr.Message)
if rpcErr.Message != "GATEWAY_PROVIDER_REQUIRED" {
t.Fatalf("expected GATEWAY_PROVIDER_REQUIRED, got %q", rpcErr.Message)
}
}
func TestExecuteSessionTaskRequiresExplicitGatewayProvider(t *testing.T) {
server := NewServer()
_, rpcErr := server.executeSessionTask(task{
req: shared.RPCRequest{
Method: "session.start",
Params: map[string]any{
"sessionId": "session-gateway-missing-provider",
"threadId": "thread-gateway-missing-provider",
"taskPrompt": "search latest news",
"routing": map[string]any{
"routingMode": "explicit",
"explicitExecutionTarget": "gateway",
},
},
},
})
if rpcErr == nil {
t.Fatal("expected gateway provider required error")
}
if rpcErr.Message != "GATEWAY_PROVIDER_REQUIRED" {
t.Fatalf("expected GATEWAY_PROVIDER_REQUIRED, got %#v", rpcErr)
}
}
@ -477,48 +506,31 @@ func TestExecuteSessionTaskRequiresRouting(t *testing.T) {
}
}
func TestExecuteSessionTaskAutoRoutingPromotesComplexRequestToMultiAgent(t *testing.T) {
func TestExecuteSessionTaskComplexRequestNoLongerPromotesToMultiAgent(t *testing.T) {
workspaceDir := filepath.Join(t.TempDir(), "workspace")
if err := os.MkdirAll(workspaceDir, 0o755); err != nil {
t.Fatalf("create workspace: %v", err)
}
aiGateway := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"planner output"}}]}`))
}),
)
defer aiGateway.Close()
server := NewServer()
response, rpcErr := server.executeSessionTask(task{
req: shared.RPCRequest{
Params: map[string]any{
"sessionId": "session-complex",
"threadId": "thread-complex",
"provider": "claude",
"taskPrompt": "collect latest news and summarize it into a report for review",
"workingDirectory": workspaceDir,
"aiGatewayBaseUrl": aiGateway.URL,
"aiGatewayApiKey": "test-key",
"routing": map[string]any{
"routingMode": "auto",
},
},
},
})
if rpcErr != nil {
t.Fatalf("expected success, got rpc error: %v", rpcErr)
if rpcErr == nil {
t.Fatalf("expected gateway-not-connected error, got response %#v", response)
}
if success, _ := response["success"].(bool); !success {
t.Fatalf("expected success response, got %#v", response)
}
if got := response["mode"]; got != "multi-agent" {
t.Fatalf("expected session mode to be promoted to multi-agent, got %#v", got)
}
if got := response["resolvedExecutionTarget"]; got != "multi-agent" {
t.Fatalf("expected resolved execution target multi-agent, got %#v", got)
if strings.Contains(rpcErr.Message, "multi-agent") {
t.Fatalf("expected no multi-agent path, got rpc error: %v", rpcErr)
}
}

View File

@ -43,6 +43,7 @@ func (s *Server) handleRequest(request shared.RPCRequest, notify func(map[string
"resolvedModel": res.Model,
"resolvedSkills": res.Skills,
"status": res.Status,
"unavailable": res.Status == "unavailable",
"unavailableCode": res.UnavailableCode,
"unavailableMessage": res.UnavailableMsg,
"skillResolutionSource": res.SkillResolutionSource,
@ -64,15 +65,19 @@ func (s *Server) handleRequest(request shared.RPCRequest, notify func(map[string
func (s *Server) cancelSession(ctx context.Context, sessionID string) {
s.mu.RLock()
adapter, ok := s.sessionToAdapter[sessionID]
sess, ok := s.sessions[sessionID]
s.mu.RUnlock()
if ok {
_ = adapter.Cancel(ctx, sessionID)
if ok && sess != nil && sess.compat != nil {
_ = sess.compat.CancelSession(ctx, sessionID)
}
}
func (s *Server) closeSession(ctx context.Context, sessionID string) {
s.mu.Lock()
delete(s.sessionToAdapter, sessionID)
sess, ok := s.sessions[sessionID]
delete(s.sessions, sessionID)
s.mu.Unlock()
if ok && sess != nil && sess.compat != nil {
_ = sess.compat.CloseSession(ctx, sessionID)
}
}

View File

@ -14,7 +14,7 @@ type session struct {
mode string
provider string // The Provider ID
target string // The Execution Target ID
adapter ProviderAdapter
compat ProviderCompat
cancel context.CancelFunc
closed bool
mu sync.Mutex
@ -28,14 +28,13 @@ type Server struct {
// Core Control Plane Components
routingEngine RoutingEngine
adapters map[string]ProviderAdapter
providers map[string]ProviderCompat
catalog *CapabilityCatalog
orchestrator *SessionOrchestrator
memoryService memory.Service
providerOrder []string
sessionToAdapter map[string]ProviderAdapter
gateway *gatewayruntime.Manager
providerOrder []string
gateway *gatewayruntime.Manager
// Legacy / Common
authService interface{} // Minimal auth dependency

View File

@ -60,138 +60,28 @@ func TestHTTPHandlerRootAndPingExposeRuntimeVersionInfo(t *testing.T) {
}
}
func TestHTTPHandlerBareAliasPathsExposeCapabilities(t *testing.T) {
func TestHTTPHandlerKeepsLegacyACPCodexPathAlive(t *testing.T) {
t.Setenv("BRIDGE_AUTH_TOKEN", "")
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
server := NewServer()
handler := server.Handler()
cases := []struct {
name string
path string
wantMode string
wantID string
}{
{name: "gateway", path: "/gateway/openclaw", wantMode: "gateway", wantID: "openclaw"},
{name: "codex", path: "/acp-server/codex", wantMode: "agent", wantID: "codex"},
{name: "opencode", path: "/acp-server/opencode", wantMode: "agent", wantID: "opencode"},
{name: "gemini", path: "/acp-server/gemini", wantMode: "agent", wantID: "gemini"},
{name: "hermes", path: "/acp-server/hermes", wantMode: "agent", wantID: "hermes"},
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/acp-server/codex", nil)
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", recorder.Code)
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "http://127.0.0.1"+tc.path, nil)
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", recorder.Code)
}
if got := recorder.Header().Get("Content-Type"); !strings.Contains(got, "application/json") {
t.Fatalf("expected application/json content type, got %q", got)
}
var envelope map[string]any
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode capability alias response: %v", err)
}
result := shared.AsMap(envelope["result"])
if got := result["singleAgent"]; got != true {
t.Fatalf("expected singleAgent true, got %#v", got)
}
if got := result["resolvedExecutionTarget"]; got != nil {
t.Fatalf("did not expect resolvedExecutionTarget in alias response, got %#v", got)
}
if tc.wantMode == "gateway" {
gatewayProviders := mustObjectList(t, result["gatewayProviders"])
if len(gatewayProviders) != 1 {
t.Fatalf("expected one gateway provider, got %#v", gatewayProviders)
}
if got := gatewayProviders[0]["providerId"]; got != tc.wantID {
t.Fatalf("expected gateway provider %q, got %#v", tc.wantID, got)
}
return
}
providerCatalog := mustObjectList(t, result["providerCatalog"])
found := false
for _, provider := range providerCatalog {
if provider["providerId"] == tc.wantID {
found = true
break
}
}
if !found {
t.Fatalf("expected provider %q in providerCatalog, got %#v", tc.wantID, providerCatalog)
}
})
var payload map[string]any
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode legacy payload: %v", err)
}
}
func TestHTTPHandlerBareAliasPathsServeRPC(t *testing.T) {
t.Setenv("BRIDGE_AUTH_TOKEN", "")
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
server := NewServer()
handler := server.Handler()
cases := []struct {
name string
path string
wantMode string
wantID string
}{
{name: "gateway", path: "/gateway/openclaw", wantMode: "gateway", wantID: "openclaw"},
{name: "codex", path: "/acp-server/codex", wantMode: "agent", wantID: "codex"},
{name: "opencode", path: "/acp-server/opencode", wantMode: "agent", wantID: "opencode"},
{name: "gemini", path: "/acp-server/gemini", wantMode: "agent", wantID: "gemini"},
{name: "hermes", path: "/acp-server/hermes", wantMode: "agent", wantID: "hermes"},
if got := payload["providerId"]; got != "codex" {
t.Fatalf("expected providerId codex, got %#v", got)
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "http://127.0.0.1"+tc.path, strings.NewReader(`{"jsonrpc":"2.0","id":"rpc-1","method":"acp.capabilities"}`))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer test-token")
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", recorder.Code)
}
if got := recorder.Header().Get("Content-Type"); !strings.Contains(got, "application/json") {
t.Fatalf("expected application/json content type, got %q", got)
}
var envelope map[string]any
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode rpc alias response: %v", err)
}
if got := envelope["id"]; got != "rpc-1" {
t.Fatalf("expected rpc id rpc-1, got %#v", got)
}
result := shared.AsMap(envelope["result"])
if tc.wantMode == "gateway" {
gatewayProviders := mustObjectList(t, result["gatewayProviders"])
if len(gatewayProviders) != 1 {
t.Fatalf("expected one gateway provider, got %#v", gatewayProviders)
}
if got := gatewayProviders[0]["providerId"]; got != tc.wantID {
t.Fatalf("expected gateway provider %q, got %#v", tc.wantID, got)
}
return
}
providerCatalog := mustObjectList(t, result["providerCatalog"])
found := false
for _, provider := range providerCatalog {
if provider["providerId"] == tc.wantID {
found = true
break
}
}
if !found {
t.Fatalf("expected provider %q in providerCatalog, got %#v", tc.wantID, providerCatalog)
}
})
if got := payload["legacy"]; got != true {
t.Fatalf("expected legacy flag true, got %#v", got)
}
}

View File

@ -213,9 +213,9 @@ func reconcileCodex(
}
state.DiscoveredMCPCount = discovered
state.ManagedMCPCount = len(managedServers)
state.Detail = "Codex public base URL: https://xworkmate-bridge.svc.plus/acp-server/codex\n" +
"Preferred WebSocket endpoint: https://xworkmate-bridge.svc.plus/acp-server/codex/acp\n" +
"Compatibility HTTP RPC endpoint: https://xworkmate-bridge.svc.plus/acp-server/codex/acp/rpc"
state.Detail = "Codex is exposed through the bridge control plane.\n" +
"Canonical WebSocket endpoint: https://xworkmate-bridge.svc.plus/acp\n" +
"Secondary HTTP RPC endpoint: https://xworkmate-bridge.svc.plus/acp/rpc"
return state
}
@ -246,9 +246,9 @@ func reconcileCLIListTarget(
state.ManagedMCPCount = len(enabledServers(config.ManagedMCPServers))
if targetID == "gemini" {
state.Detail = "Gemini public base URL: https://xworkmate-bridge.svc.plus/acp-server/gemini\n" +
"Preferred WebSocket endpoint: https://xworkmate-bridge.svc.plus/acp-server/gemini/acp\n" +
"Compatibility HTTP RPC endpoint: https://xworkmate-bridge.svc.plus/acp-server/gemini/acp/rpc"
state.Detail = "Gemini is exposed through the bridge control plane.\n" +
"Canonical WebSocket endpoint: https://xworkmate-bridge.svc.plus/acp\n" +
"Secondary HTTP RPC endpoint: https://xworkmate-bridge.svc.plus/acp/rpc"
} else {
state.Detail = "MCP discovery uses `" + strings.Join(command, " ") +
"`; LLM API stays launch-scoped."
@ -291,8 +291,9 @@ func reconcileOpencode(config Config, opencodeHome string) MountTargetState {
}
state.DiscoveredMCPCount = discovered
state.ManagedMCPCount = len(managedServers)
state.Detail = "OpenCode public base URL: https://xworkmate-bridge.svc.plus/acp-server/opencode\n" +
"Preferred WebSocket endpoint: https://xworkmate-bridge.svc.plus/acp-server/opencode/acp"
state.Detail = "OpenCode is exposed through the bridge control plane.\n" +
"Canonical WebSocket endpoint: https://xworkmate-bridge.svc.plus/acp\n" +
"Secondary HTTP RPC endpoint: https://xworkmate-bridge.svc.plus/acp/rpc"
return state
}

View File

@ -49,7 +49,7 @@ func (LLMClassifier) Classify(req ClassificationRequest) string {
[]map[string]string{
{
"role": "system",
"content": "Classify the user task into exactly one label: single-agent, multi-agent, or gateway. Return only the label.",
"content": "Classify the user task into exactly one label: single-agent or gateway. Return only the label.",
},
{
"role": "user",
@ -68,8 +68,6 @@ func normalizeClassifierLabel(value string) string {
switch {
case strings.Contains(normalized, ExecutionTargetSingleAgent):
return ExecutionTargetSingleAgent
case strings.Contains(normalized, ExecutionTargetMultiAgent):
return ExecutionTargetMultiAgent
case strings.Contains(normalized, ExecutionTargetGateway):
return ExecutionTargetGateway
default:

View File

@ -13,7 +13,6 @@ const (
RoutingModeExplicit = "explicit"
ExecutionTargetSingleAgent = "single-agent"
ExecutionTargetMultiAgent = "multi-agent"
ExecutionTargetGateway = "gateway"
ExecutionTargetGatewayChat = "gateway-chat"
@ -142,21 +141,14 @@ func (r Resolver) resolveExecution(req Request, prefs memory.Preferences) (strin
localTask := looksLocal(prompt)
onlineTask := looksOnline(prompt)
complexTask := looksComplex(prompt)
switch {
case localTask && complexTask:
return ExecutionTargetMultiAgent, ""
case onlineTask && complexTask:
return ExecutionTargetMultiAgent, ""
case localTask:
return ExecutionTargetSingleAgent, ""
case onlineTask:
return ExecutionTargetGateway, resolveGatewayProvider(
req.PreferredGatewayProviderID,
)
case complexTask:
return ExecutionTargetMultiAgent, ""
}
switch normalizeExecutionTarget(r.classify(req)) {
@ -164,8 +156,6 @@ func (r Resolver) resolveExecution(req Request, prefs memory.Preferences) (strin
return ExecutionTargetGateway, resolveGatewayProvider(
req.PreferredGatewayProviderID,
)
case ExecutionTargetMultiAgent:
return ExecutionTargetMultiAgent, ""
case ExecutionTargetSingleAgent:
return ExecutionTargetSingleAgent, ""
}
@ -175,8 +165,6 @@ func (r Resolver) resolveExecution(req Request, prefs memory.Preferences) (strin
return ExecutionTargetGateway, resolveGatewayProvider(
req.PreferredGatewayProviderID,
)
case ExecutionTargetMultiAgent:
return ExecutionTargetMultiAgent, ""
case ExecutionTargetSingleAgent:
if len(normalizeProviders(req.AvailableProviders)) > 0 {
return ExecutionTargetSingleAgent, ""
@ -206,8 +194,6 @@ func mapExplicitTarget(
preferredGatewayProviderID string,
) (string, string) {
switch strings.TrimSpace(value) {
case "multiAgent", ExecutionTargetMultiAgent:
return ExecutionTargetMultiAgent, ""
case "singleAgent", ExecutionTargetSingleAgent:
return ExecutionTargetSingleAgent, ""
case ExecutionTargetGateway:
@ -319,49 +305,6 @@ func looksOnline(prompt string) bool {
})
}
func looksComplex(prompt string) bool {
strongSignals := containsAny(prompt, []string{
"multiple deliverables", "multiple outputs", "多个产物", "多个输出",
"审阅", "复核", "汇编", "end-to-end", "end to end",
})
if strongSignals {
return true
}
reviewSignals := containsAny(prompt, []string{
"review", "audit", "verify", "summarize", "compare",
"审阅", "复核", "汇总", "对比", "整理", "整合", "汇编",
})
multiStepSignals := containsAny(prompt, []string{
"workflow", "pipeline", "step by step", "multi-step", "collect and",
"analyze and", "review and", "compare and", "summarize and",
"先", "然后", "之后",
})
structuredOutputSignals := containsAny(prompt, []string{
"report", "memo", "table", "spreadsheet", "document", "deck", "slides",
"presentation", "报告", "总结", "表格", "文档", "演示",
})
onlineCollectionSignals := containsAny(prompt, []string{
"browser", "search", "news", "research", "crawl", "scrape",
"跨浏览器", "搜索", "资讯", "采集", "检索",
})
score := 0
if reviewSignals {
score++
}
if multiStepSignals {
score++
}
if structuredOutputSignals {
score++
}
if onlineCollectionSignals && structuredOutputSignals {
return true
}
return score >= 2
}
func containsAny(haystack string, needles []string) bool {
for _, needle := range needles {
if strings.Contains(haystack, normalize(needle)) {

View File

@ -124,7 +124,7 @@ func TestResolveAutoOnlineTaskToGateway(t *testing.T) {
}
}
func TestResolveComplexTaskUpgradesToMultiAgent(t *testing.T) {
func TestResolveComplexTaskNoLongerPromotesToMultiAgent(t *testing.T) {
resolver := Resolver{
SkillFinder: skills.StaticFinder{},
SkillInstaller: nil,
@ -132,11 +132,12 @@ func TestResolveComplexTaskUpgradesToMultiAgent(t *testing.T) {
}
result := resolver.Resolve(Request{
Prompt: "analyze these files, review the output, and summarize multiple deliverables",
Prompt: "analyze these files, review the output, and summarize multiple deliverables",
AvailableProviders: []string{"codex"},
})
if result.ResolvedExecutionTarget != ExecutionTargetMultiAgent {
t.Fatalf("expected multi-agent route, got %#v", result)
if result.ResolvedExecutionTarget != ExecutionTargetSingleAgent {
t.Fatalf("expected single-agent route after control-plane refocus, got %#v", result)
}
}

View File

@ -1,5 +1,4 @@
#!/usr/bin/env bash
# scripts/ci/verify_api_interface_contract.sh
set -euo pipefail
BRIDGE_SERVER_URL="${BRIDGE_SERVER_URL:-https://xworkmate-bridge.svc.plus}"
@ -10,69 +9,99 @@ if [[ -z "${BRIDGE_AUTH_TOKEN}" ]]; then
exit 1
fi
echo "--- Verifying API Interface Contract for $BRIDGE_SERVER_URL ---"
check_endpoint() {
local name=$1
local path=$2
local expected_status=$3
local content_type=$4
echo -n "Checking $name ($path)... "
local response_info
response_info=$(curl -s -o /tmp/resp.body -w "%{http_code} %{content_type}" \
-H "Authorization: Bearer $BRIDGE_AUTH_TOKEN" \
"$BRIDGE_SERVER_URL$path")
local status=$(echo "$response_info" | cut -d' ' -f1)
local actual_ct=$(echo "$response_info" | cut -d' ' -f2-)
if [[ "$status" == "$expected_status" ]]; then
if [[ "$actual_ct" == *"$content_type"* ]]; then
# 验证是否为有效的 JSON 且包含 ok: true
if jq -e '.ok == true' /tmp/resp.body >/dev/null 2>&1; then
echo "✅ OK ($status, application/json)"
else
echo "❌ Failed (Invalid Bridge Response Structure)"
cat /tmp/resp.body
return 1
fi
else
echo "❌ Failed: Wrong Content-Type (Expected $content_type, got $actual_ct)"
return 1
fi
else
echo "❌ Failed (Expected $expected_status, got $status)"
return 1
fi
rpc_call() {
local payload="$1"
curl \
--silent \
--show-error \
--fail \
--location \
--max-time 60 \
-H "Authorization: Bearer ${BRIDGE_AUTH_TOKEN}" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
--data "${payload}" \
"${BRIDGE_SERVER_URL%/}/acp/rpc"
}
# 现在的架构下,所有路径都应该由 Bridge 统一处理并返回 200 JSON
check_endpoint "OpenClaw" "/gateway/openclaw" "200" "application/json"
check_endpoint "OpenCode" "/acp-server/opencode" "200" "application/json"
check_endpoint "Codex" "/acp-server/codex" "200" "application/json"
check_endpoint "Gemini" "/acp-server/gemini" "200" "application/json"
check_endpoint "Hermes" "/acp-server/hermes" "200" "application/json"
echo "--- Verifying canonical bridge contract for ${BRIDGE_SERVER_URL} ---"
# 6. Aggregate RPC Endpoint
echo -n "Checking Aggregate RPC (/acp/rpc)... "
rpc_status=$(curl -s -o /tmp/rpc.resp -w "%{http_code}" \
-X POST -H "Authorization: Bearer $BRIDGE_AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"acp.capabilities","params":{},"id":1}' \
"$BRIDGE_SERVER_URL/acp/rpc")
echo -n "Checking /api/ping... "
ping_json="$(
curl \
--silent \
--show-error \
--fail \
--location \
--max-time 20 \
"${BRIDGE_SERVER_URL%/}/api/ping"
)"
PING_JSON="${ping_json}" python3 - <<'PY'
import json
import os
if [[ "$rpc_status" == "200" ]]; then
if jq -e '.ok == true' /tmp/rpc.resp >/dev/null 2>&1; then
echo "✅ OK (200 + Valid JSON-RPC Result)"
else
echo "❌ Failed (Invalid JSON-RPC Response)"
cat /tmp/rpc.resp
exit 1
fi
else
echo "❌ Failed ($rpc_status)"
exit 1
fi
payload = json.loads(os.environ["PING_JSON"])
if payload.get("status") != "ok":
raise SystemExit("ping status is not ok")
PY
echo "OK"
echo "Interface contract verification completed."
echo -n "Checking acp.capabilities... "
capabilities_json="$(
rpc_call '{"jsonrpc":"2.0","id":"cap-1","method":"acp.capabilities","params":{}}'
)"
CAPABILITIES_JSON="${capabilities_json}" python3 - <<'PY'
import json
import os
payload = json.loads(os.environ["CAPABILITIES_JSON"])
result = payload.get("result")
if payload.get("jsonrpc") != "2.0" or not isinstance(result, dict):
raise SystemExit("invalid capabilities envelope")
provider_catalog = result.get("providerCatalog")
gateway_providers = result.get("gatewayProviders")
targets = result.get("availableExecutionTargets")
if not isinstance(provider_catalog, list):
raise SystemExit("providerCatalog missing")
if not isinstance(gateway_providers, list):
raise SystemExit("gatewayProviders missing")
if not isinstance(targets, list):
raise SystemExit("availableExecutionTargets missing")
providers = {item.get("providerId") for item in provider_catalog if isinstance(item, dict)}
if not {"codex", "opencode", "gemini", "hermes"}.issubset(providers):
raise SystemExit(f"unexpected providerCatalog: {provider_catalog!r}")
gateway_ids = {item.get("providerId") for item in gateway_providers if isinstance(item, dict)}
if "openclaw" not in gateway_ids:
raise SystemExit(f"unexpected gatewayProviders: {gateway_providers!r}")
if "agent" not in targets or "gateway" not in targets:
raise SystemExit(f"unexpected availableExecutionTargets: {targets!r}")
PY
echo "OK"
echo -n "Checking xworkmate.routing.resolve... "
routing_json="$(
rpc_call '{"jsonrpc":"2.0","id":"routing-1","method":"xworkmate.routing.resolve","params":{"taskPrompt":"create a powerpoint deck for launch","workingDirectory":"/tmp/bridge-contract","routing":{"routingMode":"explicit","explicitExecutionTarget":"singleAgent","explicitProviderId":"codex","availableSkills":[{"id":"pptx","label":"PPTX","description":"slides","installed":true}]}}}'
)"
ROUTING_JSON="${routing_json}" python3 - <<'PY'
import json
import os
payload = json.loads(os.environ["ROUTING_JSON"])
result = payload.get("result")
if payload.get("jsonrpc") != "2.0" or not isinstance(result, dict):
raise SystemExit("invalid routing envelope")
if result.get("resolvedExecutionTarget") != "single-agent":
raise SystemExit(f"unexpected resolvedExecutionTarget: {result!r}")
if result.get("resolvedProviderId") != "codex":
raise SystemExit(f"unexpected resolvedProviderId: {result!r}")
if result.get("status") != "available":
raise SystemExit(f"unexpected routing status: {result!r}")
PY
echo "OK"
echo "Canonical bridge contract verification completed."

View File

@ -3,7 +3,7 @@ set -euo pipefail
BRIDGE_SERVER_URL="${BRIDGE_SERVER_URL:-https://xworkmate-bridge.svc.plus}"
BRIDGE_AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:-}"
HERMES_RPC_URL="${HERMES_RPC_URL:-${BRIDGE_SERVER_URL%/}/acp-server/hermes/acp/rpc}"
HERMES_RPC_URL="${HERMES_RPC_URL:-${BRIDGE_SERVER_URL%/}/acp/rpc}"
if [[ -z "${BRIDGE_AUTH_TOKEN}" ]]; then
echo "Error: BRIDGE_AUTH_TOKEN is required" >&2
@ -88,7 +88,7 @@ hermes_caps="$(
"${HERMES_RPC_URL}" \
'{"jsonrpc":"2.0","id":"hermes-capabilities","method":"acp.capabilities","params":{}}'
)"
assert_contains "hermes capabilities" "$(json_get "${hermes_caps}" "result.providers")" "hermes"
assert_contains "hermes capabilities" "$(json_get "${hermes_caps}" "result.providerCatalog")" "hermes"
hermes_session_id="hermes-scenario-$(date +%s)"
hermes_thread_id="${hermes_session_id}"
@ -96,7 +96,7 @@ hermes_thread_id="${hermes_session_id}"
hermes_start="$(
rpc_post \
"${HERMES_RPC_URL}" \
"{\"jsonrpc\":\"2.0\",\"id\":\"hermes-start\",\"method\":\"session.start\",\"params\":{\"sessionId\":\"${hermes_session_id}\",\"threadId\":\"${hermes_thread_id}\",\"taskPrompt\":\"Reply with exactly pong\",\"workingDirectory\":\"/tmp/hermes-long-dialogue\"}}"
"{\"jsonrpc\":\"2.0\",\"id\":\"hermes-start\",\"method\":\"session.start\",\"params\":{\"sessionId\":\"${hermes_session_id}\",\"threadId\":\"${hermes_thread_id}\",\"taskPrompt\":\"Reply with exactly pong\",\"workingDirectory\":\"/tmp/hermes-long-dialogue\",\"routing\":{\"routingMode\":\"explicit\",\"explicitExecutionTarget\":\"singleAgent\",\"explicitProviderId\":\"hermes\"}}}"
)"
assert_nonempty "hermes start output" "$(json_get "${hermes_start}" "result.output")"
assert_contains "hermes start output" "$(json_get "${hermes_start}" "result.output")" "pong"
@ -107,7 +107,7 @@ assert_nonempty "hermes upstream session id" "${hermes_upstream_session_id}"
hermes_message_1="$(
rpc_post \
"${HERMES_RPC_URL}" \
"{\"jsonrpc\":\"2.0\",\"id\":\"hermes-message-1\",\"method\":\"session.message\",\"params\":{\"sessionId\":\"${hermes_session_id}\",\"threadId\":\"${hermes_thread_id}\",\"taskPrompt\":\"Reply with exactly pong again\",\"workingDirectory\":\"/tmp/hermes-long-dialogue\"}}"
"{\"jsonrpc\":\"2.0\",\"id\":\"hermes-message-1\",\"method\":\"session.message\",\"params\":{\"sessionId\":\"${hermes_session_id}\",\"threadId\":\"${hermes_thread_id}\",\"taskPrompt\":\"Reply with exactly pong again\",\"workingDirectory\":\"/tmp/hermes-long-dialogue\",\"routing\":{\"routingMode\":\"explicit\",\"explicitExecutionTarget\":\"singleAgent\",\"explicitProviderId\":\"hermes\"}}}"
)"
assert_nonempty "hermes message1 output" "$(json_get "${hermes_message_1}" "result.output")"
assert_contains "hermes message1 upstream method" "$(json_get "${hermes_message_1}" "result.upstreamMethod")" "session/prompt"
@ -116,7 +116,7 @@ assert_nonempty "hermes message1 upstream session id" "$(json_get "${hermes_mess
hermes_message_2="$(
rpc_post \
"${HERMES_RPC_URL}" \
"{\"jsonrpc\":\"2.0\",\"id\":\"hermes-message-2\",\"method\":\"session.message\",\"params\":{\"sessionId\":\"${hermes_session_id}\",\"threadId\":\"${hermes_thread_id}\",\"taskPrompt\":\"Reply with exactly pong one more time\",\"workingDirectory\":\"/tmp/hermes-long-dialogue\"}}"
"{\"jsonrpc\":\"2.0\",\"id\":\"hermes-message-2\",\"method\":\"session.message\",\"params\":{\"sessionId\":\"${hermes_session_id}\",\"threadId\":\"${hermes_thread_id}\",\"taskPrompt\":\"Reply with exactly pong one more time\",\"workingDirectory\":\"/tmp/hermes-long-dialogue\",\"routing\":{\"routingMode\":\"explicit\",\"explicitExecutionTarget\":\"singleAgent\",\"explicitProviderId\":\"hermes\"}}}"
)"
assert_nonempty "hermes message2 output" "$(json_get "${hermes_message_2}" "result.output")"
assert_contains "hermes message2 upstream method" "$(json_get "${hermes_message_2}" "result.upstreamMethod")" "session/prompt"

41
scripts/github-actions/test-validate-deploy.sh Normal file → Executable file
View File

@ -109,15 +109,6 @@ case "${scenario}" in
https://xworkmate-bridge.svc.plus/)
printf 'xworkmate-bridge is running\n'
;;
https://xworkmate-bridge.svc.plus/acp-server/*/acp/rpc)
printf '{"jsonrpc":"2.0","result":{"providers":["ok"]}}\n'
;;
https://xworkmate-bridge.svc.plus/acp-server/*)
printf '{"jsonrpc":"2.0","result":{"providers":["ok"]}}\n'
;;
https://xworkmate-bridge.svc.plus/gateway/openclaw)
printf '{"jsonrpc":"2.0","result":{"providers":["ok"]}}\n'
;;
https://xworkmate-bridge.svc.plus/acp/rpc)
printf 'curl: (28) Operation timed out after 20001 milliseconds with 0 bytes received\n' >&2
exit 1
@ -141,33 +132,20 @@ case "${scenario}" in
https://xworkmate-bridge.svc.plus/)
printf 'xworkmate-bridge is running\n'
;;
https://xworkmate-bridge.svc.plus/acp-server/*/acp/rpc)
printf '{"jsonrpc":"2.0","result":{"providers":["ok"]}}\n'
;;
https://xworkmate-bridge.svc.plus/acp-server/*)
printf '{"jsonrpc":"2.0","result":{"providers":["ok"]}}\n'
;;
https://xworkmate-bridge.svc.plus/gateway/openclaw)
printf '{"jsonrpc":"2.0","result":{"providers":["ok"]}}\n'
;;
https://xworkmate-bridge.svc.plus/acp/rpc)
if [[ "${data}" == *'"providerId":"codex"'* ]]; then
printf '{"jsonrpc":"2.0","result":{"success":true,"providerId":"codex","capabilities":{"providers":["codex"]}}}\n'
if [[ "${data}" == *'"method":"acp.capabilities"'* ]]; then
printf '{"jsonrpc":"2.0","result":{"providerCatalog":[{"providerId":"codex"},{"providerId":"opencode"},{"providerId":"gemini"},{"providerId":"hermes"}],"gatewayProviders":[{"providerId":"openclaw"}],"availableExecutionTargets":["agent","gateway"]}}\n'
exit 0
fi
if [[ "${data}" == *'"providerId":"opencode"'* ]]; then
printf '{"jsonrpc":"2.0","result":{"success":true,"providerId":"opencode","capabilities":{"providers":["opencode"]}}}\n'
if [[ "${data}" == *'"method":"xworkmate.routing.resolve"'* && "${data}" == *'"explicitProviderId":"codex"'* ]]; then
printf '{"jsonrpc":"2.0","result":{"resolvedExecutionTarget":"single-agent","resolvedProviderId":"codex","status":"available"}}\n'
exit 0
fi
if [[ "${data}" == *'"providerId":"gemini"'* ]]; then
printf '{"jsonrpc":"2.0","result":{"success":true,"providerId":"gemini","capabilities":{"providers":["gemini"]}}}\n'
if [[ "${data}" == *'"method":"xworkmate.routing.resolve"'* && "${data}" == *'"explicitExecutionTarget":"gateway"'* ]]; then
printf '{"jsonrpc":"2.0","result":{"resolvedExecutionTarget":"gateway","resolvedGatewayProviderId":"openclaw","status":"available"}}\n'
exit 0
fi
if [[ "${data}" == *'"providerId":"hermes"'* ]]; then
printf '{"jsonrpc":"2.0","result":{"success":true,"providerId":"hermes","capabilities":{"providers":["hermes"]}}}\n'
exit 0
fi
printf 'unexpected bridge probe payload in retry-success scenario: %s\n' "${data}" >&2
printf 'unexpected bridge payload in retry-success scenario: %s\n' "${data}" >&2
exit 1
;;
*)
@ -206,11 +184,6 @@ run_validate_capture() {
FAKE_CURL_SCENARIO="${scenario}" \
FAKE_CURL_STATE_DIR="${RUN_STATE_DIR}" \
BRIDGE_SERVER_URL="https://xworkmate-bridge.svc.plus" \
OPENCLAW_URL="https://xworkmate-bridge.svc.plus/gateway/openclaw" \
CODEX_RPC_URL="https://xworkmate-bridge.svc.plus/acp-server/codex" \
OPENCODE_RPC_URL="https://xworkmate-bridge.svc.plus/acp-server/opencode" \
GEMINI_RPC_URL="https://xworkmate-bridge.svc.plus/acp-server/gemini" \
HERMES_RPC_URL="https://xworkmate-bridge.svc.plus/acp-server/hermes" \
BRIDGE_AUTH_TOKEN="test-token" \
bash "${SCRIPT_PATH}" "${IMAGE_REF}" 2>&1
)"

332
scripts/github-actions/validate-deploy.sh Normal file → Executable file
View File

@ -20,57 +20,19 @@ normalize_url() {
printf '%s\n' "${value}"
}
websocket_probe_url() {
local value="$1"
if [[ "${value}" =~ ^wss://(.*)$ ]]; then
printf 'https://%s\n' "${BASH_REMATCH[1]}"
return
fi
if [[ "${value}" =~ ^ws://(.*)$ ]]; then
printf 'http://%s\n' "${BASH_REMATCH[1]}"
return
fi
printf '%s\n' "${value}"
}
image_ref="$(printf '%s' "${IMAGE_REF}" | tr -d '\n' | xargs)"
if [[ -z "${image_ref}" ]]; then
echo "image_ref is required" >&2
exit 1
fi
image_no_digest="${image_ref%@*}"
tag="${image_no_digest##*:}"
if [[ "${image_no_digest}" == "${tag}" ]]; then
tag=""
fi
commit=""
version="${tag}"
if [[ "${tag}" =~ ^[0-9a-f]{40}$ ]]; then
commit="${tag}"
fi
BASE_URL="$(normalize_url "${BRIDGE_SERVER_URL:-${2:-https://xworkmate-bridge.svc.plus}}")"
OPENCLAW_BASE_URL="$(normalize_url "${OPENCLAW_URL:-${3:-${BASE_URL}/gateway/openclaw}}")"
CODEX_BASE_URL="$(normalize_url "${CODEX_RPC_URL:-${4:-${BASE_URL}/acp-server/codex}}")"
OPENCODE_BASE_URL="$(normalize_url "${OPENCODE_RPC_URL:-${5:-${BASE_URL}/acp-server/opencode}}")"
GEMINI_BASE_URL="$(normalize_url "${GEMINI_RPC_URL:-${6:-${BASE_URL}/acp-server/gemini}}")"
HERMES_BASE_URL="$(normalize_url "${HERMES_RPC_URL:-${7:-${BASE_URL}/acp-server/hermes}}")"
RPC_URL="${BASE_URL%/}/acp/rpc"
AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:?BRIDGE_AUTH_TOKEN is required}"
append_rpc_path() {
local url="$1"
printf '%s/acp/rpc\n' "${url%/}"
}
OPENCLAW_HTTP_PROBE_URL="$(websocket_probe_url "${OPENCLAW_BASE_URL}")"
CODEX_RPC_ENDPOINT="$(append_rpc_path "${CODEX_BASE_URL}")"
OPENCODE_RPC_ENDPOINT="$(append_rpc_path "${OPENCODE_BASE_URL}")"
GEMINI_RPC_ENDPOINT="$(append_rpc_path "${GEMINI_BASE_URL}")"
HERMES_RPC_ENDPOINT="$(append_rpc_path "${HERMES_BASE_URL}")"
fast_http_curl_common=(
--silent
--show-error
@ -87,13 +49,6 @@ bridge_rpc_curl_common=(
--max-time "${BRIDGE_RPC_TIMEOUT_SECONDS}"
)
auth_headers=()
if [[ -n "${AUTH_TOKEN}" ]]; then
auth_headers+=(-H "Authorization: Bearer ${AUTH_TOKEN}")
fi
# Use explicit assignment guards so transport failures are not swallowed inside
# nested command substitutions when bash runs without inherit_errexit.
capture_http_response() {
local label="$1"
shift
@ -123,7 +78,6 @@ should_retry_exit_code() {
return 0
fi
done
return 1
}
@ -157,162 +111,24 @@ run_with_retry() {
return 1
}
probe_jsonrpc_capabilities_once() {
local endpoint="$1"
local response
local headers=(
-H 'Content-Type: application/json'
-H 'Accept: application/json'
)
headers+=("${auth_headers[@]}")
if response="$(
capture_http_response "capabilities ${endpoint}" \
"${fast_http_curl_common[@]}" \
"${headers[@]}" \
--data '{"jsonrpc":"2.0","id":"cap-1","method":"acp.capabilities"}' \
"${endpoint}"
)"; then
:
else
local exit_code=$?
return "${exit_code}"
fi
RESPONSE_JSON="${response}" python3 - <<'PY'
import json
import os
try:
payload = json.loads(os.environ["RESPONSE_JSON"])
except json.JSONDecodeError as exc:
raise SystemExit(f"capabilities response returned invalid JSON: {exc}") from None
if payload.get("jsonrpc") != "2.0":
raise SystemExit("capabilities response missing jsonrpc envelope")
result = payload.get("result")
if not isinstance(result, dict):
raise SystemExit("capabilities response missing result payload")
if not result and "providers" not in payload:
raise SystemExit("capabilities response missing result/providers data")
PY
}
jsonrpc_bridge_call() {
local payload="$1"
local response
local headers=(
-H 'Content-Type: application/json'
-H 'Accept: application/json'
)
headers+=("${auth_headers[@]}")
if response="$(
capture_http_response "bridge rpc ${BASE_URL}/acp/rpc" \
"${bridge_rpc_curl_common[@]}" \
"${headers[@]}" \
--data "${payload}" \
"${BASE_URL}/acp/rpc"
)"; then
:
else
local exit_code=$?
return "${exit_code}"
fi
printf '%s\n' "${response}"
}
probe_bridge_provider_probe_once() {
local provider_id="$1"
local payload
local response
payload="$(cat <<JSON
{"jsonrpc":"2.0","id":"probe-${provider_id}-$(date +%s)","method":"xworkmate.provider.probe","params":{"providerId":"${provider_id}"}}
JSON
)"
if response="$(jsonrpc_bridge_call "${payload}")"; then
:
else
local exit_code=$?
return "${exit_code}"
fi
PROVIDER_ID="${provider_id}" RESPONSE_JSON="${response}" python3 - <<'PY'
import json
import os
provider = os.environ["PROVIDER_ID"]
try:
payload = json.loads(os.environ["RESPONSE_JSON"])
except json.JSONDecodeError as exc:
raise SystemExit(f"{provider}: bridge rpc returned invalid JSON: {exc}") from None
if payload.get("jsonrpc") != "2.0":
raise SystemExit(f"{provider}: missing jsonrpc envelope")
result = payload.get("result")
if not isinstance(result, dict):
raise SystemExit(f"{provider}: missing result payload")
if result.get("success") is not True:
raise SystemExit(f"{provider}: provider probe failed: {result!r}")
if result.get("providerId") != provider:
raise SystemExit(f"{provider}: providerId mismatch: {result!r}")
capabilities = result.get("capabilities")
if not isinstance(capabilities, dict):
raise SystemExit(f"{provider}: probe did not return capabilities payload: {result!r}")
PY
}
probe_safe_http_endpoint() {
local endpoint="$1"
local status
if ! status="$(
curl \
--silent \
--show-error \
--output /dev/null \
--write-out '%{http_code}' \
--location \
--max-time "${FAST_HTTP_TIMEOUT_SECONDS}" \
"${auth_headers[@]}" \
"${endpoint}" 2>&1
)"; then
printf 'HTTP probe failed for %s: %s\n' "${endpoint}" "${status}" >&2
return "${RETRYABLE_TRANSPORT}"
fi
case "${status}" in
2*|3*|404|405|426)
return 0
;;
401|403)
printf 'Authentication failure (HTTP %s) for %s\n' "${status}" "${endpoint}" >&2
return 1
;;
*)
printf 'Unexpected HTTP status %s for %s\n' "${status}" "${endpoint}" >&2
return 1
;;
esac
capture_http_response \
"bridge rpc ${RPC_URL}" \
"${bridge_rpc_curl_common[@]}" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H "Authorization: Bearer ${AUTH_TOKEN}" \
--data "${payload}" \
"${RPC_URL}"
}
wait_for_release_ping_once() {
local ping_json
if ping_json="$(
capture_http_response "bridge ping ${BASE_URL}/api/ping" \
"${fast_http_curl_common[@]}" \
"${BASE_URL}/api/ping"
"${BASE_URL%/}/api/ping"
)"; then
:
else
@ -326,23 +142,15 @@ import os
import sys
image_ref, tag, commit, version = sys.argv[1:5]
try:
payload = json.loads(os.environ["PING_JSON"])
except json.JSONDecodeError as exc:
raise SystemExit(f"bridge ping returned invalid JSON: {exc}") from None
payload = json.loads(os.environ["PING_JSON"])
if payload.get("status") != "ok":
raise SystemExit("ping status not ok")
if payload.get("image") != image_ref:
raise SystemExit(f"expected image {image_ref!r}, got {payload.get('image')!r}")
if tag and payload.get("tag") != tag:
raise SystemExit(f"expected tag {tag!r}, got {payload.get('tag')!r}")
if commit and payload.get("commit") != commit:
raise SystemExit(f"expected commit {commit!r}, got {payload.get('commit')!r}")
if version and payload.get("version") != version:
raise SystemExit(f"expected version {version!r}, got {payload.get('version')!r}")
PY
@ -355,11 +163,10 @@ PY
probe_bridge_root() {
local bridge_root
if bridge_root="$(
capture_http_response "bridge root ${BASE_URL}/" \
"${fast_http_curl_common[@]}" \
"${BASE_URL}/"
"${BASE_URL%/}/"
)"; then
:
else
@ -370,18 +177,107 @@ probe_bridge_root() {
grep -qi 'xworkmate-bridge' <<<"${bridge_root}"
}
probe_bridge_capabilities_once() {
local response
if response="$(jsonrpc_bridge_call '{"jsonrpc":"2.0","id":"cap-1","method":"acp.capabilities","params":{}}')"; then
:
else
local exit_code=$?
return "${exit_code}"
fi
RESPONSE_JSON="${response}" python3 - <<'PY'
import json
import os
payload = json.loads(os.environ["RESPONSE_JSON"])
result = payload.get("result")
if payload.get("jsonrpc") != "2.0" or not isinstance(result, dict):
raise SystemExit("capabilities response missing result payload")
provider_catalog = result.get("providerCatalog")
gateway_providers = result.get("gatewayProviders")
targets = result.get("availableExecutionTargets")
if not isinstance(provider_catalog, list):
raise SystemExit("providerCatalog missing")
if not isinstance(gateway_providers, list):
raise SystemExit("gatewayProviders missing")
if not isinstance(targets, list):
raise SystemExit("availableExecutionTargets missing")
providers = {item.get("providerId") for item in provider_catalog if isinstance(item, dict)}
if not {"codex", "opencode", "gemini", "hermes"}.issubset(providers):
raise SystemExit(f"unexpected providerCatalog: {provider_catalog!r}")
gateway_ids = {item.get("providerId") for item in gateway_providers if isinstance(item, dict)}
if "openclaw" not in gateway_ids:
raise SystemExit(f"unexpected gatewayProviders: {gateway_providers!r}")
if "agent" not in targets or "gateway" not in targets:
raise SystemExit(f"unexpected availableExecutionTargets: {targets!r}")
PY
}
probe_bridge_routing_once() {
local response
local payload='{"jsonrpc":"2.0","id":"route-1","method":"xworkmate.routing.resolve","params":{"taskPrompt":"create a powerpoint deck for launch","workingDirectory":"/tmp/validate-deploy","routing":{"routingMode":"explicit","explicitExecutionTarget":"singleAgent","explicitProviderId":"codex","availableSkills":[{"id":"pptx","label":"PPTX","description":"slides","installed":true}]}}}'
if response="$(jsonrpc_bridge_call "${payload}")"; then
:
else
local exit_code=$?
return "${exit_code}"
fi
RESPONSE_JSON="${response}" python3 - <<'PY'
import json
import os
payload = json.loads(os.environ["RESPONSE_JSON"])
result = payload.get("result")
if payload.get("jsonrpc") != "2.0" or not isinstance(result, dict):
raise SystemExit("routing response missing result payload")
if result.get("resolvedExecutionTarget") != "single-agent":
raise SystemExit(f"unexpected routing target: {result!r}")
if result.get("resolvedProviderId") != "codex":
raise SystemExit(f"unexpected routing provider: {result!r}")
if result.get("status") != "available":
raise SystemExit(f"unexpected routing status: {result!r}")
PY
}
probe_bridge_gateway_routing_once() {
local response
local payload='{"jsonrpc":"2.0","id":"route-gateway-1","method":"xworkmate.routing.resolve","params":{"taskPrompt":"search latest news","workingDirectory":"/tmp/validate-deploy","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}'
if response="$(jsonrpc_bridge_call "${payload}")"; then
:
else
local exit_code=$?
return "${exit_code}"
fi
RESPONSE_JSON="${response}" python3 - <<'PY'
import json
import os
payload = json.loads(os.environ["RESPONSE_JSON"])
result = payload.get("result")
if payload.get("jsonrpc") != "2.0" or not isinstance(result, dict):
raise SystemExit("gateway routing response missing result payload")
if result.get("resolvedExecutionTarget") != "gateway":
raise SystemExit(f"unexpected gateway target: {result!r}")
if result.get("resolvedGatewayProviderId") != "openclaw":
raise SystemExit(f"unexpected gateway provider: {result!r}")
if result.get("status") != "available":
raise SystemExit(f"unexpected gateway routing status: {result!r}")
PY
}
run_with_retry "bridge ping ${BASE_URL}/api/ping" 6 5 "${RETRYABLE_TRANSPORT},${RETRYABLE_NOT_READY}" wait_for_release_ping_once
probe_bridge_root
probe_safe_http_endpoint "${OPENCLAW_HTTP_PROBE_URL}"
probe_safe_http_endpoint "${CODEX_BASE_URL}"
probe_safe_http_endpoint "${OPENCODE_BASE_URL}"
probe_safe_http_endpoint "${GEMINI_BASE_URL}"
run_with_retry "capabilities ${CODEX_RPC_ENDPOINT}" 3 5 "${RETRYABLE_TRANSPORT}" probe_jsonrpc_capabilities_once "${CODEX_RPC_ENDPOINT}"
run_with_retry "capabilities ${OPENCODE_RPC_ENDPOINT}" 3 5 "${RETRYABLE_TRANSPORT}" probe_jsonrpc_capabilities_once "${OPENCODE_RPC_ENDPOINT}"
run_with_retry "capabilities ${GEMINI_RPC_ENDPOINT}" 3 5 "${RETRYABLE_TRANSPORT}" probe_jsonrpc_capabilities_once "${GEMINI_RPC_ENDPOINT}"
run_with_retry "bridge provider probe codex" 3 10 "${RETRYABLE_TRANSPORT}" probe_bridge_provider_probe_once "codex"
run_with_retry "bridge provider probe opencode" 3 10 "${RETRYABLE_TRANSPORT}" probe_bridge_provider_probe_once "opencode"
run_with_retry "bridge provider probe gemini" 3 10 "${RETRYABLE_TRANSPORT}" probe_bridge_provider_probe_once "gemini"
run_with_retry "bridge provider probe hermes" 3 10 "${RETRYABLE_TRANSPORT}" probe_bridge_provider_probe_once "hermes"
run_with_retry "bridge capabilities ${RPC_URL}" 3 5 "${RETRYABLE_TRANSPORT}" probe_bridge_capabilities_once
run_with_retry "bridge routing ${RPC_URL}" 3 5 "${RETRYABLE_TRANSPORT}" probe_bridge_routing_once
run_with_retry "bridge gateway routing ${RPC_URL}" 3 5 "${RETRYABLE_TRANSPORT}" probe_bridge_gateway_routing_once

View File

@ -1,8 +1,8 @@
#!/bin/bash
# ==============================================================================
# xworkmate-bridge 高覆盖率功能测试脚本 (v1.2)
# 覆盖范围:Bridge, Gemini, Codex, OpenCode, Gateway
# xworkmate-bridge 功能测试脚本
# 覆盖范围:canonical bridge contract / explicit provider routing / gateway
# ==============================================================================
set -e
@ -76,16 +76,19 @@ assert_success() {
# --- 1. 基础连通性测试 ---
log_step "1. 验证各端点 Capabilities"
log_step "1. 验证 canonical bridge Capabilities"
CAPS=$(call_api "/acp/rpc" "acp.capabilities" "{}" "false")
assert_success "$CAPS" "主 Bridge Capabilities"
GEMINI_CAPS=$(call_api "/acp-server/gemini/acp/rpc" "acp.capabilities" "{}" "false")
assert_success "$GEMINI_CAPS" "Gemini 适配器 Capabilities"
CODEX_CAPS=$(call_api "/acp-server/codex/acp/rpc" "acp.capabilities" "{}" "false")
assert_success "$CODEX_CAPS" "Codex 适配器 Capabilities"
if ! echo "$CAPS" | jq -e '.result.providerCatalog | map(.providerId) | index("gemini") != null' > /dev/null; then
log_err "providerCatalog 未暴露 gemini"
exit 1
fi
if ! echo "$CAPS" | jq -e '.result.providerCatalog | map(.providerId) | index("codex") != null' > /dev/null; then
log_err "providerCatalog 未暴露 codex"
exit 1
fi
# --- 2. Gateway 连接测试 ---
@ -97,11 +100,11 @@ assert_success "$CONNECT_RES" "Gateway 连接"
log_step "3. Gemini 流式对话测试"
log_info "正在向 Gemini 发起对话..."
START_GEMINI=$(call_api "/acp/rpc" "session.start" "{\"sessionId\":\"$SESSION_ID_GEMINI\",\"taskPrompt\":\"你好,请记住我叫 Gemini-Tester。\",\"routing\":{\"explicitProviderId\":\"gemini\"}}" "true" | grep "^data: {" | tail -n 1 | sed 's/^data: //')
START_GEMINI=$(call_api "/acp/rpc" "session.start" "{\"sessionId\":\"$SESSION_ID_GEMINI\",\"taskPrompt\":\"你好,请记住我叫 Gemini-Tester。\",\"routing\":{\"routingMode\":\"explicit\",\"explicitExecutionTarget\":\"singleAgent\",\"explicitProviderId\":\"gemini\"}}" "true" | grep "^data: {" | tail -n 1 | sed 's/^data: //')
assert_success "$START_GEMINI" "Gemini 会话启动"
log_info "验证 Gemini 上下文..."
MSG_GEMINI=$(call_api "/acp/rpc" "session.message" "{\"sessionId\":\"$SESSION_ID_GEMINI\",\"taskPrompt\":\"我刚才说我叫什么?\",\"routing\":{\"explicitProviderId\":\"gemini\"}}" "true" | grep "^data: {" | tail -n 1 | sed 's/^data: //')
MSG_GEMINI=$(call_api "/acp/rpc" "session.message" "{\"sessionId\":\"$SESSION_ID_GEMINI\",\"taskPrompt\":\"我刚才说我叫什么?\",\"routing\":{\"routingMode\":\"explicit\",\"explicitExecutionTarget\":\"singleAgent\",\"explicitProviderId\":\"gemini\"}}" "true" | grep "^data: {" | tail -n 1 | sed 's/^data: //')
assert_success "$MSG_GEMINI" "Gemini 上下文验证"
log_info "Gemini 回复: $(echo "$MSG_GEMINI" | jq -r '.result.output // .payload.output')"
@ -110,7 +113,7 @@ log_info "Gemini 回复: $(echo "$MSG_GEMINI" | jq -r '.result.output // .payloa
log_step "4. OpenCode 深度测试 (通过 Gateway)"
log_info "正在通过 Gateway 向 OpenCode 发起对话..."
# 注意OpenCode 需要通过已连接的 gateway 执行
START_OPENCODE=$(call_api "/acp/rpc" "session.start" "{\"sessionId\":\"$SESSION_ID_OPENCODE\",\"taskPrompt\":\"你好 OpenCode请问你能做什么\",\"routing\":{\"explicitProviderId\":\"opencode\"}}" "true" | grep "^data: {" | tail -n 1 | sed 's/^data: //')
START_OPENCODE=$(call_api "/acp/rpc" "session.start" "{\"sessionId\":\"$SESSION_ID_OPENCODE\",\"taskPrompt\":\"你好 OpenCode请问你能做什么\",\"routing\":{\"routingMode\":\"explicit\",\"explicitExecutionTarget\":\"singleAgent\",\"explicitProviderId\":\"opencode\"}}" "true" | grep "^data: {" | tail -n 1 | sed 's/^data: //')
# 如果 gateway 依然报错,可能是环境限制,此处做容错处理
if echo "$START_OPENCODE" | jq -e '.ok == true or .result.success == true' > /dev/null; then