@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.
Files changed (238) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/LICENSE +21 -0
  3. package/README.md +372 -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 +341 -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,1005 @@
1
+ /**
2
+ * function-call-external — §7.93.1(2026-09-25 通用化重构):
3
+ * 通用 tool-call 外置小模型能力,client 负责把 user query + tool schemas POST 给
4
+ * 一个外部 HTTP service,让该 service 返回 function_calls 结构化结果。
5
+ * 完整 pipeline(8 fail-open gate + circuit breaker + boot probe + 周期
6
+ * recovery + ToolRunner 执行 + 合成 user-role message 注入 decision.messages)
7
+ * 是 §7.93 已验证的,本文件不重做设计,只重命名 + 抽出 provider 接口。
8
+ *
9
+ * **Phase 路线**(沿用 §7.93):
10
+ * - §7.93 Phase 4=骨架;5=waterfall listener;6=breaker+probe;7=失败路径
11
+ * 细分;8=E2E+bench;9=部署+boot-verify
12
+ * - §7.93.1(本次)=generic 化:
13
+ * - 名字脱离"needle":plugin / config / env / audit / ops log 全 generic
14
+ * - 抽出 `ExternalToolCaller` interface;当前实现 = `NeedleHttpCaller`
15
+ * (cactus-needle 3.0.1,API.md v1.1 契约,GET /health + POST /extract)
16
+ * - 后续替换 = 新增一个 class implements `ExternalToolCaller`,改
17
+ * `apply()` 里一行 `new XxxCaller(...)` 即可
18
+ *
19
+ * **架构(沿用 §7.93)**:user query + tool schemas → ExternalToolCaller →
20
+ * 拿回 function_calls → TS tool-runner 执行(realm/PII/audit 零绕过)→
21
+ * 整段对话给 LOCAL LLM 合成回复。所有失败 fall through 到 §7.42 P0-1
22
+ * LLM 自提取路径(本插件是 prefill 不是替代)。
23
+ *
24
+ * **服务边界**:
25
+ * - 一切失败(caller down / 超时 / 低置信 / 空 schemas / 0 executed)
26
+ * fall through 到 LLM 自提取路径(§7.42 P0-1 patch 保留作 fallback)
27
+ * - CLOUD intent 跳过(云端 LLM 自带 native tool calling);REJECT intent
28
+ * input-guard 已拒、我们看不到 realm;undefined realm 跳过(稳健于启动
29
+ * 时序:即使 apply 顺序出错也不会 crash,只是不优化)
30
+ *
31
+ * **注入约束**(沿用 §7.93 Phase 4.5 + Phase 5 关键修正):
32
+ * 1. `agent/request` waterfall 只携带 config,**没有 messages** ——
33
+ * 真实注入点 `agent/pre-step`(decision.messages → appended as
34
+ * user/message events → deriveMessages)
35
+ * 2. assembly.tools 在 pre-step 之前就锁死 — pre-step listener 改不了;
36
+ * 强指令 + §7.42 P0-1 + Layer 3 realm gate 兜底
37
+ * 3. decision.messages 全部以 user/message append;assistant tool_call
38
+ * role 在该 hook 不可达 → 只追加 user-role message
39
+ * 4. **插件 apply 顺序**:必须在 input-guard **之前**(cordis waterfall
40
+ * 注册顺序 = 执行顺序;await next() 串到 input-guard,等 classify +
41
+ * setSessionRealm 完成后才看得到本 turn 的 realm)
42
+ *
43
+ * audit 面(source='function-call-external'):
44
+ * external.hook.registered (info,apply 时一次性)
45
+ * external.boot_probe (info/warn,Phase 6)
46
+ * external.boot_probe.skipped (info,配置关掉 boot probe)
47
+ * external.boot_probe.unexpected (error,probe 自己抛)
48
+ * external.probe (info,Phase 6 周期 probe)
49
+ * external.circuit_open (warn,Phase 6)
50
+ * external.circuit_open_skip (info,Gate 2 短路)
51
+ * external.recovered (info,周期 probe 命中 2xx 关 breaker)
52
+ * external.extract (info,/extract 调用 metadata)
53
+ * external.extract_retry (warn,Phase 7 退避重试 503/502)
54
+ * external.extract_failed (warn,4xx/5xx/网络错)
55
+ * external.low_confidence_skip (info,采纳门未过)
56
+ * external.timeout_skip (warn,extract 超时)
57
+ * external.synthesis_injected (info,合成 user-role 已注入)
58
+ * external.realm_unset_skip (info,realm 未注册跳过)
59
+ * external.listener.error (error,handlePreStep 自身 catch 兜底)
60
+ * tool-runner(source='tool-runner',Phase 3):
61
+ * external.executed / .execution_failed / .realm_rejected /
62
+ * external.fabricated_arg / .unknown_tool
63
+ *
64
+ * inject(§7.92.2 proxy trap:读 ctx.X 前必须先列 inject):
65
+ * - auditLog 审计
66
+ * - realmSkillBridge getToolSchemasForExternal + getSessionRealm
67
+ * - tools 宿主 ToolRuntime,ToolRunner 的执行面
68
+ *
69
+ * @module dsh-edge-agent/function-call-external
70
+ */
71
+ import { Service } from '@deepseek-ai/cordis';
72
+ import Schema from '@deepseek-ai/schemastery';
73
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
74
+ import { ToolRunner } from './tool-runner.js';
75
+ import { loadEdgeConfig } from './edge-config.js';
76
+ export const name = 'function-call-external';
77
+ export const inject = ['auditLog', 'realmSkillBridge', 'tools'];
78
+ /** ── Default impl: cactus-needle 3.0.1 (API.md v1.1 §4) ───────────────────
79
+ * - GET /health → 2xx healthy, 503 loading/queue-busy
80
+ * - POST /extract → {type, function_calls, confidence, validation, ...}
81
+ * - 系统 prompt: API.md v1.1 推荐传 `date: YYYY-MM-DD EEE; locale: zh-CN`
82
+ * 让 needle 拿到日期 + locale 上下文(否则只 confidence=1.0 但
83
+ * function_calls=[] 报"没有 file management tool"——见 §7.93 Phase 9 amend
84
+ * 实测)。
85
+ * - 替换实现时,这个 class 可以整个删掉,写新 caller 即可。 */
86
+ class NeedleHttpCaller {
87
+ url;
88
+ auditLog;
89
+ logger;
90
+ fetchImpl;
91
+ // ── §7.94:只读元数据(纯可观测,无新行为)────────────────────────────
92
+ id = 'needle-http';
93
+ kind = 'http';
94
+ capabilities = {
95
+ maxTools: 64,
96
+ maxQueryLen: 8192,
97
+ supportsUngrounded: true,
98
+ };
99
+ constructor(url, auditLog, logger,
100
+ /** Optional fetch override (used by tests; production uses globalThis.fetch).
101
+ * §7.92.2 proxy trap safe — global fetch fallback only when undefined. */
102
+ fetchImpl) {
103
+ this.url = url;
104
+ this.auditLog = auditLog;
105
+ this.logger = logger;
106
+ this.fetchImpl = fetchImpl;
107
+ }
108
+ /** Pick override fetch if set, else globalThis.fetch (§7.92.2 proxy trap safe). */
109
+ async _fetchImpl(url, init) {
110
+ if (this.fetchImpl)
111
+ return this.fetchImpl(url, init);
112
+ try {
113
+ const g = globalThis.fetch;
114
+ if (typeof g === 'function')
115
+ return g(url, init);
116
+ }
117
+ catch { /* nothing */ }
118
+ throw new Error('no_fetch_runtime');
119
+ }
120
+ async healthCheck(signal) {
121
+ const start = Date.now();
122
+ const controller = new AbortController();
123
+ const onAbort = () => controller.abort();
124
+ if (signal.aborted)
125
+ controller.abort();
126
+ else
127
+ signal.addEventListener('abort', onAbort, { once: true });
128
+ let ok = false;
129
+ let httpStatus = 0;
130
+ let errorMsg = '';
131
+ try {
132
+ const r = await this._fetchImpl(`${this.url}/health`, { method: 'GET', signal: controller.signal });
133
+ httpStatus = r.status;
134
+ ok = r.ok;
135
+ if (!ok) {
136
+ try {
137
+ await r.text();
138
+ }
139
+ catch { /* ignore */ }
140
+ }
141
+ }
142
+ catch (err) {
143
+ errorMsg = err?.message ?? String(err);
144
+ }
145
+ finally {
146
+ signal.removeEventListener('abort', onAbort);
147
+ }
148
+ return { ok, httpStatus, latencyMs: Date.now() - start, ...(errorMsg ? { error: errorMsg } : {}) };
149
+ }
150
+ async extract(req) {
151
+ const controller = new AbortController();
152
+ const onAbort = () => controller.abort();
153
+ if (req.signal.aborted)
154
+ controller.abort();
155
+ else
156
+ req.signal.addEventListener('abort', onAbort, { once: true });
157
+ let ok = false;
158
+ let status = 0;
159
+ let body = '';
160
+ let raw;
161
+ let r;
162
+ try {
163
+ r = await this._fetchImpl(`${this.url}/extract`, {
164
+ method: 'POST',
165
+ signal: controller.signal,
166
+ headers: { 'content-type': 'application/json' },
167
+ body: JSON.stringify({ query: req.query, tools: req.tools, ...(req.system ? { system: req.system } : {}) }),
168
+ });
169
+ status = r.status;
170
+ ok = r.ok;
171
+ body = await r.text();
172
+ if (ok) {
173
+ try {
174
+ raw = JSON.parse(body);
175
+ }
176
+ catch { /* ignore — raw stays undefined */ }
177
+ }
178
+ }
179
+ catch (err) {
180
+ // Transport error (fetch threw — typically AbortError on timeout, or network down).
181
+ // Re-throw so the orchestrator's attemptExtractOnce catches it and routes to
182
+ // the `transport` kind branch (which knows about AbortError for TimeoutSkip).
183
+ // §7.93.1: caller.extract is just an HTTP wrapper — transport semantics
184
+ // belong to the orchestrator, not the caller.
185
+ throw err;
186
+ }
187
+ req.signal.removeEventListener('abort', onAbort);
188
+ return { ok, status, body, ...(raw !== undefined ? { raw } : {}) };
189
+ }
190
+ }
191
+ /** Build a "date: ... ; locale: ..." system prompt for extract (Phase 9 amend). */
192
+ function buildDefaultSystemPrompt(locale = 'zh-CN') {
193
+ const now = new Date();
194
+ const weekday = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][now.getDay()];
195
+ const iso = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
196
+ return `date: ${iso} ${weekday}; locale: ${locale}`;
197
+ }
198
+ // §7.102: every field is **optional without a Schema default** on purpose.
199
+ //
200
+ // Schemastery fills Schema defaults into the config object before apply()
201
+ // runs, so a defaulted field can no longer be told apart from "the operator
202
+ // did not configure it". The new precedence is
203
+ //
204
+ // explicit cordis value → config/edge.json → the literal below
205
+ //
206
+ // and the literal below only applies when neither of the first two provided
207
+ // a value. Keeping the defaults out of the Schema is what makes the middle
208
+ // tier reachable: a cordis patch row that sets nothing leaves the field
209
+ // undefined, and the plugin then consults config/edge.json.
210
+ export const Config = Schema.object({
211
+ functionCallExternalEnabled: Schema.boolean(),
212
+ functionCallExternalUrl: Schema.string(),
213
+ // 实测 cactus-needle /extract LAN 当前引擎下需 4-7s(dry-run #1 标
214
+ // 376-548ms 是 spec 理想值,实际 25-30x degradation);10s 给单请求 1x 余量。
215
+ // 后续引擎恢复 < 1s 时再调低。
216
+ functionCallExternalTimeoutMs: Schema.natural(),
217
+ // 0.7 太严(实测 needle 报 confidence 0.5-0.7 区间) → 0.5 让多数有
218
+ // tool match 的提取通过 adopt gate;后续模型升级再调高。
219
+ functionCallExternalConfidenceThreshold: Schema.number().min(0).max(1),
220
+ functionCallExternalBreakerTtlMs: Schema.natural(),
221
+ functionCallExternalBootProbeEnabled: Schema.boolean(),
222
+ functionCallExternalBootProbeTimeoutMs: Schema.natural(),
223
+ functionCallExternalBreakerProbeMs: Schema.natural(),
224
+ functionCallExternalProbeTimeoutMs: Schema.natural(),
225
+ });
226
+ export class FunctionCallExternalServiceImpl extends Service {
227
+ auditLog;
228
+ functionCallExternalEnabled;
229
+ functionCallExternalUrl;
230
+ functionCallExternalTimeoutMs;
231
+ functionCallExternalConfidenceThreshold;
232
+ functionCallExternalBreakerTtlMs;
233
+ functionCallExternalBootProbeEnabled;
234
+ functionCallExternalBootProbeTimeoutMs;
235
+ functionCallExternalBreakerProbeMs;
236
+ functionCallExternalProbeTimeoutMs;
237
+ /** Provider abstraction(§7.93.1):所有 HTTP 走 caller。Plugin 不直接
238
+ * fetch。替换 = 改 apply() 里的构造,plugin 代码不变。 */
239
+ caller;
240
+ // ── Phase 6 runtime state:circuit breaker + boot probe + periodic probe ──
241
+ /** §7.92.6:ms-epoch timestamp until which the breaker is open.
242
+ * 0 = closed. openBreaker() sets it; closeBreaker() resets it. */
243
+ functionCallExternalBreakerOpenUntil = 0;
244
+ /** §7.92.7:setInterval handle for the periodic recovery probe. */
245
+ functionCallExternalBreakerProbeTimer = null;
246
+ /** §7.92.7:in-flight boot probe promise。track 让 stop() / dispose
247
+ * 能 await 而不留下 dangling fetch racing constructor's first
248
+ * classify() call. */
249
+ functionCallExternalBootProbePromise = null;
250
+ /** §7.92.7 helper:最近一次 periodic probe wall-clock。 */
251
+ functionCallExternalLastProbeLatencyMs = 0;
252
+ stats;
253
+ constructor(ctx, config, auditLog, caller) {
254
+ super(ctx, 'functionCallExternal');
255
+ this.auditLog = auditLog;
256
+ this.functionCallExternalEnabled = config.functionCallExternalEnabled ?? false;
257
+ this.functionCallExternalUrl = config.functionCallExternalUrl ?? 'http://127.0.0.1:8081';
258
+ this.functionCallExternalTimeoutMs = config.functionCallExternalTimeoutMs ?? 10000;
259
+ this.functionCallExternalConfidenceThreshold = config.functionCallExternalConfidenceThreshold ?? 0.5;
260
+ this.functionCallExternalBreakerTtlMs = config.functionCallExternalBreakerTtlMs ?? 30000;
261
+ this.functionCallExternalBootProbeEnabled = config.functionCallExternalBootProbeEnabled ?? true;
262
+ this.functionCallExternalBootProbeTimeoutMs = config.functionCallExternalBootProbeTimeoutMs ?? 2000;
263
+ this.functionCallExternalBreakerProbeMs = config.functionCallExternalBreakerProbeMs ?? 5000;
264
+ this.functionCallExternalProbeTimeoutMs = config.functionCallExternalProbeTimeoutMs ?? 2000;
265
+ this.caller = caller ?? new NeedleHttpCaller(this.functionCallExternalUrl, this.auditLog, this.ctx.logger,
266
+ // ctx.fetch: optional override for tests. In production, ctx is a
267
+ // cordis proxy and `fetch` isn't in `inject:`, so this access throws
268
+ // (§7.92.2 proxy trap). Swallow + fall through to globalThis.fetch.
269
+ this.tryFetchOverride());
270
+ this.stats = {
271
+ functionCallExternalEnabled: this.functionCallExternalEnabled,
272
+ functionCallExternalBootProbeOk: 0,
273
+ functionCallExternalBootProbeFail: 0,
274
+ functionCallExternalProbeAttempts: 0,
275
+ functionCallExternalRecoveredProbe: 0,
276
+ functionCallExternalCircuitOpen: false,
277
+ functionCallExternalCircuitSkip: 0,
278
+ functionCallExternalRealmUnsetSkip: 0,
279
+ functionCallExternalExtractCalls: 0,
280
+ functionCallExternalExtractOk: 0,
281
+ functionCallExternalLowConfSkip: 0,
282
+ functionCallExternalTimeoutSkip: 0,
283
+ functionCallExternalExecuted: 0,
284
+ functionCallExternalErrors: 0,
285
+ functionCallExternalSynthesisInjected: 0,
286
+ functionCallExternalLatencyMsTotal: 0,
287
+ functionCallExternalRetryAttempts: 0,
288
+ };
289
+ }
290
+ // ── availability ──────────────────────────────────────────────────────
291
+ /**
292
+ * §7.92.6 (2026-09-24):breaker-aware availability。
293
+ * Phase 4 语义:enabled。Phase 6 叠加:enabled && Date.now() >= breakerOpenUntil
294
+ * (jev `isJevAvailable()` 同款语义)。Phase 5 listener Gate 2
295
+ * `if (!isAvailable()) return decision` 因此自动承担 short-circuit:
296
+ * 0ms 短路,不付出 extract 1.5s 超时。audit `external.circuit_open_skip`
297
+ * 由 Gate 2 显式落(见 handlePreStep)。
298
+ */
299
+ isAvailable() {
300
+ return this.functionCallExternalEnabled && Date.now() >= this.functionCallExternalBreakerOpenUntil;
301
+ }
302
+ /** ctx.fetch access — production ctx is a cordis proxy and throws if the
303
+ * key isn't in `inject:` (§7.92.2). Tests use a plain object ctx and
304
+ * set `fetch` directly. Swallow the cordis proxy throw + fall through
305
+ * to NeedleHttpCaller's globalThis.fetch fallback. */
306
+ tryFetchOverride() {
307
+ try {
308
+ const f = this.ctx.fetch;
309
+ return typeof f === 'function' ? f : undefined;
310
+ }
311
+ catch {
312
+ return undefined;
313
+ }
314
+ }
315
+ /** 配置只读视图(ops 排查 + 测试断言;env 翻译层是否生效一眼可见)。 */
316
+ getConfigView() {
317
+ return {
318
+ functionCallExternalEnabled: this.functionCallExternalEnabled,
319
+ functionCallExternalUrl: this.functionCallExternalUrl,
320
+ functionCallExternalTimeoutMs: this.functionCallExternalTimeoutMs,
321
+ functionCallExternalConfidenceThreshold: this.functionCallExternalConfidenceThreshold,
322
+ functionCallExternalBreakerTtlMs: this.functionCallExternalBreakerTtlMs,
323
+ functionCallExternalBootProbeEnabled: this.functionCallExternalBootProbeEnabled,
324
+ functionCallExternalBootProbeTimeoutMs: this.functionCallExternalBootProbeTimeoutMs,
325
+ functionCallExternalBreakerProbeMs: this.functionCallExternalBreakerProbeMs,
326
+ functionCallExternalProbeTimeoutMs: this.functionCallExternalProbeTimeoutMs,
327
+ };
328
+ }
329
+ getStats() {
330
+ return {
331
+ ...this.stats,
332
+ functionCallExternalCircuitOpen: Date.now() < this.functionCallExternalBreakerOpenUntil,
333
+ };
334
+ }
335
+ // ── lifecycle(Phase 6 填实:boot probe + 周期 recovery probe)─────────
336
+ start() {
337
+ if (!this.functionCallExternalEnabled)
338
+ return;
339
+ if (!this.functionCallExternalBootProbeEnabled) {
340
+ this.audit('info', 'external.boot_probe.skipped', {
341
+ callerId: this.caller.id,
342
+ reason: 'disabled-by-config',
343
+ url: this.functionCallExternalUrl,
344
+ });
345
+ return;
346
+ }
347
+ this.functionCallExternalBootProbePromise = this.probeHealth('boot', this.functionCallExternalBootProbeTimeoutMs)
348
+ .then((ok) => {
349
+ if (!ok) {
350
+ // Pre-open breaker AND start periodic recovery probe
351
+ this.openBreaker('transport');
352
+ }
353
+ })
354
+ .catch((err) => {
355
+ this.audit('error', 'external.boot_probe.unexpected', {
356
+ callerId: this.caller.id,
357
+ error: err?.message ?? String(err),
358
+ });
359
+ })
360
+ .finally(() => {
361
+ this.functionCallExternalBootProbePromise = null;
362
+ });
363
+ }
364
+ async stop() {
365
+ this.stopBreakerProbe();
366
+ if (this.functionCallExternalBootProbePromise) {
367
+ try {
368
+ await this.functionCallExternalBootProbePromise;
369
+ }
370
+ catch { /* swallow */ }
371
+ }
372
+ }
373
+ // ── §7.92.6/§7.92.7:probe + breaker ──────────────────────────────────
374
+ /**
375
+ * §7.92.7:probe caller /health.returns true on 2xx, false on anything else
376
+ * (transport error, timeout, non-2xx). Always resolves — never throws.
377
+ */
378
+ async probeHealth(phase, timeoutMs) {
379
+ if (phase === 'periodic')
380
+ this.stats.functionCallExternalProbeAttempts++;
381
+ const controller = new AbortController();
382
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
383
+ let ok = false;
384
+ let httpStatus = 0;
385
+ let errorMsg = '';
386
+ let latencyMs = 0;
387
+ try {
388
+ const r = await this.caller.healthCheck(controller.signal);
389
+ ok = r.ok;
390
+ httpStatus = r.httpStatus;
391
+ latencyMs = r.latencyMs;
392
+ if (!ok)
393
+ errorMsg = r.error ?? 'no_response';
394
+ }
395
+ catch (err) {
396
+ errorMsg = err?.message ?? String(err);
397
+ }
398
+ finally {
399
+ clearTimeout(timer);
400
+ }
401
+ if (phase === 'boot') {
402
+ if (ok) {
403
+ this.stats.functionCallExternalBootProbeOk++;
404
+ this.audit('info', 'external.boot_probe', {
405
+ callerId: this.caller.id,
406
+ url: this.functionCallExternalUrl, httpStatus, latencyMs, ok: true,
407
+ });
408
+ this.opsLog('boot probe', `ok=true latencyMs=${latencyMs}ms httpStatus=${httpStatus}`);
409
+ }
410
+ else {
411
+ this.stats.functionCallExternalBootProbeFail++;
412
+ this.audit('warn', 'external.boot_probe', {
413
+ callerId: this.caller.id,
414
+ url: this.functionCallExternalUrl, httpStatus, latencyMs, ok: false, error: errorMsg,
415
+ });
416
+ this.opsLog('boot probe', `ok=false latencyMs=${latencyMs}ms httpStatus=${httpStatus} error=${errorMsg || 'none'}`);
417
+ }
418
+ }
419
+ else {
420
+ this.functionCallExternalLastProbeLatencyMs = latencyMs;
421
+ this.audit('info', 'external.probe', {
422
+ callerId: this.caller.id,
423
+ url: this.functionCallExternalUrl, httpStatus, latencyMs, ok,
424
+ error: ok ? undefined : errorMsg,
425
+ });
426
+ this.opsLog('periodic probe', `ok=${ok} latencyMs=${latencyMs}ms httpStatus=${httpStatus}${ok ? '' : ` error=${errorMsg || 'none'}`}`);
427
+ }
428
+ return ok;
429
+ }
430
+ /**
431
+ * §7.92.7:启动周期 recovery probe。idempotent。
432
+ */
433
+ startBreakerProbe() {
434
+ if (this.functionCallExternalBreakerProbeTimer !== null)
435
+ return;
436
+ this.functionCallExternalBreakerProbeTimer = setInterval(() => {
437
+ void this.probeHealth('periodic', this.functionCallExternalProbeTimeoutMs)
438
+ .then((ok) => {
439
+ if (!ok)
440
+ return;
441
+ if (Date.now() < this.functionCallExternalBreakerOpenUntil) {
442
+ this.functionCallExternalBreakerOpenUntil = 0;
443
+ this.stats.functionCallExternalRecoveredProbe++;
444
+ this.audit('info', 'external.recovered', {
445
+ callerId: this.caller.id,
446
+ url: this.functionCallExternalUrl,
447
+ probeLatencyMs: this.functionCallExternalLastProbeLatencyMs,
448
+ });
449
+ this.opsLog('circuit recovered', `probeLatencyMs=${this.functionCallExternalLastProbeLatencyMs}ms`);
450
+ }
451
+ this.stopBreakerProbe();
452
+ })
453
+ .catch(() => { });
454
+ }, this.functionCallExternalBreakerProbeMs);
455
+ if (typeof this.functionCallExternalBreakerProbeTimer.unref === 'function') {
456
+ this.functionCallExternalBreakerProbeTimer.unref();
457
+ }
458
+ }
459
+ stopBreakerProbe() {
460
+ if (this.functionCallExternalBreakerProbeTimer === null)
461
+ return;
462
+ clearInterval(this.functionCallExternalBreakerProbeTimer);
463
+ this.functionCallExternalBreakerProbeTimer = null;
464
+ }
465
+ /**
466
+ * §7.92.6:open-on-first-failure(jev 是 all-batches 失败才开,这里
467
+ * 单 fetch 等价于"这一发失败")。timeout 与 5xx 都计入(§7.93 dry-run #1
468
+ * 验证 needle 引擎当前 25x degradation,1.5s timeout 几乎全中)。
469
+ * 4xx 不开(client error ≠ engine down)。idempotent:已开则 no-op。
470
+ */
471
+ openBreaker(reason) {
472
+ if (Date.now() < this.functionCallExternalBreakerOpenUntil)
473
+ return;
474
+ this.functionCallExternalBreakerOpenUntil = Date.now() + this.functionCallExternalBreakerTtlMs;
475
+ this.audit('warn', 'external.circuit_open', {
476
+ callerId: this.caller.id,
477
+ reason,
478
+ url: this.functionCallExternalUrl,
479
+ ttlMs: this.functionCallExternalBreakerTtlMs,
480
+ openUntilMs: this.functionCallExternalBreakerOpenUntil,
481
+ });
482
+ this.opsLog('circuit open', `reason=${reason} openUntilMs=${this.functionCallExternalBreakerOpenUntil} ttlMs=${this.functionCallExternalBreakerTtlMs}`);
483
+ this.startBreakerProbe();
484
+ }
485
+ closeBreaker() {
486
+ if (this.functionCallExternalBreakerOpenUntil === 0)
487
+ return;
488
+ this.functionCallExternalBreakerOpenUntil = 0;
489
+ }
490
+ // ── ToolRunner 装配(Phase 3 边界的持有者)───────────────────────────
491
+ createToolRunner() {
492
+ try {
493
+ const c = this.ctx;
494
+ const tools = c.tools;
495
+ const bridge = c.realmSkillBridge;
496
+ if (!tools || typeof tools.execute !== 'function')
497
+ return undefined;
498
+ if (!bridge || typeof bridge.getAgentRealm !== 'function' || typeof bridge.checkToolRealm !== 'function')
499
+ return undefined;
500
+ return new ToolRunner({
501
+ tools: tools,
502
+ realmSkillBridge: bridge,
503
+ auditLog: this.auditLog,
504
+ logger: c.logger,
505
+ source: name,
506
+ });
507
+ }
508
+ catch {
509
+ return undefined;
510
+ }
511
+ }
512
+ // ── audit / ops log helpers ──────────────────────────────────────────
513
+ audit(level, kind, payload) {
514
+ if (!this.auditLog)
515
+ return;
516
+ void this.auditLog.log({ level, kind, source: name, payload }).catch(() => { });
517
+ }
518
+ /**
519
+ * §7.46 ops-log-coverage-principle 双轨(file-logger + audit-log)。
520
+ * audit 走 JSONL 解析器;ops log 走 file-logger 运维 grep。
521
+ * 两者 metadata only,绝不落 query/arguments/result 内容(PII 纪律)。
522
+ */
523
+ opsLog(event, fields) {
524
+ try {
525
+ this.ctx.logger?.info?.(`[function-call-external] ${event} ${fields}`);
526
+ }
527
+ catch { /* debug only */ }
528
+ }
529
+ // ── loose reads(§7.92.2 proxy trap safe)────────────────────────────
530
+ bridge() {
531
+ try {
532
+ return this.ctx.realmSkillBridge;
533
+ }
534
+ catch {
535
+ return undefined;
536
+ }
537
+ }
538
+ // ── extract(Phase 7 失败路径细分)─────────────────────────────────────
539
+ /**
540
+ * POST /extract(caller.extract)。失败路径细分(沿用 §7.93 Phase 7):
541
+ * 422 invalid body → 不重试,不开 breaker
542
+ * 503 + body 'loading' → 不重试,开 breaker
543
+ * 503 + body 'busy' → 重试 1 次 200ms,二次仍 503 → 开 breaker
544
+ * 502 engine internal → 重试 1 次 200ms,二次仍 502 → 开 breaker
545
+ * 408/504 timeout proxy → 不重试,开 breaker
546
+ * 其他 5xx → 不重试,开 breaker
547
+ * AbortError(本地超时) → 不重试,开 breaker
548
+ * 其它网络错 → 不重试,开 breaker
549
+ * no fetcher → 不重试,开 breaker
550
+ */
551
+ async extractCalls(query, schemas, sessionId, upstreamSignal) {
552
+ const retryPlan = [
553
+ { delayMs: 0, isRetry: false },
554
+ { delayMs: 200, isRetry: true },
555
+ ];
556
+ this.stats.functionCallExternalExtractCalls += 1;
557
+ for (const attempt of retryPlan) {
558
+ if (attempt.delayMs > 0) {
559
+ await new Promise(r => setTimeout(r, attempt.delayMs));
560
+ this.stats.functionCallExternalRetryAttempts += 1;
561
+ }
562
+ const r = await this.attemptExtractOnce(query, schemas, sessionId, upstreamSignal);
563
+ if (r.kind === 'fatal_422') {
564
+ this.audit('warn', 'external.extract_failed', {
565
+ callerId: this.caller.id,
566
+ sessionId, durationMs: r.durationMs, status: 422,
567
+ reason: 'invalid_body_422',
568
+ bodyPreview: r.body.slice(0, 80),
569
+ retried: attempt.isRetry,
570
+ });
571
+ this.opsLog('extract invalid_body_422', `sessionId=${sessionId ?? 'unknown'} durationMs=${r.durationMs} retried=${attempt.isRetry}`);
572
+ return null;
573
+ }
574
+ if (r.kind === 'transport') {
575
+ const isAbort = r.err?.name === 'AbortError';
576
+ if (isAbort)
577
+ this.stats.functionCallExternalTimeoutSkip += 1;
578
+ this.openBreaker(isAbort ? 'timeout' : 'transport');
579
+ this.audit('warn', isAbort ? 'external.timeout_skip' : 'external.extract_failed', {
580
+ callerId: this.caller.id,
581
+ sessionId, durationMs: r.durationMs, thrown: true,
582
+ error: r.err?.message ?? String(r.err),
583
+ });
584
+ this.opsLog(isAbort ? 'extract timeout_skip' : 'extract failed', `sessionId=${sessionId ?? 'unknown'} durationMs=${r.durationMs} thrown=true reason=${isAbort ? 'timeout' : 'transport'}`);
585
+ return null;
586
+ }
587
+ if (r.kind === 'http') {
588
+ const code = r.code;
589
+ const isTimeoutProxy = code === 408 || code === 504;
590
+ const isLoading503 = code === 503 && r.body.includes('loading');
591
+ const isRetryable503or502 = !isLoading503 && !isTimeoutProxy && (code === 503 || code === 502) && !attempt.isRetry;
592
+ if (isRetryable503or502) {
593
+ this.audit('warn', 'external.extract_retry', {
594
+ callerId: this.caller.id,
595
+ sessionId, status: code, durationMs: r.durationMs,
596
+ bodyPreview: r.body.slice(0, 80),
597
+ });
598
+ this.opsLog('extract retry', `sessionId=${sessionId ?? 'unknown'} status=${code} durationMs=${r.durationMs}`);
599
+ continue;
600
+ }
601
+ const reason = isTimeoutProxy ? 'timeout_proxy'
602
+ : code === 503 && r.body.includes('loading') ? '503_loading'
603
+ : code === 503 ? '503_busy'
604
+ : `http_${code}`;
605
+ if (isTimeoutProxy)
606
+ this.stats.functionCallExternalTimeoutSkip += 1;
607
+ this.openBreaker(isTimeoutProxy ? 'timeout' : 'http_5xx');
608
+ this.audit('warn', isTimeoutProxy ? 'external.timeout_skip' : 'external.extract_failed', {
609
+ callerId: this.caller.id,
610
+ sessionId, durationMs: r.durationMs, status: code, reason,
611
+ retried: attempt.isRetry,
612
+ bodyPreview: r.body.slice(0, 80),
613
+ });
614
+ this.opsLog(isTimeoutProxy ? 'extract timeout_skip' : 'extract failed', `sessionId=${sessionId ?? 'unknown'} durationMs=${r.durationMs} status=${code} reason=${reason} retried=${attempt.isRetry}`);
615
+ return null;
616
+ }
617
+ // r.kind === 'success'
618
+ this.closeBreaker();
619
+ this.audit('info', 'external.extract', {
620
+ callerId: this.caller.id,
621
+ sessionId, durationMs: r.durationMs, status: r.status,
622
+ type: r.parsed.type, confidence: r.parsed.confidence,
623
+ functionCallCount: r.parsed.calls.length,
624
+ queryLen: query.length, schemaCount: schemas.length,
625
+ retried: attempt.isRetry,
626
+ });
627
+ this.opsLog('extract', `sessionId=${sessionId ?? 'unknown'} durationMs=${r.durationMs} status=${r.status} type=${r.parsed.type} callCount=${r.parsed.calls.length} confidence=${r.parsed.confidence.toFixed(2)} retried=${attempt.isRetry}`);
628
+ return r.parsed;
629
+ }
630
+ return null;
631
+ }
632
+ /** Single attempt:thin wrapper around caller.extract + AbortSignal composition. */
633
+ async attemptExtractOnce(query, schemas, sessionId, upstreamSignal) {
634
+ const controller = new AbortController();
635
+ const timer = setTimeout(() => controller.abort(), this.functionCallExternalTimeoutMs);
636
+ if (upstreamSignal) {
637
+ if (upstreamSignal.aborted)
638
+ controller.abort();
639
+ else
640
+ upstreamSignal.addEventListener('abort', () => controller.abort(), { once: true });
641
+ }
642
+ const start = Date.now();
643
+ let res;
644
+ try {
645
+ res = await this.caller.extract({
646
+ query,
647
+ tools: schemas,
648
+ system: buildDefaultSystemPrompt('zh-CN'),
649
+ signal: controller.signal,
650
+ });
651
+ }
652
+ catch (err) {
653
+ clearTimeout(timer);
654
+ const durationMs = Date.now() - start;
655
+ return { kind: 'transport', err, durationMs };
656
+ }
657
+ clearTimeout(timer);
658
+ const durationMs = Date.now() - start;
659
+ this.stats.functionCallExternalLatencyMsTotal += durationMs;
660
+ if (res.ok) {
661
+ return {
662
+ kind: 'success',
663
+ status: res.status,
664
+ durationMs,
665
+ parsed: this.parseExtractResponse((res.raw ?? {})),
666
+ };
667
+ }
668
+ if (res.status === 422) {
669
+ return { kind: 'fatal_422', durationMs, body: res.body };
670
+ }
671
+ return { kind: 'http', code: res.status, durationMs, body: res.body };
672
+ }
673
+ /** Parse 200 response fields(API.md v1.1 §4.5 contract). */
674
+ parseExtractResponse(raw) {
675
+ const type = raw.type === 'call' || raw.type === 'respond' ? raw.type : 'respond';
676
+ const confidence = typeof raw.confidence === 'number' ? raw.confidence : 0;
677
+ const validation = (raw.validation && typeof raw.validation === 'object')
678
+ ? raw.validation
679
+ : {};
680
+ const ungrounded = Array.isArray(validation.ungrounded)
681
+ ? validation.ungrounded.filter((x) => typeof x === 'string')
682
+ : [];
683
+ const callsRaw = Array.isArray(raw.function_calls) ? raw.function_calls : [];
684
+ const calls = callsRaw
685
+ .filter((c) => !!c && typeof c === 'object')
686
+ .map(c => ({
687
+ name: typeof c.name === 'string' ? c.name : '',
688
+ arguments: c.arguments,
689
+ }));
690
+ return { type, calls, confidence, validation: { ungrounded }, raw };
691
+ }
692
+ // ── synthesis context builder ────────────────────────────────────────
693
+ buildSynthesisContext(originalQuery, calls, outcomes) {
694
+ const lines = [];
695
+ lines.push('[System — function-call-external pre-execution summary]');
696
+ lines.push('The following tool calls were already executed. Do NOT call them again. Answer the user directly based on these results.');
697
+ lines.push('');
698
+ // ── §7.96 Track B: synthesis few-shot + clarification rules ────────
699
+ // 教 LOCAL LLM 在 needle 抽错工具/漏抽参数时如何合成更智能的回复。
700
+ // 关键原则:**澄清优先于猜测** — 当工具结果与用户意图不匹配时,
701
+ // 优先向用户澄清,而非盲目传回错误结果。
702
+ lines.push('### 澄清规则(Clarification rules)');
703
+ lines.push('1. 若某个 tool 被 SKIPPED(参数缺失),向用户请求缺少的参数,不要自己猜。');
704
+ lines.push('2. 若某个 tool 已执行但结果与用户 query 明显不匹配(如用户问账户却拿到登录状态),');
705
+ lines.push(' 不要盲传结果 — 向用户澄清他们想做什么。');
706
+ lines.push('3. 优先使用「是 X 还是 Y?」形式,而不是「请告诉我 X」,给用户选择比索要信息更易回答。');
707
+ lines.push('4. 中文 query 用中文回复,英文 query 用英文回复。');
708
+ lines.push('');
709
+ lines.push('### 例:工具 SKIPPED 缺参数 → 请求澄清');
710
+ lines.push('User: "切换到 office profile"');
711
+ lines.push('Tool result: etscli_auth_switch() → SKIPPED: missing required argument(s): name');
712
+ lines.push('Good response: "您想切到哪个 profile?常见的有:office、home、work。"');
713
+ lines.push('');
714
+ lines.push('### 例:工具错选(auth_list vs user_list)→ 澄清');
715
+ lines.push('User: "把设备上的账户都列一下"');
716
+ lines.push('Tool result: etscli_auth_list() → 返回 3 个 login profile 列表');
717
+ lines.push('Good response: "设备上有 3 个登录 profile 已列出。您是想看设备上的用户账户,还是登录 profile?"');
718
+ lines.push(' 如果是用户账户,我可以用 user-list 查询。"');
719
+ lines.push('');
720
+ lines.push('### 例:工具错选(auth_switch vs fs_stat)→ 澄清');
721
+ lines.push('User: "切换到 /tmp 目录"');
722
+ lines.push('Tool result: etscli_auth_switch(name="/tmp") → 错误:没有叫 /tmp 的 profile');
723
+ lines.push('Good response: "/tmp 不是 profile 名,看起来您想切工作目录。需要我用 bash 工具 `cd /tmp` 吗?"');
724
+ lines.push('');
725
+ lines.push('### 例:多工具部分失败 → 传成功的,为失败的澄清');
726
+ lines.push('User: "创建会议并分享链接"');
727
+ lines.push('Tool results:');
728
+ lines.push(' - etscli_meeting_create(name="sync") → meeting_id=123 已创建');
729
+ lines.push(' - etscli_sharing_create() → SKIPPED: missing required argument(s): file_id');
730
+ lines.push('Good response: "会议 sync 已创建(id=123)。分享链接需要指定文件 — 您要分享哪个文件?"');
731
+ lines.push('');
732
+ lines.push('---');
733
+ lines.push('');
734
+ lines.push(`Original query: ${originalQuery}`);
735
+ lines.push('');
736
+ for (let i = 0; i < outcomes.length; i++) {
737
+ const o = outcomes[i];
738
+ const call = calls[i];
739
+ const argStr = call ? this.safeArgSummary(call.arguments) : '';
740
+ if (o.status === 'executed') {
741
+ lines.push(`- ${o.name}(${argStr}) → ${o.text ?? '(no text content)'}`);
742
+ }
743
+ else if (o.status === 'skipped') {
744
+ lines.push(`- ${o.name}(${argStr}) → SKIPPED: ${o.reason ?? 'fabricated required argument'}`);
745
+ }
746
+ else if (o.status === 'denied') {
747
+ lines.push(`- ${o.name}(${argStr}) → DENIED: ${o.reason ?? 'realm/precheck'}`);
748
+ }
749
+ else if (o.status === 'error') {
750
+ lines.push(`- ${o.name}(${argStr}) → ERROR: ${o.reason ?? 'execution failed'}`);
751
+ }
752
+ else {
753
+ lines.push(`- ${o.name}(${argStr}) → unknown status=${o.status}`);
754
+ }
755
+ }
756
+ return lines.join('\n');
757
+ }
758
+ safeArgSummary(args) {
759
+ if (!args || typeof args !== 'object' || Array.isArray(args))
760
+ return '';
761
+ return Object.entries(args)
762
+ .map(([k, v]) => {
763
+ const t = Array.isArray(v) ? 'array' : v === null ? 'null' : typeof v;
764
+ return `${k}:${t}`;
765
+ })
766
+ .join(', ');
767
+ }
768
+ // ── Phase 5 主路径:agent/pre-step listener ──────────────────────────
769
+ /**
770
+ * agent/pre-step waterfall listener. **总是 await next() FIRST** ——
771
+ * 让我们(plugin)在 chain 中是 input-guard 的外层,input-guard 的
772
+ * classify + setSessionRealm 完成后才返回到这里,我们才看得到本 turn
773
+ * 的 realm。决策树(全 fail-open,永不 throw):
774
+ *
775
+ * await next()
776
+ * ├─ decision.kind === 'reject' → 透传(input-guard 已拒)
777
+ * ├─ !isAvailable() → 透传(总开关关 / breaker 开)
778
+ * ├─ sessionId 缺失 → 透传
779
+ * ├─ realm 未注册 / 非 LOCAL|SANITIZED → 透传
780
+ * ├─ 用户最后一条文本为空 → 透传
781
+ * ├─ getToolSchemasForExternal 空 → 透传
782
+ * ├─ caller.extract 失败(非 retryable)→ 透传
783
+ * ├─ adopt gate 未过 → 透传
784
+ * ├─ createToolRunner undefined → 透传
785
+ * ├─ outcomes ≥1 executed → 注入 user-role synthesis message
786
+ * └─ 全 denied/skipped/error → 透传(0 executed 不污染)
787
+ *
788
+ * 整个 body 包 try/catch,decision 提到 try 外:catch 拿到 decision → 透传
789
+ * (不污染 cordis dispatch);catch 没 decision(await next() 自身抛)→ 重抛
790
+ * (让上层处理真基础设施错)。
791
+ */
792
+ async handlePreStep(payload, next) {
793
+ let decision;
794
+ try {
795
+ decision = await next();
796
+ if (decision.kind === 'reject')
797
+ return decision;
798
+ if (!this.isAvailable()) {
799
+ if (this.functionCallExternalEnabled && Date.now() < this.functionCallExternalBreakerOpenUntil) {
800
+ this.stats.functionCallExternalCircuitSkip += 1;
801
+ this.audit('info', 'external.circuit_open_skip', {
802
+ callerId: this.caller.id,
803
+ sessionId: payload.agent?.session?.id,
804
+ url: this.functionCallExternalUrl,
805
+ openUntilMs: this.functionCallExternalBreakerOpenUntil,
806
+ remainingMs: this.functionCallExternalBreakerOpenUntil - Date.now(),
807
+ });
808
+ this.opsLog('circuit open skip', `sessionId=${payload.agent?.session?.id ?? 'unknown'} remainingMs=${this.functionCallExternalBreakerOpenUntil - Date.now()}`);
809
+ }
810
+ return decision;
811
+ }
812
+ const sessionId = payload.agent?.session?.id;
813
+ if (!sessionId)
814
+ return decision;
815
+ const bridge = this.bridge();
816
+ if (!bridge?.getSessionRealm || !bridge?.getToolSchemasForExternal)
817
+ return decision;
818
+ const realm = bridge.getSessionRealm(sessionId);
819
+ if (realm !== 'LOCAL' && realm !== 'SANITIZED') {
820
+ this.stats.functionCallExternalRealmUnsetSkip += 1;
821
+ return decision;
822
+ }
823
+ const lastUser = this.findLastUserText(payload.messages);
824
+ if (!lastUser || !lastUser.text.trim())
825
+ return decision;
826
+ const schemas = bridge.getToolSchemasForExternal(realm);
827
+ if (schemas.length === 0)
828
+ return decision;
829
+ const extract = await this.extractCalls(lastUser.text, schemas, sessionId, payload.signal);
830
+ if (!extract)
831
+ return decision;
832
+ if (extract.type !== 'call' || extract.calls.length === 0
833
+ || extract.confidence < this.functionCallExternalConfidenceThreshold) {
834
+ this.stats.functionCallExternalLowConfSkip += 1;
835
+ this.audit('info', 'external.low_confidence_skip', {
836
+ callerId: this.caller.id,
837
+ sessionId,
838
+ type: extract.type,
839
+ callCount: extract.calls.length,
840
+ confidence: extract.confidence,
841
+ threshold: this.functionCallExternalConfidenceThreshold,
842
+ });
843
+ this.opsLog('low confidence skip', `sessionId=${sessionId ?? 'unknown'} type=${extract.type} callCount=${extract.calls.length} confidence=${extract.confidence.toFixed(2)} threshold=${this.functionCallExternalConfidenceThreshold}`);
844
+ return decision;
845
+ }
846
+ this.stats.functionCallExternalExtractOk += 1;
847
+ const runner = this.createToolRunner();
848
+ if (!runner)
849
+ return decision;
850
+ const outcomes = await runner.executeAll(extract.calls, {
851
+ agent: payload.agent,
852
+ sessionId,
853
+ schemas,
854
+ ungrounded: extract.validation.ungrounded,
855
+ signal: payload.signal,
856
+ });
857
+ const executedCount = outcomes.filter(o => o.status === 'executed').length;
858
+ this.stats.functionCallExternalExecuted += executedCount;
859
+ this.stats.functionCallExternalErrors += outcomes.filter(o => o.status === 'error').length;
860
+ if (executedCount === 0)
861
+ return decision;
862
+ const text = this.buildSynthesisContext(lastUser.text, extract.calls, outcomes);
863
+ const injected = createUserMessage({
864
+ content: [{ type: 'text', text }],
865
+ source: { kind: 'user' },
866
+ });
867
+ const baseMessages = decision.messages ?? payload.messages;
868
+ this.stats.functionCallExternalSynthesisInjected += 1;
869
+ this.audit('info', 'external.synthesis_injected', {
870
+ callerId: this.caller.id,
871
+ sessionId,
872
+ toolCount: outcomes.length,
873
+ executedCount,
874
+ skippedCount: outcomes.filter(o => o.status === 'skipped').length,
875
+ deniedCount: outcomes.filter(o => o.status === 'denied').length,
876
+ errorCount: outcomes.filter(o => o.status === 'error').length,
877
+ originalQueryLen: lastUser.text.length,
878
+ messageLen: text.length,
879
+ });
880
+ this.opsLog('synthesis injected', `sessionId=${sessionId ?? 'unknown'} toolCount=${outcomes.length}` +
881
+ ` executedCount=${executedCount} skippedCount=${outcomes.filter(o => o.status === 'skipped').length}` +
882
+ ` deniedCount=${outcomes.filter(o => o.status === 'denied').length}` +
883
+ ` errorCount=${outcomes.filter(o => o.status === 'error').length}` +
884
+ ` messageLen=${text.length}`);
885
+ return {
886
+ ...decision,
887
+ messages: [...baseMessages, injected],
888
+ };
889
+ }
890
+ catch (err) {
891
+ this.audit('error', 'external.listener.error', {
892
+ callerId: this.caller?.id ?? 'unknown',
893
+ sessionId: payload.agent?.session?.id,
894
+ error: err?.message ?? String(err),
895
+ hadDecision: decision !== undefined,
896
+ });
897
+ this.opsLog('handlePreStep error', `${err?.message ?? String(err)} hadDecision=${decision !== undefined}`);
898
+ if (decision !== undefined)
899
+ return decision;
900
+ throw err;
901
+ }
902
+ }
903
+ /** Last user-role text message(沿用 input-guard.findLastUserText 模式)。 */
904
+ findLastUserText(messages) {
905
+ for (let i = messages.length - 1; i >= 0; i--) {
906
+ const m = messages[i];
907
+ if (m?.role !== 'user')
908
+ continue;
909
+ const content = m.content;
910
+ if (typeof content === 'string' && content.length > 0) {
911
+ return { text: content, index: i };
912
+ }
913
+ if (Array.isArray(content)) {
914
+ const texts = [];
915
+ for (const block of content) {
916
+ if (block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') {
917
+ texts.push(block.text);
918
+ }
919
+ }
920
+ const joined = texts.join('');
921
+ if (joined.length > 0)
922
+ return { text: joined, index: i };
923
+ }
924
+ }
925
+ return undefined;
926
+ }
927
+ }
928
+ export async function apply(ctx, config) {
929
+ // §7.102: the external-caller knobs come from config/edge.json
930
+ // (functionCallExternal.*). The cordis patch row no longer reads
931
+ // process.env — the desktop GUI never had those variables.
932
+ //
933
+ // Precedence: explicit cordis value > config/edge.json > the literal
934
+ // fallback in FunctionCallExternalServiceImpl. The Config schema carries
935
+ // no defaults on purpose (see the Config schema comment), so a field left
936
+ // unset by the patch row stays `undefined` here and the file can fill it.
937
+ const fce = loadEdgeConfig().functionCallExternal;
938
+ if (fce) {
939
+ const c = config;
940
+ const fill = (key, value) => {
941
+ if (value !== undefined && c[key] === undefined)
942
+ c[key] = value;
943
+ };
944
+ fill('functionCallExternalEnabled', fce.enabled);
945
+ fill('functionCallExternalUrl', fce.url);
946
+ fill('functionCallExternalTimeoutMs', fce.timeoutMs);
947
+ fill('functionCallExternalConfidenceThreshold', fce.confidenceThreshold);
948
+ fill('functionCallExternalBreakerTtlMs', fce.breakerTtlMs);
949
+ fill('functionCallExternalBootProbeEnabled', fce.bootProbeEnabled);
950
+ fill('functionCallExternalBootProbeTimeoutMs', fce.bootProbeTimeoutMs);
951
+ fill('functionCallExternalBreakerProbeMs', fce.breakerProbeMs);
952
+ fill('functionCallExternalProbeTimeoutMs', fce.probeTimeoutMs);
953
+ }
954
+ const auditLog = ctx.auditLog;
955
+ const service = new FunctionCallExternalServiceImpl(ctx, config, auditLog);
956
+ // §7.46 ops-log-coverage-principle:boot 关键节点一行(metadata only,
957
+ // url/enabled 是配置不是 PII)。运维排查"为什么没生效"第一眼看这行。
958
+ try {
959
+ ctx.logger?.info?.(`[function-call-external] boot enabled=${service.isAvailable()} url=${config.functionCallExternalUrl ?? 'http://127.0.0.1:8081'}` +
960
+ ` timeoutMs=${config.functionCallExternalTimeoutMs ?? 10000} threshold=${config.functionCallExternalConfidenceThreshold ?? 0.5}`);
961
+ }
962
+ catch { /* debug only */ }
963
+ service.start();
964
+ // ── Phase 5:agent/pre-step waterfall listener ──
965
+ // **必须早于 input-guard apply**(cordis.patch.yml - insert: 数组里
966
+ // function-call-external 在前)—— cordis waterfall 注册顺序 = 执行顺序;
967
+ // 我们的 `await next()` 串到 input-guard,等 input-guard 的 classify +
968
+ // setSessionRealm 完成后才返回给我们,我们才看得到本 turn 的 realm。
969
+ // §7.93.1 §7.93 file header 里有详细分析。
970
+ const on = ctx.on;
971
+ const disposers = [];
972
+ if (on) {
973
+ // §7.136(2026-10-28):去掉 `{ global: true }`。agent 事件用
974
+ // scopeTarget 派发,监听器只在自己 scope(边缘模式 preset)或其子 scope
975
+ // 收到 —— 标准模式绑定到标准 preset,链上没有本插件的 scope ⇒ 不触发。
976
+ const d1 = on('agent/pre-step', async (payload, next) => {
977
+ return service.handlePreStep(payload, next);
978
+ });
979
+ void auditLog?.log?.({
980
+ level: 'info',
981
+ kind: 'external.hook.registered',
982
+ source: 'function-call-external',
983
+ payload: { event: 'agent/pre-step', scopedTo: 'agent-preset', disposerReturned: typeof d1 === 'function' },
984
+ });
985
+ if (typeof d1 === 'function')
986
+ disposers.push(d1);
987
+ }
988
+ else {
989
+ void auditLog?.log?.({
990
+ level: 'warn',
991
+ kind: 'external.hook.skipped',
992
+ source: 'function-call-external',
993
+ payload: { reason: 'ctx.on unavailable — listener not registered; external will be a no-op' },
994
+ });
995
+ }
996
+ return async () => {
997
+ for (const d of disposers) {
998
+ try {
999
+ d();
1000
+ }
1001
+ catch { /* debug only */ }
1002
+ }
1003
+ await service.stop();
1004
+ };
1005
+ }