@dsh-edge/dsh-edge-agent 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +142 -0
- package/LICENSE +21 -0
- package/README.md +372 -2
- package/config/edge.example.json +179 -0
- package/config/privacy-policy.json +202 -0
- package/cordis.desktop-safe.patch.yml +129 -0
- package/cordis.patch.yml +382 -0
- package/cordis.yml +374 -0
- package/lib/attestation.d.ts +2 -0
- package/lib/attestation.d.ts.map +1 -0
- package/lib/attestation.js +2 -0
- package/lib/audit-log.d.ts +2 -0
- package/lib/audit-log.d.ts.map +1 -0
- package/lib/audit-log.js +2 -0
- package/lib/client.js +87 -0
- package/lib/cloud-llm-adapter.d.ts +2 -0
- package/lib/cloud-llm-adapter.d.ts.map +1 -0
- package/lib/cloud-llm-adapter.js +2 -0
- package/lib/dsh-adapter/attestation.d.ts +42 -0
- package/lib/dsh-adapter/attestation.d.ts.map +1 -0
- package/lib/dsh-adapter/attestation.js +40 -0
- package/lib/dsh-adapter/audit-log.d.ts +139 -0
- package/lib/dsh-adapter/audit-log.d.ts.map +1 -0
- package/lib/dsh-adapter/audit-log.js +376 -0
- package/lib/dsh-adapter/cloud-egress.d.ts +181 -0
- package/lib/dsh-adapter/cloud-egress.d.ts.map +1 -0
- package/lib/dsh-adapter/cloud-egress.js +257 -0
- package/lib/dsh-adapter/cloud-llm-adapter.d.ts +416 -0
- package/lib/dsh-adapter/cloud-llm-adapter.d.ts.map +1 -0
- package/lib/dsh-adapter/cloud-llm-adapter.js +1472 -0
- package/lib/dsh-adapter/contract.d.ts +80 -0
- package/lib/dsh-adapter/contract.d.ts.map +1 -0
- package/lib/dsh-adapter/contract.js +194 -0
- package/lib/dsh-adapter/desktop-cookie-janitor.d.ts +214 -0
- package/lib/dsh-adapter/desktop-cookie-janitor.d.ts.map +1 -0
- package/lib/dsh-adapter/desktop-cookie-janitor.js +409 -0
- package/lib/dsh-adapter/dsh-edge-agent.d.ts +48 -0
- package/lib/dsh-adapter/dsh-edge-agent.d.ts.map +1 -0
- package/lib/dsh-adapter/dsh-edge-agent.js +64 -0
- package/lib/dsh-adapter/edge-config.d.ts +484 -0
- package/lib/dsh-adapter/edge-config.d.ts.map +1 -0
- package/lib/dsh-adapter/edge-config.js +898 -0
- package/lib/dsh-adapter/edge-context-view.d.ts +184 -0
- package/lib/dsh-adapter/edge-context-view.d.ts.map +1 -0
- package/lib/dsh-adapter/edge-context-view.js +483 -0
- package/lib/dsh-adapter/effective-model.d.ts +85 -0
- package/lib/dsh-adapter/effective-model.d.ts.map +1 -0
- package/lib/dsh-adapter/effective-model.js +171 -0
- package/lib/dsh-adapter/etscli-shell-guard.d.ts +244 -0
- package/lib/dsh-adapter/etscli-shell-guard.d.ts.map +1 -0
- package/lib/dsh-adapter/etscli-shell-guard.js +420 -0
- package/lib/dsh-adapter/etscli-tools.d.ts +112 -0
- package/lib/dsh-adapter/etscli-tools.d.ts.map +1 -0
- package/lib/dsh-adapter/etscli-tools.js +393 -0
- package/lib/dsh-adapter/file-logger.d.ts +294 -0
- package/lib/dsh-adapter/file-logger.d.ts.map +1 -0
- package/lib/dsh-adapter/file-logger.js +564 -0
- package/lib/dsh-adapter/function-call-external.d.ts +357 -0
- package/lib/dsh-adapter/function-call-external.d.ts.map +1 -0
- package/lib/dsh-adapter/function-call-external.js +1005 -0
- package/lib/dsh-adapter/host-egress-guard.d.ts +161 -0
- package/lib/dsh-adapter/host-egress-guard.d.ts.map +1 -0
- package/lib/dsh-adapter/host-egress-guard.js +213 -0
- package/lib/dsh-adapter/host-llm-trace.d.ts +198 -0
- package/lib/dsh-adapter/host-llm-trace.d.ts.map +1 -0
- package/lib/dsh-adapter/host-llm-trace.js +344 -0
- package/lib/dsh-adapter/http-api-routes.d.ts +61 -0
- package/lib/dsh-adapter/http-api-routes.d.ts.map +1 -0
- package/lib/dsh-adapter/http-api-routes.js +79 -0
- package/lib/dsh-adapter/http-api-sessions.d.ts +105 -0
- package/lib/dsh-adapter/http-api-sessions.d.ts.map +1 -0
- package/lib/dsh-adapter/http-api-sessions.js +173 -0
- package/lib/dsh-adapter/http-api.d.ts +221 -0
- package/lib/dsh-adapter/http-api.d.ts.map +1 -0
- package/lib/dsh-adapter/http-api.js +1811 -0
- package/lib/dsh-adapter/index.d.ts +62 -0
- package/lib/dsh-adapter/index.d.ts.map +1 -0
- package/lib/dsh-adapter/index.js +97 -0
- package/lib/dsh-adapter/input-guard-messages.d.ts +56 -0
- package/lib/dsh-adapter/input-guard-messages.d.ts.map +1 -0
- package/lib/dsh-adapter/input-guard-messages.js +136 -0
- package/lib/dsh-adapter/input-guard.d.ts +508 -0
- package/lib/dsh-adapter/input-guard.d.ts.map +1 -0
- package/lib/dsh-adapter/input-guard.js +2174 -0
- package/lib/dsh-adapter/intent-prompts.d.ts +77 -0
- package/lib/dsh-adapter/intent-prompts.d.ts.map +1 -0
- package/lib/dsh-adapter/intent-prompts.js +97 -0
- package/lib/dsh-adapter/intent-router.d.ts +710 -0
- package/lib/dsh-adapter/intent-router.d.ts.map +1 -0
- package/lib/dsh-adapter/intent-router.js +2802 -0
- package/lib/dsh-adapter/intent-signals.d.ts +208 -0
- package/lib/dsh-adapter/intent-signals.d.ts.map +1 -0
- package/lib/dsh-adapter/intent-signals.js +391 -0
- package/lib/dsh-adapter/invariant-install.d.ts +16 -0
- package/lib/dsh-adapter/invariant-install.d.ts.map +1 -0
- package/lib/dsh-adapter/invariant-install.js +15 -0
- package/lib/dsh-adapter/invariant.d.ts +16 -0
- package/lib/dsh-adapter/invariant.d.ts.map +1 -0
- package/lib/dsh-adapter/invariant.js +22 -0
- package/lib/dsh-adapter/llm-adapters.d.ts +19 -0
- package/lib/dsh-adapter/llm-adapters.d.ts.map +1 -0
- package/lib/dsh-adapter/llm-adapters.js +16 -0
- package/lib/dsh-adapter/llm-internal-caller.d.ts +92 -0
- package/lib/dsh-adapter/llm-internal-caller.d.ts.map +1 -0
- package/lib/dsh-adapter/llm-internal-caller.js +100 -0
- package/lib/dsh-adapter/llm-max-turns.d.ts +55 -0
- package/lib/dsh-adapter/llm-max-turns.d.ts.map +1 -0
- package/lib/dsh-adapter/llm-max-turns.js +127 -0
- package/lib/dsh-adapter/llm-trace-store.d.ts +131 -0
- package/lib/dsh-adapter/llm-trace-store.d.ts.map +1 -0
- package/lib/dsh-adapter/llm-trace-store.js +205 -0
- package/lib/dsh-adapter/llm-trace.d.ts +450 -0
- package/lib/dsh-adapter/llm-trace.d.ts.map +1 -0
- package/lib/dsh-adapter/llm-trace.js +463 -0
- package/lib/dsh-adapter/local-llm-adapter.d.ts +141 -0
- package/lib/dsh-adapter/local-llm-adapter.d.ts.map +1 -0
- package/lib/dsh-adapter/local-llm-adapter.js +787 -0
- package/lib/dsh-adapter/local-storage.d.ts +96 -0
- package/lib/dsh-adapter/local-storage.d.ts.map +1 -0
- package/lib/dsh-adapter/local-storage.js +198 -0
- package/lib/dsh-adapter/model-router.d.ts +79 -0
- package/lib/dsh-adapter/model-router.d.ts.map +1 -0
- package/lib/dsh-adapter/model-router.js +107 -0
- package/lib/dsh-adapter/ohttp-gateway.d.ts +49 -0
- package/lib/dsh-adapter/ohttp-gateway.d.ts.map +1 -0
- package/lib/dsh-adapter/ohttp-gateway.js +32 -0
- package/lib/dsh-adapter/openai-sse.d.ts +85 -0
- package/lib/dsh-adapter/openai-sse.d.ts.map +1 -0
- package/lib/dsh-adapter/openai-sse.js +462 -0
- package/lib/dsh-adapter/pii-utils.d.ts +96 -0
- package/lib/dsh-adapter/pii-utils.d.ts.map +1 -0
- package/lib/dsh-adapter/pii-utils.js +324 -0
- package/lib/dsh-adapter/plugin-host.d.ts +38 -0
- package/lib/dsh-adapter/plugin-host.d.ts.map +1 -0
- package/lib/dsh-adapter/plugin-host.js +38 -0
- package/lib/dsh-adapter/plugin.d.ts +46 -0
- package/lib/dsh-adapter/plugin.d.ts.map +1 -0
- package/lib/dsh-adapter/plugin.js +100 -0
- package/lib/dsh-adapter/privacy-policy.d.ts +237 -0
- package/lib/dsh-adapter/privacy-policy.d.ts.map +1 -0
- package/lib/dsh-adapter/privacy-policy.js +391 -0
- package/lib/dsh-adapter/prompt-guard.d.ts +152 -0
- package/lib/dsh-adapter/prompt-guard.d.ts.map +1 -0
- package/lib/dsh-adapter/prompt-guard.js +872 -0
- package/lib/dsh-adapter/realm-skill-bridge.d.ts +365 -0
- package/lib/dsh-adapter/realm-skill-bridge.d.ts.map +1 -0
- package/lib/dsh-adapter/realm-skill-bridge.js +1180 -0
- package/lib/dsh-adapter/sanitizer-pipeline.d.ts +140 -0
- package/lib/dsh-adapter/sanitizer-pipeline.d.ts.map +1 -0
- package/lib/dsh-adapter/sanitizer-pipeline.js +431 -0
- package/lib/dsh-adapter/secure-bridge.d.ts +47 -0
- package/lib/dsh-adapter/secure-bridge.d.ts.map +1 -0
- package/lib/dsh-adapter/secure-bridge.js +35 -0
- package/lib/dsh-adapter/session-attribution.d.ts +70 -0
- package/lib/dsh-adapter/session-attribution.d.ts.map +1 -0
- package/lib/dsh-adapter/session-attribution.js +76 -0
- package/lib/dsh-adapter/sessions.d.ts +86 -0
- package/lib/dsh-adapter/sessions.d.ts.map +1 -0
- package/lib/dsh-adapter/sessions.js +179 -0
- package/lib/dsh-adapter/tcm.d.ts +55 -0
- package/lib/dsh-adapter/tcm.d.ts.map +1 -0
- package/lib/dsh-adapter/tcm.js +94 -0
- package/lib/dsh-adapter/td-bus.d.ts +87 -0
- package/lib/dsh-adapter/td-bus.d.ts.map +1 -0
- package/lib/dsh-adapter/td-bus.js +196 -0
- package/lib/dsh-adapter/tool-runner.d.ts +185 -0
- package/lib/dsh-adapter/tool-runner.d.ts.map +1 -0
- package/lib/dsh-adapter/tool-runner.js +271 -0
- package/lib/dsh-adapter/tools.d.ts +118 -0
- package/lib/dsh-adapter/tools.d.ts.map +1 -0
- package/lib/dsh-adapter/tools.js +292 -0
- package/lib/dsh-adapter/types.d.ts +23 -0
- package/lib/dsh-adapter/types.d.ts.map +1 -0
- package/lib/dsh-adapter/types.js +16 -0
- package/lib/dsh-adapter/voice-input.d.ts +30 -0
- package/lib/dsh-adapter/voice-input.d.ts.map +1 -0
- package/lib/dsh-adapter/voice-input.js +34 -0
- package/lib/dsh-adapter/web-search-tavily-bocha.d.ts +152 -0
- package/lib/dsh-adapter/web-search-tavily-bocha.d.ts.map +1 -0
- package/lib/dsh-adapter/web-search-tavily-bocha.js +289 -0
- package/lib/http-api.d.ts +2 -0
- package/lib/http-api.d.ts.map +1 -0
- package/lib/http-api.js +2 -0
- package/lib/index.d.ts +48 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +49 -0
- package/lib/intent-router.d.ts +2 -0
- package/lib/intent-router.d.ts.map +1 -0
- package/lib/intent-router.js +2 -0
- package/lib/llm-adapters.d.ts +2 -0
- package/lib/llm-adapters.d.ts.map +1 -0
- package/lib/llm-adapters.js +2 -0
- package/lib/local-llm-adapter.d.ts +2 -0
- package/lib/local-llm-adapter.d.ts.map +1 -0
- package/lib/local-llm-adapter.js +2 -0
- package/lib/local-storage.d.ts +2 -0
- package/lib/local-storage.d.ts.map +1 -0
- package/lib/local-storage.js +2 -0
- package/lib/model-router.d.ts +2 -0
- package/lib/model-router.d.ts.map +1 -0
- package/lib/model-router.js +2 -0
- package/lib/ohttp-gateway.d.ts +2 -0
- package/lib/ohttp-gateway.d.ts.map +1 -0
- package/lib/ohttp-gateway.js +2 -0
- package/lib/plugin-host.d.ts +2 -0
- package/lib/plugin-host.d.ts.map +1 -0
- package/lib/plugin-host.js +2 -0
- package/lib/privacy-policy.d.ts +2 -0
- package/lib/privacy-policy.d.ts.map +1 -0
- package/lib/privacy-policy.js +2 -0
- package/lib/prompt-guard.d.ts +2 -0
- package/lib/prompt-guard.d.ts.map +1 -0
- package/lib/prompt-guard.js +2 -0
- package/lib/sanitizer-pipeline.d.ts +2 -0
- package/lib/sanitizer-pipeline.d.ts.map +1 -0
- package/lib/sanitizer-pipeline.js +2 -0
- package/lib/secure-bridge.d.ts +2 -0
- package/lib/secure-bridge.d.ts.map +1 -0
- package/lib/secure-bridge.js +2 -0
- package/lib/sessions.d.ts +2 -0
- package/lib/sessions.d.ts.map +1 -0
- package/lib/sessions.js +2 -0
- package/lib/tcm.d.ts +2 -0
- package/lib/tcm.d.ts.map +1 -0
- package/lib/tcm.js +2 -0
- package/lib/td-bus.d.ts +2 -0
- package/lib/td-bus.d.ts.map +1 -0
- package/lib/td-bus.js +2 -0
- package/lib/tools.d.ts +2 -0
- package/lib/tools.d.ts.map +1 -0
- package/lib/tools.js +2 -0
- package/lib/voice-input.d.ts +2 -0
- package/lib/voice-input.d.ts.map +1 -0
- package/lib/voice-input.js +2 -0
- package/package.json +341 -3
- package/presets/agent-preset-edge.json +50 -0
- package/presets/edge.desktop-safe.patch.yml +503 -0
- package/presets/edge.patch.yml +557 -0
|
@@ -0,0 +1,1180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* realm-skill-bridge — Realm-aware skill and tool isolation for dsh-edge-agent.
|
|
3
|
+
*
|
|
4
|
+
* Reads a user-editable JSON config (`skill-realms.json`) that maps skill/tool
|
|
5
|
+
* names (or glob patterns) to LOCAL | CLOUD realms. Tags every discovered skill
|
|
6
|
+
* and tool accordingly, then enforces the mapping at three enforcement points:
|
|
7
|
+
*
|
|
8
|
+
* Layer 1 (agent/pre-step): filter the <available_skills> catalog so each
|
|
9
|
+
* agent only sees skills in its own realm. Skills tagged `hidden` are
|
|
10
|
+
* invisible to every agent.
|
|
11
|
+
*
|
|
12
|
+
* Layer 1.5-replacement (system-prompt/assemble): filter the tool schema
|
|
13
|
+
* list BEFORE dsh-agent-loop wraps it in deepFreeze and passes to the
|
|
14
|
+
* LLM. Replaces the deleted `llm/stream` tools probe — `payload.tools`
|
|
15
|
+
* at llm/stream is already frozen (dsh-agent-loop/lib/index.js:729
|
|
16
|
+
* `markAgentLoopRequest(deepFreeze({ ... tools: header.tools, ... }))`)
|
|
17
|
+
* so splice/defineProperty mutations silently fail there. The
|
|
18
|
+
* system-prompt/assemble waterfall value IS authoritative (per the dsh
|
|
19
|
+
* type declaration: "the returned value is authoritative") and tools are
|
|
20
|
+
* a plain array at that point. After this layer the LLM never sees
|
|
21
|
+
* out-of-realm tools.
|
|
22
|
+
*
|
|
23
|
+
* Layer 3 (tools/pre-execute): deny tool execution when the tool's realm
|
|
24
|
+
* does not match the calling agent's realm (defense-in-depth — also
|
|
25
|
+
* catches tools added via runtime registrations that bypass assembly).
|
|
26
|
+
*
|
|
27
|
+
* The config file is hot-reloaded via stat-polling (same pattern as
|
|
28
|
+
* privacy-policy.ts) so changes take effect without a restart.
|
|
29
|
+
*
|
|
30
|
+
* @module dsh-edge-agent/realm-skill-bridge
|
|
31
|
+
*/
|
|
32
|
+
import { existsSync } from 'node:fs';
|
|
33
|
+
import { copyFile, readFile, stat } from 'node:fs/promises';
|
|
34
|
+
import { dirname, resolve } from 'node:path';
|
|
35
|
+
import { fileURLToPath } from 'node:url';
|
|
36
|
+
import { Service } from '@deepseek-ai/cordis';
|
|
37
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
38
|
+
import { edgeHome, loadEdgeConfig } from './edge-config.js';
|
|
39
|
+
import { sessionIdField } from './session-attribution.js';
|
|
40
|
+
export const name = 'realm-skill-bridge';
|
|
41
|
+
// §7.92.2 (2026-09-22): 必须声明全部 soft deps — `edgeTools` + `tools` 是
|
|
42
|
+
// tagToolsFromConfig() 实际读取的 ctx 属性。cordis Proxy 在未声明时会
|
|
43
|
+
// 静默 throw "cannot get property X without inject",原 silent catch 吞
|
|
44
|
+
// 掉导致 Layer 1.5-replacement 装死 1 天。必须跟 cordis.patch.yml 一致。
|
|
45
|
+
export const inject = ['auditLog', 'skills', 'edgeTools', 'tools'];
|
|
46
|
+
export const Config = Schema.object({
|
|
47
|
+
// §7.102 "one config file": no defaults here — apply() fills configPath
|
|
48
|
+
// from config/edge.json (privacy.realmConfigPath) or the
|
|
49
|
+
// ~/.dsh-edge-agent/skill-realms.json default, and the hot-reload knobs
|
|
50
|
+
// from privacy.realmHotReload / realmWatchMs. (The old computed default
|
|
51
|
+
// froze the HOME lookup at module-load time.)
|
|
52
|
+
configPath: Schema.string(),
|
|
53
|
+
hotReload: Schema.boolean(),
|
|
54
|
+
watchIntervalMs: Schema.natural(),
|
|
55
|
+
});
|
|
56
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
57
|
+
// Glob matching
|
|
58
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
59
|
+
/**
|
|
60
|
+
* Minimal glob matcher — supports `*` as "any sequence of characters".
|
|
61
|
+
* We do not pull in the `minimatch` dependency for this; the only patterns
|
|
62
|
+
* used in skill-realms.json are simple suffix globs like "lark-*".
|
|
63
|
+
*/
|
|
64
|
+
export function globMatch(pattern, value) {
|
|
65
|
+
if (!pattern.includes('*'))
|
|
66
|
+
return pattern === value;
|
|
67
|
+
// Escape regex-special chars (except `*` which we convert to `.*`).
|
|
68
|
+
const regex = new RegExp('^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$');
|
|
69
|
+
return regex.test(value);
|
|
70
|
+
}
|
|
71
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
72
|
+
// Config loading
|
|
73
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
74
|
+
/**
|
|
75
|
+
* 把工具返回的 error message 变成一行可读的摘要。
|
|
76
|
+
*
|
|
77
|
+
* §7.151(2026-10-29)修:原来只有 `typeof message === 'string'` 才记内容,
|
|
78
|
+
* 否则一律打 `'unknown'`。但宿主的工具失败**大多数不是字符串** ——
|
|
79
|
+
* `deepseek-harness/docs/subsystems/user-questions.md:212` 明确写着
|
|
80
|
+
* `UserQuestionError` 继承 `HarnessError`,`ctx.tools.execute()` 保留
|
|
81
|
+
* `{ name, code }`(`NO_PROVIDER` / `BAD_INTENT` / `EMPTY_QUESTIONS` /
|
|
82
|
+
* `ASK_ABORTED`)。这些是**对象**,于是每一次这类失败在 ops log 里都只留下
|
|
83
|
+
* `errorKind=unknown` —— 真实错误码被吞掉,排查时无从下手
|
|
84
|
+
* (本轮就是这样误判了 ask_user_question 的失败原因)。
|
|
85
|
+
*
|
|
86
|
+
* 现在:字符串直接截断;对象/数组 JSON 序列化后截断;取不到就退回
|
|
87
|
+
* `String(message)`。**信息不能因为形态不是字符串就丢。**
|
|
88
|
+
*/
|
|
89
|
+
export function describeToolError(message) {
|
|
90
|
+
if (typeof message === 'string')
|
|
91
|
+
return message.slice(0, 100);
|
|
92
|
+
if (message === undefined || message === null)
|
|
93
|
+
return 'unknown';
|
|
94
|
+
try {
|
|
95
|
+
const json = JSON.stringify(message);
|
|
96
|
+
if (typeof json === 'string' && json.length > 0)
|
|
97
|
+
return json.slice(0, 100);
|
|
98
|
+
}
|
|
99
|
+
catch { /* 循环引用 / 不可序列化 → 退回 String() */ }
|
|
100
|
+
try {
|
|
101
|
+
return String(message).slice(0, 100);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return 'unknown';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* 默认屏蔽清单(§7.150, 2026-10-29)。
|
|
109
|
+
*
|
|
110
|
+
* 这些是**宿主自带、但在 dsh 会话里要么无意义、要么必然失败**的工具。
|
|
111
|
+
* 实机取证(本机审计链 + ops log):
|
|
112
|
+
*
|
|
113
|
+
* | 工具 | 现象 | 为什么挡 |
|
|
114
|
+
* |---|---|---|
|
|
115
|
+
* | `get_goal` | 14:05 主人发「登录的密盒」,模型调它 → **返回空** → 模型把「我在循环调它拿到 null」的自白当最终回复输出 | Goal 族的契约是「没设 Goal 就是空」。dsh 的普通会话没有 Goal ⇒ 必然空。模型在 27 个工具里挑「查状态」时误选它 |
|
|
116
|
+
* | `create_goal` / `update_goal` | 14:28 换 qwen3.8-flash 后仍误选 `update_goal` → **执行报错** `status=error errorKind=unknown` | 同族。dsh 会话不建 Goal,这两个要么无意义要么报错 |
|
|
117
|
+
* | `ask_user_question` | 14:30 模型调它两次 → **执行报错** `status=error errorKind=unknown` | §7.150 初稿把这条归因为「模型传了 `{"text":...}`、参数不匹配」—— **那是误判**:`{text:…}` 是 `TraceText` 的**字段名**(装的是 arguments 字符串),模型实际传的是 `{"questions":[…]}`,**格式正确**。真因是宿主侧 `UserQuestionError`(带 `{name, code}`)⇒ 工具在桌面版执行失败;而 `errorKind=unknown` 是**本插件日志自己打的占位符**(只认字符串 message),真实错误码被吞(§7.151 修)。**屏蔽本身仍然成立**:工具确实连败两次,挡住比让它反复重试干净 |
|
|
118
|
+
*
|
|
119
|
+
* 挡掉之后 agent 改用**纯文本**提问 —— 实测可行:14:30 那轮模型直接输出了
|
|
120
|
+
* 完整的密盒列表 + 「或者告诉我要登录哪台设备」。
|
|
121
|
+
*
|
|
122
|
+
* **这份清单是默认值,不是不可改的**:`skill-realms.json` 里显式写
|
|
123
|
+
* `"get_goal": "LOCAL"` 就能放开(config 覆盖 default,见 `loadConfig` 与
|
|
124
|
+
* `tagToolsFromConfig` 的 config 循环)。
|
|
125
|
+
*/
|
|
126
|
+
const DEFAULT_CONFIG = {
|
|
127
|
+
skills: {},
|
|
128
|
+
tools: {
|
|
129
|
+
get_goal: 'hidden',
|
|
130
|
+
create_goal: 'hidden',
|
|
131
|
+
update_goal: 'hidden',
|
|
132
|
+
ask_user_question: 'hidden',
|
|
133
|
+
},
|
|
134
|
+
defaultRealm: 'hidden',
|
|
135
|
+
};
|
|
136
|
+
/**
|
|
137
|
+
* Read and parse skill-realms.json. Returns DEFAULT_CONFIG on any failure
|
|
138
|
+
* (missing file, invalid JSON, schema error) so the plugin still boots —
|
|
139
|
+
* everything just becomes hidden until the user creates/fixes the file.
|
|
140
|
+
*/
|
|
141
|
+
async function loadConfig(configPath) {
|
|
142
|
+
try {
|
|
143
|
+
const raw = await readFile(configPath, 'utf-8');
|
|
144
|
+
const parsed = JSON.parse(raw);
|
|
145
|
+
return {
|
|
146
|
+
skills: parsed.skills && typeof parsed.skills === 'object' ? parsed.skills : {},
|
|
147
|
+
tools: {
|
|
148
|
+
// §7.150: 默认屏蔽清单与文件**合并**,文件优先。
|
|
149
|
+
//
|
|
150
|
+
// 合并而不是替换,是为了两件事同时成立:
|
|
151
|
+
// 1. 没写 skill-realms.json 的机器也能拿到默认屏蔽(修一次,全机器生效);
|
|
152
|
+
// 2. 写了文件的机器能**逐个放开** —— `"get_goal": "LOCAL"` 即可。
|
|
153
|
+
//
|
|
154
|
+
// 「放开」还需要 config 循环把 default 的 'hidden' 移除,否则
|
|
155
|
+
// Set{hidden, LOCAL} 里 hidden 仍在 ⇒ 照样被挡(见 tagToolsFromConfig)。
|
|
156
|
+
...DEFAULT_CONFIG.tools,
|
|
157
|
+
...(parsed.tools && typeof parsed.tools === 'object' ? parsed.tools : {}),
|
|
158
|
+
},
|
|
159
|
+
defaultRealm: isValidRealm(parsed.defaultRealm) ? parsed.defaultRealm : 'hidden',
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return { ...DEFAULT_CONFIG };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function isValidRealm(value) {
|
|
167
|
+
return value === 'LOCAL' || value === 'CLOUD' || value === 'SANITIZED' || value === 'hidden';
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* §7.47.6 (2026-09-13): serialize the `turnEnds` object from dsh-agent-loop
|
|
171
|
+
* `turn/end` event into a compact ops-log-friendly string.
|
|
172
|
+
*
|
|
173
|
+
* Background: dsh-agent-loop appends `turnEnds = { kind, ...payload }` where
|
|
174
|
+
* kind ∈ { 'completed', 'max-tokens', 'blocked', 'aborted', 'unknown' }.
|
|
175
|
+
* For 'aborted' it also carries `reason: <AbortSignal.reason>`. The pre-fix
|
|
176
|
+
* code dropped any non-string to 'unknown' — silently hiding the actual
|
|
177
|
+
* termination cause. Now: stringify in `kind=key=value` form so the ops log
|
|
178
|
+
* reveals e.g. `kind=aborted reason=signal-aborted` or `kind=max-tokens`.
|
|
179
|
+
*
|
|
180
|
+
* Falls back to 'unknown' only when dsh-agent-loop emits no reason at all
|
|
181
|
+
* (truly undefined — should never happen in normal flow but guard anyway).
|
|
182
|
+
*/
|
|
183
|
+
function serializeTurnEndReason(reason) {
|
|
184
|
+
if (typeof reason === 'string')
|
|
185
|
+
return reason;
|
|
186
|
+
if (reason === null || reason === undefined)
|
|
187
|
+
return 'unknown';
|
|
188
|
+
if (typeof reason !== 'object')
|
|
189
|
+
return `unknown(${String(reason)})`;
|
|
190
|
+
const obj = reason;
|
|
191
|
+
const kind = typeof obj.kind === 'string' ? obj.kind : 'unknown';
|
|
192
|
+
// Pull the 2 most useful fields besides kind. For 'aborted', include the
|
|
193
|
+
// nested reason text so ops log shows what actually aborted the signal.
|
|
194
|
+
const parts = [`kind=${kind}`];
|
|
195
|
+
if (obj.reason !== undefined) {
|
|
196
|
+
const r = obj.reason;
|
|
197
|
+
if (typeof r === 'string')
|
|
198
|
+
parts.push(`reason=${r}`);
|
|
199
|
+
else if (r !== null && typeof r === 'object') {
|
|
200
|
+
const rkind = r.kind;
|
|
201
|
+
if (typeof rkind === 'string')
|
|
202
|
+
parts.push(`reason.kind=${rkind}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (typeof obj.tokenCount === 'number')
|
|
206
|
+
parts.push(`tokenCount=${obj.tokenCount}`);
|
|
207
|
+
return parts.join(' ');
|
|
208
|
+
}
|
|
209
|
+
/** Copy the bundled default template to configPath if the file does not exist. */
|
|
210
|
+
async function ensureDefaultConfig(configPath) {
|
|
211
|
+
if (existsSync(configPath))
|
|
212
|
+
return;
|
|
213
|
+
// The template lives next to the compiled JS: <repo>/templates/skill-realms.default.json.
|
|
214
|
+
// At runtime this plugin is at <repo>/src/dsh-adapter/realm-skill-bridge.ts → compiled to
|
|
215
|
+
// dist/dsh-adapter/realm-skill-bridge.js. The repo root is three levels up from dist.
|
|
216
|
+
let templatePath;
|
|
217
|
+
try {
|
|
218
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
219
|
+
const repoRoot = resolve(dirname(thisFile), '..', '..', '..');
|
|
220
|
+
templatePath = resolve(repoRoot, 'templates', 'skill-realms.default.json');
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (!existsSync(templatePath))
|
|
226
|
+
return;
|
|
227
|
+
try {
|
|
228
|
+
const dir = dirname(configPath);
|
|
229
|
+
const { mkdir } = await import('node:fs/promises');
|
|
230
|
+
await mkdir(dir, { recursive: true });
|
|
231
|
+
await copyFile(templatePath, configPath);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
// Best-effort bootstrap; non-fatal.
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
238
|
+
// Service
|
|
239
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
240
|
+
export class RealmSkillBridgeService extends Service {
|
|
241
|
+
config;
|
|
242
|
+
auditLog;
|
|
243
|
+
/** Skill name → realm tag (including 'hidden'). */
|
|
244
|
+
skillRealms = new Map();
|
|
245
|
+
/** Tool name → set of realm tags. A tool can belong to multiple realms. */
|
|
246
|
+
toolRealms = new Map();
|
|
247
|
+
/** Session id → realm. Written by http-api after intent classification. */
|
|
248
|
+
sessionRealms = new Map();
|
|
249
|
+
/** sessionRealms 的 LRU 上限;活跃会话远小于此(http-api 默认 8)。 */
|
|
250
|
+
static SESSION_REALMS_CAP = 512;
|
|
251
|
+
/** Parsed realm config. Refreshed on hot-reload. */
|
|
252
|
+
realmConfig = { ...DEFAULT_CONFIG };
|
|
253
|
+
/** mtime of the config file at last load (for stat-polling). */
|
|
254
|
+
lastMtime = 0;
|
|
255
|
+
/** Hot-reload timer handle. */
|
|
256
|
+
watchTimer;
|
|
257
|
+
constructor(ctx, config, auditLog) {
|
|
258
|
+
super(ctx, 'realmSkillBridge');
|
|
259
|
+
this.config = config;
|
|
260
|
+
this.auditLog = auditLog;
|
|
261
|
+
}
|
|
262
|
+
/** Boot: ensure config file exists, load it, tag skills/tools. */
|
|
263
|
+
async start() {
|
|
264
|
+
await ensureDefaultConfig(this.config.configPath);
|
|
265
|
+
await this.reloadConfig();
|
|
266
|
+
await this.tagSkillsAsync();
|
|
267
|
+
this.tagToolsFromConfig();
|
|
268
|
+
if (this.config.hotReload) {
|
|
269
|
+
this.watchTimer = setInterval(() => {
|
|
270
|
+
void this.checkForUpdates();
|
|
271
|
+
}, this.config.watchIntervalMs);
|
|
272
|
+
if (typeof this.watchTimer.unref === 'function')
|
|
273
|
+
this.watchTimer.unref();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/** Stop hot-reload timer. */
|
|
277
|
+
stop() {
|
|
278
|
+
if (this.watchTimer !== undefined) {
|
|
279
|
+
clearInterval(this.watchTimer);
|
|
280
|
+
this.watchTimer = undefined;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/** Drop all per-session realm tags (plugin dispose path). */
|
|
284
|
+
clearSessionRealms() {
|
|
285
|
+
this.sessionRealms.clear();
|
|
286
|
+
}
|
|
287
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
288
|
+
/** Set the realm for a session (called by http-api after intent classification). */
|
|
289
|
+
setSessionRealm(sessionId, realm) {
|
|
290
|
+
if (realm === 'hidden')
|
|
291
|
+
return;
|
|
292
|
+
// LRU:delete-then-set 让最近路由的 session 排队尾;超限淘汰队头。
|
|
293
|
+
// 该 Map 曾只增不减(每 session 一条,进程生命周期内累积),
|
|
294
|
+
// 同文件的 sessionStartTimes 反而在 turn/end 有 delete —— 补齐。
|
|
295
|
+
const map = this.sessionRealms;
|
|
296
|
+
map.delete(sessionId);
|
|
297
|
+
map.set(sessionId, realm);
|
|
298
|
+
while (map.size > RealmSkillBridgeService.SESSION_REALMS_CAP) {
|
|
299
|
+
const oldest = map.keys().next().value;
|
|
300
|
+
if (oldest === undefined)
|
|
301
|
+
break;
|
|
302
|
+
map.delete(oldest);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/** Resolve the realm for an agent by looking up its session.
|
|
306
|
+
* §7.36 / §7.37 (2026-09-04): prefer `session.id` — input-guard registers
|
|
307
|
+
* via `payload.agent.session.id` (input-guard.ts:537) and that is the
|
|
308
|
+
* only key under which sessionRealms holds the realm. Reading
|
|
309
|
+
* `session.header.id` first was a wrong assumption that caused layer-1.5
|
|
310
|
+
* (llm/stream tools filter) to silently no-op: getAgentRealm returned
|
|
311
|
+
* undefined → filterToolsField skipped → tools.filtered never fired. */
|
|
312
|
+
getAgentRealm(agent) {
|
|
313
|
+
const sessionId = agent?.session?.id;
|
|
314
|
+
if (!sessionId)
|
|
315
|
+
return undefined;
|
|
316
|
+
return this.sessionRealms.get(sessionId);
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Resolve realm directly from a sessionId string. Used by llm/stream hook
|
|
320
|
+
* where the agent's session.id path is unreliable and payload.sessionId
|
|
321
|
+
* is the canonical key.
|
|
322
|
+
*/
|
|
323
|
+
getSessionRealm(sessionId) {
|
|
324
|
+
if (!sessionId)
|
|
325
|
+
return undefined;
|
|
326
|
+
return this.sessionRealms.get(sessionId);
|
|
327
|
+
}
|
|
328
|
+
/** Look up the realm tag for a skill name.
|
|
329
|
+
* Prefers the cached tag (populated by tagSkills on skills/change), but
|
|
330
|
+
* falls back to resolveSkillRealm so skills not yet discovered still get
|
|
331
|
+
* the correct realm from the config file. */
|
|
332
|
+
skillRealm(name) {
|
|
333
|
+
return this.skillRealms.get(name) ?? this.resolveSkillRealm(name);
|
|
334
|
+
}
|
|
335
|
+
/** Look up the realm tags for a tool name. Returns undefined if no realm
|
|
336
|
+
* constraint is configured (tool is unconstrained). */
|
|
337
|
+
toolRealm(name) {
|
|
338
|
+
return this.toolRealms.get(name);
|
|
339
|
+
}
|
|
340
|
+
// ── Config reload ───────────────────────────────────────────────────────────
|
|
341
|
+
async reloadConfig() {
|
|
342
|
+
const path = this.config.configPath;
|
|
343
|
+
if (!existsSync(path)) {
|
|
344
|
+
this.realmConfig = { ...DEFAULT_CONFIG };
|
|
345
|
+
this.lastMtime = 0;
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
const stats = await stat(path);
|
|
350
|
+
this.realmConfig = await loadConfig(path);
|
|
351
|
+
this.lastMtime = stats.mtimeMs;
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
// Parse failure — keep previous config.
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
async checkForUpdates() {
|
|
358
|
+
const path = this.config.configPath;
|
|
359
|
+
if (!existsSync(path))
|
|
360
|
+
return;
|
|
361
|
+
try {
|
|
362
|
+
const stats = await stat(path);
|
|
363
|
+
if (stats.mtimeMs > this.lastMtime) {
|
|
364
|
+
await this.reloadConfig();
|
|
365
|
+
this.tagSkills();
|
|
366
|
+
this.tagToolsFromConfig();
|
|
367
|
+
this.ctx.logger?.info?.('[realm-skill-bridge] config reloaded');
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
// stat error — ignore, will retry next tick.
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// ── Realm resolution ────────────────────────────────────────────────────────
|
|
375
|
+
/**
|
|
376
|
+
* Resolve the realm for a skill by name.
|
|
377
|
+
* Priority:
|
|
378
|
+
* 1. skill-realms.json exact name match
|
|
379
|
+
* 2. skill-realms.json glob match (in declaration order)
|
|
380
|
+
* 3. defaultRealm
|
|
381
|
+
*
|
|
382
|
+
* Note: SKILL.md frontmatter `metadata.realm` is intentionally NOT consumed
|
|
383
|
+
* here — the config file is the single source of truth. This avoids a
|
|
384
|
+
* dependency on `ctx.skills.get()` (which reads the full markdown body)
|
|
385
|
+
* during the hot-path tag loop.
|
|
386
|
+
*/
|
|
387
|
+
resolveSkillRealm(name) {
|
|
388
|
+
const cfg = this.realmConfig.skills;
|
|
389
|
+
// Exact match
|
|
390
|
+
if (cfg[name] !== undefined)
|
|
391
|
+
return cfg[name];
|
|
392
|
+
// Glob match (declaration order)
|
|
393
|
+
for (const pattern of Object.keys(cfg)) {
|
|
394
|
+
if (pattern.includes('*') && globMatch(pattern, name)) {
|
|
395
|
+
return cfg[pattern];
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return this.realmConfig.defaultRealm;
|
|
399
|
+
}
|
|
400
|
+
// ── Tagging ─────────────────────────────────────────────────────────────────
|
|
401
|
+
/** Re-scan ctx.skills.list() and tag each skill by realm. */
|
|
402
|
+
tagSkills() {
|
|
403
|
+
this.skillRealms.clear();
|
|
404
|
+
// ctx.skills.list() is async; we fire-and-forget here because tagging
|
|
405
|
+
// runs on a timer and on skills/change events. The next tick will
|
|
406
|
+
// converge. For the initial boot, apply() awaits this separately.
|
|
407
|
+
void (async () => {
|
|
408
|
+
try {
|
|
409
|
+
const list = await this.ctx.skills.list();
|
|
410
|
+
for (const skill of list) {
|
|
411
|
+
const realm = this.resolveSkillRealm(skill.name);
|
|
412
|
+
this.skillRealms.set(skill.name, realm);
|
|
413
|
+
}
|
|
414
|
+
this.logTaggingResult();
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
// skills service not yet injected — will retry on next tick or skills/change.
|
|
418
|
+
}
|
|
419
|
+
})();
|
|
420
|
+
}
|
|
421
|
+
/** Synchronous variant for await contexts. */
|
|
422
|
+
async tagSkillsAsync() {
|
|
423
|
+
this.skillRealms.clear();
|
|
424
|
+
try {
|
|
425
|
+
// Debug: check if ctx.skills exists
|
|
426
|
+
const skillsService = this.ctx.skills;
|
|
427
|
+
this.ctx.logger?.info?.(`[realm-skill-bridge] ctx.skills exists: ${skillsService !== undefined}`);
|
|
428
|
+
// Debug: check ctx.skills methods
|
|
429
|
+
if (skillsService && typeof skillsService === 'object') {
|
|
430
|
+
const methods = Object.keys(skillsService);
|
|
431
|
+
this.ctx.logger?.info?.(`[realm-skill-bridge] ctx.skills methods: ${methods.join(', ')}`);
|
|
432
|
+
}
|
|
433
|
+
// Try calling list() with and without cwd
|
|
434
|
+
const list = await this.ctx.skills.list();
|
|
435
|
+
this.ctx.logger?.info?.(`[realm-skill-bridge] ctx.skills.list() returned ${list.length} skills`);
|
|
436
|
+
if (list.length > 0) {
|
|
437
|
+
this.ctx.logger?.info?.(`[realm-skill-bridge] first 5 skills: ${list.slice(0, 5).map(s => s.name).join(', ')}`);
|
|
438
|
+
}
|
|
439
|
+
// Try with cwd parameter
|
|
440
|
+
try {
|
|
441
|
+
const listWithCwd = await this.ctx.skills.list({ cwd: process.cwd() });
|
|
442
|
+
this.ctx.logger?.info?.(`[realm-skill-bridge] ctx.skills.list({cwd}) returned ${listWithCwd.length} skills`);
|
|
443
|
+
}
|
|
444
|
+
catch (err) {
|
|
445
|
+
this.ctx.logger?.info?.(`[realm-skill-bridge] ctx.skills.list({cwd}) error: ${err}`);
|
|
446
|
+
}
|
|
447
|
+
for (const skill of list) {
|
|
448
|
+
const realm = this.resolveSkillRealm(skill.name);
|
|
449
|
+
this.skillRealms.set(skill.name, realm);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
catch (err) {
|
|
453
|
+
this.ctx.logger?.info?.(`[realm-skill-bridge] tagSkillsAsync error: ${err}`);
|
|
454
|
+
}
|
|
455
|
+
this.logTaggingResult();
|
|
456
|
+
}
|
|
457
|
+
logTaggingResult() {
|
|
458
|
+
const visible = [...this.skillRealms.entries()].filter(([, r]) => r !== 'hidden');
|
|
459
|
+
const hidden = this.skillRealms.size - visible.length;
|
|
460
|
+
const summary = visible.map(([n, r]) => `${n}=${r}`).join(', ');
|
|
461
|
+
this.ctx.logger?.info?.(`[realm-skill-bridge] ${this.skillRealms.size} skills (${hidden} hidden): ${summary || '(none)'}`);
|
|
462
|
+
}
|
|
463
|
+
/** Apply tool realm tags from skill-realms.json to ctx.edgeTools. */
|
|
464
|
+
tagToolsFromConfig() {
|
|
465
|
+
let edgeTools;
|
|
466
|
+
try {
|
|
467
|
+
edgeTools = this.ctx.edgeTools;
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
// Cordis proxy throws when edgeTools is not injected — that's fine,
|
|
471
|
+
// we just skip the edgeTools.setRealm() calls.
|
|
472
|
+
}
|
|
473
|
+
this.toolRealms.clear();
|
|
474
|
+
// §7.92.2 (2026-09-22): seed from native map (DSH_NATIVE_REALM_MAP /
|
|
475
|
+
// DSH_NATIVE_MULTI_REALM, applied to edgeTools at boot by
|
|
476
|
+
// edge-tools-registry) BEFORE the config loop so config wins on conflict.
|
|
477
|
+
// Without this seed, filterToolsField would never filter anything when
|
|
478
|
+
// user hasn't written skill-realms.json yet — web_search / web_fetch
|
|
479
|
+
// would stay visible to LOCAL agents (defeating the purpose of Layer
|
|
480
|
+
// 1.5-replacement). The config loop below adds more tags on top.
|
|
481
|
+
//
|
|
482
|
+
// Requires `tools` + `edgeTools` in plugin inject list (see
|
|
483
|
+
// cordis.patch.yml) — the cordis proxy throws "cannot get property X
|
|
484
|
+
// without inject" if a plugin reads a service it didn't declare.
|
|
485
|
+
if (edgeTools) {
|
|
486
|
+
const nativeResolver = edgeTools;
|
|
487
|
+
// Walk every tool known to edgeTools, ask it for native realms,
|
|
488
|
+
// and copy into this.toolRealms.
|
|
489
|
+
try {
|
|
490
|
+
const allTools = this.ctx.tools?.view?.()?.visible;
|
|
491
|
+
if (allTools instanceof Map) {
|
|
492
|
+
for (const name of allTools.keys()) {
|
|
493
|
+
// 1. explicit setRealm (Set<ToolRealm>)
|
|
494
|
+
const explicit = nativeResolver.realm?.get(name);
|
|
495
|
+
if (explicit !== undefined && explicit.size > 0) {
|
|
496
|
+
const out = this.toolRealms.get(name) ?? new Set();
|
|
497
|
+
for (const r of explicit) {
|
|
498
|
+
// edgeTools stores string-set realm values; map into RealmTag
|
|
499
|
+
if (r === 'LOCAL' || r === 'CLOUD' || r === 'SANITIZED' || r === 'hidden') {
|
|
500
|
+
out.add(r);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (out.size > 0)
|
|
504
|
+
this.toolRealms.set(name, out);
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
// 2. resolveRealms falls through to classifyByPrefix → CLOUD; we
|
|
508
|
+
// don't want prefix tags (would over-restrict). So only accept
|
|
509
|
+
// values when edgeTools has them via explicit + native map path.
|
|
510
|
+
// realmOf (single value) is the right probe — it returns
|
|
511
|
+
// undefined for prefix-only entries.
|
|
512
|
+
const single = edgeTools.realmOf?.(name);
|
|
513
|
+
if (single === 'LOCAL' || single === 'CLOUD' || single === 'SANITIZED' || single === 'hidden') {
|
|
514
|
+
const out = this.toolRealms.get(name) ?? new Set();
|
|
515
|
+
out.add(single);
|
|
516
|
+
this.toolRealms.set(name, out);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
// edgeTools.view() may throw if tools service hasn't mounted yet;
|
|
523
|
+
// tools/change will re-fire this function once it's available.
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
for (const [toolName, realm] of Object.entries(this.realmConfig.tools)) {
|
|
527
|
+
// Store realm tag in a Set (supports multi-realm if configured multiple times)
|
|
528
|
+
const existing = this.toolRealms.get(toolName) ?? new Set();
|
|
529
|
+
// §7.150: config 覆盖 default —— 先移除 'hidden'。
|
|
530
|
+
//
|
|
531
|
+
// 不移除的话,DEFAULT_CONFIG 的 'hidden' 与文件里的 'LOCAL' 会并存成
|
|
532
|
+
// Set{hidden, LOCAL},而 hidden 在 filterToolsField / checkToolRealm 里
|
|
533
|
+
// 都是**优先判定**(`toolRealms.has('hidden')`)⇒ 文件根本放不开。
|
|
534
|
+
//
|
|
535
|
+
// 「config wins on conflict」这条注释(见上面 native seed 段)在这里才
|
|
536
|
+
// 真正成立:native seed 的 hidden 同样会被 config 覆盖。
|
|
537
|
+
existing.delete('hidden');
|
|
538
|
+
existing.add(realm);
|
|
539
|
+
this.toolRealms.set(toolName, existing);
|
|
540
|
+
// Only push non-hidden realms to edgeTools (which doesn't understand 'hidden').
|
|
541
|
+
if (edgeTools && (realm === 'LOCAL' || realm === 'CLOUD' || realm === 'SANITIZED')) {
|
|
542
|
+
edgeTools.setRealm(toolName, realm);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
// ── Layer 1: Skill catalog filtering ────────────────────────────────────────
|
|
547
|
+
/**
|
|
548
|
+
* Filter the <available_skills> catalog so the agent only sees skills in
|
|
549
|
+
* its realm. Skills tagged 'hidden' are removed from every agent's catalog.
|
|
550
|
+
*
|
|
551
|
+
* §7.37 (2026-09-04): Two message shapes carry the catalog:
|
|
552
|
+
* (a) standalone UserMessage with source.kind === 'skill-catalog'
|
|
553
|
+
* (string content).
|
|
554
|
+
* (b) dsh-system-prompt plugin snapshot with
|
|
555
|
+
* source.kind === 'plugin' && source.plugin ===
|
|
556
|
+
* '@deepseek-ai/dsh-system-prompt' (array content blocks,
|
|
557
|
+
* one of which is type:'text' containing `<available_skills>`).
|
|
558
|
+
* Both must be filtered or the catalog leaks. (a) is the canonical
|
|
559
|
+
* skill-catalog shape; (b) is what TUI / WebUI actually see because
|
|
560
|
+
* dsh-system-prompt co-locates the catalog with sandbox/approval policy
|
|
561
|
+
* snapshots — without this filter, LLM sees ALL 32 skills regardless
|
|
562
|
+
* of realm.
|
|
563
|
+
*
|
|
564
|
+
* We parse each ` - \`skill-name\`: Description` line, look up the skill's
|
|
565
|
+
* realm, and drop disallowed ones.
|
|
566
|
+
*/
|
|
567
|
+
filterSkillCatalog(messages, agentRealm) {
|
|
568
|
+
return this.filterSkillCatalogDetailed(messages, agentRealm).messages;
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* §7.46 (2026-09-11): 返回 `{ messages, droppedCount }` 让 pre-step
|
|
572
|
+
* listener 能 emit `catalogDropped=N` 到 ops log(§7.44.1 chat-finished
|
|
573
|
+
* line 加 realm/duration 时同步加的;详见 PROGRESS §7.46)。返回 messages
|
|
574
|
+
* 是 filterSkillCatalog 的同一引用,方便测试不破坏。
|
|
575
|
+
*/
|
|
576
|
+
filterSkillCatalogDetailed(messages, agentRealm) {
|
|
577
|
+
if (agentRealm === 'hidden')
|
|
578
|
+
return { messages, droppedCount: 0 };
|
|
579
|
+
let totalDropped = 0;
|
|
580
|
+
const newMessages = messages.map((msg) => {
|
|
581
|
+
if (!isCatalogMessage(msg))
|
|
582
|
+
return msg;
|
|
583
|
+
// (a) string content
|
|
584
|
+
if (typeof msg.content === 'string') {
|
|
585
|
+
if (!msg.content.includes('<available_skills>'))
|
|
586
|
+
return msg;
|
|
587
|
+
const result = this.filterCatalogText(msg.content, agentRealm);
|
|
588
|
+
totalDropped += result.droppedCount;
|
|
589
|
+
if (!result.changed)
|
|
590
|
+
return msg;
|
|
591
|
+
return { ...msg, content: result.text };
|
|
592
|
+
}
|
|
593
|
+
// (b) array content blocks (dsh-system-prompt snapshot shape)
|
|
594
|
+
if (Array.isArray(msg.content)) {
|
|
595
|
+
let changed = false;
|
|
596
|
+
const newBlocks = msg.content.map((block) => {
|
|
597
|
+
if (!isTextBlock(block) || typeof block.text !== 'string')
|
|
598
|
+
return block;
|
|
599
|
+
if (!block.text.includes('<available_skills>'))
|
|
600
|
+
return block;
|
|
601
|
+
const result = this.filterCatalogText(block.text, agentRealm);
|
|
602
|
+
totalDropped += result.droppedCount;
|
|
603
|
+
if (!result.changed)
|
|
604
|
+
return block;
|
|
605
|
+
changed = true;
|
|
606
|
+
return { ...block, text: result.text };
|
|
607
|
+
});
|
|
608
|
+
if (!changed)
|
|
609
|
+
return msg;
|
|
610
|
+
return { ...msg, content: newBlocks };
|
|
611
|
+
}
|
|
612
|
+
return msg;
|
|
613
|
+
});
|
|
614
|
+
return { messages: newMessages, droppedCount: totalDropped };
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Pure function: parse `<available_skills>` content, drop lines whose
|
|
618
|
+
* skill name resolves to a realm other than `agentRealm` (or 'hidden').
|
|
619
|
+
* Returns `{ text, changed }` so the caller can short-circuit on no-op.
|
|
620
|
+
*
|
|
621
|
+
* §7.45 (2026-09-11): SANITIZED agents see every non-hidden skill —
|
|
622
|
+
* matches `filterToolsField`'s SANITIZED branch below. Before this
|
|
623
|
+
* fix, `skillRealm(name)` only returns `LOCAL`/`CLOUD`/`hidden` (never
|
|
624
|
+
* `'SANITIZED'`), so `realm !== agentRealm` was always true for SANITIZED
|
|
625
|
+
* agents and the entire catalog was dropped (audit observed
|
|
626
|
+
* `droppedCount=33` for a session whose agentRealm=SANITIZED — LLM saw
|
|
627
|
+
* zero skills and silently answered "no mibao tool available"). Skill
|
|
628
|
+
* realm tag is fail-closed (default 'hidden'), so SANITIZED must
|
|
629
|
+
* explicitly opt out of the strict-equality check.
|
|
630
|
+
*
|
|
631
|
+
* §7.47.1 (2026-09-12): Same dual fix for LOCAL/CLOUD. The catalog
|
|
632
|
+
* filter uses `skillRealm` (single realm), but tools are tagged with a
|
|
633
|
+
* realm SET (`toolRealms: Set<RealmTag>`). `bash` skill.realm = 'LOCAL'
|
|
634
|
+
* yet bash.toolRealms = {LOCAL, CLOUD}. Before this fix, a CLOUD agent
|
|
635
|
+
* (e.g. news query) saw every LOCAL skill dropped — `droppedCount=11`
|
|
636
|
+
* for news session, identical to the SANITIZED bug. Symmetric for
|
|
637
|
+
* LOCAL agent: CLOUD skill (e.g. web_search, web_fetch) was dropped.
|
|
638
|
+
* The fix extends SANITIZED's opt-in to ALL agent realms: any
|
|
639
|
+
* non-'hidden' skill is kept in the catalog. Layer 3 (tools/pre-execute)
|
|
640
|
+
* remains the authoritative gate, mirroring `filterToolsField`'s
|
|
641
|
+
* semantics where any non-hidden tool is offered to the LLM and only
|
|
642
|
+
* blocked at execution time.
|
|
643
|
+
*/
|
|
644
|
+
filterCatalogText(content, agentRealm) {
|
|
645
|
+
const lines = content.split('\n');
|
|
646
|
+
const out = [];
|
|
647
|
+
let changed = false;
|
|
648
|
+
let droppedCount = 0;
|
|
649
|
+
for (const line of lines) {
|
|
650
|
+
const m = line.match(/^-\s+`([^`]+)`:\s*(.*)$/);
|
|
651
|
+
if (m !== null) {
|
|
652
|
+
const skillName = m[1];
|
|
653
|
+
const realm = this.skillRealm(skillName);
|
|
654
|
+
if (realm === 'hidden') {
|
|
655
|
+
changed = true;
|
|
656
|
+
droppedCount++;
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
// §7.45 + §7.47.1: ALL agent realms see every non-hidden skill.
|
|
660
|
+
// Layer 3 (tools/pre-execute) is the authoritative realm gate —
|
|
661
|
+
// see filterToolsField line ~578-588 + checkToolRealm line ~612.
|
|
662
|
+
// The catalog is a discovery surface; tool execution is the
|
|
663
|
+
// privacy boundary. Restricting catalog by realm left LLM
|
|
664
|
+
// without knowledge of tools it could otherwise safely call.
|
|
665
|
+
void agentRealm;
|
|
666
|
+
}
|
|
667
|
+
out.push(line);
|
|
668
|
+
}
|
|
669
|
+
if (droppedCount > 0) {
|
|
670
|
+
try {
|
|
671
|
+
this.auditLog?.log?.({
|
|
672
|
+
level: 'info',
|
|
673
|
+
kind: 'realm-skill-bridge.catalog.filtered',
|
|
674
|
+
source: 'realm-skill-bridge',
|
|
675
|
+
payload: { agentRealm, droppedCount },
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
catch { /* debug only */ }
|
|
679
|
+
}
|
|
680
|
+
return { text: out.join('\n'), changed, droppedCount };
|
|
681
|
+
}
|
|
682
|
+
// ── Layer 1.5: Filter LLM-visible tools list by agent realm ────────────────
|
|
683
|
+
// §7.37 (2026-09-04): <available_skills> catalog text is injected by
|
|
684
|
+
// dsh-system-prompt plugin BELOW the dsh hook surface — none of
|
|
685
|
+
// agent/pre-step, agent/request, llm/stream sees it. The only tool-level
|
|
686
|
+
// surface in any hook is `payload.tools` on llm/stream, so we filter
|
|
687
|
+
// CLOUD tools out of LLM's view there. LLM doesn't see web_search /
|
|
688
|
+
// web_fetch as available → won't pick them when LOCAL realm is set.
|
|
689
|
+
// Layer 3 (tools/pre-execute) remains the authoritative gate.
|
|
690
|
+
/**
|
|
691
|
+
* Pure function: filter the `tools` array carried on llm/stream payload so
|
|
692
|
+
* that the LLM does not see tools whose realm set does not include
|
|
693
|
+
* `agentRealm`. Tools whose realm is undefined (not in our config) are
|
|
694
|
+
* preserved (fail-closed: we only restrict what we explicitly tagged).
|
|
695
|
+
* Returns `{ tools, changed, removed }` so callers can short-circuit.
|
|
696
|
+
*
|
|
697
|
+
* SANITIZED special case: an agent whose input was sanitized may safely
|
|
698
|
+
* use both LOCAL tools (file ops don't leak) and CLOUD tools (input was
|
|
699
|
+
* already redacted). So SANITIZED agents see every non-hidden tool.
|
|
700
|
+
*
|
|
701
|
+
* §7.93 Phase 2 (§7.93.1 通用化): `options.audit === false` suppresses the
|
|
702
|
+
* `realm-skill-bridge.tools.filtered` audit entry. Used by
|
|
703
|
+
* getToolSchemasForExternal() so the LLM-path filter audit isn't duplicated
|
|
704
|
+
* per external call — function-call-external emits its own `external.*`
|
|
705
|
+
* audit kinds. Default (omitted) keeps the existing audited behavior.
|
|
706
|
+
*/
|
|
707
|
+
filterToolsField(tools, agentRealm, options) {
|
|
708
|
+
const kept = [];
|
|
709
|
+
const removed = [];
|
|
710
|
+
for (const t of tools) {
|
|
711
|
+
const name = typeof t === 'string'
|
|
712
|
+
? t
|
|
713
|
+
: (t?.name
|
|
714
|
+
?? t?.type?.name
|
|
715
|
+
?? null);
|
|
716
|
+
if (name === null) {
|
|
717
|
+
kept.push(t);
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
const toolRealms = this.toolRealm(name);
|
|
721
|
+
// Unknown tool (no realm tag) — pass through; Layer 3 has final say
|
|
722
|
+
if (toolRealms === undefined) {
|
|
723
|
+
kept.push(t);
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
// hidden tools are never visible
|
|
727
|
+
if (toolRealms.has('hidden')) {
|
|
728
|
+
removed.push(name);
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
// §7.37: SANITIZED agents see all non-hidden tools (LOCAL + CLOUD).
|
|
732
|
+
if (agentRealm === 'SANITIZED') {
|
|
733
|
+
kept.push(t);
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
if (toolRealms.has(agentRealm)) {
|
|
737
|
+
kept.push(t);
|
|
738
|
+
}
|
|
739
|
+
else {
|
|
740
|
+
removed.push(name);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
if (removed.length > 0 && options?.audit !== false) {
|
|
744
|
+
try {
|
|
745
|
+
// §7.148: 工具过滤行也要带 sessionId —— 实测本仓 132 条
|
|
746
|
+
// `realm-skill-bridge.tools.filtered` 无一带 sessionId,全落全局桶。
|
|
747
|
+
this.auditLog?.log({
|
|
748
|
+
level: 'info',
|
|
749
|
+
kind: 'realm-skill-bridge.tools.filtered',
|
|
750
|
+
source: 'realm-skill-bridge',
|
|
751
|
+
payload: { ...sessionIdField(options?.sessionId), agentRealm, removedCount: removed.length, removed },
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
catch { /* debug only */ }
|
|
755
|
+
}
|
|
756
|
+
return { tools: kept, changed: removed.length > 0, removed };
|
|
757
|
+
}
|
|
758
|
+
// ── §7.93 Phase 2 (§7.93.1 通用化): external tool-schema face ───────────────
|
|
759
|
+
// function-call-external (Phase 4+) POSTs these to the external caller's
|
|
760
|
+
// /extract endpoint (current: cactus-needle 3.0.1 / needle-http :8081).
|
|
761
|
+
// Single source of truth: the SAME registry projection the LLM sees
|
|
762
|
+
// (`ctx.tools.schemas()` → name/description/parameters whitelist, per
|
|
763
|
+
// dsh-tools "Project visible definitions onto the allowlisted model-facing
|
|
764
|
+
// schema fields") filtered by the SAME realm semantics as filterToolsField.
|
|
765
|
+
// No separate tool list, no drift (user decision #8).
|
|
766
|
+
/**
|
|
767
|
+
* Build the external caller's wire-format tool schemas for one intent's realm.
|
|
768
|
+
*
|
|
769
|
+
* Pipeline: registry projection → realm filter (filterToolsField semantics,
|
|
770
|
+
* audit suppressed — caller emits `external.*` audit) → shape normalization.
|
|
771
|
+
*
|
|
772
|
+
* Failure contract: registry unavailable (cordis proxy throw before the
|
|
773
|
+
* `tools` service mounts, §7.92.2 trap) or empty → returns []. Callers MUST
|
|
774
|
+
* treat [] as "no external caller" and fall through to the LLM
|
|
775
|
+
* self-extraction path — never POST an empty tools array to the external
|
|
776
|
+
* caller (it would echo/hallucinate).
|
|
777
|
+
*/
|
|
778
|
+
getToolSchemasForExternal(intent) {
|
|
779
|
+
const raw = this.readRegistrySchemas();
|
|
780
|
+
if (raw.length === 0)
|
|
781
|
+
return [];
|
|
782
|
+
const filtered = this.filterToolsField(raw, intent, { audit: false });
|
|
783
|
+
return normalizeExternalToolSchemas(filtered.tools);
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Defensive read of `ctx.tools.schemas()` (global view). Returns [] when
|
|
787
|
+
* the service is not mounted yet or throws — the cordis proxy raises
|
|
788
|
+
* "cannot get property tools without inject" on unbound fibers, and
|
|
789
|
+
* tools/change re-fires tagging once available (same pattern as
|
|
790
|
+
* tagToolsFromConfig, §7.92.2).
|
|
791
|
+
*/
|
|
792
|
+
readRegistrySchemas() {
|
|
793
|
+
try {
|
|
794
|
+
const toolsSvc = this.ctx.tools;
|
|
795
|
+
const schemas = toolsSvc?.schemas?.();
|
|
796
|
+
return Array.isArray(schemas) ? schemas : [];
|
|
797
|
+
}
|
|
798
|
+
catch {
|
|
799
|
+
return [];
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
// ── Layer 3: Tool execution gating ──────────────────────────────────────────
|
|
803
|
+
/**
|
|
804
|
+
* Check whether a tool call is allowed in the calling agent's realm.
|
|
805
|
+
* Returns a deny reason string, or undefined if the call is allowed.
|
|
806
|
+
* A tool can belong to multiple realms (e.g. bash → LOCAL+CLOUD); the call
|
|
807
|
+
* is allowed if the agent's realm is in the tool's realm set.
|
|
808
|
+
*/
|
|
809
|
+
checkToolRealm(toolName, agentRealm) {
|
|
810
|
+
if (agentRealm === undefined)
|
|
811
|
+
return undefined; // unknown agent → allow (fail-open for now)
|
|
812
|
+
if (agentRealm === 'hidden')
|
|
813
|
+
return `Agent realm is 'hidden'; no tools allowed`;
|
|
814
|
+
const toolRealms = this.toolRealm(toolName);
|
|
815
|
+
if (toolRealms === undefined)
|
|
816
|
+
return undefined; // unknown tool → no realm constraint
|
|
817
|
+
if (toolRealms.has('hidden'))
|
|
818
|
+
return `Tool "${toolName}" is hidden`;
|
|
819
|
+
if (!toolRealms.has(agentRealm)) {
|
|
820
|
+
const realms = [...toolRealms].join(', ');
|
|
821
|
+
return `Tool "${toolName}" (${realms}) not allowed in ${agentRealm} realm`;
|
|
822
|
+
}
|
|
823
|
+
return undefined;
|
|
824
|
+
}
|
|
825
|
+
/** Log a realm denial to the audit log (security level). */
|
|
826
|
+
auditDeny(toolName, toolRealms, agentRealm, sessionId) {
|
|
827
|
+
this.auditLog?.log({
|
|
828
|
+
level: 'security',
|
|
829
|
+
kind: 'tool.realm.denied',
|
|
830
|
+
source: 'realm-skill-bridge',
|
|
831
|
+
// §7.148: 拒绝行也要带 sessionId —— 否则它落全局桶,Edge 面板的拦截
|
|
832
|
+
// 面板说不清「哪一轮的哪个工具被拒」。
|
|
833
|
+
payload: { ...sessionIdField(sessionId), tool: toolName, toolRealms: [...toolRealms], agentRealm },
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
838
|
+
// §7.93 Phase 2 (§7.93.1 通用化): external schema normalization (pure, exported for tests)
|
|
839
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
840
|
+
function isPlainRecord(value) {
|
|
841
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Normalize registry projections onto the external caller's flat wire contract
|
|
845
|
+
* (`{name, description, parameters}`; current cactus-needle 3.0.1 impl follows
|
|
846
|
+
* `~/work/needle/API.md` v1.1 §4.5):
|
|
847
|
+
*
|
|
848
|
+
* - entries without a non-empty string `name` are dropped (caller keys
|
|
849
|
+
* function_calls by name — a nameless schema is unexecutable)
|
|
850
|
+
* - missing/non-string `description` → '' (caller tolerates it; the tool
|
|
851
|
+
* name + parameters carry the selection signal)
|
|
852
|
+
* - missing/non-object `parameters` → permissive `{type:'object',
|
|
853
|
+
* properties:{}}` root (an unconstrained-args tool, e.g. no-arg status
|
|
854
|
+
* probes; JSON Schema semantics: absent properties = any object)
|
|
855
|
+
*
|
|
856
|
+
* Defensive by design: the registry projection is already whitelisted
|
|
857
|
+
* upstream, but a runtime-registered tool with a hand-rolled shape must not
|
|
858
|
+
* crash the external path — worst case it is dropped and the LLM path covers it.
|
|
859
|
+
*/
|
|
860
|
+
export function normalizeExternalToolSchemas(tools) {
|
|
861
|
+
const out = [];
|
|
862
|
+
for (const t of tools) {
|
|
863
|
+
if (!isPlainRecord(t))
|
|
864
|
+
continue;
|
|
865
|
+
const name = t.name;
|
|
866
|
+
if (typeof name !== 'string' || name === '')
|
|
867
|
+
continue;
|
|
868
|
+
out.push({
|
|
869
|
+
name,
|
|
870
|
+
description: typeof t.description === 'string' ? t.description : '',
|
|
871
|
+
parameters: isPlainRecord(t.parameters)
|
|
872
|
+
? t.parameters
|
|
873
|
+
: { type: 'object', properties: {} },
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
return out;
|
|
877
|
+
}
|
|
878
|
+
function isTextBlock(block) {
|
|
879
|
+
return typeof block === 'object' && block !== null && block.type === 'text';
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* Recognise a message as a carrier of `<available_skills>` catalog content.
|
|
883
|
+
*
|
|
884
|
+
* Two shapes are accepted (§7.37):
|
|
885
|
+
* - `source.kind === 'skill-catalog'` — canonical dsh-tool-skill message
|
|
886
|
+
* (string content).
|
|
887
|
+
* - `source.kind === 'plugin' && source.plugin === '@deepseek-ai/dsh-system-prompt'`
|
|
888
|
+
* — dsh-system-prompt snapshot that bundles the catalog alongside
|
|
889
|
+
* sandbox/approval policy text. Content is array of text blocks; one
|
|
890
|
+
* block contains `<available_skills>`.
|
|
891
|
+
*
|
|
892
|
+
* Both must be filtered or LLM sees the full skill catalog and ignores
|
|
893
|
+
* realm boundaries.
|
|
894
|
+
*/
|
|
895
|
+
function isCatalogMessage(msg) {
|
|
896
|
+
const src = msg.source;
|
|
897
|
+
if (!src)
|
|
898
|
+
return false;
|
|
899
|
+
if (src.kind === 'skill-catalog')
|
|
900
|
+
return true;
|
|
901
|
+
if (src.kind === 'plugin' && src.plugin === '@deepseek-ai/dsh-system-prompt') {
|
|
902
|
+
// Cheap probe: any text in the message that mentions <available_skills>?
|
|
903
|
+
const c = msg.content;
|
|
904
|
+
if (typeof c === 'string')
|
|
905
|
+
return c.includes('<available_skills>');
|
|
906
|
+
if (Array.isArray(c)) {
|
|
907
|
+
return c.some((b) => isTextBlock(b) && typeof b.text === 'string' && b.text.includes('<available_skills>'));
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
return false;
|
|
911
|
+
}
|
|
912
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
913
|
+
// Plugin entry point
|
|
914
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
915
|
+
export async function apply(ctx, config) {
|
|
916
|
+
// §7.102: fill unset fields from config/edge.json (privacy.*).
|
|
917
|
+
const p = loadEdgeConfig().privacy;
|
|
918
|
+
const c = config;
|
|
919
|
+
if (c.configPath === undefined) {
|
|
920
|
+
c.configPath = p?.realmConfigPath && p.realmConfigPath.length > 0
|
|
921
|
+
? p.realmConfigPath
|
|
922
|
+
: edgeHome('skill-realms.json');
|
|
923
|
+
}
|
|
924
|
+
if (c.hotReload === undefined && p?.realmHotReload !== undefined)
|
|
925
|
+
c.hotReload = p.realmHotReload;
|
|
926
|
+
if (c.watchIntervalMs === undefined && p?.realmWatchMs !== undefined)
|
|
927
|
+
c.watchIntervalMs = p.realmWatchMs;
|
|
928
|
+
const auditLog = ctx.auditLog;
|
|
929
|
+
const service = new RealmSkillBridgeService(ctx, config, auditLog);
|
|
930
|
+
// Initial async tagging (needs ctx.skills.list() which is async).
|
|
931
|
+
await service.start();
|
|
932
|
+
// ── Subscribe to skills/change ────────────────────────────────────────────
|
|
933
|
+
const disposeSkillsChange = ctx.on?.('skills/change', () => {
|
|
934
|
+
void service['tagSkillsAsync']();
|
|
935
|
+
});
|
|
936
|
+
// ── Subscribe to tools/change (to re-apply tool realm tags) ───────────────
|
|
937
|
+
const disposeToolsChange = ctx.on?.('tools/change', () => {
|
|
938
|
+
service['tagToolsFromConfig']();
|
|
939
|
+
});
|
|
940
|
+
// ── Layer 1: Filter skill catalog on every agent step ─────────────────────
|
|
941
|
+
// §7.36 (2026-09-04): 必须传 { global: true } —— agent/pre-step 默认 per-agent,
|
|
942
|
+
// 只在注册时的 agent 实例上生效。http-api /v1/chat 路径上的 record.agent 是
|
|
943
|
+
// 动态创建的(每次 followup 新 agent),per-agent 注册的 hook 收不到,导致
|
|
944
|
+
// Layer 1 catalog filter 和 Layer 3 tool gating 永远不触发 → fail-open。
|
|
945
|
+
// input-guard 用了 { global: true },realm-skill-bridge 之前漏了 → 整个
|
|
946
|
+
// fail-open 链路"hook 在,但 agent 收不到"。
|
|
947
|
+
//
|
|
948
|
+
// §7.136 (2026-10-28, 主人裁决「只有边缘模式才有本项目能力」): 上面那条
|
|
949
|
+
// 顾虑在宿主的 **scope** 语义下已经不成立 —— agent 事件用
|
|
950
|
+
// `scopeTarget(agent, agent)` 派发,filter 规则是「监听器的 scope 必须是
|
|
951
|
+
// 派发 scope 或其祖先」。动态创建的 Agent 同样经宿主工厂创建、注册表把它
|
|
952
|
+
// bind 到当前 preset ⇒ scope 链一样成立。
|
|
953
|
+
//
|
|
954
|
+
// 而 `{ global: true }` 会**绕过**这个过滤 ⇒ 护栏在任何工作方式下都生效、
|
|
955
|
+
// 标准模式不是纯宿主。所以这里去掉它:本插件挂在边缘模式 preset 的 scope,
|
|
956
|
+
// 绑定标准模式的 Agent 收不到这些 hook。
|
|
957
|
+
//
|
|
958
|
+
// §7.37 (2026-09-04): catalog filter on agent/pre-step is currently a no-op
|
|
959
|
+
// because decision.messages is empty (msgCount=0) — <available_skills> is
|
|
960
|
+
// injected by dsh-system-prompt plugin below the hook surface. Kept as a
|
|
961
|
+
// no-op hook (decision.messages filter still runs in case future DSH
|
|
962
|
+
// versions populate it) and supplemented by Layer 1.5 (llm/stream tools).
|
|
963
|
+
const disposePreStep = ctx.on?.('agent/pre-step', async (payload, next) => {
|
|
964
|
+
const decision = (await next());
|
|
965
|
+
if (decision.kind === 'reject')
|
|
966
|
+
return decision;
|
|
967
|
+
const p = payload;
|
|
968
|
+
const agentRealm = service.getAgentRealm(p.agent);
|
|
969
|
+
if (agentRealm === undefined || decision.messages === undefined)
|
|
970
|
+
return decision;
|
|
971
|
+
// §7.46 (2026-09-11): 用 detailed 版本拿到 droppedCount,
|
|
972
|
+
// emit ops log 让"哪些 skill 被 filter 掉"在每个 chat 都可见
|
|
973
|
+
const result = service.filterSkillCatalogDetailed(decision.messages, agentRealm);
|
|
974
|
+
if (result.messages === decision.messages)
|
|
975
|
+
return decision;
|
|
976
|
+
if (result.droppedCount > 0 || agentRealm !== 'hidden') {
|
|
977
|
+
const sessionId = p.agent?.session?.id ?? 'unknown';
|
|
978
|
+
try {
|
|
979
|
+
ctx.logger?.info?.(`[realm-skill-bridge] pre-step filter sessionId=${sessionId} realm=${agentRealm} catalogDropped=${result.droppedCount}`);
|
|
980
|
+
}
|
|
981
|
+
catch { /* debug only */ }
|
|
982
|
+
}
|
|
983
|
+
return { ...decision, messages: result.messages };
|
|
984
|
+
});
|
|
985
|
+
// ── Layer 1.5-replacement: Filter tool schemas at system-prompt/assemble ───
|
|
986
|
+
// §7.37.x (2026-09-22): replace the deleted `llm/stream` tools probe.
|
|
987
|
+
// Why the original probe is dead:
|
|
988
|
+
// dsh-agent-loop's buildRequest() deepFreezes the entire request payload
|
|
989
|
+
// (config + messages + system + tools + sessionId + signal) BEFORE the
|
|
990
|
+
// `llm/stream` waterfall fires. See @deepseek-ai/dsh-agent-loop/lib/index.js:729:
|
|
991
|
+
// markAgentLoopRequest(deepFreeze({
|
|
992
|
+
// ...header.config,
|
|
993
|
+
// messages: boundaryMessages,
|
|
994
|
+
// ...header.system !== void 0 ? { system: header.system } : {},
|
|
995
|
+
// ...header.tools !== void 0 ? { tools: header.tools } : {},
|
|
996
|
+
// sessionId: this.session.id, signal,
|
|
997
|
+
// }))
|
|
998
|
+
// After this, payload.tools at `llm/stream` is frozen — splice, push,
|
|
999
|
+
// Object.defineProperty all silently fail.
|
|
1000
|
+
//
|
|
1001
|
+
// Why system-prompt/assemble works:
|
|
1002
|
+
// dsh-system-prompt/lib/index.js:339 builds assembly.tools via plain
|
|
1003
|
+
// array concat (no freeze), then runs the `system-prompt/assemble`
|
|
1004
|
+
// waterfall (line 342). The dsh type declaration explicitly states:
|
|
1005
|
+
// "the returned value is authoritative"
|
|
1006
|
+
// so we can return a new assembly with filtered tools. dsh-agent-loop
|
|
1007
|
+
// then picks up `assembly.tools` and freezes them only later (line 729).
|
|
1008
|
+
//
|
|
1009
|
+
// Trade-off: `system-prompt/assemble` runs in `agent.preStep()` (every
|
|
1010
|
+
// step), so we filter once per LLM call. Layer 3 (tools/pre-execute)
|
|
1011
|
+
// remains the authoritative execution-time gate for runtime-added tools.
|
|
1012
|
+
// Layer 1.5-replacement here is a UX gate — LLM doesn't even ATTEMPT a
|
|
1013
|
+
// cross-realm tool call when its schema is hidden.
|
|
1014
|
+
const disposeAssemble = ctx.on?.('system-prompt/assemble', async (assembly, ctx2, next) => {
|
|
1015
|
+
const result = (await next());
|
|
1016
|
+
if (!result || !Array.isArray(result.tools))
|
|
1017
|
+
return result;
|
|
1018
|
+
const assembleCtx = ctx2;
|
|
1019
|
+
const sessionId = assembleCtx?.scope?.session?.id;
|
|
1020
|
+
const agentRealm = service.getSessionRealm(sessionId);
|
|
1021
|
+
// No realm classified yet → fail-open at this step. http-api sets
|
|
1022
|
+
// session realm via service.setSessionRealm() in the same request
|
|
1023
|
+
// lifecycle, so undefined should not happen in production. Layer 3
|
|
1024
|
+
// (tools/pre-execute) catches anything that slips through.
|
|
1025
|
+
if (agentRealm === undefined)
|
|
1026
|
+
return result;
|
|
1027
|
+
const filter = service.filterToolsField(result.tools, agentRealm, { sessionId });
|
|
1028
|
+
if (!filter.changed)
|
|
1029
|
+
return result;
|
|
1030
|
+
ctx.logger?.info?.(`[realm-skill-bridge] assemble filter sessionId=${sessionId ?? 'unknown'} realm=${agentRealm} removedCount=${filter.removed.length} removed=${filter.removed.join(',')}`);
|
|
1031
|
+
return { ...result, tools: filter.tools };
|
|
1032
|
+
});
|
|
1033
|
+
// ── Layer 3: Gate tool execution by realm ─────────────────────────────────
|
|
1034
|
+
const disposePreExec = ctx.on?.('tools/pre-execute', async (exec, next) => {
|
|
1035
|
+
const e = exec;
|
|
1036
|
+
const agentRealm = service.getAgentRealm(e.agent);
|
|
1037
|
+
const rawSessionId = e.agent?.session?.id;
|
|
1038
|
+
// §7.148: ops log 的显示约定保留 `'unknown'`;但**审计载荷**要拿真值 ——
|
|
1039
|
+
// `'unknown'` 不是会话 id,写进载荷会造出一个不存在的桶(§7.147 口径)。
|
|
1040
|
+
const sessionId = rawSessionId ?? 'unknown';
|
|
1041
|
+
const denyReason = service.checkToolRealm(e.name, agentRealm);
|
|
1042
|
+
// §7.46.7 (2026-09-12): 尝试从 exec.args/input/arguments 抽出 skill name —
|
|
1043
|
+
// 当 tool=skill 时,ops log 应该报具体 skill 名(ets-auth 等),而不是只报 tool=skill。
|
|
1044
|
+
// dshtool-skill 的参数是 { name: '<skill>' }(lib/index.js:62),但 dsh-agent-loop
|
|
1045
|
+
// 把 args 放在哪个字段(input / args / arguments)随版本变,所以三个都试。
|
|
1046
|
+
// mibao-empty-result-diagnosis 排查时就是因为 ops log 只报 tool=skill
|
|
1047
|
+
// 看不出 LLM 实际选了哪个 skill("查密盒登录状态"应该选 ets-auth,
|
|
1048
|
+
// 但 trace 完全看不到 skill 名字,无法判断是选错 skill 还是 skill 执行失败)。
|
|
1049
|
+
let skillName;
|
|
1050
|
+
if (e.name === 'skill') {
|
|
1051
|
+
const argsObj = e.input ?? e.args ?? e.arguments;
|
|
1052
|
+
const raw = argsObj && typeof argsObj === 'object' ? argsObj.name : undefined;
|
|
1053
|
+
skillName = typeof raw === 'string' ? raw : undefined;
|
|
1054
|
+
}
|
|
1055
|
+
// §7.46 (2026-09-11): ops log 标记每个 tool call 的 allow/deny 决策,
|
|
1056
|
+
// 配合 chat-received/finished/preflight/pre-step 形成完整 flow log
|
|
1057
|
+
try {
|
|
1058
|
+
const skillSuffix = skillName ? ` skillName=${skillName}` : '';
|
|
1059
|
+
if (denyReason !== undefined) {
|
|
1060
|
+
ctx.logger?.info?.(`[realm-skill-bridge] tool deny sessionId=${sessionId} tool=${e.name}${skillSuffix} realm=${agentRealm ?? 'unknown'} reason=${denyReason}`);
|
|
1061
|
+
}
|
|
1062
|
+
else {
|
|
1063
|
+
ctx.logger?.info?.(`[realm-skill-bridge] tool allow sessionId=${sessionId} tool=${e.name}${skillSuffix} realm=${agentRealm ?? 'unknown'}`);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
catch { /* debug only */ }
|
|
1067
|
+
if (denyReason !== undefined) {
|
|
1068
|
+
const toolRealms = service.toolRealm(e.name) ?? new Set(['hidden']);
|
|
1069
|
+
service['auditDeny'](e.name, toolRealms, agentRealm ?? 'hidden', rawSessionId);
|
|
1070
|
+
return { kind: 'deny', reason: denyReason };
|
|
1071
|
+
}
|
|
1072
|
+
return next();
|
|
1073
|
+
});
|
|
1074
|
+
// ── Layer 3.5: tool execution result ops log (§7.47.6) ──────────────────
|
|
1075
|
+
//
|
|
1076
|
+
// user feedback 2026-09-13: "每次修BUG通过日志来修理,确保日志里有所有错误的信息"
|
|
1077
|
+
//
|
|
1078
|
+
// tools/pre-execute (Layer 3 above) only emits the realm check decision.
|
|
1079
|
+
// The actual tool execution result (success / error / duration / signal-aborted)
|
|
1080
|
+
// was completely invisible in ops log — 150 tool allow lines vs 3 tool result
|
|
1081
|
+
// lines. Diagnosing "news query returned empty because web_search hung"
|
|
1082
|
+
// required manual trace, not ops log. Now: subscribe to the serial
|
|
1083
|
+
// `tools/result` event (dispatched by dsh-tools after `tools/post-execute`
|
|
1084
|
+
// waterfall finishes — pure observation, no next chain so we can't break it).
|
|
1085
|
+
//
|
|
1086
|
+
// Payload shape (from dsh-tools): exec = { name, agent, signal, ... },
|
|
1087
|
+
// result = { isError: boolean, message?: unknown, ... }. We emit metadata
|
|
1088
|
+
// only — full result content stays in audit-log (opt-in via
|
|
1089
|
+
// DSH_EDGE_LOG_LEVEL=debug).
|
|
1090
|
+
const disposeToolResult = ctx.on?.('tools/result', (exec, result) => {
|
|
1091
|
+
try {
|
|
1092
|
+
const e = exec;
|
|
1093
|
+
const r = result;
|
|
1094
|
+
const toolName = typeof e?.name === 'string' ? e.name : '<unknown>';
|
|
1095
|
+
const sessionId = e?.agent?.session?.id ?? 'unknown';
|
|
1096
|
+
const isError = r?.isError === true;
|
|
1097
|
+
const signalAborted = r?.signalAborted === true;
|
|
1098
|
+
const status = signalAborted ? 'aborted' : isError ? 'error' : 'ok';
|
|
1099
|
+
try {
|
|
1100
|
+
ctx.logger?.info?.(`[realm-skill-bridge] tool result sessionId=${sessionId} tool=${toolName} status=${status}${isError ? ` errorKind=${describeToolError(r?.message)}` : ''}`);
|
|
1101
|
+
}
|
|
1102
|
+
catch { /* debug only */ }
|
|
1103
|
+
}
|
|
1104
|
+
catch { /* debug only */ }
|
|
1105
|
+
});
|
|
1106
|
+
// ── Flow-level ops log for every chat round-trip (§7.44.1) ───────────────
|
|
1107
|
+
//
|
|
1108
|
+
// 用户反馈 §7.44 把 ops log 默认从 debug 翻到 info 后,TUI 输入的请求
|
|
1109
|
+
// 在 log 里没有记录 —— 因为 TUI 不走 http-api(http-api 才有 flow
|
|
1110
|
+
// info log 'POST /v1/chat 200 ...ms'),TUI 直接走 dsh-agent-loop +
|
|
1111
|
+
// Session。session/event 上的 request/header 和 turn/end 是 user
|
|
1112
|
+
// prompt lifecycle 的最稳两个 marker(每个 chat round-trip 各 fire
|
|
1113
|
+
// 一次,TUI / http-api / 未来 webui 都覆盖)。
|
|
1114
|
+
//
|
|
1115
|
+
// 这里 emit 单行 info(metadata only:session id + turn + reason),
|
|
1116
|
+
// 不带任何 user PII;body capture 仍走 audit-log + opt-in
|
|
1117
|
+
// DSH_EDGE_LOG_LEVEL=debug。
|
|
1118
|
+
// §7.46 (2026-09-11): per-session start time map 算 chat duration,
|
|
1119
|
+
// 配合 chat finished line 的 realm= 让"这条 chat 路由了哪个 realm /
|
|
1120
|
+
// 用了多久"在 ops log 一目了然。
|
|
1121
|
+
const sessionStartTimes = new Map();
|
|
1122
|
+
const disposeSessionEvents = ctx.on?.('session/event', (session, evt) => {
|
|
1123
|
+
const e = evt;
|
|
1124
|
+
if (e === undefined || typeof e.type !== 'string')
|
|
1125
|
+
return;
|
|
1126
|
+
const sessionId = session?.id ?? 'unknown';
|
|
1127
|
+
// §7.44.1 (2026-09-11): 为 TUI / 任何非 http-api 入口加 flow-level
|
|
1128
|
+
// info log —— §7.44 把 ops log 默认翻 info 后,TUI 走 dsh-agent-loop
|
|
1129
|
+
// 不再被 file-logger 默认捕到(http-api 才有自己的 POST /v1/chat 200
|
|
1130
|
+
// 行),整轮 chat 在 log 里无痕迹。session/event 是 dsh 内部一套
|
|
1131
|
+
// per-session 事件流,覆盖所有 entry point(TUI / http-api / 未来
|
|
1132
|
+
// webui 都走)。每个 chat round-trip fire 一次 'request/header'
|
|
1133
|
+
// (agent-loop 在 user prompt 起点 emit,see
|
|
1134
|
+
// @deepseek-ai/dsh-agent-loop/lib/index.js)和一次 'turn/end',
|
|
1135
|
+
// 我们在 info 级各 emit 单行(metadata only:不带 user 文本 / PII),
|
|
1136
|
+
// 让 ops log 始终能看到"什么时候来了什么请求、什么时候结束"。
|
|
1137
|
+
if (e.type === 'request/header') {
|
|
1138
|
+
// 之前的 'startsSeries=true' 过滤在本路径下永远不命中(实测 data
|
|
1139
|
+
// 里不带 startsSeries 字段,session/event 的 data shape 跟
|
|
1140
|
+
// SessionEventMap 声明不完全一致),改成无条件 log 每个 request/
|
|
1141
|
+
// header 事件 —— 实测每个 chat 恰好 fire 1 次(user/message 那
|
|
1142
|
+
// 种 N 次是因为 session hydration / 重注入,与 request series
|
|
1143
|
+
// 不是一回事)。
|
|
1144
|
+
const data = e.data;
|
|
1145
|
+
const reason = typeof data?.reason === 'string' ? data.reason : 'unknown';
|
|
1146
|
+
const ss = data?.startsSeries === true ? ' series-start' : '';
|
|
1147
|
+
sessionStartTimes.set(sessionId, Date.now());
|
|
1148
|
+
ctx.logger?.info?.(`[realm-skill-bridge] chat received sessionId=${sessionId} reason=${reason}${ss}`);
|
|
1149
|
+
}
|
|
1150
|
+
else if (e.type === 'turn/end') {
|
|
1151
|
+
const data = e.data ?? {};
|
|
1152
|
+
const turn = typeof data.turn === 'number' ? data.turn : -1;
|
|
1153
|
+
// §7.47.6 (2026-09-13): serialize turnEnds object instead of dropping
|
|
1154
|
+
// to 'unknown'. dsh-agent-loop emits `turnEnds = { kind: "aborted",
|
|
1155
|
+
// reason: signal.reason }` (object, not string). Pre-§7.47.6 code
|
|
1156
|
+
// checked `typeof === 'string'` and silently fell back to 'unknown',
|
|
1157
|
+
// hiding abort reasons like max-turns / signal-abort / completed.
|
|
1158
|
+
// Now: stringify object into compact key=value form for ops log.
|
|
1159
|
+
const reason = serializeTurnEndReason(data.reason);
|
|
1160
|
+
const startMs = sessionStartTimes.get(sessionId);
|
|
1161
|
+
const durationMs = startMs !== undefined ? Date.now() - startMs : -1;
|
|
1162
|
+
const realm = service.getSessionRealm(sessionId) ?? 'unset';
|
|
1163
|
+
const durationStr = durationMs >= 0 ? ` duration=${durationMs}ms` : ' duration=-1';
|
|
1164
|
+
ctx.logger?.info?.(`[realm-skill-bridge] chat finished sessionId=${sessionId} turn=${turn} reason=${reason} realm=${realm}${durationStr}`);
|
|
1165
|
+
sessionStartTimes.delete(sessionId);
|
|
1166
|
+
}
|
|
1167
|
+
});
|
|
1168
|
+
return async () => {
|
|
1169
|
+
service.stop();
|
|
1170
|
+
service.clearSessionRealms();
|
|
1171
|
+
disposeSkillsChange?.();
|
|
1172
|
+
disposeToolsChange?.();
|
|
1173
|
+
disposePreStep?.();
|
|
1174
|
+
disposeAssemble?.();
|
|
1175
|
+
disposePreExec?.();
|
|
1176
|
+
disposeToolResult?.();
|
|
1177
|
+
disposeSessionEvents?.();
|
|
1178
|
+
sessionStartTimes.clear();
|
|
1179
|
+
};
|
|
1180
|
+
}
|