bridge: add gateway provider routing model

This commit is contained in:
Haitao Pan 2026-04-11 16:35:01 +08:00
parent 17bbe1f71a
commit 26db84561a
14 changed files with 523 additions and 98 deletions

View File

@ -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)

View File

@ -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:

View File

@ -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:

View File

@ -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 能力发现<br/>acp.capabilities"]
APPGW["Gateway 连接<br/>xworkmate.gateway.connect"]
APP --> APPACP
APP --> APPGW
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 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<br/>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/*`

View File

@ -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.

View File

@ -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 动态变化。

View File

@ -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)

View File

@ -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",
},
}
}

View File

@ -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) {

View File

@ -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

View File

@ -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)
}

View File

@ -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

View File

@ -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
}
}

View File

@ -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)
}
}