@aiwayds/dsh-topics-memory 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -0
- package/README.zh.md +112 -0
- package/cordis.patch.yml +8 -0
- package/lib/commands.d.ts +21 -0
- package/lib/commands.js +368 -0
- package/lib/config.d.ts +122 -0
- package/lib/config.js +123 -0
- package/lib/delegation.d.ts +23 -0
- package/lib/delegation.js +21 -0
- package/lib/digest.d.ts +42 -0
- package/lib/digest.js +101 -0
- package/lib/distill.d.ts +186 -0
- package/lib/distill.js +759 -0
- package/lib/git.d.ts +66 -0
- package/lib/git.js +169 -0
- package/lib/ilog.d.ts +53 -0
- package/lib/ilog.js +70 -0
- package/lib/index.d.ts +73 -0
- package/lib/index.js +504 -0
- package/lib/observer.d.ts +41 -0
- package/lib/observer.js +128 -0
- package/lib/okf.d.ts +112 -0
- package/lib/okf.js +279 -0
- package/lib/onboard.d.ts +205 -0
- package/lib/onboard.js +664 -0
- package/lib/paths.d.ts +48 -0
- package/lib/paths.js +84 -0
- package/lib/retrieval.d.ts +61 -0
- package/lib/retrieval.js +178 -0
- package/lib/service.d.ts +96 -0
- package/lib/service.js +375 -0
- package/lib/store.d.ts +153 -0
- package/lib/store.js +489 -0
- package/lib/sync.d.ts +52 -0
- package/lib/sync.js +154 -0
- package/lib/tools.d.ts +8 -0
- package/lib/tools.js +208 -0
- package/lib/viz.d.ts +33 -0
- package/lib/viz.js +310 -0
- package/lib/yaml.d.ts +18 -0
- package/lib/yaml.js +334 -0
- package/package.json +97 -0
- package/src/commands.ts +397 -0
- package/src/config.ts +149 -0
- package/src/delegation.ts +27 -0
- package/src/digest.ts +125 -0
- package/src/distill.ts +885 -0
- package/src/git.ts +209 -0
- package/src/ilog.ts +99 -0
- package/src/index.ts +574 -0
- package/src/observer.ts +150 -0
- package/src/okf.ts +317 -0
- package/src/onboard.ts +762 -0
- package/src/paths.ts +104 -0
- package/src/retrieval.ts +219 -0
- package/src/service.ts +400 -0
- package/src/store.ts +567 -0
- package/src/sync.ts +170 -0
- package/src/tools.ts +225 -0
- package/src/viz.ts +333 -0
- package/src/yaml.ts +322 -0
package/src/distill.ts
ADDED
|
@@ -0,0 +1,885 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Distill lane (M2, ADR 0004) — background LLM process that turns undistilled
|
|
3
|
+
* observations into Topic writes.
|
|
4
|
+
*
|
|
5
|
+
* Single-flight per session, fire-and-forget from the observer, never throws
|
|
6
|
+
* into the caller. The model route comes from config (distill-provider +
|
|
7
|
+
* distill-model); when unset the lane stays idle with a visible status. The
|
|
8
|
+
* actual LLM seam is injected as a `ModelCaller` so unit tests run hermetic.
|
|
9
|
+
*
|
|
10
|
+
* @module distill
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as okf from './okf.ts'
|
|
14
|
+
import type { TopicsService } from './service.ts'
|
|
15
|
+
|
|
16
|
+
export interface ModelRequest {
|
|
17
|
+
system: string
|
|
18
|
+
user: string
|
|
19
|
+
purpose: string
|
|
20
|
+
sessionId?: string
|
|
21
|
+
maxTokens: number
|
|
22
|
+
signal?: AbortSignal
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ModelCaller = (req: ModelRequest) => Promise<string>
|
|
26
|
+
|
|
27
|
+
export interface DistillOp {
|
|
28
|
+
op: 'create' | 'update'
|
|
29
|
+
slug?: string
|
|
30
|
+
title?: string
|
|
31
|
+
description?: string
|
|
32
|
+
tags?: string[]
|
|
33
|
+
depends?: string[]
|
|
34
|
+
open_questions?: string[]
|
|
35
|
+
impact?: string[]
|
|
36
|
+
conclusion?: string
|
|
37
|
+
recommendations?: string
|
|
38
|
+
status?: string
|
|
39
|
+
observed_ids?: string[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DistillResult {
|
|
43
|
+
ok: boolean
|
|
44
|
+
reason?: 'no-model' | 'no-observations' | 'in-flight' | 'model-error' | 'invalid-output' | 'no-ops' | 'stalled'
|
|
45
|
+
created: string[]
|
|
46
|
+
updated: string[]
|
|
47
|
+
marked: number
|
|
48
|
+
/** Observations deleted by the post-run GC this run (present when > 0). */
|
|
49
|
+
gcDropped?: number
|
|
50
|
+
detail?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const SYSTEM_PROMPT = [
|
|
54
|
+
'你是 topic 记忆库的蒸馏引擎。输入是若干条未蒸馏的会话观察(JSON)和现有 topic 索引。',
|
|
55
|
+
'任务:把观察沉淀成 Topic 操作。只输出一个 JSON 对象,不要任何其他文字、Markdown 代码块或解释。',
|
|
56
|
+
'输出格式:{"ops": [...]},每个元素:',
|
|
57
|
+
' {"op":"create","title":"主题名","description":"一句话","tags":["小写标签"],"depends":["前置topic的slug"],',
|
|
58
|
+
' "open_questions":["未决问题"],"impact":["影响面"],"conclusion":"当前结论(自含散文)","recommendations":"可执行建议","status":"draft",',
|
|
59
|
+
' "observed_ids":["obs-..."]}',
|
|
60
|
+
' {"op":"update","slug":"已有slug","conclusion":"修订后的完整结论","open_questions":[...],"observed_ids":["obs-..."]}',
|
|
61
|
+
'规则:',
|
|
62
|
+
'- 硬性要求:每个 op 必须带 observed_ids 字段,值只能从本次输入「未蒸馏观察」里列出的 id 中逐字复制(形如 "obs-...");create 填它所综合依据的观察 id,update 填促使本次修订的观察 id。含列表之外 id 的条目会被过滤;observed_ids 缺失、为空或过滤后不剩任何有效 id 的 op 会被整体丢弃。',
|
|
63
|
+
'- conclusion 必须自含(不依赖观察原文也能读懂),写「目前有效的结论」,不是流水账。',
|
|
64
|
+
'- 同一主题只允许一个 create;已有相近 topic 时用 update 修订它的 conclusion。',
|
|
65
|
+
'- 观察里有价值就沉淀,没价值就跳过该观察(被跳过观察的 id 不要出现在任何 op 里);没有可沉淀内容时输出 {"ops":[]}。',
|
|
66
|
+
'- 中文主题用中文写;tags 全小写。',
|
|
67
|
+
].join('\n')
|
|
68
|
+
|
|
69
|
+
/** Adaptive-batch floor — below this, shrinking cannot rescue an output limit. */
|
|
70
|
+
const MIN_BATCH_SIZE = 5
|
|
71
|
+
/** Fallbacks when the config fields are absent (bare test harnesses). */
|
|
72
|
+
const DEFAULT_BATCH_SIZE = 40
|
|
73
|
+
const DEFAULT_MAX_MODEL_CALLS = 8
|
|
74
|
+
|
|
75
|
+
/** Minimal observation surface the batch payload needs (structural, no store import). */
|
|
76
|
+
interface ObservationLike {
|
|
77
|
+
id: string
|
|
78
|
+
kind: string
|
|
79
|
+
source: string
|
|
80
|
+
text: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Outcome of one model call over one batch (see Distiller.runBatch). */
|
|
84
|
+
interface BatchOutcome {
|
|
85
|
+
created: string[]
|
|
86
|
+
updated: string[]
|
|
87
|
+
marked: number
|
|
88
|
+
/** Model calls this batch spent (1, or 2 when the corrective retry fired). */
|
|
89
|
+
callsUsed?: number
|
|
90
|
+
/** observed_ids entries dropped for not matching the batch's true id set. */
|
|
91
|
+
filteredIds?: number
|
|
92
|
+
/** Model returned valid JSON with an empty ops array. */
|
|
93
|
+
noOps?: boolean
|
|
94
|
+
/** Model call died on the output-token limit (retryable with a smaller batch). */
|
|
95
|
+
maxTokens?: boolean
|
|
96
|
+
/** Non-retryable call failure classification (parse or stream error). */
|
|
97
|
+
fatalReason?: 'model-error' | 'invalid-output'
|
|
98
|
+
/** Batch ids an op actually consumed — the GC's consumed set for this batch. */
|
|
99
|
+
consumedIds?: string[]
|
|
100
|
+
detail?: string
|
|
101
|
+
/**
|
|
102
|
+
* Specific zero-consumption explanation for the stalled stop (undefined =
|
|
103
|
+
* fall back to the generic slug hint).
|
|
104
|
+
*/
|
|
105
|
+
stallDetail?: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Outcome of executing (a sanitized subset of) one batch's ops. */
|
|
109
|
+
interface AppliedOps {
|
|
110
|
+
created: string[]
|
|
111
|
+
updated: string[]
|
|
112
|
+
/** Batch-valid observation ids consumed by executed ops. */
|
|
113
|
+
consumedIds: string[]
|
|
114
|
+
consumedSlugs: string[]
|
|
115
|
+
/** observed_ids entries dropped for not matching the batch's id set. */
|
|
116
|
+
filteredIds: number
|
|
117
|
+
/**
|
|
118
|
+
* Structurally valid ops held back entirely because none of their
|
|
119
|
+
* observed_ids matched the batch — the corrective-retry trigger.
|
|
120
|
+
*/
|
|
121
|
+
droppedForIds: number
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export class Distiller {
|
|
125
|
+
private inFlight = new Map<string, Promise<DistillResult>>()
|
|
126
|
+
private readonly service: TopicsService
|
|
127
|
+
private readonly caller: ModelCaller | undefined
|
|
128
|
+
/** Live batch size — adopted from config each run, shrunk on output-limit failures. */
|
|
129
|
+
private batchSize = DEFAULT_BATCH_SIZE
|
|
130
|
+
/** Config value the live batch size was adopted from; a config change resets the shrink state. */
|
|
131
|
+
private batchSizeBase = DEFAULT_BATCH_SIZE
|
|
132
|
+
|
|
133
|
+
constructor(service: TopicsService, caller: ModelCaller | undefined) {
|
|
134
|
+
this.service = service
|
|
135
|
+
this.caller = caller
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Adopt the configured batch size for this run. The shrink state sticks
|
|
140
|
+
* across runs (a model that overflowed at 40 stays at 20 until restart),
|
|
141
|
+
* but an explicit config change resets it — /topics set is the manual
|
|
142
|
+
* grow-back lever, so the lane needs no speculative auto-recovery that
|
|
143
|
+
* would just re-pay a failed call on every run.
|
|
144
|
+
*/
|
|
145
|
+
private adoptBatchSize(configured: number): void {
|
|
146
|
+
if (this.batchSizeBase !== configured) {
|
|
147
|
+
this.batchSizeBase = configured
|
|
148
|
+
this.batchSize = configured
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
get configured(): boolean {
|
|
153
|
+
return this.caller !== undefined
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Fire-and-forget from the observer; dedups concurrent runs per session.
|
|
158
|
+
* Returns the run promise (undefined when deduped or unconfigured) so the
|
|
159
|
+
* caller can hook post-run cleanup — the per-session llm capture in
|
|
160
|
+
* index.ts must live exactly as long as a run that may read it. `manual`
|
|
161
|
+
* is the /topics distill trigger: same lane, same in-flight guard.
|
|
162
|
+
*/
|
|
163
|
+
request(sessionId: string, reason: 'every-n' | 'session-end' | 'manual'): Promise<DistillResult> | undefined {
|
|
164
|
+
if (!this.configured) return undefined
|
|
165
|
+
if (this.inFlight.has(sessionId)) return undefined
|
|
166
|
+
const run = this.run(sessionId).finally(() => this.inFlight.delete(sessionId))
|
|
167
|
+
this.inFlight.set(sessionId, run)
|
|
168
|
+
void run.catch(() => undefined)
|
|
169
|
+
return run
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** True while a run for the session is still in flight (teardown guard). */
|
|
173
|
+
hasPending(sessionId: string): boolean {
|
|
174
|
+
return this.inFlight.has(sessionId)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** True while ANY session's run is still in flight (exit-dispose guard). */
|
|
178
|
+
hasAnyPending(): boolean {
|
|
179
|
+
return this.inFlight.size > 0
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async run(sessionId?: string): Promise<DistillResult> {
|
|
183
|
+
const result = await this.runInner(sessionId)
|
|
184
|
+
// The GC count rides the detail (and the state file) so /topics status and
|
|
185
|
+
// e2e diagnostics see how many observations were deleted, not just marked.
|
|
186
|
+
const gcNote =
|
|
187
|
+
result.gcDropped !== undefined && result.gcDropped > 0
|
|
188
|
+
? `;gc: dropped ${result.gcDropped} unprocessable observation(s)`
|
|
189
|
+
: ''
|
|
190
|
+
const detail =
|
|
191
|
+
result.detail === undefined ? (gcNote === '' ? undefined : gcNote.replace(/^;/, '')) : `${result.detail}${gcNote}`
|
|
192
|
+
// Persist the outcome — /topics status and e2e diagnostics both read it.
|
|
193
|
+
// A failed write must not masquerade as success: the marks have already
|
|
194
|
+
// landed, so the only witness left is the run detail the command output
|
|
195
|
+
// shows. Surface the write error there instead of swallowing it.
|
|
196
|
+
let stateNote = ''
|
|
197
|
+
await this.service.store
|
|
198
|
+
.writeDistillState({
|
|
199
|
+
at: new Date().toISOString(),
|
|
200
|
+
ok: result.ok,
|
|
201
|
+
reason: result.reason,
|
|
202
|
+
created: result.created,
|
|
203
|
+
updated: result.updated,
|
|
204
|
+
marked: result.marked,
|
|
205
|
+
gcDropped: result.gcDropped,
|
|
206
|
+
detail,
|
|
207
|
+
})
|
|
208
|
+
.catch((err: unknown) => {
|
|
209
|
+
stateNote = `;distill-state 写入失败: ${err instanceof Error ? err.message : String(err)}`
|
|
210
|
+
})
|
|
211
|
+
const fullDetail =
|
|
212
|
+
stateNote === '' ? detail : detail === undefined ? stateNote.replace(/^;/, '') : `${detail}${stateNote}`
|
|
213
|
+
return { ...result, detail: fullDetail }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private async runInner(sessionId?: string): Promise<DistillResult> {
|
|
217
|
+
if (!this.configured || this.caller === undefined) {
|
|
218
|
+
return { ok: false, reason: 'no-model', created: [], updated: [], marked: 0 }
|
|
219
|
+
}
|
|
220
|
+
// Route gate — checked PER RUN here rather than once at wiring time, so
|
|
221
|
+
// `/topics set distill-provider` stays live without a plugin reload. An
|
|
222
|
+
// empty route must never start lane bookkeeping: a GC attempt recorded
|
|
223
|
+
// against a lane that cannot call the model would turn three automatic
|
|
224
|
+
// triggers into data deletion. Short-circuit BEFORE the first fetch,
|
|
225
|
+
// with the readable reason /topics distill surfaces.
|
|
226
|
+
const routeCfg = this.service.cfg
|
|
227
|
+
if ((routeCfg.distillProvider ?? '') === '' || (routeCfg.distillModel ?? '') === '') {
|
|
228
|
+
return {
|
|
229
|
+
ok: false,
|
|
230
|
+
reason: 'no-model',
|
|
231
|
+
created: [],
|
|
232
|
+
updated: [],
|
|
233
|
+
marked: 0,
|
|
234
|
+
detail: 'distill route not configured (set distill-provider and distill-model)',
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// Bounded batch loop (livelock fix): the pre-fix lane fed the whole
|
|
238
|
+
// newest-40 undistilled window into one model call and failed the entire
|
|
239
|
+
// run on a single output-limit overflow — nothing was ever marked, so
|
|
240
|
+
// the same batch was re-fetched forever. The loop walks the pool in
|
|
241
|
+
// batches, halves the batch on an output-limit failure (the escape
|
|
242
|
+
// hatch: a smaller head provably converges toward the floor), stops when
|
|
243
|
+
// a batch cannot make progress, and keeps the marks of every successful
|
|
244
|
+
// batch (partial progress beats zero progress) under a per-run budget.
|
|
245
|
+
const cfg = this.service.cfg
|
|
246
|
+
// The floor bounds the ADAPTIVE shrink only — an explicit (small) config
|
|
247
|
+
// value is honored as-is, clamped merely to a positive fetch size.
|
|
248
|
+
this.adoptBatchSize(Math.max(1, cfg.distillBatchSize ?? DEFAULT_BATCH_SIZE))
|
|
249
|
+
const maxCalls = Math.max(1, cfg.distillMaxModelCalls ?? DEFAULT_MAX_MODEL_CALLS)
|
|
250
|
+
const created: string[] = []
|
|
251
|
+
const updated: string[] = []
|
|
252
|
+
let marked = 0
|
|
253
|
+
let calls = 0
|
|
254
|
+
// Why the loop ended before draining the pool (undefined = pool drained).
|
|
255
|
+
let stopped: 'budget' | 'no-ops' | 'stalled' | 'max-tokens' | 'failure' | undefined
|
|
256
|
+
let failureReason: 'model-error' | 'invalid-output' | undefined
|
|
257
|
+
let failureDetail: string | undefined
|
|
258
|
+
let firstFetch = true
|
|
259
|
+
let filteredTotal = 0
|
|
260
|
+
let stallDetail: string | undefined
|
|
261
|
+
// GC bookkeeping (store.recordUnconsumed): every id the model EVALUATED
|
|
262
|
+
// this run (parseable answer, however useless) minus the ids an op
|
|
263
|
+
// consumed = one failed attempt per observation; three failed attempts
|
|
264
|
+
// delete it from the pool. Collection happens per-outcome in the loop
|
|
265
|
+
// below, not at fetch time: a batch whose call threw (model-error) or
|
|
266
|
+
// produced unparseable text (invalid-output) never counts, and neither
|
|
267
|
+
// does a shrink retry that has not been evaluated yet (BLOCKER-1: three
|
|
268
|
+
// ECONNREFUSED runs must not delete data the model never saw).
|
|
269
|
+
const fedIds = new Set<string>()
|
|
270
|
+
const consumedRunIds = new Set<string>()
|
|
271
|
+
for (;;) {
|
|
272
|
+
const observations = await this.service.store.undistilledObservations(this.batchSize)
|
|
273
|
+
if (observations.length === 0) {
|
|
274
|
+
if (firstFetch) {
|
|
275
|
+
return { ok: false, reason: 'no-observations', created: [], updated: [], marked: 0 }
|
|
276
|
+
}
|
|
277
|
+
break
|
|
278
|
+
}
|
|
279
|
+
firstFetch = false
|
|
280
|
+
if (calls >= maxCalls) {
|
|
281
|
+
stopped = 'budget'
|
|
282
|
+
break
|
|
283
|
+
}
|
|
284
|
+
// The batch spends 1 call, or 2 when the corrective observed_ids retry
|
|
285
|
+
// fires — the budget check above already guaranteed room for both.
|
|
286
|
+
const outcome = await this.runBatch(sessionId, observations, maxCalls - calls)
|
|
287
|
+
calls += Math.max(1, outcome.callsUsed ?? 1)
|
|
288
|
+
for (const id of outcome.consumedIds ?? []) consumedRunIds.add(id)
|
|
289
|
+
filteredTotal += outcome.filteredIds ?? 0
|
|
290
|
+
created.push(...outcome.created)
|
|
291
|
+
updated.push(...outcome.updated)
|
|
292
|
+
marked += outcome.marked
|
|
293
|
+
if (outcome.maxTokens) {
|
|
294
|
+
// Provisional failure record: if a later batch succeeds it stays
|
|
295
|
+
// unused (progress branch wins); if the run ends without progress
|
|
296
|
+
// (budget out, floor reached) it is exactly what the state should show.
|
|
297
|
+
failureReason = 'model-error'
|
|
298
|
+
failureDetail = outcome.detail
|
|
299
|
+
if (this.batchSize > MIN_BATCH_SIZE) {
|
|
300
|
+
this.batchSize = Math.max(MIN_BATCH_SIZE, Math.floor(this.batchSize / 2))
|
|
301
|
+
// Not evaluated yet: the same head is re-fed smaller below, and
|
|
302
|
+
// only the outcome that actually renders a verdict counts a GC
|
|
303
|
+
// attempt (a mid-shrink overflow is capacity, not a content
|
|
304
|
+
// judgment).
|
|
305
|
+
continue // retry the smaller same-head batch — the livelock escape
|
|
306
|
+
}
|
|
307
|
+
// Floor reached: repeated output-limit finishes ARE an evaluation
|
|
308
|
+
// verdict on this content (the model saw it and could not fit an
|
|
309
|
+
// answer) — the batch counts toward the GC.
|
|
310
|
+
for (const o of observations) fedIds.add(o.id)
|
|
311
|
+
stopped = 'max-tokens'
|
|
312
|
+
failureDetail = `批次无法再缩小(当前 ${this.batchSize} 条)仍触发输出上限:${outcome.detail ?? 'model finish: max-tokens'}`
|
|
313
|
+
break
|
|
314
|
+
}
|
|
315
|
+
if (outcome.fatalReason !== undefined) {
|
|
316
|
+
// model-error (call threw) / invalid-output (unparseable text): the
|
|
317
|
+
// model never evaluated the content, so the batch gains NO GC
|
|
318
|
+
// attempt — three network blips must not delete the pool.
|
|
319
|
+
stopped = 'failure'
|
|
320
|
+
failureReason = outcome.fatalReason
|
|
321
|
+
failureDetail = outcome.detail
|
|
322
|
+
break
|
|
323
|
+
}
|
|
324
|
+
// Parseable answer (ops, empty or not): the batch was genuinely
|
|
325
|
+
// evaluated — its unconsumed ids gain one failed attempt.
|
|
326
|
+
for (const o of observations) fedIds.add(o.id)
|
|
327
|
+
if (outcome.noOps) {
|
|
328
|
+
stopped = 'no-ops'
|
|
329
|
+
break
|
|
330
|
+
}
|
|
331
|
+
if (outcome.marked === 0) {
|
|
332
|
+
// Ops came back but consumed none of this batch's observations — the
|
|
333
|
+
// head cannot advance, so re-calling would only repeat (or duplicate
|
|
334
|
+
// topics). Stop and surface what already landed.
|
|
335
|
+
stopped = 'stalled'
|
|
336
|
+
stallDetail = outcome.stallDetail
|
|
337
|
+
break
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// Post-run GC: every fed-but-unconsumed observation gains a failed
|
|
341
|
+
// attempt; three strikes delete it. Best-effort — never fail a run over
|
|
342
|
+
// bookkeeping.
|
|
343
|
+
let gcDropped: number | undefined
|
|
344
|
+
if (fedIds.size > 0) {
|
|
345
|
+
try {
|
|
346
|
+
const { dropped } = await this.service.store.recordUnconsumed([...fedIds], [...consumedRunIds])
|
|
347
|
+
if (dropped > 0) gcDropped = dropped
|
|
348
|
+
} catch {
|
|
349
|
+
// contained — the run result stands without the GC note
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const progress = created.length + updated.length > 0
|
|
353
|
+
let result: DistillResult
|
|
354
|
+
if (progress) {
|
|
355
|
+
const head =
|
|
356
|
+
stopped === 'budget'
|
|
357
|
+
? `已达单次 run 模型调用预算(${calls}/${maxCalls}),剩余积压留待后续 run`
|
|
358
|
+
: stopped === 'max-tokens'
|
|
359
|
+
? '随后批次触发输出上限,本轮停止'
|
|
360
|
+
: stopped === 'failure'
|
|
361
|
+
? `随后批次失败,本轮停止:${failureDetail ?? 'unknown'}`
|
|
362
|
+
: stopped === 'no-ops'
|
|
363
|
+
? '后续批次模型未产出 ops,本轮停止'
|
|
364
|
+
: stopped === 'stalled'
|
|
365
|
+
? `后续批次未消费任何观察,本轮停止${stallDetail !== undefined ? `:${stallDetail}` : ''}`
|
|
366
|
+
: undefined
|
|
367
|
+
const filteredNote = filteredTotal > 0 ? `;filtered ${filteredTotal} invalid observed_ids` : ''
|
|
368
|
+
const base = head === undefined ? undefined : `partial: 已蒸馏标记 ${marked} 条观察(${calls} 次模型调用);${head}`
|
|
369
|
+
result = {
|
|
370
|
+
ok: true,
|
|
371
|
+
created,
|
|
372
|
+
updated,
|
|
373
|
+
marked,
|
|
374
|
+
detail:
|
|
375
|
+
base === undefined
|
|
376
|
+
? filteredNote === ''
|
|
377
|
+
? undefined
|
|
378
|
+
: filteredNote.replace(/^;/, '')
|
|
379
|
+
: `${base}${filteredNote}`,
|
|
380
|
+
}
|
|
381
|
+
} else {
|
|
382
|
+
result = {
|
|
383
|
+
ok: false,
|
|
384
|
+
reason:
|
|
385
|
+
failureReason ??
|
|
386
|
+
(stopped === 'no-ops' ? 'no-ops' : stopped === 'stalled' ? 'stalled' : undefined),
|
|
387
|
+
created: [],
|
|
388
|
+
updated: [],
|
|
389
|
+
marked: 0,
|
|
390
|
+
// A stalled run must never land as an unexplained failure: the batch
|
|
391
|
+
// names its own zero-consumption cause when it can (invalid
|
|
392
|
+
// observed_ids), otherwise the generic slug hint stands.
|
|
393
|
+
detail:
|
|
394
|
+
failureDetail ??
|
|
395
|
+
(stopped === 'stalled'
|
|
396
|
+
? stallDetail ?? '批次 ops 未消费任何观察(常见原因:ops 引用了不存在的 topic slug)'
|
|
397
|
+
: undefined),
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (gcDropped !== undefined) result.gcDropped = gcDropped
|
|
401
|
+
return result
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* One model call (plus at most one corrective retry) over one batch:
|
|
406
|
+
* payload build → ops → topic writes → marks. `budgetLeft` is the calls
|
|
407
|
+
* still available to the run including this batch's first call (≥ 1); the
|
|
408
|
+
* corrective retry only fires when it can stay inside that budget.
|
|
409
|
+
*/
|
|
410
|
+
private async runBatch(
|
|
411
|
+
sessionId: string | undefined,
|
|
412
|
+
observations: readonly ObservationLike[],
|
|
413
|
+
budgetLeft: number,
|
|
414
|
+
): Promise<BatchOutcome> {
|
|
415
|
+
const metas = await this.service.store.listTopics()
|
|
416
|
+
// Real open questions need the docs; fetch for index (bounded).
|
|
417
|
+
const indexDetailed = []
|
|
418
|
+
for (const m of metas.slice(0, 100)) {
|
|
419
|
+
const doc = await this.service.store.readTopic(m.slug).catch(() => undefined)
|
|
420
|
+
indexDetailed.push({
|
|
421
|
+
slug: m.slug,
|
|
422
|
+
title: m.title,
|
|
423
|
+
status: m.status,
|
|
424
|
+
tags: m.tags,
|
|
425
|
+
open_questions: doc?.fm.open_questions ?? [],
|
|
426
|
+
conclusion: (okf.firstParagraph(okf.sectionOf(doc?.body ?? '', okf.CONCLUSION_HEADING) ?? '')).slice(0, 200),
|
|
427
|
+
})
|
|
428
|
+
}
|
|
429
|
+
const observationsPayload = observations.map((o) => ({ id: o.id, kind: o.kind, source: o.source, text: o.text }))
|
|
430
|
+
const user = [
|
|
431
|
+
`现有 topic 索引(${indexDetailed.length} 个):`,
|
|
432
|
+
JSON.stringify(indexDetailed),
|
|
433
|
+
'',
|
|
434
|
+
`未蒸馏观察(${observationsPayload.length} 条):`,
|
|
435
|
+
JSON.stringify(observationsPayload),
|
|
436
|
+
'',
|
|
437
|
+
'请输出蒸馏结果(严格 JSON,{"ops":[...]}):',
|
|
438
|
+
].join('\n')
|
|
439
|
+
let raw: string
|
|
440
|
+
const caller = this.caller
|
|
441
|
+
if (caller === undefined) {
|
|
442
|
+
// Unreachable via runInner (it guards), but never let a future call site
|
|
443
|
+
// turn a wiring bug into a silent invalid-output.
|
|
444
|
+
return { created: [], updated: [], marked: 0, fatalReason: 'model-error', detail: 'distill caller unavailable' }
|
|
445
|
+
}
|
|
446
|
+
try {
|
|
447
|
+
raw = await caller({ system: SYSTEM_PROMPT, user, purpose: 'topics-distill', sessionId, maxTokens: 4000 })
|
|
448
|
+
} catch (e) {
|
|
449
|
+
const detail = String(e instanceof Error ? e.message : e).slice(0, 200)
|
|
450
|
+
return isMaxTokens(e)
|
|
451
|
+
? { created: [], updated: [], marked: 0, maxTokens: true, detail }
|
|
452
|
+
: { created: [], updated: [], marked: 0, fatalReason: 'model-error', detail }
|
|
453
|
+
}
|
|
454
|
+
let ops: DistillOp[]
|
|
455
|
+
try {
|
|
456
|
+
ops = parseOps(raw)
|
|
457
|
+
} catch (e) {
|
|
458
|
+
return {
|
|
459
|
+
created: [],
|
|
460
|
+
updated: [],
|
|
461
|
+
marked: 0,
|
|
462
|
+
fatalReason: 'invalid-output',
|
|
463
|
+
detail: String(e instanceof Error ? e.message : e).slice(0, 200),
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (ops.length === 0) {
|
|
467
|
+
return { created: [], updated: [], marked: 0, noOps: true, callsUsed: 1 }
|
|
468
|
+
}
|
|
469
|
+
// observed_ids are the ONLY bridge between ops and marks — without a valid
|
|
470
|
+
// id the store cannot mark anything and the batch head can never advance
|
|
471
|
+
// (the real-machine zero-consumption livelock: topics landed, backlog
|
|
472
|
+
// never moved). Sanitize against the batch's TRUE id set BEFORE executing:
|
|
473
|
+
// an op whose ids all miss the batch is unattributable and gets held back,
|
|
474
|
+
// because executing it anyway would duplicate the topic when the
|
|
475
|
+
// corrective retry re-emits it with fixed ids.
|
|
476
|
+
const validIds = new Set(observations.map((o) => o.id))
|
|
477
|
+
const idList = observations.map((o) => o.id)
|
|
478
|
+
let applied = await this.applyOps(ops, validIds)
|
|
479
|
+
let callsUsed = 1
|
|
480
|
+
if (applied.consumedIds.length === 0 && applied.droppedForIds > 0) {
|
|
481
|
+
if (budgetLeft < 2) {
|
|
482
|
+
// No free lane: the retry must fit the same run budget.
|
|
483
|
+
return {
|
|
484
|
+
created: [],
|
|
485
|
+
updated: [],
|
|
486
|
+
marked: 0,
|
|
487
|
+
callsUsed,
|
|
488
|
+
filteredIds: applied.filteredIds,
|
|
489
|
+
stallDetail: `ops 未包含有效 observed_ids(已过滤 ${applied.filteredIds} 个无效 id),且模型调用预算不足以纠错重试(剩余 ${budgetLeft - 1} 次)`,
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const pass1 = applied
|
|
493
|
+
const { applied: pass2, why } = await this.correctiveRetry(sessionId, user, idList, validIds)
|
|
494
|
+
callsUsed = 2
|
|
495
|
+
if (pass2 === undefined || pass2.consumedIds.length === 0) {
|
|
496
|
+
const tail =
|
|
497
|
+
pass2 === undefined
|
|
498
|
+
? `纠错重试未产出可消费的 ops(${why})`
|
|
499
|
+
: `纠错重试后 ops 仍未包含有效 observed_ids(累计过滤 ${pass1.filteredIds + pass2.filteredIds} 个无效 id)`
|
|
500
|
+
return {
|
|
501
|
+
created: [...pass1.created, ...pass2?.created ?? []],
|
|
502
|
+
updated: [...pass1.updated, ...pass2?.updated ?? []],
|
|
503
|
+
marked: 0,
|
|
504
|
+
callsUsed,
|
|
505
|
+
filteredIds: pass1.filteredIds + (pass2?.filteredIds ?? 0),
|
|
506
|
+
stallDetail: `${tail};本批 ${observations.length} 条观察零消费`,
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
applied = {
|
|
510
|
+
created: [...pass1.created, ...pass2.created],
|
|
511
|
+
updated: [...pass1.updated, ...pass2.updated],
|
|
512
|
+
consumedIds: pass2.consumedIds,
|
|
513
|
+
consumedSlugs: pass2.consumedSlugs,
|
|
514
|
+
filteredIds: pass1.filteredIds + pass2.filteredIds,
|
|
515
|
+
droppedForIds: pass2.droppedForIds,
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
let marked = 0
|
|
519
|
+
if (applied.consumedIds.length > 0) {
|
|
520
|
+
marked = await this.service.store.markDistilled(applied.consumedIds, applied.consumedSlugs)
|
|
521
|
+
}
|
|
522
|
+
void this.service.sync?.schedulePush()
|
|
523
|
+
return {
|
|
524
|
+
created: applied.created,
|
|
525
|
+
updated: applied.updated,
|
|
526
|
+
marked,
|
|
527
|
+
callsUsed,
|
|
528
|
+
filteredIds: applied.filteredIds,
|
|
529
|
+
consumedIds: applied.consumedIds,
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* The ONE corrective retry for a zero-valid-observed_ids batch: re-ask with
|
|
535
|
+
* the batch's legal id list spelled out verbatim. It spends a second call
|
|
536
|
+
* from the same run budget; any failure here resolves to
|
|
537
|
+
* `{ applied: undefined, why }` and the caller falls through to the stalled
|
|
538
|
+
* stop — a max-tokens correction does NOT trigger the batch halving (that
|
|
539
|
+
* escape hatch belongs to the main pass).
|
|
540
|
+
*/
|
|
541
|
+
private async correctiveRetry(
|
|
542
|
+
sessionId: string | undefined,
|
|
543
|
+
originalUser: string,
|
|
544
|
+
idList: readonly string[],
|
|
545
|
+
validIds: ReadonlySet<string>,
|
|
546
|
+
): Promise<{ applied?: AppliedOps; why: string }> {
|
|
547
|
+
const caller = this.caller
|
|
548
|
+
if (caller === undefined) return { why: 'distill caller unavailable' }
|
|
549
|
+
const user = [
|
|
550
|
+
originalUser,
|
|
551
|
+
'',
|
|
552
|
+
'---',
|
|
553
|
+
'纠错重试:你上一次返回的 ops 未包含任何有效的 observed_ids。',
|
|
554
|
+
`本批合法观察 id 列表(共 ${idList.length} 个,必须逐字复制,禁止改写、缩写或编造):`,
|
|
555
|
+
JSON.stringify(idList),
|
|
556
|
+
'请重新输出完整蒸馏结果(严格 JSON,{"ops":[...]}):每个 op 必须带 observed_ids,值只能从上面的列表逐字选取;create 填它所综合依据的观察 id,update 填促使本次修订的观察 id。没有可沉淀内容就输出 {"ops":[]}。',
|
|
557
|
+
].join('\n')
|
|
558
|
+
let raw: string
|
|
559
|
+
try {
|
|
560
|
+
raw = await caller({ system: SYSTEM_PROMPT, user, purpose: 'topics-distill', sessionId, maxTokens: 4000 })
|
|
561
|
+
} catch (e) {
|
|
562
|
+
return { why: `模型调用失败:${String(e instanceof Error ? e.message : e).slice(0, 160)}` }
|
|
563
|
+
}
|
|
564
|
+
let retried: DistillOp[]
|
|
565
|
+
try {
|
|
566
|
+
retried = parseOps(raw)
|
|
567
|
+
} catch (e) {
|
|
568
|
+
return { why: `输出无法解析:${String(e instanceof Error ? e.message : e).slice(0, 160)}` }
|
|
569
|
+
}
|
|
570
|
+
if (retried.length === 0) return { why: '模型返回空 ops' }
|
|
571
|
+
return { applied: await this.applyOps(retried, validIds), why: '' }
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Execute a batch's ops with observed_ids sanitized upfront. A scalar
|
|
576
|
+
* observed_ids string (the commonest model shape drift) is normalized to a
|
|
577
|
+
* one-element list; other non-array shapes are held back as droppedForIds
|
|
578
|
+
* rather than dying in the per-op catch. An op whose
|
|
579
|
+
* sanitized id set is empty is held back entirely (droppedForIds): an
|
|
580
|
+
* unattributable topic write is one the marks can never account for, and
|
|
581
|
+
* the stalled stop would leave it duplicated on the next run. Structural
|
|
582
|
+
* failures (missing fields, unknown update slug) keep the per-op try/catch
|
|
583
|
+
* isolation — one bad op never sinks the batch, and a stall they cause is
|
|
584
|
+
* NOT attributed to observed_ids (no corrective retry for those).
|
|
585
|
+
*/
|
|
586
|
+
private async applyOps(ops: readonly DistillOp[], validIds: ReadonlySet<string>): Promise<AppliedOps> {
|
|
587
|
+
const applied: AppliedOps = {
|
|
588
|
+
created: [],
|
|
589
|
+
updated: [],
|
|
590
|
+
consumedIds: [],
|
|
591
|
+
consumedSlugs: [],
|
|
592
|
+
filteredIds: 0,
|
|
593
|
+
droppedForIds: 0,
|
|
594
|
+
}
|
|
595
|
+
for (const op of ops) {
|
|
596
|
+
try {
|
|
597
|
+
// Shape normalization first: models most often deviate by returning a
|
|
598
|
+
// scalar string instead of a list — rescue it into a one-element list.
|
|
599
|
+
// Any other non-array shape counts toward droppedForIds so the
|
|
600
|
+
// corrective-retry gate fires, instead of a silent TypeError in the
|
|
601
|
+
// per-op catch below that would misattribute the stall.
|
|
602
|
+
const rawIds: readonly unknown[] = Array.isArray(op.observed_ids)
|
|
603
|
+
? op.observed_ids
|
|
604
|
+
: typeof op.observed_ids === 'string'
|
|
605
|
+
? [op.observed_ids]
|
|
606
|
+
: []
|
|
607
|
+
const ids = rawIds.filter((id): id is string => typeof id === 'string' && validIds.has(id))
|
|
608
|
+
applied.filteredIds += Math.max(0, rawIds.length - ids.length)
|
|
609
|
+
if (ids.length === 0) {
|
|
610
|
+
applied.droppedForIds += 1
|
|
611
|
+
continue
|
|
612
|
+
}
|
|
613
|
+
if (op.op === 'create' && typeof op.title === 'string' && typeof op.conclusion === 'string') {
|
|
614
|
+
const res = await this.service.saveTopic({
|
|
615
|
+
title: op.title,
|
|
616
|
+
conclusion: op.conclusion,
|
|
617
|
+
description: op.description,
|
|
618
|
+
tags: op.tags,
|
|
619
|
+
depends: op.depends,
|
|
620
|
+
openQuestions: op.open_questions,
|
|
621
|
+
impact: op.impact,
|
|
622
|
+
recommendations: op.recommendations,
|
|
623
|
+
status: op.status === 'stable' ? 'stable' : 'draft',
|
|
624
|
+
source: 'distill',
|
|
625
|
+
})
|
|
626
|
+
applied.created.push(res.slug)
|
|
627
|
+
applied.consumedSlugs.push(res.slug)
|
|
628
|
+
} else if (op.op === 'update' && typeof op.slug === 'string') {
|
|
629
|
+
const existing = await this.service.store.readTopic(op.slug)
|
|
630
|
+
if (existing === undefined) continue
|
|
631
|
+
const res = await this.service.saveTopic({
|
|
632
|
+
title: existing.fm.title,
|
|
633
|
+
conclusion: typeof op.conclusion === 'string' ? op.conclusion : undefined,
|
|
634
|
+
openQuestions: op.open_questions,
|
|
635
|
+
status: op.status === 'stable' || op.status === 'deprecated' ? op.status : undefined,
|
|
636
|
+
slug: op.slug,
|
|
637
|
+
source: 'distill',
|
|
638
|
+
})
|
|
639
|
+
applied.updated.push(res.slug)
|
|
640
|
+
applied.consumedSlugs.push(res.slug)
|
|
641
|
+
} else {
|
|
642
|
+
continue
|
|
643
|
+
}
|
|
644
|
+
applied.consumedIds.push(...ids)
|
|
645
|
+
} catch {
|
|
646
|
+
// One bad op must not sink the batch; unconsumed observations stay pending.
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
return applied
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** Extract the first balanced JSON object from model output (tolerates fences). */
|
|
654
|
+
export function parseOps(raw: string): DistillOp[] {
|
|
655
|
+
let text = raw.trim()
|
|
656
|
+
const fence = /```(?:json)?\s*([\s\S]*?)```/.exec(text)
|
|
657
|
+
if (fence !== null) text = fence[1].trim()
|
|
658
|
+
const start = text.indexOf('{')
|
|
659
|
+
if (start < 0) throw new Error('no JSON object in output')
|
|
660
|
+
// Balanced-brace scan respecting strings.
|
|
661
|
+
let depth = 0
|
|
662
|
+
let inString = false
|
|
663
|
+
let escaped = false
|
|
664
|
+
for (let i = start; i < text.length; i += 1) {
|
|
665
|
+
const ch = text[i]
|
|
666
|
+
if (escaped) {
|
|
667
|
+
escaped = false
|
|
668
|
+
continue
|
|
669
|
+
}
|
|
670
|
+
if (ch === '\\') {
|
|
671
|
+
escaped = true
|
|
672
|
+
continue
|
|
673
|
+
}
|
|
674
|
+
if (ch === '"') inString = !inString
|
|
675
|
+
if (inString) continue
|
|
676
|
+
if (ch === '{') depth += 1
|
|
677
|
+
if (ch === '}') {
|
|
678
|
+
depth -= 1
|
|
679
|
+
if (depth === 0) {
|
|
680
|
+
const parsed: unknown = JSON.parse(text.slice(start, i + 1))
|
|
681
|
+
const ops = (parsed as { ops?: unknown }).ops
|
|
682
|
+
if (!Array.isArray(ops)) throw new Error('output has no ops array')
|
|
683
|
+
return ops.filter((o): o is DistillOp => o !== null && typeof o === 'object')
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
throw new Error('unbalanced JSON in output')
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Minimal live-route probe surface, shared by the distill lane and the
|
|
692
|
+
* distill-route pickers: any llm-like service instance with an optional
|
|
693
|
+
* provider listing. A disposed scope throws on access.
|
|
694
|
+
*/
|
|
695
|
+
export interface LlmProbeShape {
|
|
696
|
+
listProviders?(): readonly { id: string }[]
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* A candidate llm instance plus the cheap liveness probe. `listProviders` is
|
|
701
|
+
* optional so bare fakes still work; when present it must list the route's
|
|
702
|
+
* provider for the instance to qualify.
|
|
703
|
+
*/
|
|
704
|
+
export interface LlmCandidateShape extends LlmProbeShape {
|
|
705
|
+
stream(options: unknown): AsyncIterable<unknown>
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
export interface LlmCandidatePick {
|
|
709
|
+
llm?: LlmProbeShape
|
|
710
|
+
/** How many candidates exposed a live route table (probe ran cleanly). */
|
|
711
|
+
probed: number
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* The one walker behind both llm-instance picks (distill lane + route pickers)
|
|
716
|
+
* so their semantics cannot drift: walk the candidates in priority order and
|
|
717
|
+
* return the first whose live route table satisfies `probe`, plus how many
|
|
718
|
+
* candidates were cleanly probed along the way (the caller uses that to tell
|
|
719
|
+
* 「有实例但不匹配」 apart from 「没有实例」). A disposed scope throws on
|
|
720
|
+
* access — that instance is skipped, not fatal. An instance WITHOUT the probe
|
|
721
|
+
* is accepted as-is unless `requireProbe` is set: it offers nothing to compare
|
|
722
|
+
* against, so an unprobable candidate can never vouch for the route.
|
|
723
|
+
*/
|
|
724
|
+
export function pickLlmCandidate(
|
|
725
|
+
candidates: readonly (LlmProbeShape | undefined)[],
|
|
726
|
+
probe: (providers: readonly { id: string }[]) => boolean,
|
|
727
|
+
requireProbe = false,
|
|
728
|
+
): LlmCandidatePick {
|
|
729
|
+
let probed = 0
|
|
730
|
+
for (const candidate of candidates) {
|
|
731
|
+
if (candidate === undefined) continue
|
|
732
|
+
try {
|
|
733
|
+
const providers = typeof candidate.listProviders === 'function' ? candidate.listProviders() : undefined
|
|
734
|
+
if (providers !== undefined) {
|
|
735
|
+
probed += 1
|
|
736
|
+
if (!probe(providers)) continue
|
|
737
|
+
} else if (requireProbe) {
|
|
738
|
+
// An unprobeable instance cannot prove an adapter serves the route —
|
|
739
|
+
// skipping it is what lets the pre-flight fail with the readable
|
|
740
|
+
// message instead of letting a later stream() surface a raw NO_ADAPTER.
|
|
741
|
+
continue
|
|
742
|
+
}
|
|
743
|
+
} catch {
|
|
744
|
+
continue // disposed scope: even touching the service throws
|
|
745
|
+
}
|
|
746
|
+
return { llm: candidate, probed }
|
|
747
|
+
}
|
|
748
|
+
return { probed }
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Cheap pre-flight for the distill lane: walk the candidates in priority order
|
|
753
|
+
* (per-session capture first, then the session-wide capture, apply-time root
|
|
754
|
+
* last) and return the only instance that honestly qualifies — one whose live
|
|
755
|
+
* `listProviders()` PROVES a registered adapter serves `provider`. On dsh-llm
|
|
756
|
+
* 0.1.2-alpha.4 `listProviders()` reads the same adapter registry `stream()`
|
|
757
|
+
* dispatches through (lib/index.js:1308 vs :1626), so a probed match is an
|
|
758
|
+
* adapter guarantee; candidates that cannot be probed are skipped, not
|
|
759
|
+
* trusted. Fails BEFORE calling stream, with the failure mode readable: a
|
|
760
|
+
* reachable instance that lacks the route (scope released by a teardown, or a
|
|
761
|
+
* typo'd provider) says so; no reachable instance at all says that instead.
|
|
762
|
+
*/
|
|
763
|
+
export function pickLiveLlm(candidates: readonly (LlmCandidateShape | undefined)[], provider: string): LlmCandidateShape {
|
|
764
|
+
const { llm, probed } = pickLlmCandidate(
|
|
765
|
+
candidates,
|
|
766
|
+
(providers) => providers.some((p) => p.id === provider),
|
|
767
|
+
true,
|
|
768
|
+
)
|
|
769
|
+
if (llm !== undefined) return llm as LlmCandidateShape
|
|
770
|
+
if (probed > 0) {
|
|
771
|
+
throw new Error(`distill-provider «${provider}» 没有匹配的模型路由(检查拼写或本机 provider 配置),等待下次会话启动重试`)
|
|
772
|
+
}
|
|
773
|
+
throw new Error(`蒸馏模型路由 ${provider} 暂不可用:没有可用的模型服务实例(会话已结束或本机 llm 服务缺失)`)
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** True for the adapter-registry miss thrown by `llm.stream()` on a route nobody registered. */
|
|
777
|
+
function isNoAdapter(error: unknown): boolean {
|
|
778
|
+
if (error === null || typeof error !== 'object') return false
|
|
779
|
+
if ((error as { code?: unknown }).code === 'NO_ADAPTER') return true
|
|
780
|
+
return typeof (error as { message?: unknown }).message === 'string' && /no adapter registered for provider/i.test((error as { message: string }).message)
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* True for the output-token-limit finish — the one model failure a smaller
|
|
785
|
+
* batch can actually rescue (fewer observations in, fewer ops out). Stamped
|
|
786
|
+
* as `code: 'MAX_TOKENS'` by defaultModelCaller's finish branch; the message
|
|
787
|
+
* fallback is anchored to the exact finish shape this lane itself emits
|
|
788
|
+
* (`model finish: <kind>`), so a request-side error that merely mentions
|
|
789
|
+
* max-tokens in its text can never trigger the persistent batch shrink.
|
|
790
|
+
*/
|
|
791
|
+
function isMaxTokens(error: unknown): boolean {
|
|
792
|
+
if (error === null || typeof error !== 'object') return false
|
|
793
|
+
if ((error as { code?: unknown }).code === 'MAX_TOKENS') return true
|
|
794
|
+
return (
|
|
795
|
+
typeof (error as { message?: unknown }).message === 'string' &&
|
|
796
|
+
/^model finish: (max-tokens|length)$/i.test((error as { message: string }).message)
|
|
797
|
+
)
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Default ModelCaller over the dsh LLM seam (`ctx.llm.stream` + BlockAssembler),
|
|
802
|
+
* following dsh-session-title-llm's call policy. `getCandidates` supplies the
|
|
803
|
+
* ordered instance candidates — production passes the triggering session's
|
|
804
|
+
* captured instance first, then the session-wide capture and the apply-time
|
|
805
|
+
* root; the first one whose live routes prove the configured provider wins.
|
|
806
|
+
* The configured-route gate is NOT here: `Distiller.runInner` checks the
|
|
807
|
+
* route per run (so `/topics set distill-provider` stays live without a
|
|
808
|
+
* reload); this closure's own route check at call time is defense-in-depth
|
|
809
|
+
* for direct callers.
|
|
810
|
+
*
|
|
811
|
+
* Last-line guarantee: whatever host shape the candidates come from, a
|
|
812
|
+
* `NO_ADAPTER` miss inside `stream()` is never recorded raw — it is rethrown
|
|
813
|
+
* as a readable failure naming the route and the likely cause, so the
|
|
814
|
+
* distill-state detail stays actionable (CHANGELOG promise).
|
|
815
|
+
*
|
|
816
|
+
* dsh 0.1.2-alpha.3 seam shape: `GenerateOptions.purpose` is the closed union
|
|
817
|
+
* `'compaction' | 'session-title'` — a distill call has no sanctioned value
|
|
818
|
+
* there, so it stays unset (ordinary-conversation classification). `sessionId`
|
|
819
|
+
* is `Branded<'SessionId'>`, stamped by the agent loop for request routing and
|
|
820
|
+
* replay cursors; this lane runs outside the loop (often after session end),
|
|
821
|
+
* so it stays unset too. `deepFreeze` moved to @deepseek-ai/dsh-util-values.
|
|
822
|
+
*/
|
|
823
|
+
export function defaultModelCaller(
|
|
824
|
+
getCandidates: (req?: ModelRequest) => readonly (LlmCandidateShape | undefined)[],
|
|
825
|
+
getRoute: () => { provider: string; model: string } | undefined,
|
|
826
|
+
): ModelCaller | undefined {
|
|
827
|
+
return async (req) => {
|
|
828
|
+
const route = getRoute()
|
|
829
|
+
if (route === undefined) throw new Error('distill route not configured (set distill-provider and distill-model)')
|
|
830
|
+
const llm = pickLiveLlm(getCandidates(req), route.provider)
|
|
831
|
+
const { BlockAssembler, createUserMessage } = await import('@deepseek-ai/dsh-llm')
|
|
832
|
+
const { deepFreeze } = await import('@deepseek-ai/dsh-util-values')
|
|
833
|
+
const messages = [
|
|
834
|
+
createUserMessage({
|
|
835
|
+
content: [{ type: 'text', text: req.user }],
|
|
836
|
+
source: { kind: 'plugin', plugin: 'dsh-topics-memory' },
|
|
837
|
+
}),
|
|
838
|
+
]
|
|
839
|
+
const options = deepFreeze({
|
|
840
|
+
provider: route.provider,
|
|
841
|
+
model: route.model,
|
|
842
|
+
messages,
|
|
843
|
+
system: req.system,
|
|
844
|
+
maxTokens: req.maxTokens,
|
|
845
|
+
signal: req.signal,
|
|
846
|
+
})
|
|
847
|
+
const assembler = new BlockAssembler()
|
|
848
|
+
try {
|
|
849
|
+
for await (const chunk of llm.stream(options)) {
|
|
850
|
+
;(assembler as unknown as { push(c: unknown): void }).push(chunk)
|
|
851
|
+
}
|
|
852
|
+
} catch (e) {
|
|
853
|
+
if (isNoAdapter(e)) {
|
|
854
|
+
const message = e instanceof Error ? e.message : String(e)
|
|
855
|
+
throw new Error(
|
|
856
|
+
`蒸馏模型路由 ${route.provider}/${route.model} 缺少 provider 适配器(${message}):` +
|
|
857
|
+
'会话级 llm 实例可能已随会话结束释放,请在会话活跃期间触发蒸馏,或检查宿主是否加载了 provider 适配器插件',
|
|
858
|
+
)
|
|
859
|
+
}
|
|
860
|
+
throw e
|
|
861
|
+
}
|
|
862
|
+
// Defensive: tolerate both object {kind, ...} and bare-string finish
|
|
863
|
+
// shapes; the bare-string form has never been observed in any known
|
|
864
|
+
// dsh-llm version.
|
|
865
|
+
const finish = assembler.finish as string | { kind?: string; failure?: { message: string } } | undefined
|
|
866
|
+
const finishKind = typeof finish === 'string' ? finish : finish?.kind
|
|
867
|
+
const failure = typeof finish === 'object' && finish !== undefined ? finish.failure : undefined
|
|
868
|
+
if (finish !== undefined && finishKind !== 'stop') {
|
|
869
|
+
const message = failure?.message ?? `model finish: ${String(finishKind)}`
|
|
870
|
+
if (finishKind === 'max-tokens' || finishKind === 'length') {
|
|
871
|
+
// Code-stamped so the distill loop can classify it as the one
|
|
872
|
+
// retryable-with-a-smaller-batch failure (isMaxTokens).
|
|
873
|
+
throw Object.assign(new Error(message), { code: 'MAX_TOKENS' })
|
|
874
|
+
}
|
|
875
|
+
throw new Error(message)
|
|
876
|
+
}
|
|
877
|
+
const blocks = assembler.blocks() as { type: string; text?: string }[]
|
|
878
|
+
const text = blocks
|
|
879
|
+
.filter((b) => b.type === 'text' && typeof b.text === 'string')
|
|
880
|
+
.map((b) => b.text as string)
|
|
881
|
+
.join('')
|
|
882
|
+
if (text.trim() === '') throw new Error('model produced no text')
|
|
883
|
+
return text
|
|
884
|
+
}
|
|
885
|
+
}
|