diff --git a/README.md b/README.md
index b64207a..a933cce 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,14 @@
## ACP Forwarding Topology
-This repository exposes one bridge entrypoint and forwards to four verified public targets. The full Mermaid diagram lives in [docs/architecture/acp-forwarding-topology.md](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-bridge/docs/architecture/acp-forwarding-topology.md).
+This repository exposes one APP-facing bridge entrypoint and proxies traffic
+to four independent upstream production services. The APP-facing canonical ACP
+paths remain `/acp/rpc` and `/acp` under
+`https://xworkmate-bridge.svc.plus`.
+
+Architecture topology: [docs/architecture/acp-forwarding-topology.md](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-bridge/docs/architecture/acp-forwarding-topology.md)
+
+ADR for the unified APP-facing bridge contract: [docs/architecture/adr-unified-bridge-entrypoints.md](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-bridge/docs/architecture/adr-unified-bridge-entrypoints.md)
Example provider sync config: [example/config.yaml](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-bridge/example/config.yaml)
diff --git a/docs/acp-public-validation-2026-04-09.md b/docs/acp-public-validation-2026-04-09.md
index d499deb..5b7c2f6 100644
--- a/docs/acp-public-validation-2026-04-09.md
+++ b/docs/acp-public-validation-2026-04-09.md
@@ -1,8 +1,13 @@
# ACP Public Validation - 2026-04-09
-This document records the post-deployment public validation for `xworkmate-bridge.svc.plus` and the unified ACP ingress at `acp-server.svc.plus`.
+This document records the post-deployment validation of the bridge public
+origin at `xworkmate-bridge.svc.plus` and the independent upstream ACP ingress
+at `acp-server.svc.plus`.
-It is intended as an app-integration reference so future clients use the verified public endpoints and expected JSON-RPC methods.
+For APP integration, the canonical public contract remains the bridge origin
+and the `.../acp/rpc` path on that origin. The direct `acp-server.svc.plus`
+URLs in this document are upstream validation targets, not the preferred APP
+entry points.
## Verified Public Endpoints
@@ -18,6 +23,10 @@ The public ACP JSON-RPC endpoint is the `.../acp/rpc` path.
Do not send JSON-RPC requests to `.../acp` for HTTP clients.
+Recommended APP-facing endpoint:
+
+- `https://xworkmate-bridge.svc.plus/acp/rpc`
+
Verified public HTTP JSON-RPC endpoints:
- Codex: `https://acp-server.svc.plus/codex/acp/rpc`
@@ -146,13 +155,15 @@ Verified result summary:
- `opencode` long conversation passed
- `gemini` long conversation passed
-This is the current app-integration baseline for `acp-server.svc.plus`.
+This confirms the upstream ACP baseline. The APP-facing baseline remains
+`https://xworkmate-bridge.svc.plus/acp/rpc`.
## App Integration Notes
### Recommended request shape
-Use JSON-RPC `POST` requests against `.../acp/rpc`.
+For APP integration, use JSON-RPC `POST` requests against
+`https://xworkmate-bridge.svc.plus/acp/rpc`.
For capability discovery:
diff --git a/docs/api-reference.md b/docs/api-reference.md
index 52fb046..6d7dce9 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -26,6 +26,18 @@ Relevant source:
## 2. ACP Bridge HTTP / WebSocket API
+Canonical APP-facing public origin:
+
+- `https://xworkmate-bridge.svc.plus`
+
+Canonical APP-facing ACP paths:
+
+- `POST /acp/rpc`
+- `GET /acp` for WebSocket ACP
+
+Independent upstream ACP and gateway services may exist behind the bridge, but
+they are not the primary APP contract.
+
### 2.1 Default listen address
`serve` mode reads:
@@ -121,6 +133,10 @@ Response shape:
{ "providerId": "opencode", "label": "OpenCode" },
{ "providerId": "gemini", "label": "Gemini" }
],
+ "gatewayProviders": [
+ { "providerId": "local", "label": "Local" },
+ { "providerId": "openclaw", "label": "OpenClaw" }
+ ],
"capabilities": {
"single_agent": true,
"multi_agent": true,
@@ -128,6 +144,10 @@ Response shape:
{ "providerId": "codex", "label": "Codex" },
{ "providerId": "opencode", "label": "OpenCode" },
{ "providerId": "gemini", "label": "Gemini" }
+ ],
+ "gatewayProviders": [
+ { "providerId": "local", "label": "Local" },
+ { "providerId": "openclaw", "label": "OpenClaw" }
]
}
}
@@ -137,10 +157,13 @@ Response shape:
Notes:
- `providerCatalog` is bridge-owned and built in at startup
-- production provider map is fixed to:
+- `gatewayProviders` is the APP-facing gateway provider catalog
+- production upstream routing map is fixed to:
- `codex` -> `https://acp-server.svc.plus/codex/acp/rpc`
- `opencode` -> `https://acp-server.svc.plus/opencode/acp/rpc`
- `gemini` -> `https://acp-server.svc.plus/gemini/acp/rpc`
+- APP traffic reaches those upstreams through the bridge's canonical public
+ ACP path, not by depending on upstream URLs directly
- upstream ACP auth uses `Authorization: Bearer $INTERNAL_SERVICE_TOKEN`
- `multiAgent` is controlled by `ACP_MULTI_AGENT_ENABLED`, default `true`
@@ -298,8 +321,8 @@ Purpose:
- resolve routing metadata into:
- execution target
- - endpoint target
- provider
+ - gateway provider
- model
- selected skills
- install suggestion / unavailable state
@@ -318,6 +341,7 @@ Key input fields:
- `taskPrompt`
- `workingDirectory`
- `routing.routingMode`
+- `routing.preferredGatewayProviderId`
- `routing.preferredGatewayTarget`
- `routing.explicitExecutionTarget`
- `routing.explicitProviderId`
@@ -332,8 +356,9 @@ Key input fields:
Representative response fields:
- `resolvedExecutionTarget`
-- `resolvedEndpointTarget`
- `resolvedProviderId`
+- `resolvedGatewayProviderId`
+- `resolvedEndpointTarget`
- `resolvedModel`
- `resolvedSkills`
- `skillResolutionSource`
@@ -345,6 +370,76 @@ Representative response fields:
- `skillCandidates`
- `memorySources`
+APP-facing interpretation:
+
+- if `resolvedExecutionTarget = single-agent`, use `resolvedProviderId`
+- if `resolvedExecutionTarget = gateway`, use `resolvedGatewayProviderId`
+- `resolvedEndpointTarget` is retained as a compatibility field for bridge
+ internals; APP code should prefer `resolvedGatewayProviderId`
+
+### 3.7.1 UI / APP consumption model
+
+Recommended APP-side flow:
+
+1. Call `acp.capabilities` at startup or refresh time.
+2. Read:
+ - `providerCatalog` as `singleAgentProviders`
+ - `gatewayProviders` as `gatewayProviders`
+3. Before execution, call `xworkmate.routing.resolve`.
+4. Use the resolved fields, not local heuristics, to decide which UI state and
+ execution branch to use.
+5. Send `session.start` or `session.message` through the bridge canonical path.
+
+Suggested APP-side view model:
+
+```json
+{
+ "executionTargets": ["single-agent", "multi-agent", "gateway"],
+ "singleAgentProviders": ["codex", "opencode", "gemini"],
+ "gatewayProviders": ["local", "openclaw"]
+}
+```
+
+Recommended interpretation rules:
+
+- if `resolvedExecutionTarget = single-agent`
+ - read `resolvedProviderId`
+ - ignore `resolvedGatewayProviderId`
+- if `resolvedExecutionTarget = multi-agent`
+ - treat the run as bridge-orchestrated multi-agent execution
+ - do not force a single provider picker as the primary routing control
+- if `resolvedExecutionTarget = gateway`
+ - read `resolvedGatewayProviderId`
+ - ignore `resolvedProviderId`
+
+Compatibility rule:
+
+- `resolvedEndpointTarget` is a compatibility field
+- APP code should not use `local` / `remote` as its primary business model
+- current bridge mapping is:
+ - `resolvedGatewayProviderId = local` -> `resolvedEndpointTarget = local`
+ - `resolvedGatewayProviderId = openclaw` -> `resolvedEndpointTarget = remote`
+
+UI binding guidance:
+
+- provider picker for single-agent mode should be populated from
+ `providerCatalog`
+- gateway picker should be populated from `gatewayProviders`
+- gateway UI should display `local` and `openclaw` as selectable providers
+ rather than exposing transport-level `local` / `remote` terminology
+- disabled or unavailable states should come from `xworkmate.routing.resolve`
+ response fields such as:
+ - `unavailable`
+ - `unavailableCode`
+ - `unavailableMessage`
+
+Do not:
+
+- infer provider availability from hardcoded lists
+- treat `openclaw` as a single-agent provider
+- re-derive auto-routing from prompt text on the APP side
+- use upstream URLs as the APP-facing contract
+
### 3.8 `xworkmate.mounts.reconcile`
Purpose:
@@ -381,6 +476,8 @@ Managed MCP server item shape:
Purpose:
- connect a bridge runtime session to the bridge-owned production gateway route
+- from the APP perspective this still enters through the canonical bridge ACP
+ path and bridge JSON-RPC methods, not a direct `openclaw` URL contract
Key params:
diff --git a/docs/architecture/acp-forwarding-topology.md b/docs/architecture/acp-forwarding-topology.md
index 0372204..972f9ae 100644
--- a/docs/architecture/acp-forwarding-topology.md
+++ b/docs/architecture/acp-forwarding-topology.md
@@ -2,6 +2,8 @@
This document describes the bridge-only production forwarding model for `xworkmate-bridge.svc.plus`.
+See also: [adr-unified-bridge-entrypoints.md](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-bridge/docs/architecture/adr-unified-bridge-entrypoints.md)
+
## Topology
```mermaid
@@ -30,27 +32,29 @@ flowchart TD
## Three-Layer View
This view separates what the app sees, what the bridge owns, and what the
-real upstream production targets are.
+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"]
- APPACP["ACP 能力发现
acp.capabilities"]
- APPGW["Gateway 连接
xworkmate.gateway.connect"]
- APP --> APPACP
- APP --> APPGW
+ APPENTRY["https://xworkmate-bridge.svc.plus
统一代理入口"]
+ APPMETHODS["bridge methods
acp.capabilities / session.* / xworkmate.gateway.*"]
+ APP --> APPENTRY
+ APPENTRY --> APPMETHODS
end
subgraph L2["Bridge 视角"]
BRIDGE["xworkmate-bridge
唯一上游发现真源"]
- CAP["Bridge-owned ACP server list"]
+ CAP["Bridge-owned ACP routing catalog"]
CAP1["codex"]
CAP2["opencode"]
CAP3["gemini"]
- GW["Bridge-owned gateway upstream"]
+ GW["Bridge-owned gateway routing"]
GW1["remote mode -> openclaw"]
BRIDGE --> CAP
@@ -69,8 +73,7 @@ flowchart LR
U4["wss://openclaw.svc.plus
reported as openclaw.svc.plus:443"]
end
- APPACP --> BRIDGE
- APPGW --> BRIDGE
+ APPMETHODS --> BRIDGE
CAP1 --> U1
CAP2 --> U2
@@ -80,6 +83,12 @@ flowchart LR
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.providerCatalog` currently advertises only the ACP
single-agent providers: `codex`, `opencode`, and `gemini`
- `gateway` is not part of that provider catalog; it is exposed through the
@@ -89,20 +98,30 @@ Important distinction:
## Production Truth
-Bridge owns the production map:
+The production upstream services exist independently. The bridge owns the
+routing map used to proxy app traffic to them:
- `codex` -> `https://acp-server.svc.plus/codex/acp/rpc`
- `opencode` -> `https://acp-server.svc.plus/opencode/acp/rpc`
- `gemini` -> `https://acp-server.svc.plus/gemini/acp/rpc`
- gateway -> `wss://openclaw.svc.plus`
-Upstream auth is bridge-internal:
+Upstream auth is unified and bridge-internal:
- `Authorization: Bearer $INTERNAL_SERVICE_TOKEN`
+Canonical APP-facing paths stay on the bridge origin:
+
+- `POST https://xworkmate-bridge.svc.plus/acp/rpc`
+- `GET https://xworkmate-bridge.svc.plus/acp`
+
## Invariants
- app-facing cloud entry is only `https://xworkmate-bridge.svc.plus`
+- app traffic reaches upstream ACP and gateway services only through the
+ bridge proxy
+- upstream ACP and gateway routes use the same bearer token contract:
+ `Authorization: Bearer $INTERNAL_SERVICE_TOKEN`
- `acp.capabilities` returns the built-in production catalog
- no production `xworkmate.providers.sync`
- no app direct call to `acp-server.svc.plus/*`
diff --git a/docs/architecture/adr-unified-bridge-entrypoints.md b/docs/architecture/adr-unified-bridge-entrypoints.md
new file mode 100644
index 0000000..02218fb
--- /dev/null
+++ b/docs/architecture/adr-unified-bridge-entrypoints.md
@@ -0,0 +1,126 @@
+# ADR: Unified Bridge Entry Points for APP Traffic
+
+## Status
+
+Accepted
+
+## Date
+
+2026-04-11
+
+## Context
+
+`xworkmate-bridge` currently proxies app traffic to four independent upstream
+production services:
+
+- `codex` -> `https://acp-server.svc.plus/codex/acp/rpc`
+- `opencode` -> `https://acp-server.svc.plus/opencode/acp/rpc`
+- `gemini` -> `https://acp-server.svc.plus/gemini/acp/rpc`
+- `gateway` -> `wss://openclaw.svc.plus`
+
+These upstream services exist independently, but exposing them directly as
+APP-facing endpoints creates several problems:
+
+- the APP would need to know provider-specific or gateway-specific hostnames
+- routing truth would be split between URL shape and bridge-side routing logic
+- auth handling would be harder to keep consistent
+- upstream implementation details would leak into client contracts
+
+The bridge already acts as the single public integration surface for ACP
+discovery, task execution, and gateway runtime operations.
+
+## Decision
+
+For APP traffic, the canonical public entry point is the bridge origin:
+
+- `https://xworkmate-bridge.svc.plus`
+
+The canonical APP-facing ACP paths are:
+
+- `POST /acp/rpc`
+- `GET /acp` for WebSocket ACP
+
+The APP should not depend on provider-specific public URLs such as:
+
+- `/codex/acp/rpc`
+- `/opencode/acp/rpc`
+- `/gemini/acp/rpc`
+- `/openclaw/`
+
+Provider choice remains bridge-owned routing, not URL-owned routing.
+
+APP-facing routing should be modeled in three layers:
+
+- `executionTarget`
+ - `single-agent`
+ - `multi-agent`
+ - `gateway`
+- `singleAgentProviders`
+ - `codex`
+ - `opencode`
+ - `gemini`
+- `gatewayProviders`
+ - `local`
+ - `openclaw`
+
+For APP integration, `gatewayProviders` is the stable gateway-facing concept.
+The older `local` / `remote` endpoint-target split is retained only as a
+bridge-internal compatibility layer.
+
+APP and UI code should consume bridge state in two phases:
+
+1. `acp.capabilities`
+ - discover `singleAgentProviders`
+ - discover `gatewayProviders`
+2. `xworkmate.routing.resolve`
+ - determine `resolvedExecutionTarget`
+ - determine `resolvedProviderId` or `resolvedGatewayProviderId`
+ - determine unavailable state
+
+The APP should treat `resolvedProviderId` and `resolvedGatewayProviderId` as
+mutually exclusive routing outputs depending on `resolvedExecutionTarget`.
+
+Gateway access remains bridge-owned via JSON-RPC methods:
+
+- `xworkmate.gateway.connect`
+- `xworkmate.gateway.request`
+- `xworkmate.gateway.disconnect`
+
+Upstream authentication is unified for both ACP and gateway routes:
+
+- `Authorization: Bearer $INTERNAL_SERVICE_TOKEN`
+
+## Consequences
+
+### Positive
+
+- APP integration stays stable behind one public origin
+- provider and gateway topology remain internal bridge concerns
+- auth contract is consistent across all upstream forwarding
+- bridge can change upstream mappings without changing APP contracts
+
+### Trade-offs
+
+- direct provider-specific bridge URLs, if exposed at all, must be treated as
+ aliases or operator/debug paths, not primary client contracts
+- documentation must clearly distinguish canonical APP paths from independent
+ upstream targets
+
+## Path Naming Guidance
+
+Use these terms consistently in docs:
+
+- `canonical APP-facing path`: `/acp/rpc` and `/acp`
+- `independent upstream service`: `acp-server.svc.plus/*` and
+ `wss://openclaw.svc.plus`
+- `bridge-owned routing`: bridge logic that selects and proxies to upstreams
+- `gatewayProvider`: the APP-facing identifier for a gateway backend such as
+ `local` or `openclaw`
+- `endpoint target`: an internal compatibility field, not the preferred APP
+ concept
+
+Avoid describing upstream URLs as if the APP should call them directly.
+
+If provider-specific public bridge paths are ever introduced, they should be
+documented as optional aliases only. They should not replace `/acp/rpc` as the
+canonical APP-facing contract.
diff --git a/docs/xworkmate-bridge-svc-plus-core-functional-test-plan-v1.md b/docs/xworkmate-bridge-svc-plus-core-functional-test-plan-v1.md
index 292a519..d1c6a5a 100644
--- a/docs/xworkmate-bridge-svc-plus-core-functional-test-plan-v1.md
+++ b/docs/xworkmate-bridge-svc-plus-core-functional-test-plan-v1.md
@@ -14,12 +14,17 @@
### 1. 路由发现层
- `acp.capabilities` 返回动态 provider 列表。
-- 至少覆盖 `opencode / codex / openclaw / gateway` 中当前环境真实可用项。
+- 至少覆盖:
+ - `singleAgentProviders`: `opencode / codex / gemini`
+ - `gatewayProviders`: `local / openclaw`
- `xworkmate.routing.resolve` 根据 `taskPrompt`、`executionTarget`、`selectedSkills` 返回正确的:
- `resolvedExecutionTarget`
- `resolvedProviderId`
+ - `resolvedGatewayProviderId`
- `resolvedEndpointTarget`
- `acp.capabilities` 暴露 bridge 内建的生产 provider catalog,并参与后续路由选择。
+- APP 对 gateway 的分区应以 `gatewayProviders` / `resolvedGatewayProviderId`
+ 为主,不以 `local / remote` 传输语义为主。
### 2. 典型 Case 层
@@ -97,7 +102,11 @@ flutter test test/runtime/app_controller_single_agent_workspace_binding_regressi
- `acp.capabilities` 的 provider 列表来自 bridge 当前环境,而不是本地写死。
- bridge 内建生产 catalog 包含 `codex / opencode / gemini`,且不依赖 app 侧预同步。
-- `xworkmate.routing.resolve` 在 skill / prompt / target 组合下,返回合理的 provider 与 endpoint target。
+- bridge 还会暴露 `gatewayProviders = local / openclaw`。
+- `xworkmate.routing.resolve` 在 skill / prompt / target 组合下,返回合理的
+ `resolvedProviderId` 或 `resolvedGatewayProviderId`。
+- `resolvedEndpointTarget` 仅作为兼容字段保留,APP 侧 gateway 分流以
+ `resolvedGatewayProviderId` 为主。
### 执行层断言
@@ -145,7 +154,7 @@ flutter test test/runtime/app_controller_single_agent_workspace_binding_regressi
额外约定:
-- `openclaw` 作为扩展路由的一部分,先按 bridge 发现结果驱动。
+- `openclaw` 作为 `gatewayProviders` 之一,按 bridge 发现结果驱动。
- 如果当前环境没有暴露某个 provider,测试允许 `skip`,但要保留断言入口和记录。
- UI 本轮不改结构,只验证 provider 列表来源与展示结果是否随 bridge 动态变化。
diff --git a/internal/acp/execution.go b/internal/acp/execution.go
index f48ef7d..4bffdde 100644
--- a/internal/acp/execution.go
+++ b/internal/acp/execution.go
@@ -44,6 +44,9 @@ func buildResolvedExecutionParams(
if strings.TrimSpace(resolved.ResolvedProviderID) != "" {
next["provider"] = strings.TrimSpace(resolved.ResolvedProviderID)
}
+ if strings.TrimSpace(resolved.ResolvedGatewayProviderID) != "" {
+ next["gatewayProvider"] = strings.TrimSpace(resolved.ResolvedGatewayProviderID)
+ }
if strings.TrimSpace(resolved.ResolvedModel) != "" {
next["model"] = strings.TrimSpace(resolved.ResolvedModel)
}
@@ -53,6 +56,7 @@ func buildResolvedExecutionParams(
next["resolvedExecutionTarget"] = resolved.ResolvedExecutionTarget
next["resolvedEndpointTarget"] = resolved.ResolvedEndpointTarget
next["resolvedProviderId"] = resolved.ResolvedProviderID
+ next["resolvedGatewayProviderId"] = resolved.ResolvedGatewayProviderID
next["resolvedModel"] = resolved.ResolvedModel
next["resolvedSkills"] = append([]string(nil), resolved.ResolvedSkills...)
return next
@@ -178,6 +182,7 @@ func sanitizeExternalACPParams(method string, params map[string]any) map[string]
delete(next, "resolvedExecutionTarget")
delete(next, "resolvedEndpointTarget")
delete(next, "resolvedProviderId")
+ delete(next, "resolvedGatewayProviderId")
delete(next, "resolvedModel")
delete(next, "resolvedSkills")
delete(next, inboundAuthorizationHeaderKey)
diff --git a/internal/acp/provider_catalog.go b/internal/acp/provider_catalog.go
index 66e00d3..7cccf73 100644
--- a/internal/acp/provider_catalog.go
+++ b/internal/acp/provider_catalog.go
@@ -3,6 +3,7 @@ package acp
import (
"strings"
+ "xworkmate-bridge/internal/router"
"xworkmate-bridge/internal/shared"
)
@@ -112,3 +113,16 @@ func providerLabel(provider syncedProvider) string {
}
return provider.ProviderID
}
+
+func availableGatewayProviderCatalog() []map[string]any {
+ return []map[string]any{
+ {
+ "providerId": router.GatewayProviderLocal,
+ "label": "Local",
+ },
+ {
+ "providerId": router.GatewayProviderOpenClaw,
+ "label": "OpenClaw",
+ },
+ }
+}
diff --git a/internal/acp/providers_sync_test.go b/internal/acp/providers_sync_test.go
index ff975ff..70ded50 100644
--- a/internal/acp/providers_sync_test.go
+++ b/internal/acp/providers_sync_test.go
@@ -38,9 +38,16 @@ func TestCapabilitiesExposeBuiltInProductionProviderCatalog(t *testing.T) {
if !ok {
t.Fatalf("expected providerCatalog array, got %#v", result)
}
+ gatewayProviders, ok := result["gatewayProviders"].([]map[string]any)
+ if !ok {
+ t.Fatalf("expected gatewayProviders array, got %#v", result)
+ }
if len(providerCatalog) != 3 {
t.Fatalf("expected 3 built-in providers, got %#v", providerCatalog)
}
+ if len(gatewayProviders) != 2 {
+ t.Fatalf("expected 2 built-in gateway providers, got %#v", gatewayProviders)
+ }
wantOrder := []string{"codex", "opencode", "gemini"}
wantLabels := []string{"Codex", "OpenCode", "Gemini"}
for index, want := range wantOrder {
@@ -51,6 +58,16 @@ func TestCapabilitiesExposeBuiltInProductionProviderCatalog(t *testing.T) {
t.Fatalf("expected label %q at index %d, got %#v", wantLabels[index], index, providerCatalog)
}
}
+ wantGatewayOrder := []string{"local", "openclaw"}
+ wantGatewayLabels := []string{"Local", "OpenClaw"}
+ for index, want := range wantGatewayOrder {
+ if got := gatewayProviders[index]["providerId"]; got != want {
+ t.Fatalf("expected gateway provider %q at index %d, got %#v", want, index, gatewayProviders)
+ }
+ if got := gatewayProviders[index]["label"]; got != wantGatewayLabels[index] {
+ t.Fatalf("expected gateway label %q at index %d, got %#v", wantGatewayLabels[index], index, gatewayProviders)
+ }
+ }
}
func TestProvidersSyncMethodIsRemovedFromProductionFlow(t *testing.T) {
diff --git a/internal/acp/routing.go b/internal/acp/routing.go
index 0943507..d4146b8 100644
--- a/internal/acp/routing.go
+++ b/internal/acp/routing.go
@@ -27,23 +27,24 @@ func resolveRoutingMetadataWithProviders(
resolver := router.NewResolver()
result := resolver.Resolve(router.Request{
- Prompt: strings.TrimSpace(sharedString(params, "taskPrompt")),
- WorkingDirectory: strings.TrimSpace(sharedString(params, "workingDirectory")),
- RoutingMode: strings.TrimSpace(sharedString(routingParams, "routingMode")),
- PreferredGatewayTarget: strings.TrimSpace(sharedString(routingParams, "preferredGatewayTarget")),
- ExplicitExecutionTarget: strings.TrimSpace(sharedString(routingParams, "explicitExecutionTarget")),
- ExplicitProviderID: strings.TrimSpace(sharedString(routingParams, "explicitProviderId")),
- ExplicitModel: strings.TrimSpace(sharedString(routingParams, "explicitModel")),
- ExplicitSkills: parseRoutingStringSlice(routingParams["explicitSkills"]),
- AllowSkillInstall: parseBool(routingParams["allowSkillInstall"]),
+ Prompt: strings.TrimSpace(sharedString(params, "taskPrompt")),
+ WorkingDirectory: strings.TrimSpace(sharedString(params, "workingDirectory")),
+ RoutingMode: strings.TrimSpace(sharedString(routingParams, "routingMode")),
+ PreferredGatewayTarget: strings.TrimSpace(sharedString(routingParams, "preferredGatewayTarget")),
+ PreferredGatewayProviderID: strings.TrimSpace(sharedString(routingParams, "preferredGatewayProviderId")),
+ ExplicitExecutionTarget: strings.TrimSpace(sharedString(routingParams, "explicitExecutionTarget")),
+ ExplicitProviderID: strings.TrimSpace(sharedString(routingParams, "explicitProviderId")),
+ ExplicitModel: strings.TrimSpace(sharedString(routingParams, "explicitModel")),
+ ExplicitSkills: parseRoutingStringSlice(routingParams["explicitSkills"]),
+ AllowSkillInstall: parseBool(routingParams["allowSkillInstall"]),
InstallApproval: skills.InstallApproval{
RequestID: strings.TrimSpace(sharedString(installApproval, "requestId")),
ApprovedSkillKeys: parseRoutingStringSlice(installApproval["approvedSkillKeys"]),
},
- AvailableSkills: parseRoutingSkillCandidates(routingParams["availableSkills"]),
+ AvailableSkills: parseRoutingSkillCandidates(routingParams["availableSkills"]),
AvailableProviders: append([]string(nil), availableProviders...),
- AIGatewayBaseURL: strings.TrimSpace(sharedString(params, "aiGatewayBaseUrl")),
- AIGatewayAPIKey: strings.TrimSpace(sharedString(params, "aiGatewayApiKey")),
+ AIGatewayBaseURL: strings.TrimSpace(sharedString(params, "aiGatewayBaseUrl")),
+ AIGatewayAPIKey: strings.TrimSpace(sharedString(params, "aiGatewayApiKey")),
})
return result, true
}
@@ -55,6 +56,7 @@ func mergeRoutingResponse(response map[string]any, result router.Result) map[str
response["resolvedExecutionTarget"] = result.ResolvedExecutionTarget
response["resolvedEndpointTarget"] = result.ResolvedEndpointTarget
response["resolvedProviderId"] = result.ResolvedProviderID
+ response["resolvedGatewayProviderId"] = result.ResolvedGatewayProviderID
response["resolvedModel"] = result.ResolvedModel
response["resolvedSkills"] = append([]string(nil), result.ResolvedSkills...)
response["skillResolutionSource"] = result.SkillResolutionSource
diff --git a/internal/acp/routing_test.go b/internal/acp/routing_test.go
index 4c13e30..ececfde 100644
--- a/internal/acp/routing_test.go
+++ b/internal/acp/routing_test.go
@@ -58,6 +58,7 @@ func TestHandleRoutingResolveCoversNineScenarioBuckets(t *testing.T) {
name string
prompt string
expectedExecutionTarget string
+ expectedGatewayProviderID string
expectedSkillSource string
expectedResolvedSkill string
expectedNeedsSkillInstall bool
@@ -101,6 +102,7 @@ func TestHandleRoutingResolveCoversNineScenarioBuckets(t *testing.T) {
name: "image-cog",
prompt: "use image-cog to generate consistent characters",
expectedExecutionTarget: "gateway",
+ expectedGatewayProviderID: "local",
expectedSkillSource: "find_skills",
expectedNeedsSkillInstall: true,
},
@@ -108,6 +110,7 @@ func TestHandleRoutingResolveCoversNineScenarioBuckets(t *testing.T) {
name: "image-video-generation-editting",
prompt: "wan 图生视频并做视频编辑",
expectedExecutionTarget: "gateway",
+ expectedGatewayProviderID: "local",
expectedSkillSource: "find_skills",
expectedNeedsSkillInstall: true,
},
@@ -115,15 +118,17 @@ func TestHandleRoutingResolveCoversNineScenarioBuckets(t *testing.T) {
name: "video-translator",
prompt: "translate video subtitles and dub the clip",
expectedExecutionTarget: "gateway",
+ expectedGatewayProviderID: "local",
expectedSkillSource: "find_skills",
expectedNeedsSkillInstall: true,
},
{
- name: "browser-search-news",
- prompt: "跨浏览器执行并搜索最新资讯采集结果",
- expectedExecutionTarget: "gateway",
- expectedSkillSource: "local_match",
- expectedResolvedSkill: "Browser Automation",
+ name: "browser-search-news",
+ prompt: "跨浏览器执行并搜索最新资讯采集结果",
+ expectedExecutionTarget: "gateway",
+ expectedGatewayProviderID: "local",
+ expectedSkillSource: "local_match",
+ expectedResolvedSkill: "Browser Automation",
},
}
@@ -149,6 +154,11 @@ func TestHandleRoutingResolveCoversNineScenarioBuckets(t *testing.T) {
if got := result["resolvedExecutionTarget"]; got != tc.expectedExecutionTarget {
t.Fatalf("expected execution target %q, got %#v", tc.expectedExecutionTarget, got)
}
+ if tc.expectedGatewayProviderID != "" {
+ if got := result["resolvedGatewayProviderId"]; got != tc.expectedGatewayProviderID {
+ t.Fatalf("expected gateway provider %q, got %#v", tc.expectedGatewayProviderID, got)
+ }
+ }
if got := result["skillResolutionSource"]; got != tc.expectedSkillSource {
t.Fatalf("expected skill source %q, got %#v", tc.expectedSkillSource, got)
}
diff --git a/internal/acp/server.go b/internal/acp/server.go
index d421e43..3abd13d 100644
--- a/internal/acp/server.go
+++ b/internal/acp/server.go
@@ -300,19 +300,22 @@ func (s *Server) handleRequest(
switch method {
case "acp.capabilities":
providerCatalog := s.availableProviderCatalog()
+ gatewayProviders := availableGatewayProviderCatalog()
singleAgent := len(providerCatalog) > 0
multiAgent := shared.BoolArg(
shared.EnvOrDefault("ACP_MULTI_AGENT_ENABLED", "true"),
true,
)
result := map[string]any{
- "singleAgent": singleAgent,
- "multiAgent": multiAgent,
- "providerCatalog": providerCatalog,
+ "singleAgent": singleAgent,
+ "multiAgent": multiAgent,
+ "providerCatalog": providerCatalog,
+ "gatewayProviders": gatewayProviders,
"capabilities": map[string]any{
- "single_agent": singleAgent,
- "multi_agent": multiAgent,
- "providerCatalog": providerCatalog,
+ "single_agent": singleAgent,
+ "multi_agent": multiAgent,
+ "providerCatalog": providerCatalog,
+ "gatewayProviders": gatewayProviders,
},
}
return result, nil
diff --git a/internal/router/router.go b/internal/router/router.go
index 690c189..7b6afa0 100644
--- a/internal/router/router.go
+++ b/internal/router/router.go
@@ -20,39 +20,44 @@ const (
EndpointTargetSingleAgent = "singleAgent"
EndpointTargetLocal = "local"
EndpointTargetRemote = "remote"
+
+ GatewayProviderLocal = "local"
+ GatewayProviderOpenClaw = "openclaw"
)
type Request struct {
- Prompt string
- WorkingDirectory string
- RoutingMode string
- PreferredGatewayTarget string
- ExplicitExecutionTarget string
- ExplicitProviderID string
- ExplicitModel string
- ExplicitSkills []string
- AllowSkillInstall bool
- InstallApproval skills.InstallApproval
- AvailableSkills []skills.Candidate
- AvailableProviders []string
- AIGatewayBaseURL string
- AIGatewayAPIKey string
+ Prompt string
+ WorkingDirectory string
+ RoutingMode string
+ PreferredGatewayTarget string
+ PreferredGatewayProviderID string
+ ExplicitExecutionTarget string
+ ExplicitProviderID string
+ ExplicitModel string
+ ExplicitSkills []string
+ AllowSkillInstall bool
+ InstallApproval skills.InstallApproval
+ AvailableSkills []skills.Candidate
+ AvailableProviders []string
+ AIGatewayBaseURL string
+ AIGatewayAPIKey string
}
type Result struct {
- ResolvedExecutionTarget string
- ResolvedEndpointTarget string
- ResolvedProviderID string
- ResolvedModel string
- ResolvedSkills []string
- SkillResolutionSource string
- SkillCandidates []skills.Candidate
- NeedsSkillInstall bool
- SkillInstallRequestID string
- MemorySources []memory.Source
- Unavailable bool
- UnavailableCode string
- UnavailableMessage string
+ ResolvedExecutionTarget string
+ ResolvedEndpointTarget string
+ ResolvedProviderID string
+ ResolvedGatewayProviderID string
+ ResolvedModel string
+ ResolvedSkills []string
+ SkillResolutionSource string
+ SkillCandidates []skills.Candidate
+ NeedsSkillInstall bool
+ SkillInstallRequestID string
+ MemorySources []memory.Source
+ Unavailable bool
+ UnavailableCode string
+ UnavailableMessage string
}
type Resolver struct {
@@ -81,7 +86,7 @@ func (r Resolver) Resolve(req Request) Result {
MemorySources: mem.Sources,
}
- result.ResolvedExecutionTarget, result.ResolvedEndpointTarget = r.resolveExecution(req, mem.Preferences)
+ result.ResolvedExecutionTarget, result.ResolvedEndpointTarget, result.ResolvedGatewayProviderID = r.resolveExecution(req, mem.Preferences)
result.ResolvedProviderID, result.Unavailable, result.UnavailableCode, result.UnavailableMessage = resolveProvider(
req,
mem.Preferences,
@@ -124,7 +129,10 @@ func (r Resolver) Resolve(req Request) Result {
}
if result.ResolvedEndpointTarget == "" {
if result.ResolvedExecutionTarget == ExecutionTargetGateway {
- result.ResolvedEndpointTarget = normalizeGatewayTarget(req.PreferredGatewayTarget)
+ result.ResolvedGatewayProviderID, result.ResolvedEndpointTarget = resolveGatewayRouting(
+ req.PreferredGatewayProviderID,
+ req.PreferredGatewayTarget,
+ )
} else {
result.ResolvedEndpointTarget = EndpointTargetSingleAgent
}
@@ -132,10 +140,14 @@ func (r Resolver) Resolve(req Request) Result {
return result
}
-func (r Resolver) resolveExecution(req Request, prefs memory.Preferences) (string, string) {
+func (r Resolver) resolveExecution(req Request, prefs memory.Preferences) (string, string, string) {
explicit := strings.TrimSpace(req.ExplicitExecutionTarget)
if strings.EqualFold(strings.TrimSpace(req.RoutingMode), RoutingModeExplicit) && explicit != "" {
- return mapExplicitTarget(explicit)
+ return mapExplicitTarget(
+ explicit,
+ req.PreferredGatewayProviderID,
+ req.PreferredGatewayTarget,
+ )
}
prompt := normalize(req.Prompt)
@@ -146,40 +158,56 @@ func (r Resolver) resolveExecution(req Request, prefs memory.Preferences) (strin
switch {
case localTask && complexTask:
- return ExecutionTargetMultiAgent, EndpointTargetSingleAgent
+ return ExecutionTargetMultiAgent, EndpointTargetSingleAgent, ""
case onlineTask && complexTask:
- return ExecutionTargetMultiAgent, EndpointTargetSingleAgent
+ return ExecutionTargetMultiAgent, EndpointTargetSingleAgent, ""
case localTask:
- return ExecutionTargetSingleAgent, EndpointTargetSingleAgent
+ return ExecutionTargetSingleAgent, EndpointTargetSingleAgent, ""
case onlineTask:
- return ExecutionTargetGateway, normalizeGatewayTarget(req.PreferredGatewayTarget)
+ providerID, endpointTarget := resolveGatewayRouting(
+ req.PreferredGatewayProviderID,
+ req.PreferredGatewayTarget,
+ )
+ return ExecutionTargetGateway, endpointTarget, providerID
case complexTask:
- return ExecutionTargetMultiAgent, EndpointTargetSingleAgent
+ return ExecutionTargetMultiAgent, EndpointTargetSingleAgent, ""
}
switch normalizeExecutionTarget(r.classify(req)) {
case ExecutionTargetGateway:
- return ExecutionTargetGateway, normalizeGatewayTarget(req.PreferredGatewayTarget)
+ providerID, endpointTarget := resolveGatewayRouting(
+ req.PreferredGatewayProviderID,
+ req.PreferredGatewayTarget,
+ )
+ return ExecutionTargetGateway, endpointTarget, providerID
case ExecutionTargetMultiAgent:
- return ExecutionTargetMultiAgent, EndpointTargetSingleAgent
+ return ExecutionTargetMultiAgent, EndpointTargetSingleAgent, ""
case ExecutionTargetSingleAgent:
- return ExecutionTargetSingleAgent, EndpointTargetSingleAgent
+ return ExecutionTargetSingleAgent, EndpointTargetSingleAgent, ""
}
switch normalizeExecutionTarget(strings.TrimSpace(prefs.PreferredRoute)) {
case ExecutionTargetGateway:
- return ExecutionTargetGateway, normalizeGatewayTarget(req.PreferredGatewayTarget)
+ providerID, endpointTarget := resolveGatewayRouting(
+ req.PreferredGatewayProviderID,
+ req.PreferredGatewayTarget,
+ )
+ return ExecutionTargetGateway, endpointTarget, providerID
case ExecutionTargetMultiAgent:
- return ExecutionTargetMultiAgent, EndpointTargetSingleAgent
+ return ExecutionTargetMultiAgent, EndpointTargetSingleAgent, ""
case ExecutionTargetSingleAgent:
if len(normalizeProviders(req.AvailableProviders)) > 0 {
- return ExecutionTargetSingleAgent, EndpointTargetSingleAgent
+ return ExecutionTargetSingleAgent, EndpointTargetSingleAgent, ""
}
}
if len(normalizeProviders(req.AvailableProviders)) > 0 {
- return ExecutionTargetSingleAgent, EndpointTargetSingleAgent
+ return ExecutionTargetSingleAgent, EndpointTargetSingleAgent, ""
}
- return ExecutionTargetGateway, normalizeGatewayTarget(req.PreferredGatewayTarget)
+ providerID, endpointTarget := resolveGatewayRouting(
+ req.PreferredGatewayProviderID,
+ req.PreferredGatewayTarget,
+ )
+ return ExecutionTargetGateway, endpointTarget, providerID
}
func (r Resolver) classify(req Request) string {
@@ -193,27 +221,75 @@ func (r Resolver) classify(req Request) string {
}))
}
-func mapExplicitTarget(value string) (string, string) {
+func mapExplicitTarget(
+ value string,
+ preferredGatewayProviderID string,
+ preferredGatewayTarget string,
+) (string, string, string) {
switch strings.TrimSpace(value) {
case EndpointTargetLocal:
- return ExecutionTargetGateway, EndpointTargetLocal
+ return ExecutionTargetGateway, EndpointTargetLocal, GatewayProviderLocal
case EndpointTargetRemote:
- return ExecutionTargetGateway, EndpointTargetRemote
+ return ExecutionTargetGateway, EndpointTargetRemote, GatewayProviderOpenClaw
case "multiAgent", ExecutionTargetMultiAgent:
- return ExecutionTargetMultiAgent, EndpointTargetSingleAgent
+ return ExecutionTargetMultiAgent, EndpointTargetSingleAgent, ""
case EndpointTargetSingleAgent, ExecutionTargetSingleAgent:
- return ExecutionTargetSingleAgent, EndpointTargetSingleAgent
+ return ExecutionTargetSingleAgent, EndpointTargetSingleAgent, ""
+ case ExecutionTargetGateway:
+ providerID, endpointTarget := resolveGatewayRouting(
+ preferredGatewayProviderID,
+ preferredGatewayTarget,
+ )
+ return ExecutionTargetGateway, endpointTarget, providerID
default:
- return ExecutionTargetSingleAgent, EndpointTargetSingleAgent
+ return ExecutionTargetSingleAgent, EndpointTargetSingleAgent, ""
}
}
func normalizeGatewayTarget(value string) string {
- switch strings.TrimSpace(value) {
- case EndpointTargetLocal, "":
- return EndpointTargetLocal
+ _, endpointTarget := resolveGatewayRouting("", value)
+ return endpointTarget
+}
+
+func resolveGatewayRouting(preferredGatewayProviderID, preferredGatewayTarget string) (string, string) {
+ providerID := normalizeGatewayProvider(preferredGatewayProviderID)
+ if providerID == "" {
+ providerID = gatewayProviderFromEndpointTarget(preferredGatewayTarget)
+ }
+ if providerID == "" {
+ providerID = GatewayProviderLocal
+ }
+ return providerID, endpointTargetForGatewayProvider(providerID)
+}
+
+func normalizeGatewayProvider(value string) string {
+ switch normalize(value) {
+ case GatewayProviderLocal:
+ return GatewayProviderLocal
+ case GatewayProviderOpenClaw:
+ return GatewayProviderOpenClaw
default:
+ return ""
+ }
+}
+
+func gatewayProviderFromEndpointTarget(value string) string {
+ switch strings.TrimSpace(value) {
+ case EndpointTargetRemote:
+ return GatewayProviderOpenClaw
+ case EndpointTargetLocal, "":
+ return GatewayProviderLocal
+ default:
+ return ""
+ }
+}
+
+func endpointTargetForGatewayProvider(providerID string) string {
+ switch normalizeGatewayProvider(providerID) {
+ case GatewayProviderOpenClaw:
return EndpointTargetRemote
+ default:
+ return EndpointTargetLocal
}
}
diff --git a/internal/router/router_test.go b/internal/router/router_test.go
index 58d552e..4881a53 100644
--- a/internal/router/router_test.go
+++ b/internal/router/router_test.go
@@ -96,6 +96,9 @@ func TestResolveAutoOnlineTaskToGateway(t *testing.T) {
if result.ResolvedEndpointTarget != EndpointTargetLocal {
t.Fatalf("expected local gateway target, got %#v", result)
}
+ if result.ResolvedGatewayProviderID != GatewayProviderLocal {
+ t.Fatalf("expected local gateway provider, got %#v", result)
+ }
}
func TestResolveComplexTaskUpgradesToMultiAgent(t *testing.T) {
@@ -133,4 +136,30 @@ func TestResolveUsesClassifierForBoundarySamples(t *testing.T) {
if result.ResolvedEndpointTarget != EndpointTargetLocal {
t.Fatalf("expected local endpoint target, got %#v", result)
}
+ if result.ResolvedGatewayProviderID != GatewayProviderLocal {
+ t.Fatalf("expected local gateway provider, got %#v", result)
+ }
+}
+
+func TestResolveGatewayProviderMapsOpenClawToRemoteEndpoint(t *testing.T) {
+ resolver := Resolver{
+ SkillFinder: skills.StaticFinder{},
+ SkillInstaller: nil,
+ MemoryService: memory.Service{},
+ }
+
+ result := resolver.Resolve(Request{
+ Prompt: "search the web for latest news",
+ PreferredGatewayProviderID: GatewayProviderOpenClaw,
+ })
+
+ if result.ResolvedExecutionTarget != ExecutionTargetGateway {
+ t.Fatalf("expected gateway route, got %#v", result)
+ }
+ if result.ResolvedGatewayProviderID != GatewayProviderOpenClaw {
+ t.Fatalf("expected openclaw gateway provider, got %#v", result)
+ }
+ if result.ResolvedEndpointTarget != EndpointTargetRemote {
+ t.Fatalf("expected remote endpoint target for openclaw, got %#v", result)
+ }
}