@dsh-edge/dsh-edge-agent 0.0.0-stage → 0.1.2

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.
Files changed (238) hide show
  1. package/CHANGELOG.md +158 -0
  2. package/LICENSE +21 -0
  3. package/README.md +181 -2
  4. package/config/edge.example.json +179 -0
  5. package/config/privacy-policy.json +202 -0
  6. package/cordis.desktop-safe.patch.yml +129 -0
  7. package/cordis.patch.yml +382 -0
  8. package/cordis.yml +374 -0
  9. package/lib/attestation.d.ts +2 -0
  10. package/lib/attestation.d.ts.map +1 -0
  11. package/lib/attestation.js +2 -0
  12. package/lib/audit-log.d.ts +2 -0
  13. package/lib/audit-log.d.ts.map +1 -0
  14. package/lib/audit-log.js +2 -0
  15. package/lib/client.js +87 -0
  16. package/lib/cloud-llm-adapter.d.ts +2 -0
  17. package/lib/cloud-llm-adapter.d.ts.map +1 -0
  18. package/lib/cloud-llm-adapter.js +2 -0
  19. package/lib/dsh-adapter/attestation.d.ts +42 -0
  20. package/lib/dsh-adapter/attestation.d.ts.map +1 -0
  21. package/lib/dsh-adapter/attestation.js +40 -0
  22. package/lib/dsh-adapter/audit-log.d.ts +139 -0
  23. package/lib/dsh-adapter/audit-log.d.ts.map +1 -0
  24. package/lib/dsh-adapter/audit-log.js +376 -0
  25. package/lib/dsh-adapter/cloud-egress.d.ts +181 -0
  26. package/lib/dsh-adapter/cloud-egress.d.ts.map +1 -0
  27. package/lib/dsh-adapter/cloud-egress.js +257 -0
  28. package/lib/dsh-adapter/cloud-llm-adapter.d.ts +416 -0
  29. package/lib/dsh-adapter/cloud-llm-adapter.d.ts.map +1 -0
  30. package/lib/dsh-adapter/cloud-llm-adapter.js +1472 -0
  31. package/lib/dsh-adapter/contract.d.ts +80 -0
  32. package/lib/dsh-adapter/contract.d.ts.map +1 -0
  33. package/lib/dsh-adapter/contract.js +194 -0
  34. package/lib/dsh-adapter/desktop-cookie-janitor.d.ts +214 -0
  35. package/lib/dsh-adapter/desktop-cookie-janitor.d.ts.map +1 -0
  36. package/lib/dsh-adapter/desktop-cookie-janitor.js +409 -0
  37. package/lib/dsh-adapter/dsh-edge-agent.d.ts +48 -0
  38. package/lib/dsh-adapter/dsh-edge-agent.d.ts.map +1 -0
  39. package/lib/dsh-adapter/dsh-edge-agent.js +64 -0
  40. package/lib/dsh-adapter/edge-config.d.ts +484 -0
  41. package/lib/dsh-adapter/edge-config.d.ts.map +1 -0
  42. package/lib/dsh-adapter/edge-config.js +898 -0
  43. package/lib/dsh-adapter/edge-context-view.d.ts +184 -0
  44. package/lib/dsh-adapter/edge-context-view.d.ts.map +1 -0
  45. package/lib/dsh-adapter/edge-context-view.js +483 -0
  46. package/lib/dsh-adapter/effective-model.d.ts +85 -0
  47. package/lib/dsh-adapter/effective-model.d.ts.map +1 -0
  48. package/lib/dsh-adapter/effective-model.js +171 -0
  49. package/lib/dsh-adapter/etscli-shell-guard.d.ts +244 -0
  50. package/lib/dsh-adapter/etscli-shell-guard.d.ts.map +1 -0
  51. package/lib/dsh-adapter/etscli-shell-guard.js +420 -0
  52. package/lib/dsh-adapter/etscli-tools.d.ts +112 -0
  53. package/lib/dsh-adapter/etscli-tools.d.ts.map +1 -0
  54. package/lib/dsh-adapter/etscli-tools.js +393 -0
  55. package/lib/dsh-adapter/file-logger.d.ts +294 -0
  56. package/lib/dsh-adapter/file-logger.d.ts.map +1 -0
  57. package/lib/dsh-adapter/file-logger.js +564 -0
  58. package/lib/dsh-adapter/function-call-external.d.ts +357 -0
  59. package/lib/dsh-adapter/function-call-external.d.ts.map +1 -0
  60. package/lib/dsh-adapter/function-call-external.js +1005 -0
  61. package/lib/dsh-adapter/host-egress-guard.d.ts +161 -0
  62. package/lib/dsh-adapter/host-egress-guard.d.ts.map +1 -0
  63. package/lib/dsh-adapter/host-egress-guard.js +213 -0
  64. package/lib/dsh-adapter/host-llm-trace.d.ts +198 -0
  65. package/lib/dsh-adapter/host-llm-trace.d.ts.map +1 -0
  66. package/lib/dsh-adapter/host-llm-trace.js +344 -0
  67. package/lib/dsh-adapter/http-api-routes.d.ts +61 -0
  68. package/lib/dsh-adapter/http-api-routes.d.ts.map +1 -0
  69. package/lib/dsh-adapter/http-api-routes.js +79 -0
  70. package/lib/dsh-adapter/http-api-sessions.d.ts +105 -0
  71. package/lib/dsh-adapter/http-api-sessions.d.ts.map +1 -0
  72. package/lib/dsh-adapter/http-api-sessions.js +173 -0
  73. package/lib/dsh-adapter/http-api.d.ts +221 -0
  74. package/lib/dsh-adapter/http-api.d.ts.map +1 -0
  75. package/lib/dsh-adapter/http-api.js +1811 -0
  76. package/lib/dsh-adapter/index.d.ts +62 -0
  77. package/lib/dsh-adapter/index.d.ts.map +1 -0
  78. package/lib/dsh-adapter/index.js +97 -0
  79. package/lib/dsh-adapter/input-guard-messages.d.ts +56 -0
  80. package/lib/dsh-adapter/input-guard-messages.d.ts.map +1 -0
  81. package/lib/dsh-adapter/input-guard-messages.js +136 -0
  82. package/lib/dsh-adapter/input-guard.d.ts +508 -0
  83. package/lib/dsh-adapter/input-guard.d.ts.map +1 -0
  84. package/lib/dsh-adapter/input-guard.js +2174 -0
  85. package/lib/dsh-adapter/intent-prompts.d.ts +77 -0
  86. package/lib/dsh-adapter/intent-prompts.d.ts.map +1 -0
  87. package/lib/dsh-adapter/intent-prompts.js +97 -0
  88. package/lib/dsh-adapter/intent-router.d.ts +710 -0
  89. package/lib/dsh-adapter/intent-router.d.ts.map +1 -0
  90. package/lib/dsh-adapter/intent-router.js +2802 -0
  91. package/lib/dsh-adapter/intent-signals.d.ts +208 -0
  92. package/lib/dsh-adapter/intent-signals.d.ts.map +1 -0
  93. package/lib/dsh-adapter/intent-signals.js +391 -0
  94. package/lib/dsh-adapter/invariant-install.d.ts +16 -0
  95. package/lib/dsh-adapter/invariant-install.d.ts.map +1 -0
  96. package/lib/dsh-adapter/invariant-install.js +15 -0
  97. package/lib/dsh-adapter/invariant.d.ts +16 -0
  98. package/lib/dsh-adapter/invariant.d.ts.map +1 -0
  99. package/lib/dsh-adapter/invariant.js +22 -0
  100. package/lib/dsh-adapter/llm-adapters.d.ts +19 -0
  101. package/lib/dsh-adapter/llm-adapters.d.ts.map +1 -0
  102. package/lib/dsh-adapter/llm-adapters.js +16 -0
  103. package/lib/dsh-adapter/llm-internal-caller.d.ts +92 -0
  104. package/lib/dsh-adapter/llm-internal-caller.d.ts.map +1 -0
  105. package/lib/dsh-adapter/llm-internal-caller.js +100 -0
  106. package/lib/dsh-adapter/llm-max-turns.d.ts +55 -0
  107. package/lib/dsh-adapter/llm-max-turns.d.ts.map +1 -0
  108. package/lib/dsh-adapter/llm-max-turns.js +127 -0
  109. package/lib/dsh-adapter/llm-trace-store.d.ts +131 -0
  110. package/lib/dsh-adapter/llm-trace-store.d.ts.map +1 -0
  111. package/lib/dsh-adapter/llm-trace-store.js +205 -0
  112. package/lib/dsh-adapter/llm-trace.d.ts +450 -0
  113. package/lib/dsh-adapter/llm-trace.d.ts.map +1 -0
  114. package/lib/dsh-adapter/llm-trace.js +463 -0
  115. package/lib/dsh-adapter/local-llm-adapter.d.ts +141 -0
  116. package/lib/dsh-adapter/local-llm-adapter.d.ts.map +1 -0
  117. package/lib/dsh-adapter/local-llm-adapter.js +787 -0
  118. package/lib/dsh-adapter/local-storage.d.ts +96 -0
  119. package/lib/dsh-adapter/local-storage.d.ts.map +1 -0
  120. package/lib/dsh-adapter/local-storage.js +198 -0
  121. package/lib/dsh-adapter/model-router.d.ts +79 -0
  122. package/lib/dsh-adapter/model-router.d.ts.map +1 -0
  123. package/lib/dsh-adapter/model-router.js +107 -0
  124. package/lib/dsh-adapter/ohttp-gateway.d.ts +49 -0
  125. package/lib/dsh-adapter/ohttp-gateway.d.ts.map +1 -0
  126. package/lib/dsh-adapter/ohttp-gateway.js +32 -0
  127. package/lib/dsh-adapter/openai-sse.d.ts +85 -0
  128. package/lib/dsh-adapter/openai-sse.d.ts.map +1 -0
  129. package/lib/dsh-adapter/openai-sse.js +462 -0
  130. package/lib/dsh-adapter/pii-utils.d.ts +96 -0
  131. package/lib/dsh-adapter/pii-utils.d.ts.map +1 -0
  132. package/lib/dsh-adapter/pii-utils.js +324 -0
  133. package/lib/dsh-adapter/plugin-host.d.ts +38 -0
  134. package/lib/dsh-adapter/plugin-host.d.ts.map +1 -0
  135. package/lib/dsh-adapter/plugin-host.js +38 -0
  136. package/lib/dsh-adapter/plugin.d.ts +46 -0
  137. package/lib/dsh-adapter/plugin.d.ts.map +1 -0
  138. package/lib/dsh-adapter/plugin.js +100 -0
  139. package/lib/dsh-adapter/privacy-policy.d.ts +237 -0
  140. package/lib/dsh-adapter/privacy-policy.d.ts.map +1 -0
  141. package/lib/dsh-adapter/privacy-policy.js +391 -0
  142. package/lib/dsh-adapter/prompt-guard.d.ts +152 -0
  143. package/lib/dsh-adapter/prompt-guard.d.ts.map +1 -0
  144. package/lib/dsh-adapter/prompt-guard.js +872 -0
  145. package/lib/dsh-adapter/realm-skill-bridge.d.ts +365 -0
  146. package/lib/dsh-adapter/realm-skill-bridge.d.ts.map +1 -0
  147. package/lib/dsh-adapter/realm-skill-bridge.js +1180 -0
  148. package/lib/dsh-adapter/sanitizer-pipeline.d.ts +140 -0
  149. package/lib/dsh-adapter/sanitizer-pipeline.d.ts.map +1 -0
  150. package/lib/dsh-adapter/sanitizer-pipeline.js +431 -0
  151. package/lib/dsh-adapter/secure-bridge.d.ts +47 -0
  152. package/lib/dsh-adapter/secure-bridge.d.ts.map +1 -0
  153. package/lib/dsh-adapter/secure-bridge.js +35 -0
  154. package/lib/dsh-adapter/session-attribution.d.ts +70 -0
  155. package/lib/dsh-adapter/session-attribution.d.ts.map +1 -0
  156. package/lib/dsh-adapter/session-attribution.js +76 -0
  157. package/lib/dsh-adapter/sessions.d.ts +86 -0
  158. package/lib/dsh-adapter/sessions.d.ts.map +1 -0
  159. package/lib/dsh-adapter/sessions.js +179 -0
  160. package/lib/dsh-adapter/tcm.d.ts +55 -0
  161. package/lib/dsh-adapter/tcm.d.ts.map +1 -0
  162. package/lib/dsh-adapter/tcm.js +94 -0
  163. package/lib/dsh-adapter/td-bus.d.ts +87 -0
  164. package/lib/dsh-adapter/td-bus.d.ts.map +1 -0
  165. package/lib/dsh-adapter/td-bus.js +196 -0
  166. package/lib/dsh-adapter/tool-runner.d.ts +185 -0
  167. package/lib/dsh-adapter/tool-runner.d.ts.map +1 -0
  168. package/lib/dsh-adapter/tool-runner.js +271 -0
  169. package/lib/dsh-adapter/tools.d.ts +118 -0
  170. package/lib/dsh-adapter/tools.d.ts.map +1 -0
  171. package/lib/dsh-adapter/tools.js +292 -0
  172. package/lib/dsh-adapter/types.d.ts +23 -0
  173. package/lib/dsh-adapter/types.d.ts.map +1 -0
  174. package/lib/dsh-adapter/types.js +16 -0
  175. package/lib/dsh-adapter/voice-input.d.ts +30 -0
  176. package/lib/dsh-adapter/voice-input.d.ts.map +1 -0
  177. package/lib/dsh-adapter/voice-input.js +34 -0
  178. package/lib/dsh-adapter/web-search-tavily-bocha.d.ts +152 -0
  179. package/lib/dsh-adapter/web-search-tavily-bocha.d.ts.map +1 -0
  180. package/lib/dsh-adapter/web-search-tavily-bocha.js +289 -0
  181. package/lib/http-api.d.ts +2 -0
  182. package/lib/http-api.d.ts.map +1 -0
  183. package/lib/http-api.js +2 -0
  184. package/lib/index.d.ts +48 -0
  185. package/lib/index.d.ts.map +1 -0
  186. package/lib/index.js +49 -0
  187. package/lib/intent-router.d.ts +2 -0
  188. package/lib/intent-router.d.ts.map +1 -0
  189. package/lib/intent-router.js +2 -0
  190. package/lib/llm-adapters.d.ts +2 -0
  191. package/lib/llm-adapters.d.ts.map +1 -0
  192. package/lib/llm-adapters.js +2 -0
  193. package/lib/local-llm-adapter.d.ts +2 -0
  194. package/lib/local-llm-adapter.d.ts.map +1 -0
  195. package/lib/local-llm-adapter.js +2 -0
  196. package/lib/local-storage.d.ts +2 -0
  197. package/lib/local-storage.d.ts.map +1 -0
  198. package/lib/local-storage.js +2 -0
  199. package/lib/model-router.d.ts +2 -0
  200. package/lib/model-router.d.ts.map +1 -0
  201. package/lib/model-router.js +2 -0
  202. package/lib/ohttp-gateway.d.ts +2 -0
  203. package/lib/ohttp-gateway.d.ts.map +1 -0
  204. package/lib/ohttp-gateway.js +2 -0
  205. package/lib/plugin-host.d.ts +2 -0
  206. package/lib/plugin-host.d.ts.map +1 -0
  207. package/lib/plugin-host.js +2 -0
  208. package/lib/privacy-policy.d.ts +2 -0
  209. package/lib/privacy-policy.d.ts.map +1 -0
  210. package/lib/privacy-policy.js +2 -0
  211. package/lib/prompt-guard.d.ts +2 -0
  212. package/lib/prompt-guard.d.ts.map +1 -0
  213. package/lib/prompt-guard.js +2 -0
  214. package/lib/sanitizer-pipeline.d.ts +2 -0
  215. package/lib/sanitizer-pipeline.d.ts.map +1 -0
  216. package/lib/sanitizer-pipeline.js +2 -0
  217. package/lib/secure-bridge.d.ts +2 -0
  218. package/lib/secure-bridge.d.ts.map +1 -0
  219. package/lib/secure-bridge.js +2 -0
  220. package/lib/sessions.d.ts +2 -0
  221. package/lib/sessions.d.ts.map +1 -0
  222. package/lib/sessions.js +2 -0
  223. package/lib/tcm.d.ts +2 -0
  224. package/lib/tcm.d.ts.map +1 -0
  225. package/lib/tcm.js +2 -0
  226. package/lib/td-bus.d.ts +2 -0
  227. package/lib/td-bus.d.ts.map +1 -0
  228. package/lib/td-bus.js +2 -0
  229. package/lib/tools.d.ts +2 -0
  230. package/lib/tools.d.ts.map +1 -0
  231. package/lib/tools.js +2 -0
  232. package/lib/voice-input.d.ts +2 -0
  233. package/lib/voice-input.d.ts.map +1 -0
  234. package/lib/voice-input.js +2 -0
  235. package/package.json +253 -3
  236. package/presets/agent-preset-edge.json +50 -0
  237. package/presets/edge.desktop-safe.patch.yml +503 -0
  238. package/presets/edge.patch.yml +557 -0
