Refine bridge routing and remove fallback paths
This commit is contained in:
parent
e808f7a19e
commit
17c0fa6f16
@ -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 定义
|
||||
|
||||
39
docs/acp-test-report-2026-04-23.md
Normal file
39
docs/acp-test-report-2026-04-23.md
Normal 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
@ -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 flag,app-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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 控制逻辑。
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 路由确权引擎
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
250
internal/acp/provider_compat.go
Normal file
250
internal/acp/provider_compat.go
Normal 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
|
||||
}
|
||||
@ -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{
|
||||
|
||||
@ -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"]),
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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
41
scripts/github-actions/test-validate-deploy.sh
Normal file → Executable 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
332
scripts/github-actions/validate-deploy.sh
Normal file → Executable 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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user