@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,787 @@
1
+ /**
2
+ * local-llm-adapter — M4 multi-model + routing.
3
+ *
4
+ * Registers one or more provider routes on the shared `ctx.llm`
5
+ * runtime, all backed by a real OpenAI-compatible `/chat/completions`
6
+ * endpoint. Defaults to `http://127.0.0.1:8080/v1` (llama.cpp's
7
+ * `server` binary); the reachable endpoint in this repo is
8
+ * `http://192.0.2.101:8701/v1`. No in-process mock — every call
9
+ * leaves the process.
10
+ *
11
+ * The wire-protocol parser lives in `./openai-sse.js` so changes to
12
+ * the SSE framing land in one place and are shared by the cloud
13
+ * adapter. The body builder flips `stream: true` and merges adapter
14
+ * defaults (model, temperature) so the upstream server sees the same
15
+ * shape the dsh agent loop emits.
16
+ *
17
+ * @module dsh-edge-agent/local-llm-adapter
18
+ */
19
+ import Schema from '@deepseek-ai/schemastery';
20
+ import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm';
21
+ import { buildChatCompletionsBody, extractText, iterateSseFrames, sseEventToChunk, sseFinishReason, terminalChunks, } from './openai-sse.js';
22
+ // §7.171: 本地池的 DELEGATE 模式复用云池的转发适配器(同一个类,`poolKind` 区分口径)。
23
+ // cloud-llm-adapter 不 import 本模块 ⇒ 无环。
24
+ import { CloudLlmDelegateAdapterImpl, localPoolTargetCache, } from './cloud-llm-adapter.js';
25
+ import { danglingSecretRefs, formatDanglingSecretRefs, localPool, loadEdgeConfig, } from './edge-config.js';
26
+ import { buildRequestTrace, buildResponseTrace, emitTrace, resolveLlmTraceConfig, } from './llm-trace.js';
27
+ import { getTraceStore, callIdFor } from './llm-trace-store.js';
28
+ import { bumpSessionToolCallCount, MAX_CONSECUTIVE_TOOL_CALLS, resetSessionToolCallCount, } from './llm-max-turns.js';
29
+ import { egressAuditFields, internalCallerOf } from './llm-internal-caller.js';
30
+ import { PRE_SESSION_SENTINEL, sessionIdField } from './session-attribution.js';
31
+ export const name = 'local-llm-adapter';
32
+ export const inject = ['llm', 'auditLog', 'inputGuard'];
33
+ function audit(log, level, kind, payload) {
34
+ if (!log)
35
+ return;
36
+ void log.log({ level, kind, source: 'local-llm-adapter', payload }).catch(() => { });
37
+ }
38
+ export const Config = Schema.object({
39
+ providers: Schema.array(Schema.string()).default(['local-llm']),
40
+ // No default on purpose: an unset endpoint must never become a
41
+ // hard-coded port (the old `127.0.0.1:8080` default collided with
42
+ // unrelated daemons). §7.171: unset now means DELEGATE — forward to the
43
+ // host profile's provider, symmetric with `llm.cloud`. The operator opts
44
+ // into a DIRECT pool with llm.local.baseUrl in config/edge.json (§7.102).
45
+ baseUrl: Schema.string().description('OpenAI-compatible endpoint base; unset = DELEGATE mode (forward to the host provider, §7.171); set llm.local.delegate=false to leave the route unregistered.'),
46
+ defaultModel: Schema.string().default('qwen3-4b'),
47
+ apiKey: Schema.string().description('Optional bearer token for an authenticated endpoint (literal value from config/edge.json).'),
48
+ apiKeyEnv: Schema.string().description('Deprecated: env-slot name carrying an auth bearer. Prefer apiKey.'),
49
+ stripTools: Schema.boolean().default(false),
50
+ // §7.87.5 (2026-09-19): default false — production wants fast answers.
51
+ // qwen3.5 / QwQ / DeepSeek-R1 will burn max_tokens on reasoning by default.
52
+ enableThinking: Schema.boolean().default(false),
53
+ reasoningEffortNone: Schema.boolean().default(false).description('Send reasoning_effort:"none" alongside think-off (ollama dialect; strict gateways 400 it).'),
54
+ // §7.171: no default on purpose — `undefined` means "not set at this layer".
55
+ // The effective switch is AND(file `llm.local.delegate`, this field): either
56
+ // side saying false turns delegation OFF.
57
+ delegate: Schema.boolean().description('§7.171: baseUrl 留空时转发宿主提供商(DELEGATE);false = 不注册本地路由(opt-in 语义)。'),
58
+ delegateProvider: Schema.string().description('§7.171: delegate 模式:显式目标 provider route id。'),
59
+ delegateModel: Schema.string().description('§7.171: delegate 模式:wire model override(压过宿主选择)。'),
60
+ });
61
+ /**
62
+ * Adapter facade. Owns one real-HTTP inner adapter per configured
63
+ * provider route so `ctx.llm.registerAdapter([...], this)` can route
64
+ * any of them through the same instance — keeping the `dispose`
65
+ * set in lockstep with the registration.
66
+ */
67
+ export class LocalLlmAdapterImpl extends LlmAdapter {
68
+ providersList;
69
+ innerByProvider;
70
+ constructor(config, auditLog, inputGuard, logger) {
71
+ super();
72
+ this.providersList = [...config.providers];
73
+ this.innerByProvider = new Map();
74
+ // An unset endpoint leaves every route without a backend. `apply` keeps
75
+ // the adapter unregistered in that case; the guard here keeps the
76
+ // facade honest for direct construction (tests, embedding).
77
+ const baseUrl = (config.baseUrl ?? '').trim();
78
+ if (baseUrl.length === 0)
79
+ return;
80
+ for (const provider of this.providersList) {
81
+ this.innerByProvider.set(provider, new RealLocalAdapter(provider, config, baseUrl, auditLog, inputGuard, logger));
82
+ }
83
+ }
84
+ get providers() {
85
+ return [...this.providersList];
86
+ }
87
+ providerInfo(provider) {
88
+ return { id: provider, name: provider };
89
+ }
90
+ listModels(provider) {
91
+ const inner = this.innerByProvider.get(provider);
92
+ return inner
93
+ ? inner.listModels(provider)
94
+ : Promise.resolve([]);
95
+ }
96
+ resolveModel(provider, model, signal) {
97
+ const inner = this.innerByProvider.get(provider);
98
+ return inner
99
+ ? inner.resolveModel(provider, model, signal)
100
+ : Promise.resolve({ provider, id: model, name: model });
101
+ }
102
+ async *stream(options) {
103
+ const inner = this.innerByProvider.get(options.provider);
104
+ if (inner === undefined) {
105
+ throw new LlmError(`local-llm-adapter: unknown provider route "${options.provider}" (registered: ${this.providersList.join(', ')})`, 'UNKNOWN_PROVIDER');
106
+ }
107
+ yield* inner.stream(options);
108
+ }
109
+ }
110
+ /**
111
+ * Real backend. Mirrors `CloudLlmAdapterImpl` but skips the bearer
112
+ * header unless `Config.apiKeyEnv` is set (llama.cpp `server` does
113
+ * not require auth by default). Bound to LAN/loopback endpoints only.
114
+ */
115
+ class RealLocalAdapter extends LlmAdapter {
116
+ providerName;
117
+ baseUrl;
118
+ defaultModel;
119
+ apiKey;
120
+ apiKeyEnv;
121
+ auditLog;
122
+ inputGuard;
123
+ logger;
124
+ stripTools;
125
+ enableThinking;
126
+ /** §7.106: pool-level opt-in for the reasoning_effort:"none" dialect. */
127
+ reasoningEffortNone;
128
+ constructor(provider, config, baseUrl, auditLog, inputGuard, logger) {
129
+ super();
130
+ this.providerName = provider;
131
+ this.baseUrl = baseUrl.replace(/\/+$/u, '');
132
+ this.defaultModel = config.defaultModel;
133
+ // §7.102: prefer the literal key from config/edge.json; fall back to the
134
+ // deprecated env-slot lookup so an in-flight migration still authenticates.
135
+ this.apiKey = config.apiKey;
136
+ this.apiKeyEnv = config.apiKeyEnv;
137
+ this.auditLog = auditLog;
138
+ this.inputGuard = inputGuard;
139
+ this.logger = logger;
140
+ this.stripTools = config.stripTools ?? false;
141
+ // §7.87.5 (2026-09-19): opt-in flag for thinking-capable models.
142
+ this.enableThinking = config.enableThinking ?? false;
143
+ this.reasoningEffortNone = config.reasoningEffortNone ?? false;
144
+ }
145
+ /** Resolve the bearer token: literal key first, env slot second. */
146
+ resolveApiKey() {
147
+ if (this.apiKey !== undefined && this.apiKey.length > 0)
148
+ return this.apiKey;
149
+ return this.apiKeyEnv !== undefined ? readApiKey(this.apiKeyEnv) : undefined;
150
+ }
151
+ /**
152
+ * §7.118: 当前生效的 LLM trace 配置。
153
+ *
154
+ * 现取而非 apply() 存一份 —— `loadEdgeConfig()` 自带 mtime+size 指纹缓存,
155
+ * 改了 config/edge.json 立即生效。详见 ./llm-trace.js。
156
+ */
157
+ traceConfig() {
158
+ return resolveLlmTraceConfig(loadEdgeConfig());
159
+ }
160
+ /**
161
+ * §7.118: 详细 request trace → ops log(info 级别,凭据 + PII 脱敏、按字段截断)。
162
+ * §7.133: 同时落 Edge 面板的**索引行** + **正文**(正文按需拉取)。
163
+ */
164
+ traceRequest(sessionId, options, system, messages, rawBody) {
165
+ try {
166
+ const cfg = this.traceConfig();
167
+ if (!cfg.enabled || !cfg.includeRequest)
168
+ return;
169
+ const trace = buildRequestTrace({
170
+ sessionId,
171
+ provider: options.provider,
172
+ model: this.defaultModel,
173
+ baseUrl: this.baseUrl,
174
+ system,
175
+ messages,
176
+ tools: options.tools,
177
+ // §7.144: 真正发出去的 wire body(含 chat_template_kwargs / reasoning_effort
178
+ // / 工具 schema …)—— 面板上要能核对「发了什么」,不是只看投影。
179
+ rawBody,
180
+ params: { stream: true },
181
+ config: cfg,
182
+ });
183
+ this.emit('REQ', 'local-llm-adapter LLM_REQUEST', trace, options);
184
+ }
185
+ catch { /* 观测失败不拖垮主链路 */ }
186
+ }
187
+ /**
188
+ * §7.118: 详细 response trace → ops log。
189
+ * §7.133: 同时落 Edge 面板的索引行 + 正文。
190
+ */
191
+ traceResponse(sessionId, provider, content, toolCalls, finishKind, durationMs, error, options, rawBody) {
192
+ try {
193
+ const cfg = this.traceConfig();
194
+ if (!cfg.enabled || !cfg.includeResponse)
195
+ return;
196
+ const trace = buildResponseTrace({
197
+ sessionId,
198
+ provider,
199
+ model: this.defaultModel,
200
+ durationMs,
201
+ finishReason: finishKind,
202
+ content,
203
+ toolCalls,
204
+ // §7.144: 完整响应体(正文 + 工具调用 + finish_reason 的组装体)。
205
+ rawBody,
206
+ error,
207
+ config: cfg,
208
+ });
209
+ this.emit('RESP', 'local-llm-adapter LLM_RESPONSE', trace, options);
210
+ }
211
+ catch { /* 观测失败不拖垮主链路 */ }
212
+ }
213
+ /**
214
+ * §7.133: trace 的统一出口 —— ops log + Edge 面板索引行 + 正文。
215
+ *
216
+ * `options` 里有 §7.132 的 `edgeInternalCaller` 标记时,索引行带上
217
+ * caller,面板据此把「检测调用」(安检 / 分类器)与「主对话出网」分开
218
+ * 显示。检测调用与主对话走的是**同一段代码** —— 不存在「检测调用忘了
219
+ * 记」的第二套实现。
220
+ */
221
+ emit(kind, tag, trace, options) {
222
+ const caller = internalCallerOf(options);
223
+ const sink = {
224
+ log: (entry) => this.auditLog?.log(entry) ?? Promise.resolve(),
225
+ logger: this.logger,
226
+ store: getTraceStore(),
227
+ };
228
+ // §7.132 的判据落到索引行:带工具 = 主对话,一件不带 = 内部调用。
229
+ const tools = options?.tools;
230
+ emitTrace(sink, kind, tag, trace, caller, {
231
+ toolCount: Array.isArray(tools) ? tools.length : 0,
232
+ // §7.134: REQ 与 RESP 共用一个 callId —— 面板据此把两半配成一对。
233
+ // 键是 `options` 的对象身份:宿主每次 stream() 传新对象,而同一次
234
+ // 往返的 REQ/RESP 拿到的是**同一个**引用。
235
+ callId: callIdFor(options),
236
+ });
237
+ }
238
+ get providers() {
239
+ return [this.providerName];
240
+ }
241
+ providerInfo(_provider) {
242
+ return { id: this.providerName, name: this.providerName };
243
+ }
244
+ /**
245
+ * §7.180: 直连池在模型目录里**如实声明它唯一会跑的那个模型**。
246
+ *
247
+ * §7.106 Option A 当初让目录恒空,理由是「local-llm 是隐私管线内部件,
248
+ * 不是给用户选的」—— 那条理由对**云池**成立(避免误选 cloud-llm/
249
+ * deepseek-chat,也避免 INVALID_CATALOG),对**本地池**不再成立:边缘
250
+ * 模式会话本来就该指向本地池,目录里没有它 ⇒ 宿主选择器只能显示
251
+ * 「请选择模型」(主人 2026-11-11 13:52 实测)。
252
+ *
253
+ * 直连池的 wire body 恒用 `this.defaultModel`(`options.model` 只是路由键
254
+ * `route=`)⇒ 目录里就只声明这一个 id,不多不少。
255
+ */
256
+ listModels(_provider) {
257
+ const id = this.defaultModel.trim();
258
+ if (id.length === 0)
259
+ return Promise.resolve([]);
260
+ // §7.180: 留审计行 —— 「宿主模型目录里 local-llm 声明了什么」是可查证的事实。
261
+ void this.auditLog?.log({
262
+ level: 'info',
263
+ kind: 'llm.local.catalog',
264
+ source: 'local-llm-adapter',
265
+ payload: { provider: this.providerName, model: id, mode: 'direct' },
266
+ }).catch(() => { });
267
+ return Promise.resolve([{ provider: this.providerName, id, name: id }]);
268
+ }
269
+ resolveModel(_provider, model) {
270
+ // §7.40 (2026-09-04): see listModels comment — contextWindow must also
271
+ // surface through resolveModel so the runtime normalises it onto the
272
+ // active session's `info.context.contextWindow`. Without this the
273
+ // adapter's advertised contextWindow is ignored.
274
+ return Promise.resolve({
275
+ provider: this.providerName,
276
+ id: model,
277
+ name: model,
278
+ context: { contextWindow: 16384 },
279
+ });
280
+ }
281
+ async *stream(options) {
282
+ const url = `${this.baseUrl}/chat/completions`;
283
+ // 2026-08-31:input-guard 清洗孤儿 tool_call。
284
+ let sanitizedMessages = options.messages;
285
+ if (this.inputGuard) {
286
+ const result = this.inputGuard.sanitizeOrphanToolCalls(options.messages, options.sessionId);
287
+ sanitizedMessages = result.messages;
288
+ }
289
+ // §7.4? (2026-09-17) LAN minicpm5-2b Jinja2 `tojson(ensure_ascii)` bug:
290
+ // when stripTools is on, set `tools: undefined` in the spread so the
291
+ // openai-sse.ts guard skips `body.tools` assignment and the server
292
+ // template's `{% if tools %}` block is skipped. Default false → wire
293
+ // body unchanged for unaffected servers/models.
294
+ const wireOptions = this.stripTools
295
+ ? { ...options, tools: undefined, messages: sanitizedMessages }
296
+ : { ...options, messages: sanitizedMessages };
297
+ // §7.106 (2026-10-23): the reasoning_effort dialect is POOL-CONFIGURED,
298
+ // not always-on. ollama drops chat_template_kwargs and bills reasoning
299
+ // into max_tokens (content-starved model-detect → json-parse-fail), so
300
+ // ollama pools set llm.local.reasoningEffortNone=true; strict gateways
301
+ // 400 the unknown param (minimax measured) and must leave it false.
302
+ const body = buildChatCompletionsBody(wireOptions, this.defaultModel, undefined, this.enableThinking, this.reasoningEffortNone);
303
+ const headers = {
304
+ 'content-type': 'application/json',
305
+ 'accept': 'text/event-stream',
306
+ ...attributionHeaders(),
307
+ };
308
+ const apiKey = this.resolveApiKey();
309
+ if (apiKey !== undefined)
310
+ headers.authorization = `Bearer ${apiKey}`;
311
+ const promptText = sanitizedMessages.map((m) => `${m.role}:${extractText(m.content)}`).join('\n');
312
+ const promptLen = promptText.length;
313
+ // §7.54.7 (2026-09-14): capture wire-system (passed to
314
+ // buildChatCompletionsBody) for ops log so operator can see what system
315
+ // prompt actually went out to local LLM. Truncate to first 200 chars to
316
+ // keep log lines readable; full text is in the request body which
317
+ // DSH_EDGE_LOG_LEVEL=debug exposes via LLM_MESSAGES.
318
+ const wireSystem = typeof options.system === 'string' ? options.system : '';
319
+ const systemLen = wireSystem.length;
320
+ const systemPreview = systemLen > 0 ? wireSystem.slice(0, 200).replace(/\s+/g, ' ') : '';
321
+ const start = Date.now();
322
+ // §7.46.1 (2026-09-11): ops log 让 TUI 路径调哪个 local LLM / 多久 / 多少
323
+ // token 在 ops log 里可见。配合 realm-skill-bridge pre-step + tool allow
324
+ // 形成完整 trace。
325
+ const sessionId = options.sessionId ?? PRE_SESSION_SENTINEL;
326
+ try {
327
+ // §7.54 (2026-09-14): show the wire body model (`this.defaultModel`,
328
+ // which buildChatCompletionsBody uses) instead of `options.model` (which
329
+ // model-router may overwrite to the provider route id, e.g.
330
+ // 'local-llm'). Operators reading the ops log expect to see the actual
331
+ // model name qwen3.7-plus, not the routing key. `route=${options.model}`
332
+ // preserves the upstream-visible routing key for diagnosis.
333
+ this.logger?.info?.(`[local-llm-adapter] LLM call start sessionId=${sessionId} provider=${options.provider} model=${this.defaultModel} route=${options.model} messageCount=${sanitizedMessages.length} promptLen=${promptLen} systemLen=${systemLen}${systemPreview ? ` systemPreview="${systemPreview}${systemLen > 200 ? '...' : ''}"` : ''} baseUrl=${this.baseUrl}`);
334
+ }
335
+ catch { /* debug only */ }
336
+ // 2026-09-02: per-call request/response body logging to ops log at
337
+ // debug level (mirrors cloud-llm-adapter). LOCAL content never
338
+ // leaves the device, so this is purely a local-debug aid; CLOUD
339
+ // prompts are pre-sanitized by input-guard before egress. Set
340
+ // `DSH_EDGE_LOG_LEVEL=info` to suppress bodies entirely.
341
+ this.logger?.debug?.(`[local-llm-adapter] LLM_REQUEST provider=${options.provider} model=${options.model} messageCount=${sanitizedMessages.length} promptLen=${promptLen}`);
342
+ this.logger?.debug?.(`[local-llm-adapter] LLM_MESSAGES ${JSON.stringify({ messages: sanitizedMessages })}`);
343
+ // §7.118: 详细 request trace —— 逐条 message 的角色 / 正文 / 工具回显,
344
+ // 经凭据 + PII 脱敏、按字段截断后写进 ops log(info 级别,默认可见)。
345
+ this.traceRequest(sessionId, options, wireSystem, sanitizedMessages, body);
346
+ // §7.147: 这条**开始**行必须带 sessionId —— 否则它落全局桶,Edge 面板的
347
+ // 路由面板永远说不清「这一轮」(实测:本仓 640 条 `llm.local.start` 无一带
348
+ // sessionId,而同一调用的 `llm.local.end` 带了 483/569 —— 同一个调用,两半
349
+ // 归属不一致)。
350
+ //
351
+ // 用 `options.sessionId`(原始值)而不是上面那个 `sessionId` 变量:后者在
352
+ // 无会话时是 `'<pre-session>'` **哨兵**,写进载荷会造出一个不存在的桶,
353
+ // 把这些行从所有真实会话的视图里藏掉(比全局可见更糟)。哨兵在这里被判
354
+ // 成「没有会话」⇒ 不写这个键 ⇒ 归全局桶。
355
+ audit(this.auditLog, 'info', 'llm.local.start', { ...sessionIdField(options.sessionId), provider: options.provider, model: this.defaultModel, route: options.model, messageCount: sanitizedMessages.length, promptLen, baseUrl: this.baseUrl, ...egressAuditFields(options) });
356
+ // §7.106 (2026-10-23): ONE retry on transport-level failure with a short
357
+ // backoff. The LAN ollama blips (observed 11:35:09: `fetch failed` mid
358
+ // agent-turn while the same server answered fine minutes before/after —
359
+ // a single 2.6s connection drop killed the whole turn). HTTP error
360
+ // responses are NOT retried here (non-ok path untouched), and a caller
361
+ // abort never retries — the signal check keeps AbortError semantics.
362
+ const attempt = () => fetch(url, {
363
+ method: 'POST',
364
+ headers,
365
+ body: JSON.stringify(body),
366
+ signal: options.signal,
367
+ });
368
+ let response;
369
+ try {
370
+ response = await attempt();
371
+ }
372
+ catch (err) {
373
+ const aborted = options.signal?.aborted === true || err?.name === 'AbortError';
374
+ if (aborted) {
375
+ // §7.148: 错误行同样要带 sessionId —— 它们与 `llm.local.start` 是同一次
376
+ // 调用的两半(实测:本仓 43 条 `llm.local.http_error` / 22 条
377
+ // `llm.local.network_error` / 4 条 `llm.local.network_retry` 无一带
378
+ // sessionId)。失败行落全局桶 ⇒ Edge 面板看不到「哪一轮失败了」。
379
+ audit(this.auditLog, 'error', 'llm.local.network_error', { ...sessionIdField(options.sessionId), provider: options.provider, model: this.defaultModel, route: options.model, durationMs: Date.now() - start, error: err?.message ?? String(err), ...egressAuditFields(options) });
380
+ throw err;
381
+ }
382
+ audit(this.auditLog, 'warn', 'llm.local.network_retry', { ...sessionIdField(options.sessionId), provider: options.provider, model: this.defaultModel, route: options.model, error: String(err?.message ?? err), afterMs: Date.now() - start });
383
+ await new Promise(resolve => setTimeout(resolve, 800));
384
+ try {
385
+ response = await attempt();
386
+ }
387
+ catch (err2) {
388
+ audit(this.auditLog, 'error', 'llm.local.network_error', { ...sessionIdField(options.sessionId), provider: options.provider, model: this.defaultModel, route: options.model, durationMs: Date.now() - start, error: err2?.message ?? String(err2), afterRetry: true, ...egressAuditFields(options) });
389
+ throw err2;
390
+ }
391
+ }
392
+ if (!response.ok) {
393
+ const durationMs = Date.now() - start;
394
+ let respBody = '';
395
+ try {
396
+ respBody = await response.text();
397
+ }
398
+ catch { /* ignore */ }
399
+ audit(this.auditLog, 'error', 'llm.local.http_error', { ...sessionIdField(options.sessionId), provider: options.provider, model: this.defaultModel, route: options.model, status: response.status, durationMs, respBody: respBody.slice(0, 500), ...egressAuditFields(options) });
400
+ // 2026-10-21: 401/403 almost always means "this endpoint wants a
401
+ // bearer" (or "the port belongs to something else entirely"). Name the
402
+ // knob so the operator does not have to reverse-engineer the envelope.
403
+ const authHint = response.status === 401 || response.status === 403
404
+ ? ` — the endpoint requires an Authorization: Bearer token; set llm.local.keyRef`
405
+ + ` in config/edge.json to the name of a key under "secrets"`
406
+ + `${this.apiKeyEnv === undefined ? '' : `, or point apiKeyEnv at the env slot <${this.apiKeyEnv}>`}`
407
+ + `, and confirm ${this.baseUrl} is really your local LLM server.`
408
+ : '';
409
+ throw new LlmError(`local-llm-adapter (${this.providerName}): HTTP ${response.status}: ${respBody.slice(0, 200)}${authHint}`, 'PROVIDER_ERROR');
410
+ }
411
+ // 2026-09-03 §7.34.2 fix: defer `block-start` until the first text-delta.
412
+ // 之前无条件 yield block-start → 如果 LLM 返回 0 token(completion_tokens=0
413
+ // 但 finish_reason='stop',llama.cpp 在 24 条 message / 2535 tokens prompt
414
+ // 下实测会发生),BlockAssembler 拿到一个未关闭的 text block + finish,
415
+ // 状态机卡住、agent-loop 永远等不到 block-end。
416
+ //
417
+ // 实测数据:webui chat "查询下密盒有哪些图片文件"(17:20:40 +08:00),
418
+ // 远端 llama.cpp 192.0.2.102:11017 返回 completion_tokens=0 finish=stop
419
+ // (254ms),dsh 进入 idle 4+ 分钟无响应。
420
+ //
421
+ // 修复:block-start 改为 lazy(首次 text-delta 前 yield 一次),empty stream
422
+ // 不再 yield block-start → BlockAssembler 不创建 orphan block → finish
423
+ // 正常传递 → agent-loop 状态机清零。
424
+ let blockStartYielded = false;
425
+ const yieldBlockStartOnce = () => {
426
+ if (blockStartYielded)
427
+ return false;
428
+ blockStartYielded = true;
429
+ return true;
430
+ };
431
+ let assembledText = '';
432
+ let finishKind;
433
+ const toolCalls = new Map();
434
+ let sawText = false;
435
+ let sawToolCall = false;
436
+ const throwOnWire = (message) => {
437
+ throw new LlmError(`local-llm-adapter (${this.providerName}): SSE stream interrupted: ${message}`, 'PROVIDER_ERROR');
438
+ };
439
+ let wireError;
440
+ try {
441
+ for await (const event of iterateSseFrames(response, throwOnWire)) {
442
+ const reason = sseFinishReason(event);
443
+ if (reason !== undefined) {
444
+ finishKind = reason;
445
+ continue;
446
+ }
447
+ const result = sseEventToChunk(event, toolCalls);
448
+ if (result === null)
449
+ continue;
450
+ if (result.chunk.type === 'text-delta') {
451
+ // §7.34.2: yield block-start lazily before the first text-delta
452
+ if (yieldBlockStartOnce()) {
453
+ yield { type: 'block-start', index: 0, blockType: 'text' };
454
+ }
455
+ assembledText += result.chunk.text;
456
+ sawText = result.sawText;
457
+ }
458
+ else if (result.chunk.type === 'tool-call-delta') {
459
+ sawToolCall = result.sawToolCall;
460
+ }
461
+ yield result.chunk;
462
+ }
463
+ }
464
+ catch (err) {
465
+ wireError = err?.message ?? String(err);
466
+ throw err;
467
+ }
468
+ finally {
469
+ const durationMs = Date.now() - start;
470
+ // §7.46.1: ops log done 行
471
+ // §7.54 (2026-09-14): show defaultModel (= wire body) for consistency
472
+ // with start line; options.model may be the routing key 'local-llm'.
473
+ try {
474
+ this.logger?.info?.(`[local-llm-adapter] LLM call done sessionId=${sessionId} provider=${options.provider} model=${this.defaultModel} route=${options.model} durationMs=${durationMs} resultLen=${assembledText.length} toolCallCount=${toolCalls.size} finishKind=${finishKind ?? 'unknown'}${wireError !== undefined ? ` wireError=${wireError}` : ''}`);
475
+ }
476
+ catch { /* debug only */ }
477
+ audit(this.auditLog, 'info', 'llm.local.end', { sessionId: options.sessionId,
478
+ provider: options.provider,
479
+ model: this.defaultModel,
480
+ route: options.model,
481
+ durationMs,
482
+ promptLen,
483
+ resultLen: assembledText.length,
484
+ toolCallCount: toolCalls.size,
485
+ finishKind,
486
+ sawText,
487
+ sawToolCall,
488
+ ...(wireError !== undefined ? { wireError } : {}),
489
+ ...egressAuditFields(options),
490
+ });
491
+ // 2026-09-02: dump response summary + full text to the ops log at
492
+ // debug level (mirrors cloud-llm-adapter).
493
+ this.logger?.debug?.(`[local-llm-adapter] LLM_RESPONSE provider=${options.provider} durationMs=${durationMs} resultLen=${assembledText.length} finishKind=${finishKind ?? 'unknown'}`);
494
+ this.logger?.debug?.(`[local-llm-adapter] LLM_RESPONSE_TEXT ${JSON.stringify({ text: assembledText })}`);
495
+ // §7.118: 详细 response trace(正文 + tool calls + finish reason)。
496
+ this.traceResponse(sessionId, options.provider, assembledText, [...toolCalls.values()].map((c) => ({ id: c.id, name: c.name, arguments: c.arguments })), finishKind, durationMs, wireError, options,
497
+ // §7.144: 完整响应体 —— 上游回来的语义全文(正文 + 工具调用 + 结束原因)。
498
+ {
499
+ content: assembledText,
500
+ tool_calls: [...toolCalls.values()].map((c) => ({ id: c.id, name: c.name, arguments: c.arguments })),
501
+ finish_reason: finishKind ?? 'unknown',
502
+ ...(wireError !== undefined ? { wire_error: wireError } : {}),
503
+ });
504
+ }
505
+ // §7.42 (2026-09-05) P0-1: malformed-tool-call detection.
506
+ // qwen3-4b-instruct-2507 @ OpenAI-compatible endpoints sometimes emit
507
+ // ~12 bytes of XML tag fragments (e.g. `\n<think>\n`) as plain-text
508
+ // content while setting finish_reason=tool_calls. Two patterns observed:
509
+ //
510
+ // Pattern A (rare, ~1%): toolCalls.size === 0, no real tool call accumulated.
511
+ // Agent loop sees "tool-calls" but nothing to dispatch -> runaway (152
512
+ // iterations observed in tui-probe-1). Fix: rewrite finishKind='stop'.
513
+ //
514
+ // Pattern B (dominant, ~99%): toolCalls.size >= 1, real tool call also emitted.
515
+ // Agent loop dispatches the tool call, but the XML fragment text confuses
516
+ // the model -> loop retries a few times then terminates (5 iterations, 70s
517
+ // observed in p0-local test). Fix: strip the XML fragment from assembledText
518
+ // so it doesn't pollute the session context, but let the tool call proceed.
519
+ //
520
+ // Both patterns logged at WARN + audit for ops visibility.
521
+ const MALFORMED_TOOL_CALL_TEXT_RE = /^[\s<>\/a-z_]{0,30}$/u;
522
+ if (finishKind === 'tool-calls' &&
523
+ assembledText.length > 0 &&
524
+ assembledText.length <= 30 &&
525
+ MALFORMED_TOOL_CALL_TEXT_RE.test(assembledText)) {
526
+ const hasRealToolCalls = toolCalls.size > 0;
527
+ audit(this.auditLog, 'warn', 'llm.local.malformed_tool_call', { sessionId: options.sessionId,
528
+ provider: options.provider,
529
+ model: options.model,
530
+ resultLen: assembledText.length,
531
+ assembledTextPreview: assembledText.slice(0, 100),
532
+ messageCount: sanitizedMessages.length,
533
+ toolCallCount: toolCalls.size,
534
+ pattern: hasRealToolCalls ? 'B' : 'A',
535
+ });
536
+ this.logger?.warn?.(`[local-llm-adapter] malformed tool-call response detected (pattern=${hasRealToolCalls ? 'B' : 'A'}, toolCallCount=${toolCalls.size}, text=${JSON.stringify(assembledText.slice(0, 40))})`);
537
+ if (!hasRealToolCalls) {
538
+ // Pattern A: no real tool call -> rewrite to stop, loop terminates
539
+ finishKind = 'stop';
540
+ sawToolCall = false;
541
+ }
542
+ else {
543
+ // Pattern B: real tool call present -> strip XML fragment text, let tool proceed
544
+ // Note: text-delta chunks already yielded, can't un-yield. But we clear
545
+ // assembledText so subsequent context doesn't include the fragment.
546
+ assembledText = '';
547
+ sawText = false;
548
+ }
549
+ }
550
+ // §7.42 (2026-09-05) P0-2: per-session max-turns guard.
551
+ // If the local LLM keeps returning tool-calls without making progress
552
+ // (no text content, no stop/length finish), bound the resource usage
553
+ // by throwing after MAX_CONSECUTIVE_TOOL_CALLS consecutive tool-call
554
+ // responses. The counter resets on stop/length (agent loop terminates
555
+ // naturally) or on text-bearing tool-call responses (progress signal).
556
+ //
557
+ // Trigger: finishKind === 'tool-calls' && !sawText (no text content).
558
+ // - Pattern B malformed (P0-1 above) sets assembledText='' and sawText=false,
559
+ // so it also increments this counter — correct, since it's not making progress.
560
+ // - Real tool-call with text (e.g. model explains what it's doing) does NOT
561
+ // increment — that's a progress signal.
562
+ //
563
+ // Session tracking via shared module (./llm-max-turns.ts). Cross-adapter
564
+ // sessions keep a single counter so e.g. classifier fallback LOCAL → CLOUD
565
+ // doesn't reset the runaway count.
566
+ if (finishKind === 'tool-calls') {
567
+ if (!sawText) {
568
+ const consecutiveCount = bumpSessionToolCallCount(options.sessionId);
569
+ const sessionKeyForLog = typeof options.sessionId === 'string' && options.sessionId.length > 0
570
+ ? options.sessionId
571
+ : 'default';
572
+ if (consecutiveCount > MAX_CONSECUTIVE_TOOL_CALLS) {
573
+ audit(this.auditLog, 'warn', 'llm.local.max_turns_exceeded', {
574
+ provider: options.provider,
575
+ model: options.model,
576
+ sessionId: sessionKeyForLog,
577
+ consecutiveCount,
578
+ });
579
+ this.logger?.error?.(`[local-llm-adapter] per-session max-turns exceeded (session=${sessionKeyForLog}, count=${consecutiveCount}/${MAX_CONSECUTIVE_TOOL_CALLS})`);
580
+ // Reset counter so next call starts fresh (agent loop may retry).
581
+ resetSessionToolCallCount(options.sessionId);
582
+ throw new LlmError(`local-llm-adapter (${this.providerName}): per-session max-turns exceeded (count=${consecutiveCount})`, 'MAX_TURNS_EXCEEDED');
583
+ }
584
+ }
585
+ else {
586
+ // Tool-call response with text content -> progress, reset counter.
587
+ resetSessionToolCallCount(options.sessionId);
588
+ }
589
+ }
590
+ else if (finishKind === 'stop' || finishKind === 'length') {
591
+ // Natural termination -> reset counter.
592
+ resetSessionToolCallCount(options.sessionId);
593
+ }
594
+ yield* terminalChunks(sawText, assembledText, toolCalls, finishKind, sawToolCall);
595
+ }
596
+ }
597
+ function readApiKey(slot) {
598
+ if (typeof process === 'undefined' || !process.env)
599
+ return undefined;
600
+ const value = process.env[slot];
601
+ if (typeof value !== 'string' || value.length === 0)
602
+ return undefined;
603
+ return value;
604
+ }
605
+ /**
606
+ * §7.171 (2026-11-10): DELEGATE mode for the local pool.
607
+ *
608
+ * The local pool has no endpoint of its own ⇒ forward every request to the
609
+ * host provider (the model the operator picked in the host profile), exactly
610
+ * like `llm.cloud`'s delegate mode. The implementation is the SAME class as
611
+ * the cloud pool's (`CloudLlmDelegateAdapterImpl`) with `poolKind='local'` —
612
+ * one forwarding mechanism, one place to fix bugs, and the privacy口径
613
+ * difference is a single switch:
614
+ *
615
+ * - `'local'` ⇒ **no trimming, no scrubbing** (the LOCAL 口径: 带全部历史).
616
+ * The request carries the `LOCAL_POOL_FORWARD_MARKER` so the host-side
617
+ * `host-egress-guard` lets it through untouched.
618
+ * - `'cloud'` ⇒ §7.131 trimming (cloud-segment history only) + scrubbing.
619
+ *
620
+ * `egress` is passed as `undefined` for the local pool: the delegate only
621
+ * applies it when `poolKind === 'cloud'`, and leaving the guard unconstructed
622
+ * makes "the local pool never trims" structural rather than conditional.
623
+ */
624
+ async function applyDelegate(ctx, config, pool) {
625
+ const auditLog = ctx.auditLog;
626
+ const inputGuard = ctx.inputGuard;
627
+ const logger = ctx.logger;
628
+ const llm = ctx.llm;
629
+ const accessors = {
630
+ llm: llm,
631
+ // soft dep: dsh-base absent (minimal graphs) → undefined, the probe then
632
+ // skips to the live-route step. Same read as cloud-llm-adapter.
633
+ getAgentDefaultModel: () => {
634
+ try {
635
+ return ctx.get?.('agentDefaultModel');
636
+ }
637
+ catch {
638
+ return undefined;
639
+ }
640
+ },
641
+ };
642
+ const delegateConfig = {
643
+ // Own route ids — the recursion guard excludes them from the probe's
644
+ // candidate set (§7.171: both own pools exclude each other).
645
+ providers: config.providers,
646
+ delegateProvider: pool.delegateProvider,
647
+ delegateModel: pool.delegateModel,
648
+ delegateEnabled: true,
649
+ };
650
+ const delegate = new CloudLlmDelegateAdapterImpl(delegateConfig, accessors, auditLog, inputGuard, logger, undefined, // egress: the local pool never trims (§7.131 asymmetry)
651
+ 'local', // poolKind: LOCAL 口径 —— 带全部历史 + 不脱敏
652
+ // §7.180: 共享的委托目标缓存 —— wire(stream)、模型目录(listModels)
653
+ // 与会话补钉(input-guard)读同一份目标 ⇒ 界面上写的 model id 就是
654
+ // 真正会跑的那个。
655
+ localPoolTargetCache);
656
+ // §7.100 hot-reload parity: host route churn (pi-ai replace / adapter
657
+ // install-remove) → drop the cached probe target so the next call re-probes.
658
+ const offAdaptersUpdated = typeof ctx.on === 'function'
659
+ ? ctx.on('llm/adapters-updated', () => { delegate.invalidateTopology(); })
660
+ : undefined;
661
+ ctx.logger?.info?.(`[local-llm-adapter] DELEGATE mode (llm.local.baseUrl empty) — forwarding to the host provider `
662
+ + `for providers: ${config.providers?.join(', ')}`);
663
+ let registration;
664
+ try {
665
+ registration = llm.registerAdapter(config.providers, delegate);
666
+ }
667
+ catch (err) {
668
+ // A user-defined pi-ai route literally named 'local-llm' wins the name;
669
+ // that live route legitimately IS the local pool then. Degrade loudly
670
+ // instead of crashing the whole bundle (mirrors cloud-llm-adapter).
671
+ const isDuplicate = err?.code === 'DUPLICATE_ADAPTER'
672
+ || String(err?.message ?? err).includes('DUPLICATE_ADAPTER')
673
+ || String(err?.message ?? err).includes('already has an adapter');
674
+ if (isDuplicate) {
675
+ logger?.error?.(`[local-llm-adapter] DUPLICATE_ADAPTER on ${config.providers.join(',')}; host route owns it, edge adapter stands down`);
676
+ auditLog?.log({
677
+ level: 'security',
678
+ kind: 'llm.local.registration.duplicate',
679
+ source: 'local-llm-adapter',
680
+ payload: {
681
+ providers: config.providers,
682
+ mode: 'delegate',
683
+ error: String(err?.message ?? err),
684
+ },
685
+ }).catch(() => { });
686
+ offAdaptersUpdated?.();
687
+ return async () => { };
688
+ }
689
+ throw err;
690
+ }
691
+ ctx.logger?.info?.('[local-llm-adapter] Adapter registered successfully');
692
+ return async () => {
693
+ registration();
694
+ offAdaptersUpdated?.();
695
+ };
696
+ }
697
+ export async function apply(ctx, config) {
698
+ ctx.logger?.info?.(`[local-llm-adapter] apply() called with providers: ${config.providers?.join(', ')}`);
699
+ // §7.102: the effective pool comes from config/edge.json. The cordis
700
+ // patch row no longer carries any local-LLM config (it used to read
701
+ // process.env.DSH_EDGE_LOCAL_LLM_*, which the desktop GUI never had).
702
+ const pool = localPool();
703
+ // The cordis config, when present, wins over the file (a profile that
704
+ // still passes an explicit baseUrl keeps working).
705
+ const baseUrl = (typeof config.baseUrl === 'string' && config.baseUrl.trim().length > 0
706
+ ? config.baseUrl
707
+ : pool.baseUrl ?? '').trim();
708
+ // §7.171 (2026-11-10): the local-LLM route has TWO modes now, symmetric
709
+ // with `llm.cloud`:
710
+ //
711
+ // - DIRECT : `llm.local.baseUrl` configured → own OpenAI-compatible pool
712
+ // (unchanged behaviour, the key in `llm.local.keyRef` is used).
713
+ // - DELEGATE: `llm.local.baseUrl` empty → forward to the host provider
714
+ // (the model the operator picked in the host profile), exactly
715
+ // like `llm.cloud`'s delegate mode. Credentials belong to the
716
+ // host adapter, not to us.
717
+ //
718
+ // Before §7.171, "no endpoint" meant "register NOTHING" (the 2026-10-21
719
+ // opt-in semantics): the model registry never offered `local-llm`, the model
720
+ // router could not select it, and the internal intent/guard stages stayed on
721
+ // their keyword/regex tier instead of burning a request against whatever
722
+ // happens to own the default port. That behaviour is still reachable — it is
723
+ // now the explicit kill switch `llm.local.delegate=false`.
724
+ //
725
+ // Why the default flipped: `llm.cloud` already delegates to the host
726
+ // provider when it has no endpoint (§7.100), so a deployment that never
727
+ // configured `llm.local` ended up with a working CLOUD route and a dead
728
+ // LOCAL route — the asymmetry surprised operators ("cloud works, local
729
+ // doesn't"). §7.171 makes the two pools symmetric.
730
+ const delegateEnabled = pool.delegate !== false && config.delegate !== false;
731
+ if (baseUrl.length === 0) {
732
+ if (!delegateEnabled) {
733
+ ctx.logger?.info?.('[local-llm-adapter] no endpoint configured and delegation disabled — '
734
+ + 'the local-LLM route stays off (opt-in semantics). '
735
+ + 'Set llm.local.baseUrl in config/edge.json for a direct pool, '
736
+ + 'or drop llm.local.delegate=false to delegate to the host provider.');
737
+ return async () => { };
738
+ }
739
+ return applyDelegate(ctx, config, pool);
740
+ }
741
+ // §7.154: a keyRef that names a slot that is not there leaves the pool
742
+ // with NO credential. The local pool always uses its key, so this is
743
+ // always worth saying — and it must be said AT BOOT: otherwise the
744
+ // operator finds out at the first request, long after the edit that
745
+ // broke it. (DELEGATE mode returns above: there the credential belongs to
746
+ // the host adapter, not to us.)
747
+ {
748
+ const dangling = danglingSecretRefs().filter((r) => r.field.startsWith('llm.local'));
749
+ if (dangling.length > 0) {
750
+ ctx.logger?.error?.(`[local-llm-adapter] 配置里的密钥引用指向不存在的槽位:${formatDanglingSecretRefs(dangling)}`
751
+ + ` —— 本地池没有凭据,请求会以 MISSING_CREDENTIAL 失败`);
752
+ }
753
+ }
754
+ // Fold the file-derived pool into the config the adapter actually uses.
755
+ //
756
+ // Precedence: an explicit cordis config value wins, then the file, then the
757
+ // Schema default. The Schema default is already applied to `config` by the
758
+ // loader, so we cannot use it to detect "the operator did not configure a
759
+ // model" — that is why the file is consulted before falling back to the
760
+ // plugin's own default string.
761
+ const effective = {
762
+ ...config,
763
+ baseUrl,
764
+ defaultModel: pool.model ?? config.defaultModel,
765
+ apiKey: config.apiKey ?? pool.apiKey,
766
+ stripTools: config.stripTools ?? pool.stripTools,
767
+ enableThinking: config.enableThinking ?? pool.enableThinking,
768
+ // OR-fold: the Schema default (false) is already applied to `config` by
769
+ // the loader, so `??` would never let the file win — either source may
770
+ // opt in to the ollama dialect (§7.102 "file is the source of truth"
771
+ // doctrine, applied where a boolean default hides "unset").
772
+ reasoningEffortNone: config.reasoningEffortNone === true || pool.reasoningEffortNone === true,
773
+ };
774
+ const auditLog = ctx.auditLog;
775
+ // 2026-08-31: input-guard 可选注入;DSH_EDGE_INPUT_GUARD=false 或 plugin
776
+ // 启动顺序导致 inputGuard 未就绪时降级为不洗(保持旧行为)。
777
+ const inputGuard = ctx.inputGuard;
778
+ const logger = ctx.logger;
779
+ const adapter = new LocalLlmAdapterImpl(effective, auditLog, inputGuard, logger);
780
+ const llm = ctx.llm;
781
+ ctx.logger?.info?.(`[local-llm-adapter] Registering adapter for providers: ${config.providers?.join(', ')}`);
782
+ const registration = llm.registerAdapter(config.providers, adapter);
783
+ ctx.logger?.info?.(`[local-llm-adapter] Adapter registered successfully`);
784
+ return async () => {
785
+ registration();
786
+ };
787
+ }