From 11c20982e836cfc0ec22f8f2d2678b2fcd6129a9 Mon Sep 17 00:00:00 2001 From: telli Date: Fri, 18 Sep 2026 19:44:55 -0700 Subject: [PATCH 1/3] fix: enable native Nacos events and verify live capability binding Co-authored-by: geffzhang --- .github/workflows/nacos-live.yml | 81 +++++++ docs/capability-resolution.md | 8 +- docs/nacos-mcp-router.md | 16 +- ...06\346\210\220\346\236\266\346\236\204.md" | 2 +- docs/zh-CN/nacos-mcp-router.md | 159 ++++++-------- eng/NacosLiveSmoke/NacosLiveSmoke.csproj | 11 + eng/NacosLiveSmoke/Program.cs | 125 +++++++++++ eng/nacos-live/.gitignore | 1 + eng/nacos-live/README.md | 71 +++++++ eng/nacos-live/requirements.txt | 2 + eng/nacos-live/router_server.py | 14 ++ eng/nacos-live/verify.py | 197 ++++++++++++++++++ eng/nacos-live/weather_server.py | 32 +++ eng/verify-capability-adapters.sh | 14 +- .../NacosConfigSubscriptionService.cs | 17 +- .../OpenClaw.Adapters.Nacos.Events.csproj | 7 +- .../RedNbNacosConfigService.cs | 4 +- src/OpenClaw.Gateway/OpenClaw.Gateway.csproj | 2 - .../NacosConfigSubscriptionServiceTests.cs | 28 ++- .../NacosRouterIntegrationTests.cs | 49 +++++ 20 files changed, 703 insertions(+), 137 deletions(-) create mode 100644 .github/workflows/nacos-live.yml create mode 100644 eng/NacosLiveSmoke/NacosLiveSmoke.csproj create mode 100644 eng/NacosLiveSmoke/Program.cs create mode 100644 eng/nacos-live/.gitignore create mode 100644 eng/nacos-live/README.md create mode 100644 eng/nacos-live/requirements.txt create mode 100644 eng/nacos-live/router_server.py create mode 100644 eng/nacos-live/verify.py create mode 100644 eng/nacos-live/weather_server.py diff --git a/.github/workflows/nacos-live.yml b/.github/workflows/nacos-live.yml new file mode 100644 index 00000000..2d38a5b8 --- /dev/null +++ b/.github/workflows/nacos-live.yml @@ -0,0 +1,81 @@ +name: Nacos live acceptance + +on: + workflow_dispatch: + push: + branches: [main] + paths: &paths + - '.github/workflows/nacos-live.yml' + - 'eng/NacosLiveSmoke/**' + - 'eng/nacos-live/**' + - 'eng/verify-capability-adapters.sh' + - 'src/OpenClaw.Adapters.Nacos*/**' + - 'src/OpenClaw.Agent/**' + - 'src/OpenClaw.Core/**' + - 'src/OpenClaw.Gateway/**' + - 'src/OpenClaw.Tests/**' + - 'Directory.Build.*' + pull_request: + branches: [main] + paths: *paths + +permissions: + contents: read + +concurrency: + group: nacos-live-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + nacos-live-native: + runs-on: ubuntu-latest + timeout-minutes: 35 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: '10.0.x' + - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 + with: + distribution: temurin + java-version: '17' + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + with: + python-version: '3.12' + cache: pip + cache-dependency-path: eng/nacos-live/requirements.txt + - name: Install native toolchain and pinned Router + run: | + bash .github/scripts/install-nativeaot-prereqs.sh + python -m venv "$RUNNER_TEMP/nacos-python" + "$RUNNER_TEMP/nacos-python/bin/pip" install -r eng/nacos-live/requirements.txt + "$RUNNER_TEMP/nacos-python/bin/python" -c 'from chromadb.utils.embedding_functions import DefaultEmbeddingFunction; DefaultEmbeddingFunction()(["weather city"])' + curl --fail --location --retry 3 https://github.com/alibaba/nacos/releases/download/3.2.4/nacos-server-3.2.4.tar.gz -o "$RUNNER_TEMP/nacos.tar.gz" + - name: Publish NativeAOT Gateway with optional events + run: dotnet publish src/OpenClaw.Gateway -c Release -r linux-x64 -p:OpenClawEnableNacos=true -p:OpenClawEnableNacosEvents=true -p:OpenClawSkipDashboardBuild=true -o "$RUNNER_TEMP/nacos-gateway" + - name: Build acceptance executables and runtime tests + run: | + dotnet publish eng/NacosLiveSmoke -c Release -r linux-x64 -p:PublishAot=true -o "$RUNNER_TEMP/nacos-native" + dotnet build eng/NacosLiveSmoke -c Release -p:PublishAot=false + dotnet build src/OpenClaw.Tests -c Release -p:OpenClawSkipDashboardBuild=true + - name: Verify authenticated Nacos events and weather binding + run: | + "$RUNNER_TEMP/nacos-python/bin/python" eng/nacos-live/verify.py \ + --archive "$RUNNER_TEMP/nacos.tar.gz" \ + --managed eng/NacosLiveSmoke/bin/Release/net10.0/NacosLiveSmoke.dll \ + --native "$RUNNER_TEMP/nacos-native/NacosLiveSmoke" \ + --test-dll src/OpenClaw.Tests/bin/Release/net10.0/OpenClaw.Tests.dll \ + --output "$RUNNER_TEMP/nacos-evidence" + - name: Upload acceptance evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: nacos-live-evidence + path: | + ${{ runner.temp }}/nacos-evidence/*.json + ${{ runner.temp }}/nacos-evidence/*.trx + ${{ runner.temp }}/nacos-evidence/managed.log + ${{ runner.temp }}/nacos-evidence/native.log + ${{ runner.temp }}/nacos-evidence/runtimes.log diff --git a/docs/capability-resolution.md b/docs/capability-resolution.md index 197cbe0d..7fc73bdd 100644 --- a/docs/capability-resolution.md +++ b/docs/capability-resolution.md @@ -9,7 +9,7 @@ OpenClaw.NET owns deterministic capability resolution and execution. The default | `OpenClaw.Core` | Provider, candidate, binding, invalidation, trajectory, and status contracts; no vendor SDK types | | `OpenClaw.Agent` | Provider selection, deterministic ranking, policy-governed execution, bounded caches, circuit protection | | `OpenClaw.Adapters.Nacos` | Router search/add/use protocol and failure normalization over the existing MCP transport | -| `OpenClaw.Adapters.Nacos.Events` | Optional Nacos SDK, configuration, listener lifecycle, generic invalidation events; JIT only | +| `OpenClaw.Adapters.Nacos.Events` | Optional Nacos SDK, configuration, listener lifecycle, generic invalidation events; JIT and NativeAOT | | AgentQi | Ecosystem documentation, catalog curation/trust assessment, setup and operational UX | Catalog trust is input to runtime policy, never permission to bypass local authorization, approvals, hooks, or audit. Providers implement `ICapabilityProvider`; change adapters implement `ICapabilityChangeSource` and publish through `ICapabilityInvalidationSink`. @@ -43,9 +43,9 @@ The tool returns provider, server, tool, schema, schema fingerprint, and attempt | --- | --- | --- | | Default Gateway | local | absent | | `-p:OpenClawEnableNacos=true` | local, nacos | absent; suitable for NativeAOT | -| Above plus `-p:OpenClawEnableNacosEvents=true -p:PublishAot=false` | local, nacos | explicit JIT adapter | +| Above plus `-p:OpenClawEnableNacosEvents=true` | local, nacos | explicit SDK adapter; JIT or NativeAOT | -The default Gateway dependency graph has no Nacos package reference. An SDK-events NativeAOT build fails with an actionable diagnostic; native event support remains [#239](https://github.com/clawdotnet/openclaw.net/issues/239). Default serialization stays source-generated. Only the explicitly selected JIT event host enables SDK-required reflection serialization. +The default Gateway dependency graph has no Nacos package reference. The optional event adapter uses RedNb.Nacos.DependencyInjection 2.1.0 and its generated protocol JSON metadata. It supports NativeAOT without enabling reflection serialization. Use `-p:PublishAot=false` for JIT publishing. The SDK stays absent unless explicitly selected. Configure the Router as MCP server `nacos-mcp-router`, and add `provider: nacos` to capability references. See the [Router contract and deployment guide](nacos-mcp-router.md). Optional event settings live under the generic extension bag: @@ -86,7 +86,7 @@ Step evidence records provider, generation, intent, candidates/attempts, selecte Conformance tests cover both runtimes with the local provider, permission denial before discovery, authorization on cache hits, generation races, provider/security isolation, bounded expiry, circuit cooldown, and replay divergence. Router tests cover captured protocol envelopes and typed failures; listener tests cover retry, cancellation, and disposal. -Live Nacos/Router acceptance is opt-in. Historical contributor measurements in the Router guide are not a new live validation of this refactor. A correctly registered weather backend and live subscription timing still need deployment evidence; NativeAOT SDK events remain separate work. AgentQi catalog/trust and operational screen design belong in the downstream ecosystem/product backlog. +Live Nacos/Router acceptance is reproducible through [the isolated acceptance harness](../eng/nacos-live/README.md). It runs authenticated Nacos 3.2.4 and Router 0.2.2, exercises both runtimes, and checks real event invalidation and rebinds in managed and NativeAOT processes with JSON reflection disabled. The dedicated CI lane provisions its own services; ordinary unit tests remain independent of them. Historical contributor token measurements remain separately attributed in the Router guide. This proves the tested deployment contract, not arbitrary registry recall or production availability. AgentQi catalog/trust and operational screen design belong in the downstream ecosystem/product backlog. Nacos target retries require an explicit operator allowlist under `adapterSettings.nacos.retrySafeTargets`, for example `["weather-mcp/get_weather"]`. Only list operations known to be safe to repeat; other targets execute once regardless of a skill's retry count. The retry weather example requires this setting. diff --git a/docs/nacos-mcp-router.md b/docs/nacos-mcp-router.md index e572ad56..967508cb 100644 --- a/docs/nacos-mcp-router.md +++ b/docs/nacos-mcp-router.md @@ -2,7 +2,7 @@ The current architecture and build contract is [vendor-neutral capability resolution](capability-resolution.md). Select `provider: nacos` explicitly. The Router adapter and SDK event adapter are independent optional components. -The live observations below were supplied with the contributor's `nacos` branch on 2026-09-14. They preserve its protocol evidence and measurement caveats; they are not a fresh live validation of the vendor-neutral refactor. In particular, successful weather discovery was not demonstrated on the misregistered test backend. +Zhang (@geffzhang) supplied the original implementation, protocol captures, and token measurements on 2026-09-14; those historical observations and their caveats are preserved below. The [isolated acceptance harness](../eng/nacos-live/README.md) now validates the current vendor-neutral adapters with a correctly registered `get_weather(city)` backend, both runtimes, and managed/native event invalidation. ## Contract observations @@ -58,7 +58,7 @@ Live-capture findings (2026-09-14): ## Opt-in configuration -Build Gateway with `-p:OpenClawEnableNacos=true` to enable the provider. SDK events require the additional explicit JIT options below. +Build Gateway with `-p:OpenClawEnableNacos=true` to enable the provider. SDK events require the additional explicit adapter flag below, for either JIT or NativeAOT. Keep Nacos and Router on the same host when Nacos binds only to loopback. Do not change an existing deployment's networking or authentication for this example. @@ -300,15 +300,15 @@ cache with the recorded binding). See ## Nacos event subscription (issue #238) -Build with `-p:OpenClawEnableNacos=true -p:OpenClawEnableNacosEvents=true -p:PublishAot=false` and configure `adapterSettings.nacos`. See the [configuration example](capability-resolution.md#optional-adapter-builds). +Build with `-p:OpenClawEnableNacos=true -p:OpenClawEnableNacosEvents=true` (add `-p:PublishAot=false` for JIT) and configure `adapterSettings.nacos`. See the [configuration example](capability-resolution.md#optional-adapter-builds). -The optional SDK adapter registers in the background, retries failed setup, and reports `starting`, `degraded`, `active`, or `stopped`. Events publish generic invalidation signals that advance the cache generation. They do not overwrite the local workspace configuration. With no adapter or no address, TTL and explicit workspace reload remain available. NativeAOT SDK-event builds are rejected explicitly; #239 remains open. +The optional SDK adapter registers in the background, retries failed setup, and reports `starting`, `degraded`, `active`, or `stopped`. Events publish generic invalidation signals that advance the cache generation. They do not overwrite the local workspace configuration. With no adapter or no address, TTL and explicit workspace reload remain available. RedNb.Nacos 2.1.0 supplies generated protocol JSON metadata; the optional adapter supports NativeAOT and does not turn JSON reflection back on. The live harness verifies <=2 s publish-to-invalidation and static/dynamic rebinds in both managed and native processes. -## Historical contributor evidence and outstanding live acceptance +## Historical contributor evidence -The following describes the original branch, before adapter isolation. Repeat live acceptance against the refactor before claiming deployment readiness. In particular, the old dual-cache/watcher implementation below has been replaced by generic generation invalidation. +The following preserves Zhang's evidence from the original branch, before adapter isolation. The current [acceptance harness](../eng/nacos-live/README.md) tests generic generation invalidation, which replaced the old dual-cache/watcher design. The old weather registration and token data below are historical evidence, not setup instructions for the new harness. -Before closing #229 or proceeding with the dependent runtime changes: +Original acceptance record: 1. ~~Capture the real three tool schemas, success/error responses, and Router package version from the intended Nacos 3.2.4 deployment. Redact credentials.~~ @@ -366,4 +366,4 @@ Before closing #229 or proceeding with the dependent runtime changes: | Median input + output tokens (5 runs) | 0 + 0 (no LLM turn) | 17479 + 1181 (traced batch; untraced batch at the iteration cap: 29763 + 1870) | | Router version / deployment | 0.2.2 (`@latest`, requires `mcp<2`) / local Nacos 3.2.4, streamable_http :8000 | same | -For current runtime/cache/retry/replay behavior and remaining acceptance, use [capability resolution](capability-resolution.md). +For current runtime/cache/retry/replay behavior and validation scope, use [capability resolution](capability-resolution.md). diff --git "a/docs/zh-CN/Nacos-MCP-Router\344\270\216OpenClaw.NET-MetaSkill\351\233\206\346\210\220\346\236\266\346\236\204.md" "b/docs/zh-CN/Nacos-MCP-Router\344\270\216OpenClaw.NET-MetaSkill\351\233\206\346\210\220\346\236\266\346\236\204.md" index 3b5df256..c435fab0 100644 --- "a/docs/zh-CN/Nacos-MCP-Router\344\270\216OpenClaw.NET-MetaSkill\351\233\206\346\210\220\346\236\266\346\236\204.md" +++ "b/docs/zh-CN/Nacos-MCP-Router\344\270\216OpenClaw.NET-MetaSkill\351\233\206\346\210\220\346\236\266\346\236\204.md" @@ -251,7 +251,7 @@ Router 语义检索的质量完全取决于 Nacos 中 MCP Server 的 `descriptio | 缓存粒度 | 会话级(默认)+ 运行时级(静态绑定) | | 缓存键 | intent 哈希(task_description + key_words + selectionPolicy) | | 失效机制 | TTL 过期(可配,默认 300s)+ mcp.json reload 成功清空(#232 已实现);订阅 Nacos 配置变更事件(#238 已实现,2026-09-14) | -| Nacos 变更事件订阅 | `RedNb.Nacos.All 2.0.0` LongPolling 订阅 mcp.json dataId;onChange → watcher reload → 会话绑定缓存 + 运行时 added-server 缓存双清;`ServerAddr` 未配置或 Nacos 不可达时优雅降级为 no-op,TTL/reload 兜底保持生效。运行时要求:SDK 的 gRPC 载荷走反射式 System.Text.Json,而 `PublishAot=true` 会在**所有** runtimeconfig 中注入 `System.Text.Json.JsonSerializer.IsReflectionEnabledByDefault=false`(JIT 运行也会中招)——csproj 仅在 JIT 构建(无 `RuntimeIdentifier`)时重新开启该开关;NativeAOT 下 SDK 载荷类型被裁剪,订阅降级为 TTL/reload 兜底(后续 issue 跟进) | +| Nacos 变更事件订阅 | 显式启用 `OpenClawEnableNacosEvents=true`,由可选适配器使用 RedNb.Nacos 2.1.0 源生成协议元数据,支持 JIT 和 NativeAOT,无需开启 JSON 反射。onChange 通过通用失效接口推进缓存 generation,清除静态/动态绑定;不覆盖 workspace 配置。未配置或不可达时保持 TTL/reload,并报告 disabled/degraded 状态。当前[可复现验收](../../eng/nacos-live/README.md)覆盖认证服务器、≤2 秒失效及重新绑定;原型 +310 ms 数据保留在英文指南的 Zhang 历史记录中。 | ### 7.4 版本与准入治理 diff --git a/docs/zh-CN/nacos-mcp-router.md b/docs/zh-CN/nacos-mcp-router.md index 25759861..3f8f307f 100644 --- a/docs/zh-CN/nacos-mcp-router.md +++ b/docs/zh-CN/nacos-mcp-router.md @@ -1,101 +1,58 @@ -# Nacos MCP Router 概念验证 - -> 当前实现以[供应商无关能力解析契约](../capability-resolution.md)为准:默认 `local`;Nacos 需显式 `provider: nacos` 和 `OpenClawEnableNacos=true`。事件 SDK 需额外 `OpenClawEnableNacosEvents=true`、`PublishAot=false`,配置迁移到 `adapterSettings.nacos`。NativeAOT 事件支持仍由 #239 跟踪。下文保留原分支的历史协议和现场记录,不代表重构后的新现场验收。 - -状态:**已提供可复现 mock 集成基础;真实部署验收尚未完成**。对应 -[#229](https://github.com/clawdotnet/openclaw.net/issues/229),完整配置和命令见 -[英文指南](../nacos-mcp-router.md)。没有宣称已验证真实召回率或模型 token 基线。 - -## 已确认的契约差异 - -依据上游 Python Router 固定提交 `0ee95f4f353d6f66184dafdb3e0ffd342c4edb09` 的源码: - -- search 参数是 `task_description` 和逗号分隔字符串 `key_words`。 -- use 参数是 `mcp_server_name`、`mcp_tool_name`、`params`,不是 `tool_name`。 -- search 返回嵌有 JSON 对象的说明文字,没有 score/version 字段。 -- 部分安装、健康检查、执行错误只是普通文本,不设置 MCP `isError`。 -- server ID 中的连字符会保留。显式设置 `toolNamePrefix: nacos_mcp_router_` - 才能保证示例中的三个下划线工具名。 - -这些是源码观察,不是假称的真实端点抓包。协议级失败与普通错误文本必须区分; -Resolver 已定义解析(信封解析器)、排序(位置 `rank`)与错误归一化契约(见下文), -版本约束仍待 #231 收尾时确定。 - -## 验证范围 - -`examples/skills/nacos-router-weather` 使用 bind → query 的静态 DAG,并直接返回 -工具结果。`nacos-router-weather-explore` 提供模型驱动的 search → add → use 基线。 -示例不进入生产技能索引,需在独立测试 workspace 手动启用。 - -运行 mock 测试: - -```sh -dotnet test src/OpenClaw.Tests -c Release --filter FullyQualifiedName~NacosRouterIntegrationTests -``` - -已有真实 Router 和 weather-mcp 注册后,可设置 `OPENCLAW_NACOS_LIVE=1`、 -`OPENCLAW_NACOS_ROUTER_URL`,运行 `FullyQualifiedName~LiveRouter` 筛选器。 -未设置时跳过 live 测试,普通 CI 不依赖外部服务。 - -## 能力解析器(issue #230) - -`resolve_capability` 原生工具把模型驱动的三步链变成确定性代码路径: -模型只需产出 intent,绑定在代码中完成。 - -输入: - -- `task_description`(必填)— 与 Router `search_mcp_server` 接受的形式一致。 -- `keywords`(可选,推荐)— 字符串数组。 -- `key_words`(可选,旧版兼容)— 逗号分隔字符串,与 Router 的线上形式一致;不能与 `keywords` 同时指定。 -- `selection_policy`(可选)— `first`(默认)或 `exact_name`(对 - `task_description` 做大小写不敏感的名称匹配);精确匹配无结果时返回 - `failure_code: "selection_policy_no_match"` 且 `tried` 为空。 - -成功输出: - -```json -{ - "server": "weather-mcp", - "tool": "get_weather", - "schema": "{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"]}", - "tried": [ - {"name": "weather-mcp", "description": "...", "rank": 1} - ] -} -``` - -`schema` 是上游工具 schema 的 JSON 编码字符串,使用前先解析。 - -失败输出(Router 各类失败都以 JSON 返回,不抛异常): - -```text -{ "failure_code": "no_candidates", "tried": [] } -{ "failure_code": "selection_policy_no_match", "tried": [] } -{ "failure_code": "all_adds_failed", "tried": [{"name":"...","description":"...","rank":1}, ...] } -{ "failure_code": "router_unavailable", "tried": [] } -``` - -行为契约: - -1. 该工具从不调用 `use_tool`;绑定后的工具由下游 DAG 节点执行。 -2. 该工具从不调用任何 LLM;往返次数为零(测试断言 `chat.ReceivedCalls()` 为空)。 -3. 该工具遇到 Router 失败不抛异常;prose 失败、传输失败、协议级 `isError` - 全部归一化为 `failure_code`。 - - search 未到达 Router(传输失败)或返回 `isError` → `router_unavailable`。 - - add 返回 `isError` 只判该候选失败并继续轮替;`isError` 优先于文本检查, - 错误结果中的「安装完成」不能导致绑定成功。 - - add 未到达 Router 则终止轮替 → `router_unavailable`,`tried` 为已尝试候选。 - - 调用方取消仍以 `OperationCanceledException` 传播。 -4. `tried` 只列实际尝试过 add 的候选,而非全部返回候选。 -5. `rank` 是候选在上游确定性 Top-N 排序中的位置。上游 search 不返回分数, - 因此不做本地伪造。 - -## 尚未具备的验收材料 - -Issue 引用的 Windows compose 部署与架构文档不在仓库中。必须补充真实 Router -版本、三个工具的参数及成功/错误响应、注册 payload 和可复制的启动命令。 -使用同一模型、输入与配置,对两个示例各执行五次,记录真实 session usage 的 -input+output 总和中位数,并单独记录召回质量。目前这两组数据均未测量。 - -因此 #229 仍未全部完成,#230–#234 的依赖验收仍待完成;不能把 mock 数据当成 -真实 token 基线,也不能凭空补全搜索分数、版本信息或错误语义。 +# Nacos MCP Router 集成 + +当前实现遵循[供应商无关能力解析契约](../capability-resolution.md):默认 provider +为 `local`;Nacos 需要显式 `provider: nacos` 与 `OpenClawEnableNacos=true`。 +配置位于 `adapterSettings.nacos`。事件订阅额外启用 +`OpenClawEnableNacosEvents=true`,支持 JIT 和 NativeAOT;JIT 发布使用 +`PublishAot=false`。RedNb.Nacos 2.1.0 提供源生成协议 JSON 元数据,无需重新开启 +JSON 反射。默认 Gateway 不引入 Nacos SDK。 + +## 协议与能力槽位 + +Router 0.2.2 的三个工具仍是 `search_mcp_server`、`add_mcp_server`、`use_tool`。 +显式配置 `toolNamePrefix: nacos_mcp_router_`,可以获得示例中的工具名。 + +- search 参数为 `task_description` 与逗号分隔的 `key_words`,结果是嵌入说明文字的 + JSON 对象;上游没有 score/version,不伪造这些字段,候选使用位置 `rank`。 +- add 参数为 `mcp_server_name`;候选注册必须有非空 description,stdio 配置必须 + 使用 `{"mcpServers":{"weather-mcp":{...}}}` 包装。 +- use 参数为 `mcp_server_name`、`mcp_tool_name`、JSON 编码字符串 `params`。 + 部分失败以普通文本返回,适配器分别归一化传输、协议和已知文本失败。 +- `resolve_capability` 支持 `provider`、`task_description`、`keywords` 数组、 + `selection_policy`(`first` / `exact_name`);兼容旧 `key_words`,不可同时声明。 + `top_k`、`prefer_version` 等不支持的约束明确拒绝。 +- 解析仅选择并绑定,不执行工具;MetaSkill 能力节点通过现有授权、审批、hooks + 与审计路径调用绑定工具。缓存命中仍重新校验权限。 +- 失败码使用供应商无关名称,如 `provider_unavailable`、`all_bindings_failed`; + 具体执行失败见[英文指南](../nacos-mcp-router.md)。 + +`examples/skills/nacos-router-weather` 为静态能力槽位, +`nacos-router-weather-dynamic` 为动态能力槽位,均不增加 LLM 轮次。 +`nacos-router-weather-explore` 保留模型驱动基线。示例不会默认启用。 + +## 缓存、事件与验收 + +事件订阅在后台注册,暴露 disabled/starting/active/degraded/stopped 状态,失败时 +按退避策略重试。事件经通用失效接口推进缓存 generation,清除静态和动态绑定; +不覆盖 workspace 配置。未启用或不可达时,TTL 与显式 reload 继续有效。 + +[隔离验收工具与完整命令](../../eng/nacos-live/README.md)启动带认证的 Nacos 3.2.4、 +Router 0.2.2,以及真正暴露 `get_weather(city)` 的独立 MCP 服务。它检查: + +1. 双 runtime 下静态/动态技能返回天气结果、复用缓存、不重复注册工具、零模型调用。 +2. 托管与 NativeAOT 进程均禁用 JSON 反射,生产订阅适配器进入 active。 +3. 真实配置发布后 2 秒内清除已预热的绑定,下次静态/动态调用都重新绑定成功。 + +默认天气结果明确标注为 fixture,真实 Nacos/Router/事件传输照常执行; +`--live-weather` 改为查询 Open-Meteo 的 Oslo 天气。不可把 fixture 温度当作现场 +天气观测,也不把隔离部署的成功推断为任意生产注册表的召回率。 +专用 CI 自动运行隔离验收,普通测试在未设置 `OPENCLAW_NACOS_LIVE=1` 时跳过 +现场测试。成功报告含 managed/native JSON 和双 runtime TRX。 + +## Zhang 的历史贡献与测量 + +@geffzhang 提供原始实现、协议捕获和 2026-09-14 的五次运行 token 基线:静态 +DAG 的中位数为 0 input / 0 output,模型驱动探索为 17479 / 1181。原测试环境 +把 `weather-mcp` 指向了时间服务,因此探索成功率为 0/5;这不代表当前正确注册 +天气服务的结果。早期事件路径 +310 ms 的测量同样保留为历史证据。 +原始数据、限制与贡献记录见[英文指南历史章节](../nacos-mcp-router.md#historical-contributor-evidence)。 diff --git a/eng/NacosLiveSmoke/NacosLiveSmoke.csproj b/eng/NacosLiveSmoke/NacosLiveSmoke.csproj new file mode 100644 index 00000000..a2084421 --- /dev/null +++ b/eng/NacosLiveSmoke/NacosLiveSmoke.csproj @@ -0,0 +1,11 @@ + + + Exe + true + false + + + + + + diff --git a/eng/NacosLiveSmoke/Program.cs b/eng/NacosLiveSmoke/Program.cs new file mode 100644 index 00000000..107beb6d --- /dev/null +++ b/eng/NacosLiveSmoke/Program.cs @@ -0,0 +1,125 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using OpenClaw.Adapters.Nacos; +using OpenClaw.Adapters.Nacos.Events; +using OpenClaw.Agent; +using OpenClaw.Agent.Plugins; +using OpenClaw.Agent.Tools; +using OpenClaw.Core.Models; +using OpenClaw.Core.Observability; +using OpenClaw.Core.Plugins; +using OpenClaw.Core.Skills; +using OpenClaw.Core.Skills.Meta; +using RedNb.Nacos.Config; + +// Only run against an isolated acceptance deployment. The unique dataId is removed on exit. +using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(3)); +var ct = timeout.Token; +var dataId = "openclaw-acceptance-" + Guid.NewGuid().ToString("N"); +var options = new NacosOptions +{ + ServerAddr = Required("OPENCLAW_NACOS_SERVER"), DataId = dataId, + Username = "env:OPENCLAW_NACOS_USERNAME", Password = "env:OPENCLAW_NACOS_PASSWORD" +}; +var settings = new Dictionary +{ + ["nacos"] = JsonSerializer.SerializeToElement(options, SmokeJson.Default.NacosOptions) +}; +var cache = new CapabilityBindingCache(); +var services = new ServiceCollection(); +services.AddLogging(b => b.AddSimpleConsole().SetMinimumLevel(LogLevel.Warning)); +services.AddSingleton(cache); +NacosEventRegistration.Add(services, settings); +await using var host = services.BuildServiceProvider(); +var config = host.GetRequiredService(); +var subscription = host.GetRequiredService(); +await using var registry = new McpServerToolRegistry(new McpPluginsConfig(), NullLogger.Instance); +try +{ + Check(!JsonSerializer.IsReflectionEnabledByDefault, "JSON reflection must remain disabled"); + Check(await config.PublishConfigAsync(dataId, options.Group, "initial", ct), "initial config publish failed"); + await subscription.StartAsync(ct); + await WaitUntil(() => subscription.Status == "active", TimeSpan.FromSeconds(30), ct); + var tools = await registry.ReloadWorkspaceServersAsync(new Dictionary + { + ["nacos-mcp-router"] = new() { Enabled = true, Transport = "http", Url = Required("OPENCLAW_NACOS_ROUTER_URL"), ToolNamePrefix = "nacos_mcp_router_" } + }, ct); + Check(tools.AddedTools.Count == 3, "Router must expose exactly three tools"); + var providers = new CapabilityProviderRegistry([new NacosCapabilityProvider(registry)]); + var slots = new CapabilitySlotExecutor(providers, cache); + var executor = new OpenClawToolExecutor(tools.AddedTools, 30, false, [], []); + var session = new Session { Id = dataId, SenderId = "acceptance", ChannelId = "acceptance" }; + var turn = new TurnContext { SessionId = session.Id }; + MetaCapabilityRefDefinition[] references = + [ + new() { Provider = "nacos", Binding = "static", Static = new() { Target = "weather-mcp", ToolName = "get_weather" } }, + new() { Provider = "nacos", Binding = "dynamic", Intent = new() { TaskDescription = "weather city", Keywords = ["weather", "city"] } } + ]; + var checks = new List(); + async Task Run(MetaCapabilityRefDefinition reference, bool hit) + { + var result = await slots.ExecuteGovernedAsync(reference, """{"city":"Oslo"}""", session, turn, executor, Guid.NewGuid().ToString("N"), ct); + Check(result.ResultStatus == "completed", $"{reference.Binding}: {result.FailureCode}: {result.ResultText}"); + Check(result.BindingTrajectory?.CacheHit == hit, $"{reference.Binding}: expected cacheHit={hit}"); + Check(result.BindingTrajectory!.Revision == cache.Generation, "stale binding generation"); + using var weather = JsonDocument.Parse(result.ResultText); + Check(weather.RootElement.GetProperty("city").GetString() == "Oslo", "wrong weather city"); + Check(weather.RootElement.GetProperty("temperature_c").ValueKind == JsonValueKind.Number, "weather payload missing temperature"); + checks.Add(reference.Binding + (hit ? ":cache-hit" : ":bound")); + } + foreach (var reference in references) { await Run(reference, false); await Run(reference, true); } + var generation = cache.Generation; + var elapsed = Stopwatch.StartNew(); + Check(await config.PublishConfigAsync(dataId, options.Group, Guid.NewGuid().ToString("N"), ct), "config update failed"); + await WaitUntil(() => cache.Generation > generation, TimeSpan.FromSeconds(2), ct); + elapsed.Stop(); + Check(elapsed.ElapsedMilliseconds <= 2000, "publish-to-invalidation exceeded two seconds"); + Check(cache.Count == 0, "event did not clear warmed static and dynamic bindings"); + foreach (var reference in references) await Run(reference, false); + Check(turn.LlmCallCount == 0, "capability path made an LLM call"); + var report = new SmokeReport + { + NativeAot = !RuntimeFeature.IsDynamicCodeSupported, + JsonReflection = JsonSerializer.IsReflectionEnabledByDefault, + PublishToInvalidationMs = elapsed.Elapsed.TotalMilliseconds, + Generation = cache.Generation, Checks = checks.ToArray() + }; + var json = JsonSerializer.Serialize(report, SmokeJson.Default.SmokeReport); + if (Environment.GetEnvironmentVariable("OPENCLAW_NACOS_REPORT") is { Length: > 0 } path) await File.WriteAllTextAsync(path, json, ct); + Console.WriteLine("NACOS_ACCEPTANCE_PASS " + json); +} +finally +{ + // Do not let cleanup hide an acceptance failure or outlive the bounded test run. + using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try { await config.RemoveConfigAsync(dataId, options.Group, cleanup.Token); } catch (Exception ex) { Console.Error.WriteLine("Config cleanup: " + ex.GetType().Name); } +} + +static string Required(string name) => Environment.GetEnvironmentVariable(name) is { Length: > 0 } value ? value : throw new InvalidOperationException("Set " + name); +static void Check(bool condition, string message) { if (!condition) throw new InvalidOperationException(message); } +static async Task WaitUntil(Func ready, TimeSpan timeout, CancellationToken ct) +{ + var elapsed = Stopwatch.StartNew(); + while (!ready()) + { + if (elapsed.Elapsed > timeout) throw new TimeoutException("Nacos acceptance condition timed out"); + await Task.Delay(10, ct); + } +} +internal sealed class SmokeReport +{ + public bool NativeAot { get; set; } + public bool JsonReflection { get; set; } + public double PublishToInvalidationMs { get; set; } + public long Generation { get; set; } + public string[] Checks { get; set; } = []; +} +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(SmokeReport))] +[JsonSerializable(typeof(NacosOptions))] +internal partial class SmokeJson : JsonSerializerContext; diff --git a/eng/nacos-live/.gitignore b/eng/nacos-live/.gitignore new file mode 100644 index 00000000..c18dd8d8 --- /dev/null +++ b/eng/nacos-live/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/eng/nacos-live/README.md b/eng/nacos-live/README.md new file mode 100644 index 00000000..27be9655 --- /dev/null +++ b/eng/nacos-live/README.md @@ -0,0 +1,71 @@ +# Nacos live acceptance + +This harness verifies the production optional adapters against authenticated +Nacos **3.2.4**, Router **0.2.2**, and a correctly registered `weather-mcp` +server exposing `get_weather(city)`. It creates a temporary standalone Java +deployment with generated credentials and available ports. It does not change +an existing service, gateway configuration, or database. Child processes and +the temporary deployment are removed on exit; the output directory retains +reports and logs. Check third-party logs before publishing them. + +The weather tool returns explicitly labelled fixture observations by default. +`--live-weather` instead queries Open-Meteo for Oslo. Fixture mode proves live +registry/Router/event transport and binding behavior, not external weather +availability or broad discovery quality. Neither mode calls an LLM. + +## Run locally + +Requires .NET 10 with NativeAOT prerequisites, Java 17+, Python 3.12, and network +access to the pinned packages, official Nacos release, and Router embedding +model. Run from the repository root. Use a fresh output directory for each run. + +```sh +python3.12 -m venv /tmp/openclaw-nacos-python +/tmp/openclaw-nacos-python/bin/pip install -r eng/nacos-live/requirements.txt +/tmp/openclaw-nacos-python/bin/python -c 'from chromadb.utils.embedding_functions import DefaultEmbeddingFunction; DefaultEmbeddingFunction()(["weather city"])' +curl --fail --location --retry 3 \ + https://github.com/alibaba/nacos/releases/download/3.2.4/nacos-server-3.2.4.tar.gz \ + -o /tmp/nacos-server-3.2.4.tar.gz + +# Replace linux-x64 with the current host RID, e.g. osx-arm64. +dotnet publish eng/NacosLiveSmoke -c Release -r linux-x64 -p:PublishAot=true -o /tmp/openclaw-nacos-native +dotnet build eng/NacosLiveSmoke -c Release -p:PublishAot=false +dotnet build src/OpenClaw.Tests -c Release -p:OpenClawSkipDashboardBuild=true + +/tmp/openclaw-nacos-python/bin/python eng/nacos-live/verify.py \ + --archive /tmp/nacos-server-3.2.4.tar.gz \ + --managed eng/NacosLiveSmoke/bin/Release/net10.0/NacosLiveSmoke.dll \ + --native /tmp/openclaw-nacos-native/NacosLiveSmoke \ + --test-dll src/OpenClaw.Tests/bin/Release/net10.0/OpenClaw.Tests.dll \ + --output /tmp/openclaw-nacos-evidence +``` + +The archive is checked against a pinned SHA-256 before extraction. The dedicated +`Nacos live acceptance` workflow runs these checks on Linux and also publishes +the full Gateway with both optional Nacos flags and NativeAOT enabled. The +ordinary adapter matrix still verifies that default Gateway builds contain no +Nacos SDK and Router-only builds contain no RedNb packages. + +## Acceptance checks + +- Exactly three Router tools; real search, add, and use-tool round trips. +- Static and dynamic MetaSkills complete with a weather payload in both the + native agent runtime and Microsoft Agent Framework runtime, reuse bindings, + avoid duplicate tool registration, and make zero model calls. +- Managed and NativeAOT smoke executables both keep JSON reflection disabled. +- The production subscription adapter reaches `active` with authentication. +- After warming both binding modes, a real configuration publish clears the + cache and advances its generation within **2,000 ms**; both next invocations + bind again successfully. + +Success produces `NACOS_LIVE_ACCEPTANCE_PASS`, `managed.json`, `native.json`, +`acceptance.json`, and (with `--test-dll`) `live-runtimes.trx`. A skipped live test +or a successful AOT build alone does not satisfy these checks. Measurements +apply to the provisioned standalone deployment; cluster recovery, TLS/proxies, +and production service availability require deployment-specific validation. + +Zhang (@geffzhang) supplied the original Nacos integration, protocol fixtures, +and five-run token measurements. Those measurements and the earlier +310 ms +prototype event result remain in [the Router guide](../../docs/nacos-mcp-router.md#historical-contributor-evidence). +This harness validates the subsequent provider-neutral implementation without +replacing or relabelling that contributor evidence. diff --git a/eng/nacos-live/requirements.txt b/eng/nacos-live/requirements.txt new file mode 100644 index 00000000..076fd202 --- /dev/null +++ b/eng/nacos-live/requirements.txt @@ -0,0 +1,2 @@ +nacos-mcp-router==0.2.2 +mcp==1.30.0 diff --git a/eng/nacos-live/router_server.py b/eng/nacos-live/router_server.py new file mode 100644 index 00000000..1123e00e --- /dev/null +++ b/eng/nacos-live/router_server.py @@ -0,0 +1,14 @@ +"""Run the unmodified pinned Router protocol on loopback for acceptance.""" +import uvicorn +from nacos_mcp_router.router import main + +run = uvicorn.run + + +def local_run(app, **kwargs): + kwargs["host"] = "127.0.0.1" + return run(app, **kwargs) + + +uvicorn.run = local_run +main() diff --git a/eng/nacos-live/verify.py b/eng/nacos-live/verify.py new file mode 100644 index 00000000..eac09da2 --- /dev/null +++ b/eng/nacos-live/verify.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +"""Provision isolated Nacos 3.2.4 + Router 0.2.2, then check production adapters. + +Requires Java 17+, .NET 10 and a Python environment with requirements.txt. +Never connects to an existing Nacos deployment. Only owned child processes and +the temporary deployment are cleaned up; reports/logs remain in --output. +""" +import argparse +import base64 +import hashlib +import json +import os +from pathlib import Path +import secrets +import signal +import shutil +import socket +import subprocess +import sys +import tarfile +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent +VERSION = "3.2.4" + + +def request(url, data=None, headers=None): + body = urllib.parse.urlencode(data).encode() if data is not None else None + with urllib.request.urlopen(urllib.request.Request(url, data=body, headers=headers or {}), timeout=5) as response: + return json.load(response) + + +def wait_for(check, seconds, description): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + try: + if check(): + return + except (OSError, ValueError): + pass + time.sleep(0.25) + raise TimeoutError(description) + + +def free_port(offsets=(0,)): + # Nacos gRPC ports are defined relative to the main port (+1000/+1001). + for _ in range(100): + port = 20000 + secrets.randbelow(20000) + held = [] + try: + for offset in offsets: + held.append(socket.socket()) + held[-1].bind(("127.0.0.1", port + offset)) + return port + except OSError: + pass + finally: + for sock in held: + sock.close() + raise RuntimeError("Cannot reserve acceptance ports") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--archive", type=Path, required=True, help="Official nacos-server-3.2.4.tar.gz") + parser.add_argument("--python", default=sys.executable, help="Python with the pinned Router requirements") + parser.add_argument("--managed", type=Path, required=True, help="Built NacosLiveSmoke.dll") + parser.add_argument("--native", type=Path, required=True, help="Published NativeAOT NacosLiveSmoke executable") + parser.add_argument("--test-dll", type=Path, help="Built OpenClaw.Tests.dll; runs both runtime acceptance tests") + parser.add_argument("--output", type=Path, required=True, help="New directory for reports and logs") + parser.add_argument("--live-weather", action="store_true", help="Use Open-Meteo instead of labelled fixture observations") + args = parser.parse_args() + args.output = args.output.resolve() + args.output.mkdir(parents=True, exist_ok=False) + os.chmod(args.output, 0o700) + # Pin the exact official release archive, downloaded over GitHub HTTPS and + # checked against the upstream release digest before recording this SHA-256. + with args.archive.open("rb") as archive: + if hashlib.file_digest(archive, "sha256").hexdigest() != "da5eec77934140133fe93e5532079e4e99b4cae7eb50463f2f6cd2bc8f380a70": + raise ValueError("Archive does not match the official Nacos 3.2.4 release") + children = [] + logs = [] + + def start(name, command, env=None, cwd=None): + log = (args.output / (name + ".log")).open("w") + logs.append(log) + process = subprocess.Popen(command, cwd=cwd, env=env, stdout=log, stderr=subprocess.STDOUT, start_new_session=True) + children.append(process) + return process + + work = Path(tempfile.mkdtemp(prefix="openclaw-nacos-")) + try: + with tarfile.open(args.archive) as archive: + archive.extractall(work, filter="data") + nacos = work / "nacos" + port = free_port((0, 1000, 1001)) + console_port, router_port = free_port(), free_port() + password = secrets.token_urlsafe(24) + properties = nacos / "conf/application.properties" + properties.write_text(properties.read_text() + "\n" + "\n".join([ + f"nacos.server.main.port={port}", f"nacos.console.port={console_port}", + "server.address=127.0.0.1", "nacos.inetutils.ip-address=127.0.0.1", + "nacos.core.auth.enabled=true", "nacos.core.auth.admin.enabled=true", + "nacos.core.auth.console.enabled=true", "nacos.core.auth.caching.enabled=false", "nacos.ai.mcp.registry.enabled=false", + "nacos.ai.skill.registry.enabled=false", "nacos.core.auth.server.identity.key=acceptance", + "nacos.core.auth.server.identity.value=" + secrets.token_urlsafe(24), + "nacos.core.auth.plugin.nacos.token.secret.key=" + base64.b64encode(secrets.token_bytes(48)).decode(), + ]) + "\n") + os.chmod(properties, 0o600) + server = start("nacos", ["java", "-Xms256m", "-Xmx512m", "-Dnacos.standalone=true", + "--add-opens=java.base/java.lang=ALL-UNNAMED", "--add-opens=java.base/java.lang.reflect=ALL-UNNAMED", + "--add-opens=java.base/java.util=ALL-UNNAMED", + "-Dnacos.deployment.type=merged", f"-Dnacos.home={nacos}", f"-Dloader.path={nacos}/plugins", "-jar", str(nacos / "target/nacos-server.jar"), + f"--spring.config.additional-location=file:{nacos}/conf/", f"--logging.config={nacos}/conf/nacos-logback.xml"], cwd=work) + console = f"http://127.0.0.1:{console_port}" + def ready(): + if server.poll() is not None: + raise RuntimeError("Nacos exited; see nacos.log") + return request(console + "/v3/console/health/readiness") + + wait_for(ready, 90, "Nacos readiness failed; see nacos.log") + initialized = request(console + "/v3/auth/user/admin", {"password": password}) + if initialized.get("code") != 0: + raise RuntimeError("Isolated Nacos administrator initialization failed") + login = request(console + "/v3/auth/user/login", {"username": "nacos", "password": password}) + addr = f"127.0.0.1:{port}" + weather_args = [str(HERE / "weather_server.py")] + (["--live-weather"] if args.live_weather else []) + specification = { + "name": "weather-mcp", "description": "weather city 天气 城市 Oslo temperature query", + "protocol": "stdio", "enabled": True, "versionDetail": {"version": "1.0.0"}, + "localServerConfig": {"mcpServers": {"weather-mcp": {"command": str(Path(args.python).absolute()), "args": weather_args}}} + } + registered = request(f"http://{addr}/nacos/v3/admin/ai/mcp", { + "mcpName": "weather-mcp", "serverSpecification": json.dumps(specification), + "toolSpecification": json.dumps({"tools": []}), "endpointSpecification": "{}" + }, {"accessToken": login["accessToken"]}) + if registered.get("code") != 0: + raise RuntimeError("Weather MCP registration failed: " + str(registered.get("message"))) + env = os.environ.copy() + env.update({"NACOS_ADDR": addr, "NACOS_USERNAME": "nacos", "NACOS_PASSWORD": password, + "NACOS_NAMESPACE": "", "ACCESS_KEY_ID": "", "ACCESS_KEY_SECRET": "", "MODE": "router", + "TRANSPORT_TYPE": "streamable_http", "PORT": str(router_port), "UPDATE_INTERVAL": "2", + "ANONYMIZED_TELEMETRY": "False", "OPENCLAW_NACOS_LIVE": "1", + "OPENCLAW_NACOS_ROUTER_URL": f"http://127.0.0.1:{router_port}/mcp", + "OPENCLAW_NACOS_SERVER": addr, "OPENCLAW_NACOS_USERNAME": "nacos", "OPENCLAW_NACOS_PASSWORD": password}) + router = start("router", [args.python, str(HERE / "router_server.py")], env=env, cwd=work) + + def listening(): + if router.poll() is not None: + raise RuntimeError("Router exited; see router.log") + with socket.create_connection(("127.0.0.1", router_port), timeout=1): + return True + + wait_for(listening, 90, "Router startup timed out") + # Each smoke checks initial listener reconciliation, warm static/dynamic + # cache hits, <=2s invalidation, and both rebinds with reflection disabled. + for name, command in [("managed", ["dotnet", str(args.managed.resolve())]), ("native", [str(args.native.resolve())])]: + env["OPENCLAW_NACOS_REPORT"] = str(args.output / (name + ".json")) + smoke = start(name, command, env=env, cwd=work) + if smoke.wait(timeout=210) != 0: + raise RuntimeError(name + " acceptance failed; see " + name + ".log") + report = json.loads((args.output / (name + ".json")).read_text()) + if report["nativeAot"] != (name == "native") or report["jsonReflection"]: + raise RuntimeError(name + " execution mode was not verified") + print(name, json.dumps(report), flush=True) + if args.test_dll: + tests = start("runtimes", ["dotnet", "test", str(args.test_dll.resolve()), "--filter", "FullyQualifiedName~LiveRouter", "--logger", f"trx;LogFileName={args.output}/live-runtimes.trx"], env=env, cwd=ROOT) + if tests.wait(timeout=300) != 0: + raise RuntimeError("Dual-runtime acceptance failed; see runtimes.log") + (args.output / "acceptance.json").write_text(json.dumps({"nacos": VERSION, "router": "0.2.2", "authentication": True, + "weather": "Open-Meteo" if args.live_weather else "labelled fixture", "dualRuntimeTests": bool(args.test_dll), "passed": True}, indent=2)) + print("NACOS_LIVE_ACCEPTANCE_PASS", args.output, flush=True) + finally: + for process in reversed(children): + # The router can own stdio MCP children after its parent exits. + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + pass + for process in reversed(children): + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=5) + for log in logs: + log.close() + shutil.rmtree(work) + + +if __name__ == "__main__": + main() diff --git a/eng/nacos-live/weather_server.py b/eng/nacos-live/weather_server.py new file mode 100644 index 00000000..e67399c1 --- /dev/null +++ b/eng/nacos-live/weather_server.py @@ -0,0 +1,32 @@ +"""A correctly registered weather MCP backend for isolated Router acceptance. + +The default fixture is deterministic. --live-weather reads Open-Meteo for Oslo; +fixture observations are explicitly labelled and never reported as live weather. +""" +import json +import sys +import urllib.request + +from mcp.server.fastmcp import FastMCP + +server = FastMCP("weather-mcp") + + +@server.tool() +def get_weather(city: str) -> str: + """Return the temperature in Celsius for the acceptance city, Oslo.""" + if city != "Oslo": + raise ValueError("This acceptance backend supports Oslo only") + if "--live-weather" in sys.argv: + url = "https://api.open-meteo.com/v1/forecast?latitude=59.91&longitude=10.75¤t=temperature_2m" + with urllib.request.urlopen(url, timeout=15) as response: + observation = json.load(response)["current"] + result = {"city": city, "temperature_c": observation["temperature_2m"], + "time": observation["time"], "source": "Open-Meteo"} + else: + result = {"city": city, "temperature_c": 12.5, "source": "acceptance fixture"} + return json.dumps(result) + + +if __name__ == "__main__": + server.run(transport="stdio") diff --git a/eng/verify-capability-adapters.sh b/eng/verify-capability-adapters.sh index 74a1539a..bc777a19 100644 --- a/eng/verify-capability-adapters.sh +++ b/eng/verify-capability-adapters.sh @@ -21,14 +21,6 @@ assets = json.loads(Path('src/OpenClaw.Gateway/obj/standard-nacos/project.assets assert not any(p.lower().startswith('rednb.') for p in assets['libraries']), 'Router adapter must not pull in the SDK' PY -dotnet build "$project" "${fast[@]}" -p:OpenClawEnableNacos=true -p:OpenClawEnableNacosEvents=true -p:PublishAot=false -log=$(mktemp) -trap 'rm -f "$log"' EXIT -if dotnet build "$project" "${fast[@]}" -p:OpenClawEnableNacos=true -p:OpenClawEnableNacosEvents=true -p:PublishAot=true >"$log" 2>&1; then - echo 'NativeAOT SDK events unexpectedly succeeded' >&2 - exit 1 -fi -if ! grep -Eq 'Nacos.*(JIT|NativeAOT)' "$log"; then - cat "$log" - exit 1 -fi +dotnet build "$project" "${fast[@]}" -p:OpenClawEnableNacos=true -p:OpenClawEnableNacosEvents=true -p:PublishAot=false -p:JsonSerializerIsReflectionEnabledByDefault=false +dotnet build "$project" "${fast[@]}" -p:OpenClawEnableNacos=true -p:OpenClawEnableNacosEvents=true -p:PublishAot=true +# Native publication and authenticated live execution are covered by nacos-live.yml. diff --git a/src/OpenClaw.Adapters.Nacos.Events/NacosConfigSubscriptionService.cs b/src/OpenClaw.Adapters.Nacos.Events/NacosConfigSubscriptionService.cs index f0ca0f51..51d23116 100644 --- a/src/OpenClaw.Adapters.Nacos.Events/NacosConfigSubscriptionService.cs +++ b/src/OpenClaw.Adapters.Nacos.Events/NacosConfigSubscriptionService.cs @@ -1,5 +1,7 @@ using Microsoft.Extensions.Logging; using OpenClaw.Core.Skills.Meta; +using System.Security.Cryptography; +using System.Text; namespace OpenClaw.Adapters.Nacos.Events; @@ -9,6 +11,8 @@ public sealed class NacosConfigSubscriptionService( ILogger logger) : ICapabilityChangeSource { private readonly object _gate = new(); + private readonly object _changeGate = new(); + private string? _lastContentHash; private CancellationTokenSource? _lifetime; private Task? _worker; private IDisposable? _handle; @@ -67,8 +71,17 @@ private async Task SubscribeAsync(CancellationToken ct) private void OnChange(NacosConfig changed) { - if (_lifetime?.IsCancellationRequested != false) return; - invalidation.Invalidate(new CapabilityChange(ProviderId, changed.Group + "/" + changed.DataId, Guid.NewGuid().ToString("N"))); + if (changed.DataId != options.DataId || changed.Group != options.Group) return; + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(changed.Content))); + lock (_changeGate) + { + if (_lifetime?.IsCancellationRequested != false || hash == _lastContentHash) return; + // The SDK can deliver its initial snapshot after GetConfigAsync has + // reconciled the same content. Do not invalidate a binding in flight + // twice for that one revision. Retain only a digest, never config data. + invalidation.Invalidate(new CapabilityChange(ProviderId, changed.Group + "/" + changed.DataId, Guid.NewGuid().ToString("N"))); + _lastContentHash = hash; + } } public async ValueTask DisposeAsync() diff --git a/src/OpenClaw.Adapters.Nacos.Events/OpenClaw.Adapters.Nacos.Events.csproj b/src/OpenClaw.Adapters.Nacos.Events/OpenClaw.Adapters.Nacos.Events.csproj index fcfe8022..c96c78b6 100644 --- a/src/OpenClaw.Adapters.Nacos.Events/OpenClaw.Adapters.Nacos.Events.csproj +++ b/src/OpenClaw.Adapters.Nacos.Events/OpenClaw.Adapters.Nacos.Events.csproj @@ -1,12 +1,9 @@ - false + true - + - - - diff --git a/src/OpenClaw.Adapters.Nacos.Events/RedNbNacosConfigService.cs b/src/OpenClaw.Adapters.Nacos.Events/RedNbNacosConfigService.cs index 0f743727..e494801e 100644 --- a/src/OpenClaw.Adapters.Nacos.Events/RedNbNacosConfigService.cs +++ b/src/OpenClaw.Adapters.Nacos.Events/RedNbNacosConfigService.cs @@ -6,7 +6,7 @@ namespace OpenClaw.Adapters.Nacos.Events; /// /// Production backed by the RedNb.Nacos SDK -/// (RedNb.Nacos.All 2.0.0, issue #238). The SDK client is constructed by the +/// (RedNb.Nacos 2.1.0, including generated protocol JSON metadata). The SDK client is constructed by the /// DI extension RedNb.Nacos.DependencyInjection.AddNacosConfig and injected /// as ; this adapter only translates between the SDK /// surface (, ) and the gateway @@ -79,4 +79,4 @@ public void Dispose() onDispose(); } } -} \ No newline at end of file +} diff --git a/src/OpenClaw.Gateway/OpenClaw.Gateway.csproj b/src/OpenClaw.Gateway/OpenClaw.Gateway.csproj index c795586e..8bae86a4 100644 --- a/src/OpenClaw.Gateway/OpenClaw.Gateway.csproj +++ b/src/OpenClaw.Gateway/OpenClaw.Gateway.csproj @@ -162,7 +162,6 @@ $(DefineConstants);OPENCLAW_NACOS_EVENTS - true @@ -172,6 +171,5 @@ - diff --git a/src/OpenClaw.Tests/NacosConfigSubscriptionServiceTests.cs b/src/OpenClaw.Tests/NacosConfigSubscriptionServiceTests.cs index d6dd707b..6929d32b 100644 --- a/src/OpenClaw.Tests/NacosConfigSubscriptionServiceTests.cs +++ b/src/OpenClaw.Tests/NacosConfigSubscriptionServiceTests.cs @@ -50,6 +50,32 @@ public async Task OnChange_ForUnrelatedDataId_DoesNotTrigger() trigger.DidNotReceive().Invalidate(Arg.Any()); } + [Fact] + public async Task InitialSnapshot_AndDuplicateSdkNotifications_InvalidateOnlyOnContentChanges() + { + var fake = new FakeNacosConfigService(); + fake.Publish(new("d", "g", "initial")); + var cache = new OpenClaw.Agent.Tools.CapabilityBindingCache(); + await using var svc = Build(fake, new() { ServerAddr = "test", DataId = "d", Group = "g" }, cache); + await svc.StartAsync(TestContext.Current.CancellationToken); + var generation = cache.Generation; + cache.Set("session", "intent", "weather", "get_weather"); + + // The real SDK may deliver its initial snapshot after registration has + // returned. Repeated notifications must not invalidate an unchanged binding. + fake.Publish(new("d", "g", "initial")); + Assert.Equal(generation, cache.Generation); + Assert.Equal(1, cache.Count); + + fake.Publish(new("d", "g", "changed")); + Assert.Equal(generation + 1, cache.Generation); + Assert.Equal(0, cache.Count); + fake.Publish(new("d", "g", "changed")); + Assert.Equal(generation + 1, cache.Generation); + fake.Publish(new("d", "g", "initial")); // A real revert is still a change. + Assert.Equal(generation + 2, cache.Generation); + } + [Fact] public async Task EmptyServerAddr_StartAsync_IsNoOp() { @@ -151,4 +177,4 @@ public async Task Subscription_ReconcilesSnapshotAfterRegistration_AndRetriesFai handles[1].Received(1).Dispose(); } -} \ No newline at end of file +} diff --git a/src/OpenClaw.Tests/NacosRouterIntegrationTests.cs b/src/OpenClaw.Tests/NacosRouterIntegrationTests.cs index 7586d1ef..e6bd59af 100644 --- a/src/OpenClaw.Tests/NacosRouterIntegrationTests.cs +++ b/src/OpenClaw.Tests/NacosRouterIntegrationTests.cs @@ -768,6 +768,55 @@ public async Task RecordedCacheHitTrajectory_ReplaysThroughTestingHarness_WithCa public static bool LiveEnabled => Environment.GetEnvironmentVariable("OPENCLAW_NACOS_LIVE") == "1"; + [Theory(Skip = "Set OPENCLAW_NACOS_LIVE=1 for the isolated weather acceptance deployment.", SkipUnless = nameof(LiveEnabled))] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task LiveRouter_WeatherSlots_BothRuntimes_ExecuteAndReuseBinding(bool maf, bool dynamic) + { + var url = Environment.GetEnvironmentVariable("OPENCLAW_NACOS_ROUTER_URL"); + Assert.True(Uri.TryCreate(url, UriKind.Absolute, out var endpoint) && endpoint.Scheme is "http" or "https"); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + timeout.CancelAfter(TimeSpan.FromMinutes(2)); + await using var registry = new McpServerToolRegistry(new McpPluginsConfig(), NullLogger.Instance); + var reload = await registry.ReloadWorkspaceServersAsync(ServerConfig(url!), timeout.Token); + Assert.Equal(3, reload.AddedTools.Count); + var skill = dynamic ? LoadDynamicDemo() : LoadStaticDemo(); + var root = Path.Join(Path.GetTempPath(), "nacos-live-" + Guid.NewGuid().ToString("N")); + using var memory = new FileMemoryStore(root, 4); + var tools = reload.AddedTools.Append(new EmitTextTool()).ToArray(); + var (runtime, chat, execution) = CapabilityRuntimeTestFactory.Create(maf, tools, memory, skill, + new GatewayConfig { Memory = new MemoryConfig { StoragePath = root } }, + new CapabilitySlotExecutor(new CapabilityProviderRegistry([new NacosCapabilityProvider(registry)]), new())); + try + { + var session = new Session { Id = root, SenderId = "acceptance", ChannelId = "test" }; + var method = runtime.GetType().GetMethod("ExecuteMetaSkillAsync", BindingFlags.Instance | BindingFlags.NonPublic)!; + for (var i = 0; i < 2; i++) + { + var result = await (Task)method.Invoke(runtime, [session, skill.Name, "Oslo", timeout.Token])!; + using var weather = JsonDocument.Parse(result); + Assert.Equal("Oslo", weather.RootElement.GetProperty("city").GetString()); + Assert.Equal(JsonValueKind.Number, weather.RootElement.GetProperty("temperature_c").ValueKind); + var query = Assert.Single(session.MetaRunHistory.Last().StepResults, step => step.Id == "query"); + Assert.Equal("completed", query.Status); + Assert.Equal(i > 0, query.ExecutionEvidence!.CapabilityBinding!.CacheHit); + } + Assert.Empty(chat.ReceivedCalls()); + Assert.Empty(execution.ReceivedCalls()); + var unchanged = await registry.ReloadWorkspaceServersAsync(ServerConfig(url!), timeout.Token); + Assert.Empty(unchanged.AddedTools); + } + finally + { + if (runtime is IAsyncDisposable asyncDisposable) await asyncDisposable.DisposeAsync(); + else if (runtime is IDisposable disposable) disposable.Dispose(); + memory.Dispose(); + if (Directory.Exists(root)) Directory.Delete(root, true); + } + } + [Fact(Skip = "Set OPENCLAW_NACOS_LIVE=1 and OPENCLAW_NACOS_ROUTER_URL for a provisioned Router.", SkipUnless = nameof(LiveEnabled))] public async Task LiveRouter_SearchFindsRegisteredWeatherServer() { From 303619bfe9e404921c88a061a63e13e8141f78ff Mon Sep 17 00:00:00 2001 From: telli Date: Fri, 18 Sep 2026 19:50:49 -0700 Subject: [PATCH 2/3] test: wait for Nacos seed visibility before live acceptance Co-authored-by: geffzhang --- docs/nacos-mcp-router.md | 20 ++++++++++---------- eng/NacosLiveSmoke/Program.cs | 8 ++++++++ eng/nacos-live/verify.py | 3 +++ 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/docs/nacos-mcp-router.md b/docs/nacos-mcp-router.md index 967508cb..bbd94fc0 100644 --- a/docs/nacos-mcp-router.md +++ b/docs/nacos-mcp-router.md @@ -95,15 +95,15 @@ Merge this entry into `/mcp/mcp.json`; preserve existing servers: } ``` -Register a test server named `weather-mcp` using the deployment's Nacos -console/API. Verified on the referenced deployment (2026-09-14): a console -registration with a non-empty bilingual description and a stdio local config -wrapped as `{"mcpServers": {"weather-mcp": {"command": "uvx", "args": ["mcp-server-time"]}}}`. -`mcp-server-time` is a test-bed stand-in so the full `add_mcp_server` chain runs; -replace it with a real weather server. Verified 2026-09-14: with this config the -live chain `search → add → use_tool` completed for `weather-mcp` (add success -envelope `1. 安装完成, tool 列表为: [{name, description, inputSchema}]...`, -then `use_tool` returned the backend tool result). +For isolated acceptance, use the [provisioning harness](../eng/nacos-live/README.md). +It registers `weather-mcp` with a non-empty bilingual description and an +`mcpServers`-wrapped stdio configuration pointing to `eng/nacos-live/weather_server.py` +inside the pinned Python environment. The backend actually exposes +`get_weather(city)` and returns `city`, numeric `temperature_c`, and `source`. +Fixture observations are labelled; `--live-weather` requests Open-Meteo data. +Do not reuse the historical `mcp-server-time` registration as a weather backend. +For another deployment, register its real weather tool and adapt the skill to +that tool's verified name/schema. The examples stay under `examples/skills/` and are not bundled or enabled by default. Copy the two example directories into an isolated gateway workspace's @@ -137,7 +137,7 @@ runtimes (static: one cached `add`, then `use_tool` per call; dynamic: and protocol-error handling. They use no external credentials, model calls, Nacos server, or Docker. -For a provisioned Router with a registered weather server: +For a provisioned Router with the acceptance `get_weather(city)` backend described above: ```sh export OPENCLAW_NACOS_LIVE=1 diff --git a/eng/NacosLiveSmoke/Program.cs b/eng/NacosLiveSmoke/Program.cs index 107beb6d..700eff78 100644 --- a/eng/NacosLiveSmoke/Program.cs +++ b/eng/NacosLiveSmoke/Program.cs @@ -43,6 +43,14 @@ { Check(!JsonSerializer.IsReflectionEnabledByDefault, "JSON reflection must remain disabled"); Check(await config.PublishConfigAsync(dataId, options.Group, "initial", ct), "initial config publish failed"); + // Publish acknowledgement precedes visibility in Nacos's read cache. Finish + // seeding before subscribing, so its initial arrival is not the measured change. + var seed = Stopwatch.StartNew(); + while (await config.GetConfigAsync(dataId, options.Group, 10_000, ct) != "initial") + { + Check(seed.Elapsed < TimeSpan.FromSeconds(30), "initial configuration did not become readable"); + await Task.Delay(25, ct); + } await subscription.StartAsync(ct); await WaitUntil(() => subscription.Status == "active", TimeSpan.FromSeconds(30), ct); var tools = await registry.ReloadWorkspaceServersAsync(new Dictionary diff --git a/eng/nacos-live/verify.py b/eng/nacos-live/verify.py index eac09da2..5d0d5201 100644 --- a/eng/nacos-live/verify.py +++ b/eng/nacos-live/verify.py @@ -42,6 +42,7 @@ def wait_for(check, seconds, description): if check(): return except (OSError, ValueError): + # A starting service can refuse connections or return an incomplete response. pass time.sleep(0.25) raise TimeoutError(description) @@ -58,6 +59,7 @@ def free_port(offsets=(0,)): held[-1].bind(("127.0.0.1", port + offset)) return port except OSError: + # Another local process owns one of the required ports; try a new group. pass finally: for sock in held: @@ -181,6 +183,7 @@ def listening(): try: os.killpg(process.pid, signal.SIGTERM) except ProcessLookupError: + # This owned process group has already exited. pass for process in reversed(children): try: From 968e3ae84725c621e8e3b02ecbe3609a7a8b7a0b Mon Sep 17 00:00:00 2001 From: telli Date: Fri, 18 Sep 2026 20:05:00 -0700 Subject: [PATCH 3/3] fix: handle acceptance cleanup race and clarify native coverage Co-authored-by: geffzhang --- docs/capability-resolution.md | 2 +- docs/nacos-mcp-router.md | 2 +- ...kill\351\233\206\346\210\220\346\236\266\346\236\204.md" | 2 +- docs/zh-CN/nacos-mcp-router.md | 2 +- eng/nacos-live/README.md | 2 +- eng/nacos-live/verify.py | 6 +++++- 6 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/capability-resolution.md b/docs/capability-resolution.md index 7fc73bdd..c1bc4d08 100644 --- a/docs/capability-resolution.md +++ b/docs/capability-resolution.md @@ -86,7 +86,7 @@ Step evidence records provider, generation, intent, candidates/attempts, selecte Conformance tests cover both runtimes with the local provider, permission denial before discovery, authorization on cache hits, generation races, provider/security isolation, bounded expiry, circuit cooldown, and replay divergence. Router tests cover captured protocol envelopes and typed failures; listener tests cover retry, cancellation, and disposal. -Live Nacos/Router acceptance is reproducible through [the isolated acceptance harness](../eng/nacos-live/README.md). It runs authenticated Nacos 3.2.4 and Router 0.2.2, exercises both runtimes, and checks real event invalidation and rebinds in managed and NativeAOT processes with JSON reflection disabled. The dedicated CI lane provisions its own services; ordinary unit tests remain independent of them. Historical contributor token measurements remain separately attributed in the Router guide. This proves the tested deployment contract, not arbitrary registry recall or production availability. AgentQi catalog/trust and operational screen design belong in the downstream ecosystem/product backlog. +Live Nacos/Router acceptance is reproducible through [the isolated acceptance harness](../eng/nacos-live/README.md). It runs authenticated Nacos 3.2.4 and Router 0.2.2. Managed tests exercise both runtime implementations. Separate managed and NativeAOT smoke processes check real event invalidation and rebinding with JSON reflection disabled. The dedicated CI lane provisions its own services; ordinary unit tests remain independent of them. Historical contributor token measurements remain separately attributed in the Router guide. This proves the tested deployment contract, not arbitrary registry recall or production availability. AgentQi catalog/trust and operational screen design belong in the downstream ecosystem/product backlog. Nacos target retries require an explicit operator allowlist under `adapterSettings.nacos.retrySafeTargets`, for example `["weather-mcp/get_weather"]`. Only list operations known to be safe to repeat; other targets execute once regardless of a skill's retry count. The retry weather example requires this setting. diff --git a/docs/nacos-mcp-router.md b/docs/nacos-mcp-router.md index bbd94fc0..d68b58fe 100644 --- a/docs/nacos-mcp-router.md +++ b/docs/nacos-mcp-router.md @@ -302,7 +302,7 @@ cache with the recorded binding). See Build with `-p:OpenClawEnableNacos=true -p:OpenClawEnableNacosEvents=true` (add `-p:PublishAot=false` for JIT) and configure `adapterSettings.nacos`. See the [configuration example](capability-resolution.md#optional-adapter-builds). -The optional SDK adapter registers in the background, retries failed setup, and reports `starting`, `degraded`, `active`, or `stopped`. Events publish generic invalidation signals that advance the cache generation. They do not overwrite the local workspace configuration. With no adapter or no address, TTL and explicit workspace reload remain available. RedNb.Nacos 2.1.0 supplies generated protocol JSON metadata; the optional adapter supports NativeAOT and does not turn JSON reflection back on. The live harness verifies <=2 s publish-to-invalidation and static/dynamic rebinds in both managed and native processes. +The optional SDK adapter registers in the background, retries failed setup, and reports `starting`, `degraded`, `active`, or `stopped`. Events publish generic invalidation signals that advance the cache generation and clear all capability bindings; per-server invalidation is not implemented. They do not overwrite the local workspace configuration. With no adapter or no address, TTL and explicit workspace reload remain available. RedNb.Nacos 2.1.0 supplies generated protocol JSON metadata; the optional adapter supports NativeAOT and does not turn JSON reflection back on. The live harness verifies <=2 s publish-to-invalidation and static/dynamic rebinds in both managed and native processes. ## Historical contributor evidence diff --git "a/docs/zh-CN/Nacos-MCP-Router\344\270\216OpenClaw.NET-MetaSkill\351\233\206\346\210\220\346\236\266\346\236\204.md" "b/docs/zh-CN/Nacos-MCP-Router\344\270\216OpenClaw.NET-MetaSkill\351\233\206\346\210\220\346\236\266\346\236\204.md" index c435fab0..72b5a8f9 100644 --- "a/docs/zh-CN/Nacos-MCP-Router\344\270\216OpenClaw.NET-MetaSkill\351\233\206\346\210\220\346\236\266\346\236\204.md" +++ "b/docs/zh-CN/Nacos-MCP-Router\344\270\216OpenClaw.NET-MetaSkill\351\233\206\346\210\220\346\236\266\346\236\204.md" @@ -221,7 +221,7 @@ sequenceDiagram 2. **Token 最小化**:模型只接触 MetaSkill DAG 结构与 Router 的少量工具描述,而非全部后端服务的 Schema。 3. **绑定可演进**:更换/升级后端服务只需修改 Nacos 注册信息,MetaSkill 定义不变。 -> 实现对照(2026-09-14,#230/#231/#232/#233/#238 已落地):上图中动态槽位的 `search → add → use` 与静态槽位的 `add`(首次,幂等缓存)→ `use` 均为确定性代码路径;会话级绑定缓存(intent 哈希 + TTL/reload 失效)与节点级降级(fallback 路由 / Top-5 候选轮替 / retry 重试熔断)已实现;Nacos 变更事件订阅的失效联动(#238)已实现——变更到达即双清缓存(会话绑定缓存 + 运行时 added-server 缓存)并触发 watcher reload。 +> 实现对照(2026-09-14,#230/#231/#232/#233/#238 已落地):上图中动态槽位的 `search → add → use` 与静态槽位的 `add`(首次,幂等缓存)→ `use` 均为确定性代码路径;会话级绑定缓存(intent 哈希 + TTL/reload 失效)与节点级降级(fallback 路由 / Top-5 候选轮替 / retry 重试熔断)已实现;Nacos 变更事件订阅的失效联动(#238)已实现——变更到达经通用能力失效接口推进 generation 并清除所有绑定,静态槽位重新 add、动态槽位重新解析;不触发 workspace 配置 reload。 ## 7. 关键工程决策 diff --git a/docs/zh-CN/nacos-mcp-router.md b/docs/zh-CN/nacos-mcp-router.md index 3f8f307f..149c5cf9 100644 --- a/docs/zh-CN/nacos-mcp-router.md +++ b/docs/zh-CN/nacos-mcp-router.md @@ -33,7 +33,7 @@ Router 0.2.2 的三个工具仍是 `search_mcp_server`、`add_mcp_server`、`use ## 缓存、事件与验收 事件订阅在后台注册,暴露 disabled/starting/active/degraded/stopped 状态,失败时 -按退避策略重试。事件经通用失效接口推进缓存 generation,清除静态和动态绑定; +按退避策略重试。事件经通用失效接口推进缓存 generation,清除所有静态和动态绑定(未实现按 server 精细失效); 不覆盖 workspace 配置。未启用或不可达时,TTL 与显式 reload 继续有效。 [隔离验收工具与完整命令](../../eng/nacos-live/README.md)启动带认证的 Nacos 3.2.4、 diff --git a/eng/nacos-live/README.md b/eng/nacos-live/README.md index 27be9655..703b0890 100644 --- a/eng/nacos-live/README.md +++ b/eng/nacos-live/README.md @@ -49,7 +49,7 @@ Nacos SDK and Router-only builds contain no RedNb packages. ## Acceptance checks - Exactly three Router tools; real search, add, and use-tool round trips. -- Static and dynamic MetaSkills complete with a weather payload in both the +- Managed tests verify static and dynamic MetaSkills complete with a weather payload in both the native agent runtime and Microsoft Agent Framework runtime, reuse bindings, avoid duplicate tool registration, and make zero model calls. - Managed and NativeAOT smoke executables both keep JSON reflection disabled. diff --git a/eng/nacos-live/verify.py b/eng/nacos-live/verify.py index 5d0d5201..94cc1a00 100644 --- a/eng/nacos-live/verify.py +++ b/eng/nacos-live/verify.py @@ -189,7 +189,11 @@ def listening(): try: process.wait(timeout=10) except subprocess.TimeoutExpired: - os.killpg(process.pid, signal.SIGKILL) + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + # The group can exit between the timed wait and forced termination. + pass process.wait(timeout=5) for log in logs: log.close()