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