@@ -0,0 +1,196 @@
1
+ /**
2
+ * td-bus — threat detection bus.
3
+ *
4
+ * Phase D: real threat-level state machine. Subscribes to high-volume
5
+ * audit events and raises a threat level when patterns indicate abuse.
6
+ * The http-api plugin reads `getThreatLevel()` and blocks incoming
7
+ * `/v1/chat` requests when the level reaches the configured block
8
+ * threshold (default: 'critical', env DSH_EDGE_TD_BLOCK_LEVEL).
9
+ *
10
+ * Phase D decision matrix (frequency-driven):
11
+ * requests/min in window threat level
12
+ * ≥ thresholds.cloud... elevated
13
+ * ≥ 2 × thresholds... high
14
+ * ≥ 4 × thresholds... critical ← blockLevel default
15
+ *
16
+ * Other streams (failed-sanitization streak, permission-denied streak)
17
+ * are reserved for future escalation; Phase D only ships the cloud-
18
+ * requests-per-minute counter.
19
+ *
20
+ * @module dsh-edge-agent/td-bus
21
+ */
22
+ import { Service } from '@deepseek-ai/cordis';
23
+ import Schema from '@deepseek-ai/schemastery';
24
+ import { loadEdgeConfig } from './edge-config.js';
25
+ export const name = 'td-bus';
26
+ export const inject = ['auditLog', 'privacyPolicy'];
27
+ export const Config = Schema.object({
28
+ windowSeconds: Schema.natural().default(60),
29
+ // §7.102: cloudRequestsPerMinute / blockLevel carry no Schema default so
30
+ // apply()'s config/edge.json (threat.*) fill can tell "row omitted it"
31
+ // from "row pinned it" (the full patch pins 8 deliberately). The
32
+ // constructor keeps the ?? 20 / ?? 'critical' literals as last resort.
33
+ thresholds: Schema.object({
34
+ cloudRequestsPerMinute: Schema.natural(),
35
+ failedSanitizationStreak: Schema.natural().default(3),
36
+ permissionDeniedStreak: Schema.natural().default(5),
37
+ }),
38
+ blockLevel: Schema.union(['elevated', 'high', 'critical']),
39
+ });
40
+ export class TdBusServiceImpl extends Service {
41
+ windowMs;
42
+ cloudThreshold;
43
+ // Concrete union: the constructor's `?? 'critical'` guarantees a value
44
+ // even though the Config field is optional (§7.102 fill chain).
45
+ blockLevel;
46
+ currentLevel = 'normal';
47
+ /** When set, recomputeLevel() will not drop below this floor. Set by
48
+ * escalate(); cleared only when the sliding window fully decays. */
49
+ stickyLevel = null;
50
+ /** Sliding-window timestamps of cloud requests. */
51
+ cloudHits = [];
52
+ listeners = new Map();
53
+ constructor(ctx, config) {
54
+ super(ctx, 'tdBus');
55
+ this.windowMs = (config?.windowSeconds ?? 60) * 1000;
56
+ this.cloudThreshold = config?.thresholds.cloudRequestsPerMinute ?? 20;
57
+ this.blockLevel = config?.blockLevel ?? 'critical';
58
+ }
59
+ /** Recompute threat level from the current window. Called after each
60
+ * recordCloudRequest() and from getThreatLevel(). Decay: cloudHits
61
+ * older than windowMs are shifted out first, so the count reflects
62
+ * only fresh events. */
63
+ recomputeLevel() {
64
+ const now = Date.now();
65
+ while (this.cloudHits.length > 0 && now - this.cloudHits[0] > this.windowMs) {
66
+ this.cloudHits.shift();
67
+ }
68
+ const count = this.cloudHits.length;
69
+ let next = 'normal';
70
+ if (count >= this.cloudThreshold * 4)
71
+ next = 'critical';
72
+ else if (count >= this.cloudThreshold * 2)
73
+ next = 'high';
74
+ else if (count >= this.cloudThreshold)
75
+ next = 'elevated';
76
+ // Honor a manual sticky escalation: don't drop below it until the
77
+ // operator explicitly resets (no API for reset in Phase D).
78
+ if (this.stickyLevel) {
79
+ const order = { normal: 0, elevated: 1, high: 2, critical: 3 };
80
+ if (order[this.stickyLevel] > order[next])
81
+ next = this.stickyLevel;
82
+ }
83
+ if (next !== this.currentLevel) {
84
+ const prev = this.currentLevel;
85
+ this.currentLevel = next;
86
+ try {
87
+ const auditLog = this.ctx?.auditLog;
88
+ auditLog?.log?.({
89
+ level: next === 'critical' ? 'security' : 'warn',
90
+ kind: 'td-bus.escalated',
91
+ source: 'td-bus',
92
+ payload: { from: prev, to: next, count, threshold: this.cloudThreshold },
93
+ })?.catch?.(() => { });
94
+ }
95
+ catch { /* ignore */ }
96
+ }
97
+ }
98
+ getThreatLevel() {
99
+ // Each call triggers a re-decay so level reflects current window even
100
+ // when no fresh traffic has come in (e.g. 60s after a burst).
101
+ this.recomputeLevel();
102
+ return this.currentLevel;
103
+ }
104
+ on(action, listener) {
105
+ let set = this.listeners.get(action);
106
+ if (!set) {
107
+ set = new Set();
108
+ this.listeners.set(action, set);
109
+ }
110
+ set.add(listener);
111
+ return () => { set.delete(listener); };
112
+ }
113
+ escalate(reason) {
114
+ const prev = this.currentLevel;
115
+ this.currentLevel = 'critical';
116
+ this.stickyLevel = 'critical';
117
+ // Fire all registered listeners on manual escalation — operators
118
+ // explicitly want upstream plugins (secure-bridge, http-api) to react.
119
+ for (const set of this.listeners.values()) {
120
+ for (const fn of set) {
121
+ try {
122
+ fn();
123
+ }
124
+ catch { /* ignore listener faults */ }
125
+ }
126
+ }
127
+ try {
128
+ const auditLog = this.ctx?.auditLog;
129
+ auditLog?.log?.({
130
+ level: 'security',
131
+ kind: 'td-bus.escalated',
132
+ source: 'td-bus',
133
+ payload: { from: prev, to: 'critical', reason },
134
+ })?.catch?.(() => { });
135
+ }
136
+ catch { /* ignore */ }
137
+ }
138
+ recordCloudRequest() {
139
+ this.cloudHits.push(Date.now());
140
+ this.recomputeLevel();
141
+ }
142
+ reset(reason) {
143
+ const prev = this.currentLevel;
144
+ this.stickyLevel = null;
145
+ this.cloudHits.length = 0;
146
+ this.currentLevel = 'normal';
147
+ try {
148
+ const auditLog = this.ctx?.auditLog;
149
+ auditLog?.log?.({
150
+ level: 'info',
151
+ kind: 'td-bus.reset',
152
+ source: 'td-bus',
153
+ payload: { from: prev, to: 'normal', reason },
154
+ })?.catch?.(() => { });
155
+ }
156
+ catch { /* ignore */ }
157
+ }
158
+ /** Phase D: helper for http-api to decide whether to deny the request.
159
+ * Returns true iff current level is at or above the configured blockLevel. */
160
+ shouldBlock() {
161
+ const order = { normal: 0, elevated: 1, high: 2, critical: 3 };
162
+ return order[this.getThreatLevel()] >= order[this.blockLevel];
163
+ }
164
+ }
165
+ export async function apply(ctx, config) {
166
+ // §7.102: the thresholds come from config/edge.json (threat.*). The
167
+ // cordis patch row no longer reads process.env — the desktop GUI never
168
+ // had those variables.
169
+ // Fill-only-if-absent: a profile row may pin a deliberate value (the
170
+ // full patch runs cloudRequestsPerMinute=8, tighter than the shared
171
+ // file default) and that explicit wiring must win over edge.json.
172
+ const threat = loadEdgeConfig().threat;
173
+ if (threat) {
174
+ const thresholds = config.thresholds;
175
+ if (thresholds && thresholds.cloudRequestsPerMinute === undefined && threat.cloudRate !== undefined) {
176
+ thresholds.cloudRequestsPerMinute = threat.cloudRate;
177
+ }
178
+ if (config.blockLevel === undefined && threat.blockLevel !== undefined) {
179
+ config.blockLevel =
180
+ threat.blockLevel;
181
+ }
182
+ }
183
+ const service = new TdBusServiceImpl(ctx, config);
184
+ ctx.tdBus = service;
185
+ void config;
186
+ // 惯例对齐:其余插件的 apply 都返回真 dispose。这里原先返回空函数 —
187
+ // 插件 reload 后旧 service 既不注销、属性也悬空。
188
+ return async () => {
189
+ try {
190
+ service.dispose?.();
191
+ }
192
+ catch { /* best-effort */ }
193
+ if (ctx.tdBus === service)
194
+ ctx.tdBus = undefined;
195
+ };
196
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * tool-runner — §7.93 Phase 3:needle 提取结果的 TS 执行边界。
3
+ *
4
+ * needle 只产文本 + function_calls(不执行、不碰 PII、不做 realm 决策,
5
+ * user 终裁 #1)。本模块是 needle-produced tool_calls 的**唯一**执行入口,
6
+ * 与 LLM-produced tool_calls 享受完全相同的执法链:
7
+ *
8
+ * 1. schema 面校验 —— call.name 必须 ∈ POST 给 needle 的同一份 schemas
9
+ * (getToolSchemasForExternal 产物,单一真相源);幻觉工具名直接拒。
10
+ * 2. ungrounded 处理(API.md v1.1 `validation.ungrounded`)——
11
+ * required 参数被捏造 → **不执行**(skipped + audit external.fabricated_arg,
12
+ * 留给 LOCAL LLM synthesis 阶段补);非 required 被捏造 → 从 arguments
13
+ * 剥除后执行(drop),不带幻觉值进工具。
14
+ * 3. realm 预检(defense-in-depth)—— 复用 realm-skill-bridge
15
+ * checkToolRealm(name, getAgentRealm(agent));deny → external.realm_rejected
16
+ * (security 级),宿主管线都不进。
17
+ * 4. 宿主执行 —— `ctx.tools.execute()`(dsh-tools ToolRuntime),自动跑完整
18
+ * pre-execute(Layer 3 权威 realm gate 再查一遍,零绕过)/ guards /
19
+ * around-dispatch / post-execute 管线。**caller 必须透传 payload 里的
20
+ * 真 agent**:Layer 3 靠 agent.session.id 查 sessionRealms(input-guard
21
+ * 在 classify 时注册),假 agent = getAgentRealm undefined = fail-open。
22
+ *
23
+ * 审计纪律(PII):audit payload 只有 metadata(名字/时长/长度/计数),
24
+ * **绝不落 arguments 内容或 result 内容** —— 与 file-logger 两-channel
25
+ * 分工一致(ops log = 过程流水 metadata only;audit = 安全账本 redacted)。
26
+ *
27
+ * 失败契约:execute()/executeAll() **永不 throw** —— 一切异常归一成
28
+ * status:'error' outcome + external.execution_failed audit,调用方
29
+ * (function-call-external Phase 5)拿 outcome 决定合成消息或 fall through。
30
+ *
31
+ * 并发:executeAll 是**顺序**执行(不是计划草稿里的 Promise.all)——
32
+ * 并行调度是宿主 scheduler 的领地(isConcurrencySafe 门控),本边界不绕过;
33
+ * needle 单轮典型 1-2 个 call,顺序执行的延迟差可忽略,且 tool-result 合成
34
+ * 消息顺序确定。
35
+ *
36
+ * 本模块**不是 cordis plugin**(无 export const inject / apply):它是纯类,
37
+ * 由 function-call-external(Phase 4)构造并持有。类型全部结构化 loose-coupling
38
+ * (不 import dsh-tools/dsh-llm 类型,与 realm-skill-bridge 同款纪律);
39
+ * ToolCallId 在运行时就是普通 string(brand 只在类型层)。
40
+ *
41
+ * @module dsh-edge-agent/tool-runner
42
+ */
43
+ /** 宿主 ToolRuntime(ctx.tools)的最小子集 —— 只用到 execute。 */
44
+ export interface ToolRuntimeLike {
45
+ execute(input: {
46
+ callId: string;
47
+ name: string;
48
+ arguments: unknown;
49
+ agent?: unknown;
50
+ signal: AbortSignal;
51
+ }): Promise<ToolExecutionResultLike>;
52
+ }
53
+ /** dsh-tools ToolExecutionResult 的最小子集(success/failure 判别联合)。 */
54
+ export interface ToolExecutionResultLike {
55
+ isError: boolean;
56
+ content?: ReadonlyArray<{
57
+ type?: string;
58
+ text?: string;
59
+ [k: string]: unknown;
60
+ }>;
61
+ error?: {
62
+ message?: string;
63
+ info?: {
64
+ name?: string;
65
+ code?: string;
66
+ };
67
+ };
68
+ }
69
+ /** realm-skill-bridge 服务的最小子集 —— realm 预检二方法。 */
70
+ export interface RealmBridgeLike {
71
+ getAgentRealm(agent: unknown): string | undefined;
72
+ checkToolRealm(toolName: string, agentRealm: string | undefined): string | undefined;
73
+ }
74
+ /** audit-log 服务的最小子集(与 realm-skill-bridge AuditLogLike 同形)。 */
75
+ export interface AuditLogLike {
76
+ log(entry: {
77
+ level: string;
78
+ kind: string;
79
+ source: string;
80
+ payload?: Record<string, unknown>;
81
+ }): void;
82
+ }
83
+ /** Phase 2 产物:getToolSchemasForExternal 返回的平面 wire schema。 */
84
+ export interface ExternalToolSchemaLike {
85
+ name: string;
86
+ description: string;
87
+ parameters: Record<string, unknown>;
88
+ }
89
+ /** needle /extract 返回的单个 function_call(API.md v1.1)。 */
90
+ export interface ExternalFunctionCall {
91
+ name: string;
92
+ /** needle 已做 JSON 解析;防御性当 unknown 收,入口再校验。 */
93
+ arguments?: unknown;
94
+ }
95
+ /**
96
+ * - `executed` 宿主管线跑完且 isError=false
97
+ * - `denied` realm 预检拒绝 / 幻觉工具名 / call 形状非法(宿主未进)
98
+ * - `skipped` required 参数被捏造(ungrounded)→ 不执行,留给 synthesis
99
+ * - `error` 宿主执行失败(isError=true)或 execute() 抛异常
100
+ */
101
+ export type ToolRunStatus = 'executed' | 'denied' | 'skipped' | 'error';
102
+ export interface ToolRunOutcome {
103
+ status: ToolRunStatus;
104
+ name: string;
105
+ /** 运行时 callId(Phase 5 合成 assistant tool_call / tool_result 消息用)。 */
106
+ callId: string;
107
+ /** 模型可读的结果文本(content text 块拼接);非 executed 时为 ''。 */
108
+ text: string;
109
+ /** denied/skipped/error 的原因(人类可读);executed 时 undefined。 */
110
+ reason?: string;
111
+ /** 实际递进宿主的 arguments(捏造的非 required 参数已剥除)。 */
112
+ executedArguments?: Record<string, unknown>;
113
+ /** 被剥除(drop)或因之拒执行(skip)的捏造参数名。 */
114
+ fabricatedArgs?: string[];
115
+ durationMs: number;
116
+ }
117
+ export interface ToolRunRequest {
118
+ call: ExternalFunctionCall;
119
+ /**
120
+ * payload 里的**真 agent**(带 session.id)。Layer 3 gate 与 realm 预检都靠
121
+ * getAgentRealm(agent) → sessionRealms.get(session.id) 解析 realm;
122
+ * 传假 agent = realm 执法静默失效(fail-open)。
123
+ */
124
+ agent: unknown;
125
+ sessionId?: string;
126
+ /** POST 给 needle 的同一份 schemas(单一真相源,幻觉名在此拒)。 */
127
+ schemas: readonly ExternalToolSchemaLike[];
128
+ /** needle `validation.ungrounded`,形如 ["send_message.text"];可空。 */
129
+ ungrounded?: readonly string[];
130
+ /** caller 的取消信号;缺省时 runner 自建 executeTimeoutMs 超时信号。 */
131
+ signal?: AbortSignal;
132
+ }
133
+ export interface ToolRunnerDeps {
134
+ tools: ToolRuntimeLike;
135
+ realmSkillBridge: RealmBridgeLike;
136
+ auditLog?: AuditLogLike;
137
+ /** ops log(可选;与 http-api/realm-skill-bridge 同款 debug 通道)。 */
138
+ logger?: {
139
+ info?: (msg: string) => void;
140
+ warn?: (msg: string) => void;
141
+ };
142
+ /** audit source 字段;默认 'tool-runner'。 */
143
+ source?: string;
144
+ /** 无 caller signal 时的自建超时;默认 60_000ms。 */
145
+ executeTimeoutMs?: number;
146
+ }
147
+ /** content 块里抽文本(text 块拼接;非 text 块跳过 —— 与 http-api 同款投影)。 */
148
+ export declare function extractResultText(content: ToolExecutionResultLike['content']): string;
149
+ /** schema.parameters.required 里的字符串数组(JSON Schema 语义,防御性抽取)。 */
150
+ export declare function requiredArgsOf(schema: ExternalToolSchemaLike | undefined): string[];
151
+ /**
152
+ * 从 needle `validation.ungrounded`(dotted "toolName.argName")里挑出属于
153
+ * 本 call 的条目,按 schema.required 分成 skip 集(required 被捏造 → 整个
154
+ * call 不执行)与 drop 集(非 required → 从 arguments 剥除后照常执行)。
155
+ */
156
+ export declare function partitionUngrounded(toolName: string, ungrounded: readonly string[] | undefined, schema: ExternalToolSchemaLike | undefined): {
157
+ skip: string[];
158
+ drop: string[];
159
+ };
160
+ export declare class ToolRunner {
161
+ private readonly tools;
162
+ private readonly bridge;
163
+ private readonly auditLog;
164
+ private readonly logger;
165
+ private readonly source;
166
+ private readonly executeTimeoutMs;
167
+ /** callId 单调计数(同 ms 内多 call 不撞号)。 */
168
+ private callSeq;
169
+ constructor(deps: ToolRunnerDeps);
170
+ /**
171
+ * 执行单个 needle function_call。永不 throw(失败契约见文件头)。
172
+ */
173
+ execute(req: ToolRunRequest): Promise<ToolRunOutcome>;
174
+ /**
175
+ * 顺序执行一组 calls(并发是宿主 scheduler 的领地,本边界不绕过 ——
176
+ * 见文件头)。单个失败不中断后续:每个 outcome 独立归一。
177
+ */
178
+ executeAll(calls: readonly ExternalFunctionCall[], shared: Omit<ToolRunRequest, 'call'>): Promise<ToolRunOutcome[]>;
179
+ private nextCallId;
180
+ /** ops log 一行(可观测性纪律 §7.46:每个关键环节都在 ops log 体现)。 */
181
+ private finish;
182
+ /** audit 只落 metadata —— 绝不落 arguments/result 内容(PII 纪律)。 */
183
+ private audit;
184
+ }
185
+ //# sourceMappingURL=tool-runner.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tool-runner.d.ts","sourceRoot":"","sources":["../../src/dsh-adapter/tool-runner.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,271 @@
1
+ /**
2
+ * tool-runner — §7.93 Phase 3:needle 提取结果的 TS 执行边界。
3
+ *
4
+ * needle 只产文本 + function_calls(不执行、不碰 PII、不做 realm 决策,
5
+ * user 终裁 #1)。本模块是 needle-produced tool_calls 的**唯一**执行入口,
6
+ * 与 LLM-produced tool_calls 享受完全相同的执法链:
7
+ *
8
+ * 1. schema 面校验 —— call.name 必须 ∈ POST 给 needle 的同一份 schemas
9
+ * (getToolSchemasForExternal 产物,单一真相源);幻觉工具名直接拒。
10
+ * 2. ungrounded 处理(API.md v1.1 `validation.ungrounded`)——
11
+ * required 参数被捏造 → **不执行**(skipped + audit external.fabricated_arg,
12
+ * 留给 LOCAL LLM synthesis 阶段补);非 required 被捏造 → 从 arguments
13
+ * 剥除后执行(drop),不带幻觉值进工具。
14
+ * 3. realm 预检(defense-in-depth)—— 复用 realm-skill-bridge
15
+ * checkToolRealm(name, getAgentRealm(agent));deny → external.realm_rejected
16
+ * (security 级),宿主管线都不进。
17
+ * 4. 宿主执行 —— `ctx.tools.execute()`(dsh-tools ToolRuntime),自动跑完整
18
+ * pre-execute(Layer 3 权威 realm gate 再查一遍,零绕过)/ guards /
19
+ * around-dispatch / post-execute 管线。**caller 必须透传 payload 里的
20
+ * 真 agent**:Layer 3 靠 agent.session.id 查 sessionRealms(input-guard
21
+ * 在 classify 时注册),假 agent = getAgentRealm undefined = fail-open。
22
+ *
23
+ * 审计纪律(PII):audit payload 只有 metadata(名字/时长/长度/计数),
24
+ * **绝不落 arguments 内容或 result 内容** —— 与 file-logger 两-channel
25
+ * 分工一致(ops log = 过程流水 metadata only;audit = 安全账本 redacted)。
26
+ *
27
+ * 失败契约:execute()/executeAll() **永不 throw** —— 一切异常归一成
28
+ * status:'error' outcome + external.execution_failed audit,调用方
29
+ * (function-call-external Phase 5)拿 outcome 决定合成消息或 fall through。
30
+ *
31
+ * 并发:executeAll 是**顺序**执行(不是计划草稿里的 Promise.all)——
32
+ * 并行调度是宿主 scheduler 的领地(isConcurrencySafe 门控),本边界不绕过;
33
+ * needle 单轮典型 1-2 个 call,顺序执行的延迟差可忽略,且 tool-result 合成
34
+ * 消息顺序确定。
35
+ *
36
+ * 本模块**不是 cordis plugin**(无 export const inject / apply):它是纯类,
37
+ * 由 function-call-external(Phase 4)构造并持有。类型全部结构化 loose-coupling
38
+ * (不 import dsh-tools/dsh-llm 类型,与 realm-skill-bridge 同款纪律);
39
+ * ToolCallId 在运行时就是普通 string(brand 只在类型层)。
40
+ *
41
+ * @module dsh-edge-agent/tool-runner
42
+ */
43
+ // ─────────────────────────────────────────────────────────────────────────────
44
+ // Helpers (pure, exported for tests)
45
+ // ─────────────────────────────────────────────────────────────────────────────
46
+ function isPlainRecord(value) {
47
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
48
+ }
49
+ /** content 块里抽文本(text 块拼接;非 text 块跳过 —— 与 http-api 同款投影)。 */
50
+ export function extractResultText(content) {
51
+ if (!Array.isArray(content))
52
+ return '';
53
+ return content
54
+ .filter((b) => isPlainRecord(b) && b.type === 'text' && typeof b.text === 'string')
55
+ .map(b => b.text)
56
+ .join('');
57
+ }
58
+ /** schema.parameters.required 里的字符串数组(JSON Schema 语义,防御性抽取)。 */
59
+ export function requiredArgsOf(schema) {
60
+ const req = schema?.parameters?.required;
61
+ if (!Array.isArray(req))
62
+ return [];
63
+ return req.filter((x) => typeof x === 'string');
64
+ }
65
+ /**
66
+ * 从 needle `validation.ungrounded`(dotted "toolName.argName")里挑出属于
67
+ * 本 call 的条目,按 schema.required 分成 skip 集(required 被捏造 → 整个
68
+ * call 不执行)与 drop 集(非 required → 从 arguments 剥除后照常执行)。
69
+ */
70
+ export function partitionUngrounded(toolName, ungrounded, schema) {
71
+ const skip = [];
72
+ const drop = [];
73
+ if (!Array.isArray(ungrounded))
74
+ return { skip, drop };
75
+ const required = new Set(requiredArgsOf(schema));
76
+ const prefix = `${toolName}.`;
77
+ for (const entry of ungrounded) {
78
+ if (typeof entry !== 'string' || !entry.startsWith(prefix))
79
+ continue;
80
+ const arg = entry.slice(prefix.length);
81
+ if (arg === '')
82
+ continue;
83
+ (required.has(arg) ? skip : drop).push(arg);
84
+ }
85
+ return { skip, drop };
86
+ }
87
+ // ─────────────────────────────────────────────────────────────────────────────
88
+ // ToolRunner
89
+ // ─────────────────────────────────────────────────────────────────────────────
90
+ export class ToolRunner {
91
+ tools;
92
+ bridge;
93
+ auditLog;
94
+ logger;
95
+ source;
96
+ executeTimeoutMs;
97
+ /** callId 单调计数(同 ms 内多 call 不撞号)。 */
98
+ callSeq = 0;
99
+ constructor(deps) {
100
+ this.tools = deps.tools;
101
+ this.bridge = deps.realmSkillBridge;
102
+ this.auditLog = deps.auditLog;
103
+ this.logger = deps.logger;
104
+ this.source = deps.source ?? 'tool-runner';
105
+ this.executeTimeoutMs = deps.executeTimeoutMs ?? 60_000;
106
+ }
107
+ /**
108
+ * 执行单个 needle function_call。永不 throw(失败契约见文件头)。
109
+ */
110
+ async execute(req) {
111
+ const start = Date.now();
112
+ const rawName = req.call?.name;
113
+ const name = typeof rawName === 'string' ? rawName : '';
114
+ const callId = this.nextCallId(req.sessionId);
115
+ // ── 0. call 形状校验(name 非空 string;arguments 缺省 {}) ──────────
116
+ if (name === '') {
117
+ return this.finish({
118
+ status: 'denied', text: '', name: name || String(rawName ?? ''), callId,
119
+ reason: 'invalid_call: missing tool name',
120
+ durationMs: Date.now() - start,
121
+ }, undefined, req);
122
+ }
123
+ const argsIn = isPlainRecord(req.call.arguments) ? req.call.arguments : {};
124
+ // ── 1. schema 面校验(幻觉工具名拒;与 POST 给 needle 的同一份) ──────
125
+ const schema = req.schemas.find(s => s.name === name);
126
+ if (!schema) {
127
+ this.audit('warn', 'external.unknown_tool', { name, callId, sessionId: req.sessionId, schemaCount: req.schemas.length });
128
+ return this.finish({
129
+ status: 'denied', text: '', name, callId,
130
+ reason: 'unknown_tool: not in the schema set given to needle',
131
+ durationMs: Date.now() - start,
132
+ }, undefined, req);
133
+ }
134
+ // ── 2. ungrounded 处理(required 捏造 → skip;非 required → drop) ────
135
+ const { skip, drop } = partitionUngrounded(name, req.ungrounded, schema);
136
+ if (skip.length > 0) {
137
+ this.audit('warn', 'external.fabricated_arg', {
138
+ action: 'skip', name, callId, sessionId: req.sessionId,
139
+ fabricatedArgs: skip, ungroundedCount: (req.ungrounded ?? []).length,
140
+ detection: 'needle_ungrounded',
141
+ });
142
+ return this.finish({
143
+ status: 'skipped', text: '', name, callId,
144
+ reason: `fabricated required argument(s): ${skip.join(', ')} — left for LLM synthesis`,
145
+ fabricatedArgs: skip,
146
+ durationMs: Date.now() - start,
147
+ }, undefined, req);
148
+ }
149
+ // ── 2.5 客户端空参检测(§7.95:needle 不一定在 ungrounded 里标,
150
+ // arguments:{} 或 required 缺失应直接拦下,不进引擎) ─────────
151
+ const required = requiredArgsOf(schema);
152
+ if (required.length > 0) {
153
+ const missing = required.filter(k => !(k in argsIn));
154
+ if (missing.length > 0) {
155
+ this.audit('warn', 'external.fabricated_arg', {
156
+ action: 'skip', name, callId, sessionId: req.sessionId,
157
+ fabricatedArgs: missing, ungroundedCount: (req.ungrounded ?? []).length,
158
+ detection: 'client_side_missing_required',
159
+ });
160
+ return this.finish({
161
+ status: 'skipped', text: '', name, callId,
162
+ reason: `missing required argument(s): ${missing.join(', ')} — left for LLM synthesis`,
163
+ fabricatedArgs: missing,
164
+ durationMs: Date.now() - start,
165
+ }, undefined, req);
166
+ }
167
+ }
168
+ let args = argsIn;
169
+ if (drop.length > 0) {
170
+ args = { ...argsIn };
171
+ for (const k of drop)
172
+ delete args[k];
173
+ this.audit('warn', 'external.fabricated_arg', {
174
+ action: 'drop', name, callId, sessionId: req.sessionId,
175
+ fabricatedArgs: drop, ungroundedCount: (req.ungrounded ?? []).length,
176
+ detection: 'needle_ungrounded',
177
+ });
178
+ }
179
+ // ── 3. realm 预检(defense-in-depth;Layer 3 在宿主管线里还会再查) ────
180
+ const agentRealm = this.bridge.getAgentRealm(req.agent);
181
+ const denyReason = this.bridge.checkToolRealm(name, agentRealm);
182
+ if (denyReason !== undefined) {
183
+ this.audit('security', 'external.realm_rejected', {
184
+ name, callId, sessionId: req.sessionId,
185
+ agentRealm: agentRealm ?? 'unknown', reason: denyReason,
186
+ });
187
+ return this.finish({
188
+ status: 'denied', text: '', name, callId, reason: denyReason,
189
+ durationMs: Date.now() - start,
190
+ }, undefined, req);
191
+ }
192
+ // ── 4. 宿主执行(ctx.tools.execute → 完整 pre/guard/around/post 管线) ──
193
+ try {
194
+ const signal = req.signal ?? AbortSignal.timeout(this.executeTimeoutMs);
195
+ const result = await this.tools.execute({
196
+ callId, name, arguments: args, agent: req.agent, signal,
197
+ });
198
+ const durationMs = Date.now() - start;
199
+ if (result?.isError) {
200
+ const message = result.error?.message ?? 'tool execution failed';
201
+ this.audit('warn', 'external.execution_failed', {
202
+ name, callId, sessionId: req.sessionId, durationMs,
203
+ errorCode: result.error?.info?.code, errorLen: message.length,
204
+ });
205
+ return this.finish({
206
+ status: 'error', text: '', name, callId, reason: message,
207
+ executedArguments: args, durationMs,
208
+ }, undefined, req);
209
+ }
210
+ const text = extractResultText(result?.content);
211
+ this.audit('info', 'external.executed', {
212
+ name, callId, sessionId: req.sessionId, durationMs,
213
+ resultLen: text.length, droppedFabricated: drop.length,
214
+ });
215
+ return this.finish({
216
+ status: 'executed', name, callId, text,
217
+ executedArguments: args,
218
+ fabricatedArgs: drop.length > 0 ? drop : undefined,
219
+ durationMs,
220
+ }, text, req);
221
+ }
222
+ catch (err) {
223
+ // 宿主 execute 契约上不 throw(失败也 materialize 成 isError result),
224
+ // 但 registry 未挂载 / cordis proxy trap(§7.92.2)等仍可能炸 —— 归一。
225
+ const durationMs = Date.now() - start;
226
+ const message = err instanceof Error ? err.message : String(err);
227
+ this.audit('warn', 'external.execution_failed', {
228
+ name, callId, sessionId: req.sessionId, durationMs,
229
+ thrown: true, errorLen: message.length,
230
+ });
231
+ return this.finish({
232
+ status: 'error', text: '', name, callId, reason: message,
233
+ executedArguments: args, durationMs,
234
+ }, undefined, req);
235
+ }
236
+ }
237
+ /**
238
+ * 顺序执行一组 calls(并发是宿主 scheduler 的领地,本边界不绕过 ——
239
+ * 见文件头)。单个失败不中断后续:每个 outcome 独立归一。
240
+ */
241
+ async executeAll(calls, shared) {
242
+ const out = [];
243
+ for (const call of calls ?? []) {
244
+ out.push(await this.execute({ ...shared, call }));
245
+ }
246
+ return out;
247
+ }
248
+ // ── internals ─────────────────────────────────────────────────────────────
249
+ nextCallId(sessionId) {
250
+ this.callSeq += 1;
251
+ const sess = sessionId ? `-${sessionId.slice(0, 12)}` : '';
252
+ return `fcx${sess}-${Date.now().toString(36)}-${this.callSeq}`;
253
+ }
254
+ /** ops log 一行(可观测性纪律 §7.46:每个关键环节都在 ops log 体现)。 */
255
+ finish(outcome, _text, req) {
256
+ try {
257
+ this.logger?.info?.(`[tool-runner] ${outcome.status} tool=${outcome.name} callId=${outcome.callId}` +
258
+ ` sessionId=${req.sessionId ?? 'unknown'} durationMs=${outcome.durationMs}` +
259
+ (outcome.reason ? ` reason=${outcome.reason}` : ''));
260
+ }
261
+ catch { /* debug only */ }
262
+ return outcome;
263
+ }
264
+ /** audit 只落 metadata —— 绝不落 arguments/result 内容(PII 纪律)。 */
265
+ audit(level, kind, payload) {
266
+ try {
267
+ this.auditLog?.log({ level, kind, source: this.source, payload });
268
+ }
269
+ catch { /* audit 失败不阻断执行链 */ }
270
+ }
271
+ }