mateclaw/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java
MIST e4dd08b5f4
feat(agent): 注意力锚定与环境感知——MCP 工具溯源 + skill 约束固定 + 事件通知 (#490)
* feat(agent): 注意力锚定与环境感知——MCP 工具溯源 + skill 约束固定 + 事件通知

## 背景

1. **MCP 工具跨服务器混淆**:MCP 工具名是 `mcp_<serverId>_<slug>_<hash6>`,serverId 是 19 位不可读 Snowflake。LLM 在多服务器任务中常把 slug 拼到错误 serverId 上重构出不存在的工具名,反复重试到 max iterations。
2. **长对话中 skill 约束丢失**:`load_skill` 返回的 SKILL.md 正文存在 messages 历史窗口里,被压缩管线(Soft Trim / Hard Clear / Pre-Prune / LLM Summary)销毁,约束彻底消失,agent 后续步骤违反约束。
3. **运行时环境变更对 agent 不可见**:MCP 服务器断连 / skill 更新发生在 agent 推理中途时,工具列表是 turn-start 快照,LLM 无法感知,继续调用已失效的工具。
4. **ledger 条目可被 LLM 反向覆盖**:Java 用 `auto_`/`pin_` 前缀让位给 LLM,但 LLM 没有反向保护——`progress_update(stepKey="auto_read_file")` 会覆盖 Java 写入的条目,保护是单向的。
5. **SkillManifestParser 从未填充 constraints 字段**:`KNOWN_KEYS` 未列入 `"constraints"`,导致约束被静默路由到 `extras`,所有依赖 `manifest.getConstraints()` 的代码都是死代码。

## 改动内容

### 文件改动

**新增文件(生产代码 4 个)**
- **`mateclaw-server/.../agent/runtime/EnvironmentNotification.java`** — 环境变更通知 record(type / message / timestamp)。
- **`mateclaw-server/.../agent/runtime/RunningConversationRegistry.java`** — 跟踪活跃会话 + 每会话有界通知队列(上限 10)+ TTL 定时清理(30 分钟未活跃的 handle 自动回收)。
- **`mateclaw-server/.../agent/runtime/EnvironmentEventRouter.java`** — 5 个 `@EventListener` 把 MCP/skill 事件翻译成中文 LLM 通知并广播。
- **`mateclaw-server/.../skill/event/SkillUpdatedEvent.java`** — skill 更新/启用/禁用/重扫描事件。

**新增文件(测试 5 个)**
- **`mateclaw-server/.../skill/manifest/SkillManifestConstraintsParsingTest.java`** — constraints 解析白盒测试(5 用例)。
- **`mateclaw-server/.../agent/progress/ProgressLedgerPrefixGuardTest.java`** — 前缀守卫 + 三类条目 + 并发 + 批量 auto-record 白盒(22 用例)。
- **`mateclaw-server/.../agent/runtime/RunningConversationRegistryTest.java`** — registry + router 生命周期 + TTL 清理白盒(24 用例)。
- **`mateclaw-server/.../agent/context/ContextCompressionLedgerSurvivalTest.java`** — 三类条目压缩存活黑盒(5 用例)。
- **`mateclaw-server/.../agent/graph/node/EnvironmentNotificationRenderingTest.java`** — 事件→通知→LLM 可见黑盒(14 用例)。

**修改文件(生产代码 14 个)**
- **`mateclaw-server/.../agent/progress/ProgressLedger.java`** — 增加 `pinned` map + `AUTO_RECORDED_PREFIX` 常量 + 三类条目区分;`mostRecentUpdate` 只看 regular 条目;`renderStaleReminder` 补 pending 计数。
- **`mateclaw-server/.../agent/progress/ProgressLedgerService.java`** — JSON 格式升级为 wrapper `{entries, pinned}`(向后兼容旧 flat-map);`upsert` 加 `auto_`/`pin_` 前缀守卫;`upsertPinned` / `upsertAutoRecorded` / `clearPinnedByPrefix` / `upsertAutoRecordedBatch`(批量版,一次 lock+load+save 处理 N 个工具响应);auto-recorded 4 参签名避免跨服务器键碰撞,有界=5。
- **`mateclaw-server/.../agent/graph/node/ActionNode.java`** — `load_skill` 后 `pinSkillConstraints` 把约束写入 pinned;工具调用后 `autoRecordToolCalls` 收集批量后一次 `upsertAutoRecordedBatch`(避免 N 次 lock+save 串行化);setter 注入保持测试构造器兼容。
- **`mateclaw-server/.../agent/graph/node/ReasoningNode.java`** — C4 注入:drain 通知 → `renderEnvironmentNotifications` → SystemMessage 加入 nonHistoryPrefix;helper 改 package-private 供黑盒测试。
- **`mateclaw-server/.../agent/AgentGraphBuilder.java`** — 系统提示增加 ProgressLedger Discipline 段(agent-3)+ Environment Change Notifications 段(agent-1);SkillCatalog 渲染器扫描约束加 🔒 锚点(agent-4);wire ActionNode setter + ReasoningNode registry。
- **`mateclaw-server/.../agent/AgentService.java`** — `withLifecycleSync` / `withLifecycleFlux` 入口 `safeRegister`、出口 `safeUnregister`,覆盖 Flux 抛错路径。
- **`mateclaw-server/.../skill/manifest/SkillManifest.java`** — 增加 `constraints` 字段(List<String>)。
- **`mateclaw-server/.../skill/manifest/SkillManifestParser.java`** — `KNOWN_KEYS` 加 `"constraints"`;builder 链加 `.constraints(stringList(fm.get("constraints")))`。
- **`mateclaw-server/.../skill/service/SkillService.java`** — 4 个改动点发布 `SkillUpdatedEvent`(rescan / update builtin / update non-builtin / toggle enable-disable)。
- **`mateclaw-server/.../tool/builtin/ProgressLedgerTool.java`** — `@Tool` 描述声明 `auto_`/`pin_` 前缀保留;`@ToolParam stepKey` 同步警告。
- **`mateclaw-server/.../tool/mcp/runtime/PrefixedNameToolCallback.java`** — 新增 3 参构造器,serverName 非空时描述前缀 `[MCP server: <name>]`,让 LLM 区分跨服务器同名工具。
- **`mateclaw-server/.../tool/mcp/runtime/McpClientManager.java`** — `wrapServerCallbacks` 透传 serverName 到 PrefixedNameToolCallback。
- **`mateclaw-server/.../agent/context/ConversationWindowManager.java`** — `PRUNE_EXEMPT_TOOLS` 加入 `load_skill`(A1)。
- **`mateclaw-server/.../agent/graph/executor/ToolExecutionExecutor.java`** — 工具不存在时 `buildMcpAwareNotFoundMessage` 跨服务器搜索同 slug/hash 候选,给出 ≤5 个建议名。

**修改文件(测试 1 个)**
- **`mateclaw-server/.../agent/progress/ProgressLedgerStaleReminderTest.java`** — 回归适配:reminder 文本现在包含 `pending` 计数。

### 测试

- `mvn -pl mateclaw-server -am test -Dtest='SkillManifestConstraintsParsingTest,ProgressLedgerPrefixGuardTest,RunningConversationRegistryTest,ContextCompressionLedgerSurvivalTest,EnvironmentNotificationRenderingTest,ProgressLedgerStaleReminderTest'`:70/70 通过
- `mvn -pl mateclaw-server -am test`(全量回归,含上面 6 个 + 12 个深挖影响类):0 失败 0 错误

### 安全性

- **前缀保留**:`ProgressLedgerService.upsert` 拒绝 `auto_`/`pin_` 前缀,LLM 无法覆盖 Java 管理的条目;`@Tool` 描述显式声明保留前缀。
- **事件路由异常隔离**:`EnvironmentEventRouter.broadcast` 全 try/catch,路由失败永不冒泡到 Spring 事件总线。
- **并发安全**:registry 用 `ConcurrentHashMap` + `ConcurrentLinkedQueue`;ledger upsert 用 per-conversation `ReentrantLock`;批量 auto-record 在单次 lock 内完成;`ProgressLedgerPrefixGuardTest.concurrentUpsertAndAutoRecordAreSafe` 锁定。
- **内存有界**:通知队列每会话上限 10(LRU 驱逐最老);auto-recorded 条目每会话上限 5(驱逐最老);registry 后台 TTL 清理(30 分钟未活跃的 handle 自动回收)。
- **绑定机制不受影响**:MCP/skill 的 agent 绑定(`mate_agent_tool` / `mate_agent_skill` 表)完全未被触碰;C3 通知广播是有意全量(非按 agentId 过滤),最坏情况是无关 agent 多收一条 SystemMessage(LLM 被告知"如无关可忽略")。

## 逐项验证

### 改动 1:SkillManifestParser 真正解析 constraints(深挖修复)

**文件**:`mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifestParser.java:33-47,108`

| 项 | 内容 |
|---|---|
| 改了什么 | `KNOWN_KEYS` 集合加入 `"constraints"`;builder 链加入 `.constraints(stringList(fm.get("constraints")))`。 |
| 为什么 | 之前 `KNOWN_KEYS` 没列入 `"constraints"`,导致该键被静默路由到 `extras`,`manifest.getConstraints()` 永远返回空 list,下游 B2 pinSkillConstraints 和 agent-4 catalog 锚点全是死代码。 |
| 验证步骤 | 1. `cat test-fixtures/skill-with-constraints/SKILL.md`(如有)确认 frontmatter 有 `constraints: [...]`;2. 运行 `SkillManifestConstraintsParsingTest`。 |
| 预期结果 | `manifest.getConstraints()` 返回非空 list;test 5/5 通过。 |

### 改动 2:ProgressLedgerService 前缀守卫(深挖修复)

**文件**:`mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressLedgerService.java:132-137`

| 项 | 内容 |
|---|---|
| 改了什么 | `upsert()` 入口检查 key 是否以 `auto_` 或 `pin_` 开头,是则抛 `IllegalArgumentException`。 |
| 为什么 | 之前保护是单向的:Java 让位 LLM(auto 不覆盖 LLM 已有 entry),但 LLM 可以用 `progress_update(stepKey="auto_read_file")` 覆盖 Java 写入的条目,导致 auto-recorded 工具记录被改写。 |
| 验证步骤 | 1. `ProgressLedgerPrefixGuardTest.upsertRejectsAutoPrefix`;2. `ProgressLedgerPrefixGuardTest.upsertRejectsPinPrefix`。 |
| 预期结果 | 两个测试均抛 `IllegalArgumentException`;`ProgressLedgerTool` 的 `@Tool` 描述包含前缀保留声明。 |

### 改动 3:upsertAutoRecorded 4 参签名 + 批量化(深挖修复 + 性能优化)

**文件**:`mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressLedgerService.java:227-296`、`mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java`(`autoRecordToolCalls`)

| 项 | 内容 |
|---|---|
| 改了什么 | `upsertAutoRecorded` 升级为 4 参签名 `(conversationId, toolName, displayName, resultSummary)`;新增 `upsertAutoRecordedBatch` 批量方法,一次 lock+load+save 处理 N 个工具响应;ActionNode 改为先收集 `List<AutoRecordEntry>` 再一次批量调用。 |
| 为什么 | 1. 跨服务器键碰撞:两个 MCP 服务器都暴露 `search` 工具 → `auto_search` 互相覆盖;2. 并行工具串行化:每个 ToolResponse 单独 lock+load+save 抵消并行收益。 |
| 验证步骤 | 1. `ProgressLedgerPrefixGuardTest.autoRecordedDifferentServersNoCollision`:两个服务器同名工具共存;2. `ProgressLedgerPrefixGuardTest.batchInsertProducesSameResultAsSequential`:批量与逐条结果一致;3. `ProgressLedgerPrefixGuardTest.batchInsertBoundedToMaxFiveEvenWithLargeBatch`:10 条批量插入后有界=5。 |
| 预期结果 | ledger 中同时存在 `auto_mcp_4_search_xxx` 和 `auto_mcp_7_search_yyy`;5 个并行工具调用从 5 次 lock+save 降为 1 次。 |

### 改动 4:B2 pinSkillConstraints——load_skill 后约束写入 pinned

**文件**:`mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java`(`pinSkillConstraints`)

| 项 | 内容 |
|---|---|
| 改了什么 | `load_skill` 工具调用成功后,读取 `manifest.getConstraints()`,对每条约束调用 `progressLedgerService.upsertPinned(convId, "pin_<skillName>_<i>", constraintText, note)`。 |
| 为什么 | 把约束从 messages(会被压缩销毁)抽到 DB ledger.pinned(压缩免疫),解决"长对话中 skill 约束丢失"问题。 |
| 验证步骤 | 1. `ContextCompressionLedgerSurvivalTest.loadSkillBodyDestroyedByCompressionButConstraintsSurviveInLedger`;2. `ContextCompressionLedgerSurvivalTest.allThreeLedgerEntryTypesSurviveCompression`。 |
| 预期结果 | 压缩后 messages 中 load_skill 正文消失,但 `ledger.renderSnapshot()` 仍包含 `🔒 固定约束` 段,约束文本字节级保留。 |

### 改动 5:B5 autoRecordToolCalls——工具调用后批量自动记录

**文件**:`mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java`(`autoRecordToolCalls`)

| 项 | 内容 |
|---|---|
| 改了什么 | ActionNode 处理 ToolResponse 后,收集所有有效条目到 `List<AutoRecordEntry>`,一次调用 `upsertAutoRecordedBatch`。 |
| 为什么 | 让 LLM 在长对话中即使忘记自己刚调用过什么工具,也能从 ledger 快照看到最近 5 次工具调用记录;批量调用避免 N 次 lock+save 串行化。 |
| 验证步骤 | `ProgressLedgerPrefixGuardTest.autoRecordedBoundedToMaxFive`:模拟 10 次工具调用,验证 auto 条目数等于 5。 |
| 预期结果 | ledger 中 auto 条目始终 ≤ 5,最老的被驱逐;5 个并行工具调用只需 1 次 DB roundtrip。 |

### 改动 6:C4 环境通知注入 nonHistoryPrefix

**文件**:`mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java:755-765,1201-1215`

| 项 | 内容 |
|---|---|
| 改了什么 | ReasoningNode 每轮推理前 `registry.drain(conversationId)`,非空则 `renderEnvironmentNotifications` 渲染成 markdown 块,作为 SystemMessage 加入 nonHistoryPrefix。 |
| 为什么 | 让运行时环境变更(MCP 断连 / skill 更新)在下一轮推理立即可见,LLM 主动改路而不是反复重试失效工具。 |
| 验证步骤 | `EnvironmentNotificationRenderingTest.mcpConnectionLostEventEndToEnd_producesActionableLLMText`:注册会话 → 触发 `McpConnectionLostEvent(serverId=7)` → drain → render。 |
| 预期结果 | 渲染块包含 "📢 环境变更通知"、`mcp_7_` 前缀、"不要反复重试" 指令。 |

### 改动 7:A1 PRUNE_EXEMPT_TOOLS 加入 load_skill

**文件**:`mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java:96-120`

| 项 | 内容 |
|---|---|
| 改了什么 | `PRUNE_EXEMPT_TOOLS` 集合从 `{delegateToAgent, delegateParallel}` 扩展为 `{delegateToAgent, delegateParallel, load_skill}`。 |
| 为什么 | `load_skill` 返回的 SKILL.md 是 load-time 快照,skill 作者可能在执行期间更新,重载不保证恢复相同指令;且 50KB+ skill 重载昂贵。 |
| 验证步骤 | `ConversationWindowManagerToolPruningTest`(已有测试套件)。 |
| 预期结果 | load_skill 的 ToolResponseMessage 在 `pruneOldToolResultsForModelInput` / `compactAgedToolResponses` 阶段不被修剪。 |

### 改动 8:agent-2 PrefixedNameToolCallback 描述加 serverName 标签

**文件**:`mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/PrefixedNameToolCallback.java:55-80`、`mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpClientManager.java:201-300`

| 项 | 内容 |
|---|---|
| 改了什么 | 新增 3 参构造器 `(prefixedName, delegate, serverName)`,serverName 非空时描述前缀 `[MCP server: <name>]`;McpClientManager `wrapServerCallbacks` 透传 serverName。 |
| 为什么 | LLM 看到 `mcp_4_search_a1b2c3` 时无法知道这是哪个服务器的工具;多个 MCP 服务器都暴露 `search` 时,LLM 会混淆。加 `[MCP server: fetch-server]` 标签让 LLM 区分。 |
| 验证步骤 | 启动一个 MCP 服务器,在 agent 工具列表中观察工具描述是否包含 `[MCP server: <name>]` 前缀。 |
| 预期结果 | 每个 MCP 工具描述开头包含 `[MCP server: <serverName>]`;2 参构造器(无 serverName)保持向后兼容,描述不加前缀。 |

### 改动 9:ToolExecutionExecutor 工具不存在时跨服务器候选建议

**文件**:`mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java:1264-1340`

| 项 | 内容 |
|---|---|
| 改了什么 | "Tool not found" 错误信息升级:若请求名是 MCP 格式,搜索 `toolCallbackMap` 中 slug 或 hash6 匹配但 serverId 不同的候选,返回 ≤5 个建议。 |
| 为什么 | LLM 常把 slug 拼到错误 serverId 上重构出不存在工具名,反复重试到 max iterations。给候选建议后 LLM 可以直接复制正确名字。 |
| 验证步骤 | 1. 启动两个 MCP 服务器都暴露 `fetch` 工具;2. 让 LLM 调用 `mcp_<serverA>_fetch_xxx`(实际 fetch 在 serverB);3. 观察错误信息。 |
| 预期结果 | 错误信息包含 "Did you mean one of these?" + 正确的 `mcp_<serverB>_fetch_yyy` 候选名。 |

### 改动 10:Registry TTL 定时清理(防泄漏)

**文件**:`mateclaw-server/src/main/java/vip/mate/agent/runtime/RunningConversationRegistry.java:155-211`

| 项 | 内容 |
|---|---|
| 改了什么 | 新增 `cleanupStale(Duration maxAge)` 方法 + `@Scheduled scheduledCleanup()`(每 5 分钟扫一次,清理 30 分钟未活跃的 handle)。用 `remove(key, value)` 保证不误删被并发 `register` 刷新的 handle。 |
| 为什么 | 兜底防御异常路径泄漏的 handle——即使 `safeUnregister` 因异常路径未执行(如 Reactor cancel 信号不触发 doFinally),后台线程也能回收。 |
| 验证步骤 | `RunningConversationRegistryTest.cleanupStaleRemovesOldHandles`:注册 → 反射 backdate lastActiveAt → 清理 → 验证被移除;`cleanupStaleDoesNotRemoveRefreshedHandle`:backdate 后 re-register → 清理 → 验证存活。 |
| 预期结果 | 30 分钟未活跃的 handle 被清理;被 `register` 刷新的 handle 不被误删。 |

### 改动 11:JSON 格式向后兼容迁移

**文件**:`mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressLedgerService.java:79-85,284-292`

| 项 | 内容 |
|---|---|
| 改了什么 | JSON 从 flat-map `{"step1":{...}}` 升级为 wrapper `{"entries":{...},"pinned":{...}}`;`parseWrapper` 通过 peek `"entries"` 键区分新旧格式,旧格式自动迁移为 wrapper(pinned 为空)。 |
| 为什么 | 老 conversation 的 ledger 列存的是 flat-map,新代码上线后必须能加载老数据。 |
| 验证步骤 | `ContextCompressionLedgerSurvivalTest.oldFlatMapLedgerMigratesToWrapperFormatWithEmptyPinned`:写入旧 JSON → load → 验证 pinned 为空 → upsert → 验证新 JSON 包含 `entries` 和 `pinned` 键。 |
| 预期结果 | 旧 conversation 无需迁移脚本,第一次 load 即兼容;写入时自动转为新格式。 |

## 新增测试验证

**文件**:
- `mateclaw-server/src/test/java/vip/mate/skill/manifest/SkillManifestConstraintsParsingTest.java`
- `mateclaw-server/src/test/java/vip/mate/agent/progress/ProgressLedgerPrefixGuardTest.java`
- `mateclaw-server/src/test/java/vip/mate/agent/runtime/RunningConversationRegistryTest.java`
- `mateclaw-server/src/test/java/vip/mate/agent/context/ContextCompressionLedgerSurvivalTest.java`
- `mateclaw-server/src/test/java/vip/mate/agent/graph/node/EnvironmentNotificationRenderingTest.java`

| 命令 | 预期 |
|------|------|
| `mvn -pl mateclaw-server -am test -Dtest='SkillManifestConstraintsParsingTest'` | Tests run: 5, Failures: 0 |
| `mvn -pl mateclaw-server -am test -Dtest='ProgressLedgerPrefixGuardTest'` | Tests run: 22, Failures: 0 |
| `mvn -pl mateclaw-server -am test -Dtest='RunningConversationRegistryTest'` | Tests run: 24, Failures: 0 |
| `mvn -pl mateclaw-server -am test -Dtest='ContextCompressionLedgerSurvivalTest'` | Tests run: 5, Failures: 0 |
| `mvn -pl mateclaw-server -am test -Dtest='EnvironmentNotificationRenderingTest'` | Tests run: 14, Failures: 0 |

## 回归检查清单

- [ ] 全量 `mvn -pl mateclaw-server -am test` 通过(已验证 0 失败 0 错误)
- [ ] 老 conversation(flat-map ledger JSON)首次 load 不报错,pinned 字段为空
- [ ] 多 MCP 服务器场景:LLM 工具列表中每个工具描述包含 `[MCP server: <name>]` 标签
- [ ] MCP 服务器中途断连:agent 下一轮推理看到 `📢 环境变更通知` 块,主动改路
- [ ] 长 conversation(>100 轮)经多次 PTL 压缩后,`ledger.renderSnapshot()` 仍包含 pinned 约束
- [ ] `progress_update(stepKey="auto_xxx")` 被拒绝,返回 `IllegalArgumentException` 错误信息
- [ ] auto-recorded 条目数始终 ≤ 5(10 次工具调用后仍为 5)
- [ ] 5 个并行工具调用只产生 1 次 DB roundtrip(批量 auto-record)
- [ ] Registry 中 30 分钟未活跃的 handle 被后台定时清理
- [ ] MCP/skill 绑定机制(`mate_agent_tool` / `mate_agent_skill` 表)不受影响
- [ ] Plan-Execute 路径(StepExecutionNode)目前**不**接收环境通知——只有 ReAct 路径生效(已知未覆盖项,不阻塞本 PR)

* feat(agent): 六招减法重构——修复压缩销毁 skill 约束与 MCP 按需暴露

## 背景

- 压缩三阶段(softTrim / hardClear / prePruneForSummary)只检查 `isSpillMarker`,不检查 `PRUNE_EXEMPT_TOOLS`,导致 `load_skill` 返回的 SKILL.md 约束、`delegateToAgent` 子智能体转录在压缩中被裁掉,模型在长对话中"忘记"任务规则,根因是"压缩导致注意力失效"。
- skillCatalog 表只列 Skill / Status / Description 三列,bound skill 的 constraints 与 allowedTools 没有任何可见入口,模型加载 skill 后约束仍可能被忽略。
- MCP 工具默认 CORE tier,20+ MCP 工具的 schema 涌入核心列表,挤占 builtin 工具的注意力,且 `DisclosureTier.fromToken(null)` 返回 CORE 导致 `getOrDefault` 的默认值永远不生效。
- 构建期工具过滤分 4 次 pass,重复遍历且无明确 deny/allow 边界。
- skillCatalog 在每次推理步都按当前 loadedSkills 动态渲染,破坏 Anthropic SYSTEM_AND_TOOLS cache 前缀稳定性。
- 进度账本(ProgressLedger)约束条目前缀无保护,跨 MCP server 键碰撞,环境事件无统一路由入口。

## 改动内容

### 文件改动

**主代码(21 个文件)**

- **`mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java`** — Move 4:三阶段新增 `isExemptTool` 检查跳过 `load_skill`/`delegateToAgent`/`delegateParallel`;新增 Phase 2.7 无损 spill evict 在调用 LLM 摘要前把超大工具结果落盘
- **`mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java`** — Move 2 & 3:catalog 表新增 Constraints 列(仅 bound skill 显示);新增 `### Bound skill allowed tools` 块
- **`mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java`** — Move 1:skillCatalog 用 `render(Set.of())` 静态渲染进 nonHistoryPrefix;loadedThisRun hint 作为 volatile 后缀注入
- **`mateclaw-server/src/main/java/vip/mate/tool/disclosure/DefaultToolDisclosureService.java`** — Move 5:MCP 工具默认 tier 从 CORE 改 EXTENSION;`buildSnapshot` 跳过 null/blank tier 修复 `fromToken(null)→CORE` 陷阱
- **`mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java`** — Move 6:构建期权限过滤从 4 次 pass 合并为 2 次(deny 集 + allow 集)
- **`mateclaw-server/src/main/java/vip/mate/agent/AgentService.java`** — 接入 EnvironmentEventRouter 与 RunningConversationRegistry
- **`mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java`** — 工具调用后自动回填 ProgressLedger
- **`mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java`** — 渲染 ledger 三段式快照
- **`mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressLedger.java`** — constraints 前缀保护,跨 MCP server 键命名空间隔离
- **`mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressLedgerService.java`** — 写入 constraints 到 pinned 条目
- **`mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifest.java`** — 新增 constraints 字段
- **`mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifestParser.java`** — 解析 SKILL.md frontmatter 中的 constraints
- **`mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java`** — skill 更新事件发布
- **`mateclaw-server/src/main/java/vip/mate/tool/builtin/ProgressLedgerTool.java`** — 三段式渲染
- **`mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpClientManager.java`** — MCP 命名透明化
- **`mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/PrefixedNameToolCallback.java`** — 透明命名映射
- **`mateclaw-server/src/main/java/vip/mate/agent/runtime/EnvironmentEventRouter.java`** — 新增:5 个环境事件监听器
- **`mateclaw-server/src/main/java/vip/mate/agent/runtime/EnvironmentNotification.java`** — 新增:环境通知数据模型
- **`mateclaw-server/src/main/java/vip/mate/agent/runtime/RunningConversationRegistry.java`** — 新增:运行中会话注册表 + TTL 清理
- **`mateclaw-server/src/main/java/vip/mate/skill/event/SkillUpdatedEvent.java`** — 新增:skill 更新事件
- **`mateclaw-server/Dockerfile`** — 构建配置微调

**测试代码(11 个文件)**

- **`ConversationWindowManagerExemptAndSpillTest.java`** — 新增 15 个行为测试,证明 Move 4 生效
- **`SkillRuntimeServiceConstraintsAndToolsTest.java`** — 新增 8 个测试覆盖 Constraints 列与 allowedTools 块
- **`ReasoningNodeLoadedSkillsHintTest.java`** — 新增 8 个测试覆盖 loadedThisRun hint 渲染
- **`CompactionSurvivalComparisonTest.java`** — 新增 4 个场景的新旧代码对比测试(100 轮极限压缩)
- **`ContextCompressionLedgerSurvivalTest.java`** — 压缩后 ledger 存活测试
- **`EnvironmentNotificationRenderingTest.java`** — 环境通知渲染测试
- **`ProgressLedgerPrefixGuardTest.java`** — ledger 前缀保护测试
- **`RunningConversationRegistryTest.java`** — 会话注册表测试
- **`SkillManifestConstraintsParsingTest.java`** — constraints 解析测试
- **`ProgressLedgerStaleReminderTest.java`** — 修复回归
- **`ToolDisclosureServiceTest.java`** — 断言从 CORE 改为 EXTENSION

### 测试

**回归测试**

- `mvn test`(mateclaw-server 全量):**199 通过 / 1 跳过 / 0 失败**

**行为测试(证明改动生效,旧代码上失败)**

- `CompactionSurvivalComparisonTest`(4 个场景):在新代码上全部通过
- 用 `git stash` 还原旧代码后,16 个行为测试编译失败或断言失败 → 证明测试确实覆盖了新行为

**新旧代码对比测试(同一份测试源码,两套代码库运行)**

| 场景 | 旧代码 | 新代码 |
|---|---|---|
| A: 50 load_skill + 50 delegate + 50 read_file 单轮压缩 | load_skill 0/50, delegate 0/50 | **load_skill 50/50, delegate 50/50** |
| B: 100 轮极限压缩 + 头部 pinned load_skill | root_constraint_survived=**false**, 113ms | root_constraint_survived=**true**, 60ms |
| C: 20 个不同大小 load_skill 单轮压缩 | 0/20 存活, tokens 8694→754 | **20/20 存活**, tokens 8694→8694 |
| D: 30 轮稳态压缩 + pinned skill | pinned_survived=**false** | pinned_survived=**true** |

### 安全性

- `DisclosureTier.fromToken(null)` 陷阱修复:旧代码 `serverTierById.put(id, CORE)` 导致 `getOrDefault` 默认值永不生效;新代码跳过 null tier,未配置的 MCP server 才走 EXTENSION 默认值
- `PRUNE_EXEMPT_TOOLS` 保护范围从 2 处扩展到 5 处,避免 `load_skill` 约束被压缩销毁后模型在无约束下执行敏感操作

## 逐项验证

### 改动 1:nonHistoryPrefix 分层稳定化

**文件**:`mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java:701-714, 776-788, 1246-1257`

| 项 | 内容 |
|---|---|
| 改了什么 | skillCatalog 用 `render(Set.of())` 静态渲染进 nonHistoryPrefix;loadedThisRun hint 作为 volatile 后缀注入 |
| 为什么 | 每次推理步都按 loadedSkills 动态渲染会破坏 Anthropic SYSTEM_AND_TOOLS cache 前缀,导致 cache 失效增加 token 成本 |
| 验证步骤 | 1. 打开 ReasoningNode.java:701;2. 确认 `skillCatalogRenderer.render(java.util.Set.of())` 调用;3. 跑 `ReasoningNodeLoadedSkillsHintTest` |
| 预期结果 | 8 个测试通过,loadedThisRun hint 作为后缀注入,不破坏前缀缓存 |

### 改动 2:skillCatalog 增加 Constraints 列

**文件**:`mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java:502-561, 634-641`

| 项 | 内容 |
|---|---|
| 改了什么 | catalog 表从 3 列扩为 4 列,新增 Constraints 列(仅 bound skill 显示,长约束截断,pipe 转义);新增 `### Bound skill allowed tools` 块 |
| 为什么 | bound skill 的 constraints 没有任何可见入口,模型加载后仍可能忽略 |
| 验证步骤 | 1. 跑 `SkillRuntimeServiceConstraintsAndToolsTest`;2. 检查 catalog 渲染包含 Constraints 列 |
| 预期结果 | 8 个测试通过,bound skill 显示 constraints,非 bound skill 省略 |
- **边界验证**:长约束截断为单行;pipe 字符被转义不破坏表格

### 改动 3:修复 PRUNE_EXEMPT_TOOLS 在三阶段的绕过

**文件**:`mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java:990-994, 1030-1090, 1160-1210`

| 项 | 内容 |
|---|---|
| 改了什么 | `softTrimToolResults`、`hardClearToolResults`、`prePruneForSummary` 三处新增 `isExemptTool` 检查,跳过 `load_skill`/`delegateToAgent`/`delegateParallel` |
| 为什么 | 旧代码只在 `pruneOldToolResultsForModelInput` 和 `compactAgedToolResponses` 检查 exempt,三阶段不检查,导致 skill 约束在压缩中被裁掉 |
| 验证步骤 | 1. 跑 `ConversationWindowManagerExemptAndSpillTest`;2. 跑 `CompactionSurvivalComparisonTest` |
| 预期结果 | 15 个行为测试通过;100 轮压缩后 load_skill body 存活率 100% |
- **反例对照**:在新代码上跑对比测试,旧代码存活率 0%,新代码 100%

### 改动 4:Phase 2.7 无损 spill evict

**文件**:`mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java:440-468, 1263-1300`

| 项 | 内容 |
|---|---|
| 改了什么 | 在 Phase 2 hardClear 之后、LLM 摘要之前新增 Phase 2.7,把超大工具结果落盘替换为 spill marker |
| 为什么 | 旧代码超出预算直接走 LLM 摘要(有损+耗时+费 token),其实大部分场景落盘就够 |
| 验证步骤 | 1. 检查 `spillEvictToolResults` 方法;2. 跑 `ConversationWindowManagerExemptAndSpillTest.spillEvictReducesTokenCount` |
| 预期结果 | spill 后 token 数低于预算时跳过 LLM 摘要,strategy=lossless_spill_evict |

### 改动 5:MCP 工具默认 EXTENSION

**文件**:`mateclaw-server/src/main/java/vip/mate/tool/disclosure/DefaultToolDisclosureService.java:78-105, 276-336`

| 项 | 内容 |
|---|---|
| 改了什么 | `resolveTierByName` 默认返回 EXTENSION;`buildSnapshot` 跳过 null/blank tier 的 server 不放入 map |
| 为什么 | MCP schema 是 prompt 最重部分,默认 CORE 挤占 builtin 工具注意力;`fromToken(null)` 返回 CORE 导致默认值失效 |
| 验证步骤 | 1. 跑 `ToolDisclosureServiceTest.mcpDefaultsExtensionWhenServerTierUnset`;2. 检查未配置 tier 的 MCP server 工具不在 active 列表 |
| 预期结果 | 未配置 tier 的 MCP 工具进入 extensionCatalog,需 `enable_tool` 激活 |
- **边界验证**:显式 `disclosure_tier=core` 的 server 仍保持 CORE

### 改动 6:构建期权限过滤合并

**文件**:`mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java:258-310`

| 项 | 内容 |
|---|---|
| 改了什么 | 4 次 pass 合并为 2 次:先 `withDeniedToolsFiltered(deniedSet)`,再 `withAllowedToolsOnly(boundTools)` |
| 为什么 | 重复遍历浪费构建时间,且 deny/allow 边界不清晰 |
| 验证步骤 | 1. 检查 AgentGraphBuilder.java:258-310;2. 跑全量回归测试确认工具过滤行为不变 |
| 预期结果 | 工具列表与改动前一致,构建步骤减少 |

## 新增测试验证

**文件**:

- `mateclaw-server/src/test/java/vip/mate/agent/context/ConversationWindowManagerExemptAndSpillTest.java`
- `mateclaw-server/src/test/java/vip/mate/agent/context/CompactionSurvivalComparisonTest.java`
- `mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillRuntimeServiceConstraintsAndToolsTest.java`
- `mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeLoadedSkillsHintTest.java`
- `mateclaw-server/src/test/java/vip/mate/skill/manifest/SkillManifestConstraintsParsingTest.java`
- `mateclaw-server/src/test/java/vip/mate/agent/progress/ProgressLedgerPrefixGuardTest.java`
- `mateclaw-server/src/test/java/vip/mate/agent/runtime/RunningConversationRegistryTest.java`
- `mateclaw-server/src/test/java/vip/mate/agent/graph/node/EnvironmentNotificationRenderingTest.java`
- `mateclaw-server/src/test/java/vip/mate/agent/context/ContextCompressionLedgerSurvivalTest.java`

| 命令 | 预期 |
|------|------|
| `mvn -Dtest=ConversationWindowManagerExemptAndSpillTest test` | 15 个测试通过 |
| `mvn -Dtest=CompactionSurvivalComparisonTest test` | 4 个场景通过,新代码 load_skill 存活率 100% |
| `mvn -Dtest=SkillRuntimeServiceConstraintsAndToolsTest test` | 8 个测试通过 |
| `mvn -Dtest=ReasoningNodeLoadedSkillsHintTest test` | 8 个测试通过 |
| `mvn test`(全量) | 199 通过 / 1 跳过 / 0 失败 |

**新旧对比测试运行命令**:

```bash
# 新代码
cd /data/mateclaw/mateclaw-server && mvn -Dtest=CompactionSurvivalComparisonTest -Dsurefire.useFile=false test

# 旧代码(需把测试复制到 mateclaw-old)
cd /data/mateclaw/mateclaw-old/mateclaw-server && mvn -Dtest=CompactionSurvivalComparisonTest -Dsurefire.useFile=false test
```

## 回归检查清单

- [ ] `mvn test` 全量通过(199/1skip/0fail)
- [ ] 对比测试在新代码上 load_skill 存活率 100%
- [ ] 对比测试在旧代码上 load_skill 存活率 0%(证明测试有效)
- [ ] MCP 工具默认进入 extensionCatalog,`enable_tool` 可激活
- [ ] 显式 `disclosure_tier=core` 的 MCP server 仍保持 CORE
- [ ] 100 轮压缩后 root_constraint 仍存活
- [ ] Phase 2.7 spill evict 在预算内时跳过 LLM 摘要
- [ ] skillCatalog 静态渲染不依赖 loadedSkills,前缀缓存稳定
2026-07-06 11:50:41 +08:00

748 lines
35 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package vip.mate.skill.runtime;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import vip.mate.skill.acp.AcpSkillBridge;
import vip.mate.skill.lessons.SkillLessonsService;
import vip.mate.skill.manifest.SkillManifest;
import vip.mate.skill.mcp.McpSkillBridge;
import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.runtime.model.ResolvedSkill;
import vip.mate.skill.service.SkillService;
import vip.mate.skill.usage.SkillUsageService;
import vip.mate.skill.workspace.SkillWorkspaceEvent;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import java.time.Duration;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
/**
* 技能运行时服务
* 管理 active skills 运行时视图,提供缓存和刷新机制
*/
@Slf4j
@Service
public class SkillRuntimeService {
private final SkillService skillService;
private final SkillPackageResolver packageResolver;
/**
* {@code @Lazy} — SkillLessonsService depends on SkillWorkspaceManager,
* which is constructed early; the lazy proxy avoids a chicken-and-egg
* cycle when the runtime service initializes alongside the skill
* service stack. Using setter-style injection through the
* constructor below.
*/
private final SkillLessonsService lessonsService;
/**
* RFC-090 §3.2 / §10.2 Q2 — MCP-server → virtual-skill bridge.
* {@code @Lazy} because the bridge depends on McpClientManager
* which boots later in the lifecycle.
*/
private final McpSkillBridge mcpSkillBridge;
/**
* RFC-090 §3.2 (parallel) — ACP-endpoint → virtual-skill bridge.
* Same {@code @Lazy} treatment as the MCP bridge.
*/
private final AcpSkillBridge acpSkillBridge;
private final SkillUsageService usageService;
/**
* Mirrors {@code mateclaw.skill.disclosure.load-skill-tool.enabled}. When
* false the catalog guidance points at {@code readSkillFile} instead of
* {@code load_skill} (which is also unregistered upstream). Field-initialised
* to true so non-Spring unit construction keeps the default behavior.
*/
@org.springframework.beans.factory.annotation.Value(
"${mateclaw.skill.disclosure.load-skill-tool.enabled:true}")
private boolean loadSkillToolEnabled = true;
@Autowired
public SkillRuntimeService(SkillService skillService,
SkillPackageResolver packageResolver,
@Lazy SkillLessonsService lessonsService,
@Lazy McpSkillBridge mcpSkillBridge,
@Lazy AcpSkillBridge acpSkillBridge,
@Lazy SkillUsageService usageService) {
this.skillService = skillService;
this.packageResolver = packageResolver;
this.lessonsService = lessonsService;
this.mcpSkillBridge = mcpSkillBridge;
this.acpSkillBridge = acpSkillBridge;
this.usageService = usageService;
}
// 缓存已解析的 active skills5分钟过期
private final Cache<String, List<ResolvedSkill>> activeSkillsCache = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(5))
.maximumSize(10)
.build();
private static final String CACHE_KEY = "active_skills";
/**
* Debounce window for {@link #onWorkspaceEvent}. Startup typically
* fires one event per bundled skill (30+ in a row), and there's
* nothing useful to do until the whole batch settles. 500 ms is
* long enough to swallow the burst without making admin re-syncs
* feel laggy.
*/
private static final long REFRESH_DEBOUNCE_MS = 500;
private final ScheduledExecutorService refreshScheduler =
Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "skill-refresh-debouncer");
t.setDaemon(true);
return t;
});
/** Most recent pending refresh future — atomically swapped on each
* event so the previous one can be cancelled. */
private final AtomicReference<ScheduledFuture<?>> pendingRefresh = new AtomicReference<>();
@PostConstruct
public void init() {
log.info("SkillRuntimeService initialized");
// 设置反向引用,避免循环依赖
skillService.setRuntimeService(this);
}
@EventListener(ApplicationReadyEvent.class)
public void onApplicationReady() {
// 延迟到 ApplicationReady 事件触发,确保 SQL 初始化脚本已执行完毕
refreshActiveSkills();
}
@EventListener(SkillWorkspaceEvent.class)
public void onWorkspaceEvent(SkillWorkspaceEvent event) {
// Coalesce bursts of workspace events (every bundled-skill sync at
// startup fires one) into a single refresh. Without debounce, a
// 30-skill startup triggered 30 sequential refreshActiveSkills()
// calls — each running the whole resolve+scan loop. With 500 ms
// debounce it collapses to one.
log.debug("Workspace event: {} {} (refresh scheduled in {}ms)",
event.type(), event.skillName(), REFRESH_DEBOUNCE_MS);
ScheduledFuture<?> previous = pendingRefresh.get();
if (previous != null && !previous.isDone()) {
previous.cancel(false);
}
ScheduledFuture<?> task = refreshScheduler.schedule(() -> {
try {
refreshActiveSkills();
} catch (Exception e) {
log.warn("Debounced refresh failed: {}", e.getMessage());
}
}, REFRESH_DEBOUNCE_MS, TimeUnit.MILLISECONDS);
pendingRefresh.set(task);
}
@PreDestroy
public void shutdown() {
// Drain any pending refresh so JVM shutdown doesn't hang on the
// daemon thread, even though it's marked daemon and would die anyway.
ScheduledFuture<?> pending = pendingRefresh.getAndSet(null);
if (pending != null) pending.cancel(true);
refreshScheduler.shutdown();
try {
if (!refreshScheduler.awaitTermination(2, TimeUnit.SECONDS)) {
refreshScheduler.shutdownNow();
}
} catch (InterruptedException e) {
refreshScheduler.shutdownNow();
Thread.currentThread().interrupt();
}
}
/**
* RFC-090 §14.1 — single source of truth for "is this resolved
* skill currently exposable to an agent". Both the global
* {@link #refreshActiveSkills()} cache and the per-agent
* {@link #buildSkillPromptEnhancement(Set)} branch route through
* here so the two views can never disagree.
*
* <p>Rules:
* <ol>
* <li>row enabled, runtime resolved, not security-blocked — required</li>
* <li>manifest present → at least one feature READY ({@code hasAnyActiveFeature})</li>
* <li>manifest absent (legacy SKILL.md) → fall back to
* {@code dependencyReady} so old skills behave unchanged</li>
* </ol>
*/
public static boolean passesActiveGate(ResolvedSkill s) {
if (s == null) return false;
if (!s.isEnabled() || !s.isRuntimeAvailable() || s.isSecurityBlocked()) return false;
if (s.getManifest() == null) return s.isDependencyReady();
return s.hasAnyActiveFeature();
}
/**
* 获取当前启用的技能列表(运行时视图)
*/
public List<ResolvedSkill> getActiveSkills() {
List<ResolvedSkill> cached = activeSkillsCache.getIfPresent(CACHE_KEY);
if (cached != null) {
return cached;
}
return refreshActiveSkills();
}
/**
* 刷新 active skills 缓存
* 进入 active set 的 skill 必须同时满足:
* 1. enabled == true
* 2. runtimeAvailable == true
* 3. securityBlocked == false
* 4. dependencyReady == true
*/
public List<ResolvedSkill> refreshActiveSkills() {
List<SkillEntity> enabledSkills = skillService.listEnabledSkills();
List<ResolvedSkill> resolved = enabledSkills.stream()
.map(packageResolver::resolve)
.filter(SkillRuntimeService::passesActiveGate)
.collect(Collectors.toList());
// Track real skill names so a same-named bridged virtual skill is
// suppressed (real wins). Without this, a real SKILL.md packaged
// alongside a same-name MCP/ACP server produces two cards on the
// Skills page.
Set<String> realNames = resolved.stream()
.map(ResolvedSkill::getName)
.collect(Collectors.toSet());
try {
// MCP-derived virtual skills go through the same active gate
// so a disconnected MCP server doesn't pollute the prompt
// enhancement. Same-name virtuals are suppressed by the real
// skill above.
for (ResolvedSkill virt : mcpSkillBridge.listMcpDerivedResolvedSkills()) {
if (realNames.contains(virt.getName())) continue;
if (passesActiveGate(virt)) resolved.add(virt);
}
} catch (Exception e) {
log.warn("MCP skill bridge active merge failed: {}", e.getMessage());
}
try {
// ACP-derived virtual skills. Same dedup as MCP.
for (ResolvedSkill virt : acpSkillBridge.listAcpDerivedResolvedSkills()) {
if (realNames.contains(virt.getName())) continue;
if (passesActiveGate(virt)) resolved.add(virt);
}
} catch (Exception e) {
log.warn("ACP skill bridge active merge failed: {}", e.getMessage());
}
activeSkillsCache.put(CACHE_KEY, resolved);
log.info("Refreshed active skills: {} enabled", resolved.size());
return resolved;
}
/**
* 解析所有技能的运行时状态(管理页面使用,包含 disabled 和 error 信息)
*
* <p>RFC-090 §3.2 — appends virtual MCP-derived skills so the Skills
* page can render MCP servers as first-class skill cards. Real
* skills resolve through the full pipeline; virtual ones come
* pre-built from {@link McpSkillBridge}.
*/
public List<ResolvedSkill> resolveAllSkillsStatus() {
List<SkillEntity> allSkills = skillService.listSkills();
List<ResolvedSkill> resolved = allSkills.stream()
.map(packageResolver::resolve)
.collect(Collectors.toList());
// Same dedup-by-name as refreshActiveSkills(): a real skill with
// the same name as a bridged virtual one suppresses the virtual,
// so the Skills admin page never shows two cards for the same name.
Set<String> realNames = resolved.stream()
.map(ResolvedSkill::getName)
.collect(Collectors.toSet());
try {
for (ResolvedSkill virt : mcpSkillBridge.listMcpDerivedResolvedSkills()) {
if (realNames.contains(virt.getName())) continue;
resolved.add(virt);
}
} catch (Exception e) {
log.warn("MCP skill bridge merge failed: {}", e.getMessage());
}
try {
for (ResolvedSkill virt : acpSkillBridge.listAcpDerivedResolvedSkills()) {
if (realNames.contains(virt.getName())) continue;
resolved.add(virt);
}
} catch (Exception e) {
log.warn("ACP skill bridge merge failed: {}", e.getMessage());
}
return resolved;
}
/**
* Rescan one skill on demand (RFC-042 §2.3.4) — runs the full resolver
* pipeline (content + security + dependency), which writes the updated
* scan result to DB as a side-effect, and then invalidates the active
* skills cache so subsequent reads reflect the new status.
*/
public ResolvedSkill rescanSingle(SkillEntity skill) {
ResolvedSkill resolved = packageResolver.resolve(skill);
activeSkillsCache.invalidateAll();
log.info("Rescanned skill '{}' (id={}): status={}, blocked={}",
skill.getName(), skill.getId(),
skill.getSecurityScanStatus(), resolved.isSecurityBlocked());
return resolved;
}
/**
* RFC-090 review #3 — explicit lifecycle hook so SkillService
* can deregister wrapper tools without poking at the resolver
* directly. Safe to call for skill ids that never had wrappers.
*/
public void deregisterSkillWrappers(Long skillId) {
try {
packageResolver.deregisterSkillWrappers(skillId);
} catch (Exception e) {
log.warn("Failed to deregister wrappers for skill {}: {}", skillId, e.getMessage());
}
}
/**
* 根据名称查找 active skill
*/
public ResolvedSkill findActiveSkill(String name) {
return getActiveSkills().stream()
.filter(s -> s.getName().equals(name))
.findFirst()
.orElse(null);
}
/**
* 构建技能 prompt 增强片段(全局,向后兼容)
*/
public String buildSkillPromptEnhancement() {
return buildSkillPromptEnhancement(null);
}
/**
* 构建技能 prompt 增强片段(支持 per-agent 过滤)
*
* @param boundSkillIds Agent 绑定的 skill ID 集合。null 表示使用全局默认(无绑定)。
* 非 null 时仅包含指定 ID 的 skill。
*/
public String buildSkillPromptEnhancement(Set<Long> boundSkillIds) {
return buildSkillPromptEnhancement(boundSkillIds, null, null, null);
}
/**
* 构建技能目录提示片段。
*
* @param boundSkillIds Agent 绑定的 skill ID 集合。null 表示使用全局默认(无绑定)。
* @param effectiveToolNames 当前 agent 可见的工具名集合。null 表示不按 agent 绑定限制过滤。
* @param maxInputTokens 当前模型最大输入窗口,用于控制目录大小。
*/
public String buildSkillPromptEnhancement(Set<Long> boundSkillIds,
Set<String> effectiveToolNames,
Integer maxInputTokens) {
return buildSkillPromptEnhancement(boundSkillIds, effectiveToolNames, maxInputTokens, null, null);
}
public String buildSkillPromptEnhancement(Set<Long> boundSkillIds,
Set<String> effectiveToolNames,
Integer maxInputTokens,
Long agentId) {
return buildSkillPromptEnhancement(boundSkillIds, effectiveToolNames, maxInputTokens, agentId, null);
}
/**
* 构建技能目录提示片段(支持按 Agent 工作区隔离)。
*
* @param agentWorkspaceId 调用 Agent 的工作区 ID。非 null 时,目录只保留
* 内置技能(全局)与该工作区拥有的技能;其他工作区
* 的技能不会注入 prompt。null 表示不做工作区过滤
* (调试预览等全局场景)。
*/
public String buildSkillPromptEnhancement(Set<Long> boundSkillIds,
Set<String> effectiveToolNames,
Integer maxInputTokens,
Long agentId,
Long agentWorkspaceId) {
return buildSkillPromptEnhancement(boundSkillIds, effectiveToolNames, maxInputTokens,
agentId, agentWorkspaceId, Set.of());
}
/**
* Build the skill catalog prompt segment, pinning skills loaded this run to
* the top so a multi-iteration loop stops re-loading the same skill.
*
* @param loadedThisRunNames names of skills loaded via {@code load_skill}
* during the current graph run; sorted to the top
* of the catalog ahead of the usage-history
* signals. Never {@code null}.
*/
public String buildSkillPromptEnhancement(Set<Long> boundSkillIds,
Set<String> effectiveToolNames,
Integer maxInputTokens,
Long agentId,
Long agentWorkspaceId,
Set<String> loadedThisRunNames) {
List<ResolvedSkill> activeSkills;
if (boundSkillIds != null) {
// Per-agent filter: pick the agent's bound subset from the
// already-merged active set (real + MCP/ACP virtual). Using
// getActiveSkills() — instead of a fresh
// skillService.listEnabledSkills() walk — is what makes bound
// virtual skills surface in the prompt catalog. The earlier
// implementation only looked at mate_skill rows, so a user
// who explicitly checked an MCP/ACP card in the agent picker
// got its tools (via AgentBindingService.getEffectiveToolNames)
// but lost the corresponding `## Skills` catalog row, which
// confused the LLM when it tried to dispatch by skill name.
// Cache-backed get + same passesActiveGate semantics, so this
// is strictly additive for real skills.
activeSkills = getActiveSkills().stream()
.filter(s -> s.getId() != null && boundSkillIds.contains(s.getId()))
.collect(java.util.stream.Collectors.toList());
} else {
activeSkills = getActiveSkills();
}
// Platform filter — drop skills whose `platforms:` frontmatter
// names a different OS than the runtime host. apple-notes /
// findmy etc. are macOS-only; surfacing them on Linux just
// burns prompt tokens for skills the user can never run.
// Empty / missing `platforms:` means "all platforms" (the default).
String currentOs = currentOsCanonical();
activeSkills = activeSkills.stream()
.filter(s -> matchesCurrentPlatform(s, currentOs))
.collect(java.util.stream.Collectors.toList());
// Workspace filter — a workspace-B agent must not see workspace-A's
// skills in its catalog. Builtin skills are global; virtual MCP
// skills carry no workspace (null) and stay globally visible. Only
// applied when the caller supplies the agent's workspace; the debug
// preview passes null to keep its global view.
if (agentWorkspaceId != null) {
activeSkills = activeSkills.stream()
.filter(s -> matchesWorkspace(s, agentWorkspaceId))
.collect(java.util.stream.Collectors.toList());
}
if (activeSkills.isEmpty()) {
return "";
}
List<ResolvedSkill> visibleSkills = activeSkills.stream()
.filter(s -> isVisibleWithTools(s, effectiveToolNames))
.collect(java.util.stream.Collectors.toList());
if (visibleSkills.isEmpty()) return "";
Set<Long> boundIds = boundSkillIds == null ? Set.of() : boundSkillIds;
int maxEntries = promptCatalogEntryLimit(maxInputTokens);
int descLimit = promptDescriptionLimit(maxInputTokens);
Set<String> recentNames = usageService.recentLoadedSkillNames(agentId, 8);
Set<String> frequentNames = usageService.frequentlyLoadedSkillNames(8);
// Boost freshly installed skills for a short window so a skill the
// user *just* added is visible in the compact catalog before it has
// any usage history. Without this, qwen-turbo-style 8-entry budgets
// hide new skills behind 40+ existing ones, and the LLM tells the
// user "no such skill" minutes after they uploaded it.
java.time.LocalDateTime recencyCutoff = java.time.LocalDateTime.now().minus(NEW_SKILL_BOOST_WINDOW);
Set<String> loadedNames = loadedThisRunNames == null ? Set.of() : loadedThisRunNames;
List<ResolvedSkill> sorted = applyCatalogSignals(
SkillCatalogSorter.sortResolved(visibleSkills, SkillCatalogSort.RECOMMENDED),
loadedNames, recentNames, frequentNames, recencyCutoff);
List<ResolvedSkill> pinned = sorted.stream()
.filter(s -> s.getId() != null && boundIds.contains(s.getId()))
.toList();
LinkedHashSet<ResolvedSkill> selected = new LinkedHashSet<>();
selected.addAll(pinned);
for (ResolvedSkill skill : sorted) {
if (selected.size() >= Math.max(maxEntries, pinned.size())) break;
selected.add(skill);
}
StringBuilder sb = new StringBuilder();
sb.append("\n\n## Skills\n");
sb.append("This is a compact catalog. If a listed skill matches the task, ");
if (loadSkillToolEnabled) {
sb.append("first call `load_skill(skillName=<name>)` to pull its SKILL.md into the conversation, ");
sb.append("then follow its instructions. Once loaded, the skill stays available in the conversation — ");
sb.append("do not load it again. ");
} else {
sb.append("first call `readSkillFile(skillName=<name>, filePath=\"SKILL.md\")` to read its instructions, ");
sb.append("then follow them. ");
}
sb.append("If none of these skills match, call `listAvailableSkills()` to inspect the broader catalog ");
sb.append("(it accepts `keyword=<part of name>` and `limit=` up to 50 — use them to search by topic ");
sb.append("when the default page is truncated). ");
sb.append("If the user names a specific skill that isn't in this table, ");
if (loadSkillToolEnabled) {
sb.append("call `load_skill(skillName=\"<exact-name>\")` directly — ");
} else {
sb.append("call `readSkillFile(skillName=\"<exact-name>\", filePath=\"SKILL.md\")` directly — ");
}
sb.append("the catalog above is intentionally compact and doesn't list every active skill. ");
sb.append("Skills are documentation packages — calling a skill name as a tool will fail. ");
sb.append("To read a skill's reference or script files, use ");
sb.append("`readSkillFile(skillName=<name>, filePath=\"references/...\")`. ");
sb.append("Skills with a `scripts/` directory expose `runSkillScript`; ");
sb.append("SKILL.md will name the script when needed. ");
sb.append("If a skill describes steps but ships no runnable script, write the code ");
sb.append("its instructions describe and run it with ");
sb.append("`execute_code(language=<python|bash|node>, code=..., skillName=<name>)`.\n\n");
sb.append("| Skill | Status | Description | Constraints |\n");
sb.append("|-------|--------|-------------|-------------|\n");
for (ResolvedSkill skill : selected) {
sb.append("| `").append(skill.getName()).append("`");
if (skill.getIcon() != null && !skill.getIcon().isBlank()) {
sb.append(" ").append(skill.getIcon());
}
sb.append(" | ").append(statusToken(skill)).append(" | ");
if (skill.getDescription() != null && !skill.getDescription().isBlank()) {
String desc = skill.getDescription();
if (desc.length() > descLimit) {
desc = desc.substring(0, descLimit) + "...";
}
// Escape pipe and newline so a multi-line description doesn't
// break the table layout.
sb.append(desc.replace("|", "\\|").replace("\n", " "));
}
sb.append(" | ");
// Only bound skills show constraints — they're the ones the agent
// is configured to obey. Constraints are stable (agent lifecycle)
// so they live in the prompt-cache-friendly static catalog segment,
// immune to history compression. Non-bound skills (recommended /
// recent) are "visible but optional" and don't carry constraints
// the agent must follow.
if (skill.getId() != null && boundIds.contains(skill.getId())
&& skill.getManifest() != null
&& skill.getManifest().getConstraints() != null
&& !skill.getManifest().getConstraints().isEmpty()) {
sb.append(renderConstraintsSummary(skill.getManifest().getConstraints()));
}
sb.append(" |\n");
}
if (selected.size() < visibleSkills.size()) {
sb.append("\nShowing ").append(selected.size()).append(" of ")
.append(visibleSkills.size())
.append(" available skills. Use `listAvailableSkills()` for the full catalog.\n");
}
// Append a compact "bound skill tools" block so the model knows which
// tools each bound skill allows — this is the other half of the skill
// contract (constraints in the table above, allowedTools here). Kept
// in the static catalog segment (prompt-cache-friendly, immune to
// history compression) so the model always knows the tool boundary
// for bound skills without needing to load_skill.
List<ResolvedSkill> boundWithTools = selected.stream()
.filter(s -> s.getId() != null && boundIds.contains(s.getId()))
.filter(s -> s.getEffectiveAllowedTools() != null
&& !s.getEffectiveAllowedTools().isEmpty())
.toList();
if (!boundWithTools.isEmpty()) {
sb.append("\n### Bound skill allowed tools\n");
for (ResolvedSkill skill : boundWithTools) {
Set<String> tools = skill.getEffectiveAllowedTools();
sb.append("- `").append(skill.getName()).append("`: ");
sb.append(tools.stream()
.map(t -> "`" + t + "`")
.collect(java.util.stream.Collectors.joining(", ")));
sb.append('\n');
}
}
List<ResolvedSkill> lessonSkills = sorted.stream()
.filter(s -> (s.getId() != null && boundIds.contains(s.getId())) || recentNames.contains(s.getName()))
.toList();
appendLessonsBlock(sb, lessonSkills);
return sb.toString();
}
/**
* Apply the catalog ranking signals on top of the RECOMMENDED base order.
* Priority, highest first: loaded this run, freshly installed, recently
* loaded (DB history), frequently loaded (DB history), then the RECOMMENDED
* comparator as the stable tiebreak.
* <p>
* Package-private and static so it can be unit-tested without standing up
* the full service.
*/
static List<ResolvedSkill> applyCatalogSignals(List<ResolvedSkill> recommended,
Set<String> loadedThisRunNames,
Set<String> recentNames,
Set<String> frequentNames,
java.time.LocalDateTime recencyCutoff) {
Set<String> loaded = loadedThisRunNames == null ? Set.of() : loadedThisRunNames;
Set<String> recent = recentNames == null ? Set.of() : recentNames;
Set<String> frequent = frequentNames == null ? Set.of() : frequentNames;
return recommended.stream()
.sorted(java.util.Comparator
.comparingInt((ResolvedSkill s) -> loaded.contains(s.getName()) ? 0 : 1)
.thenComparingInt(s -> isRecentlyInstalled(s, recencyCutoff) ? 0 : 1)
.thenComparingInt(s -> recent.contains(s.getName()) ? 0 : 1)
.thenComparingInt(s -> frequent.contains(s.getName()) ? 0 : 1)
.thenComparing(SkillCatalogSorter.resolvedComparator(SkillCatalogSort.RECOMMENDED)))
.toList();
}
private static boolean isVisibleWithTools(ResolvedSkill skill, Set<String> effectiveToolNames) {
if (effectiveToolNames == null) return true;
Set<String> tools = skill.getEffectiveAllowedTools();
return tools == null || tools.isEmpty() || effectiveToolNames.containsAll(tools);
}
/**
* Treat skills installed within this window as "new" for the prompt
* catalog ranker. Long enough that a user who installs on Friday and
* comes back Monday still sees the boost; short enough that the
* catalog reverts to usage-based ordering before the boost slot
* crowds out genuinely useful skills.
*/
public static final java.time.Duration NEW_SKILL_BOOST_WINDOW = java.time.Duration.ofDays(7);
/**
* Returns true if the skill's row was created after {@code cutoff}.
* Builtins and virtual MCP/ACP skills typically have no createTime;
* they are not boosted (the user didn't just install them). Public so
* the user-facing {@code listAvailableSkills} catalog can apply the
* same boost as the prompt enhancement.
*/
public static boolean isRecentlyInstalled(ResolvedSkill skill, java.time.LocalDateTime cutoff) {
if (skill == null || skill.getCreateTime() == null) return false;
if (skill.isBuiltin()) return false;
return skill.getCreateTime().isAfter(cutoff);
}
private static int promptCatalogEntryLimit(Integer maxInputTokens) {
int max = maxInputTokens != null && maxInputTokens > 0 ? maxInputTokens : 8192;
if (max <= 8192) return 8;
if (max <= 16384) return 12;
if (max <= 32768) return 20;
return 32;
}
private static int promptDescriptionLimit(Integer maxInputTokens) {
int max = maxInputTokens != null && maxInputTokens > 0 ? maxInputTokens : 8192;
if (max <= 8192) return 80;
if (max <= 16384) return 100;
if (max <= 32768) return 140;
return 160;
}
/**
* Per-constraint char budget for the catalog table's Constraints column.
* Kept short so the table stays compact — the full constraints are
* available via load_skill / readSkillFile when the agent needs the
* complete text. Multiple constraints are joined with "; " before
* truncation.
*/
private static final int CONSTRAINTS_SUMMARY_LIMIT = 80;
/**
* Render a compact one-line summary of a skill's constraints for the
* catalog table. Multiple constraints are joined with "; "; the result
* is truncated to {@link #CONSTRAINTS_SUMMARY_LIMIT} chars and
* pipe/newline escaped so it doesn't break the table layout.
*/
static String renderConstraintsSummary(List<String> constraints) {
if (constraints == null || constraints.isEmpty()) return "";
String joined = String.join("; ", constraints);
if (joined.length() > CONSTRAINTS_SUMMARY_LIMIT) {
joined = joined.substring(0, CONSTRAINTS_SUMMARY_LIMIT) + "...";
}
return joined.replace("|", "\\|").replace("\n", " ");
}
private static String statusToken(ResolvedSkill skill) {
if (skill.isSecurityBlocked()) return "blocked";
if (!skill.isEnabled()) return "disabled";
if (!passesActiveGate(skill)) return "setup-needed";
return "ready";
}
/**
* Append a "## Lessons learned" block to the prompt enhancement
* with one subsection per active skill that has lessons recorded.
*
* <p>Skills opt in via {@code self-evolution.lessons_enabled} (default
* true). Skills with no LESSONS.md content contribute nothing — we
* never emit an empty subsection.
*/
private void appendLessonsBlock(StringBuilder sb, List<ResolvedSkill> activeSkills) {
if (lessonsService == null || activeSkills == null || activeSkills.isEmpty()) return;
StringBuilder lessons = new StringBuilder();
for (ResolvedSkill skill : activeSkills) {
SkillManifest manifest = skill.getManifest();
boolean enabled = manifest == null
|| manifest.getSelfEvolution() == null
|| manifest.getSelfEvolution().isLessonsEnabled();
if (!enabled) continue;
String body = lessonsService.readLessonsBody(skill);
if (body == null || body.isBlank()) continue;
lessons.append("\n### ").append(skill.getName()).append("\n");
lessons.append(body).append("\n");
}
if (lessons.length() > 0) {
sb.append("\n\n## Lessons learned\n");
sb.append("Past observations the agent recorded for these skills. ");
sb.append("Treat them as advisory hints — the canonical SKILL.md still wins on conflict.\n");
sb.append(lessons);
}
}
/**
* Map {@code System.getProperty("os.name")} to one of the canonical
* tokens used in SKILL.md {@code platforms:} ({@code macos / linux /
* windows}). Anything unrecognised → {@code "other"} which never
* matches a declared platform list, so the skill stays visible only
* if its platforms list is empty (the "all platforms" default).
*/
static String currentOsCanonical() {
String os = System.getProperty("os.name", "").toLowerCase(java.util.Locale.ROOT);
if (os.contains("mac") || os.contains("darwin")) return "macos";
if (os.contains("nux") || os.contains("nix")) return "linux";
if (os.contains("win")) return "windows";
return "other";
}
/**
* True when the skill is compatible with {@code currentOs}. A skill
* with empty / null {@code platforms:} matches every OS (legacy
* default). Otherwise the canonical OS token must appear in the list.
*/
static boolean matchesCurrentPlatform(ResolvedSkill skill, String currentOs) {
SkillManifest manifest = skill.getManifest();
if (manifest == null) return true;
List<String> platforms = manifest.getPlatforms();
if (platforms == null || platforms.isEmpty()) return true;
for (String p : platforms) {
if (p == null) continue;
if (currentOs.equalsIgnoreCase(p.trim())) return true;
}
return false;
}
/**
* True when the skill is visible to an agent in {@code agentWorkspaceId}.
* Builtin skills are global, virtual MCP-derived skills carry no
* workspace ({@code null}) and are likewise global; every other skill is
* visible only inside its owning workspace.
*/
static boolean matchesWorkspace(ResolvedSkill skill, long agentWorkspaceId) {
if (skill.isBuiltin()) return true;
Long skillWs = skill.getWorkspaceId();
if (skillWs == null) return true;
return skillWs == agentWorkspaceId;
}
}