@master0071/dsh-systemone 0.0.0-stage → 0.4.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/lib/auto.js ADDED
@@ -0,0 +1,527 @@
1
+ /**
2
+ * auto.js — 自动决策注入。
3
+ *
4
+ * 把 SystemOne 从"模型主动调用的工具"升级为"每一步推理前自动执行的判断"。
5
+ *
6
+ * 挂钩点(已核对宿主源码 @deepseek-ai/dsh-agent-loop 的 preStep):
7
+ *
8
+ * const claimed = inbox.claim(target, position.turn) // 本轮新进消息
9
+ * const assembly = await systemPrompt.assemble(...) // ① 先装配提示词
10
+ * const context = runtimeContext.project(...) // ② 渲染运行时上下文
11
+ * const decision = await waterfall('agent/pre-step', { // ③ 再跑瀑布
12
+ * messages: claimed, ... }, () => ({ kind: 'enter',
13
+ * messages: [...claimed, context] }))
14
+ *
15
+ * 时序约束(重要):
16
+ * - `systemPrompt.context` 的 text 是**同步求值**,发生在 assembly 期间;
17
+ * 而决策依赖异步 HTTP 调用。宿主在消息入箱后立即同步唤醒 driver,
18
+ * 中间没有任何可等待异步结果的挂载点。
19
+ * - 因此「同一步生效」只能走 pre-step 的 messages 注入(autoInject='message'),
20
+ * 该通道由宿主无条件持久化到会话历史(line 1061: surfaceOp:'append')。
21
+ * 本模块用「内容去重」把污染从"每步一条"降为"每个不同决策一条"。
22
+ * - autoInject='context' 通道不写历史,但在第一步装配时决策通常尚未就绪,
23
+ * 从第二步(工具续步)起才稳定可见。
24
+ *
25
+ * 安全约束(任何一条不满足都直接放行,绝不阻塞会话):
26
+ * - 默认关闭(config.autoDecide = false)
27
+ * - 超时(autoTimeoutMs)与失败一律 fail-open
28
+ * - 相同 state 走缓存(autoCacheTtlMs),并发相同 state 走 in-flight 去重
29
+ * - 只在"本轮有新用户消息"时触发,工具结果步骤不触发
30
+ * - 跳过斜杠命令、跳过本插件自己注入的运行时上下文
31
+ */
32
+ import { runScenarioDecision } from './tools.js'
33
+ import { findScenario } from './scenarios.js'
34
+ import { readBoolean, readNumber, readString } from './volatile.js'
35
+
36
+ /** 宿主运行时上下文使用的 source.kind(见 dsh-agent-loop 的 SOURCE 常量)。 */
37
+ const RUNTIME_CONTEXT_SOURCE = 'runtime-context'
38
+
39
+ /**
40
+ * 安装自动决策。返回卸载函数。
41
+ *
42
+ * @param {object} ctx - 插件上下文
43
+ * @param {{ provider: object, config: object, scenarios: object[], logger?: object }} deps
44
+ * @returns {() => void}
45
+ */
46
+ export function installAutoDecide(ctx, deps) {
47
+ const { provider, config, scenarios, logger } = deps
48
+ if (!readBoolean(config, 'autoDecide', false)) return () => {}
49
+
50
+ const disposers = []
51
+ /** agentId → { stateKey, text, scenarioId, source, headline, at }:供 context 通道渲染。 */
52
+ const perAgent = new Map()
53
+ /** agentId → 上次注入的决策文本:同一决策只注入一次,避免每步重复污染历史。 */
54
+ const lastInjected = new Map()
55
+ /** stateHash → { at, result }:避免同一内容重复请求。 */
56
+ const cache = new Map()
57
+ /** stateHash → Promise:并发相同 state 只发一次上游请求。 */
58
+ const inFlight = new Map()
59
+ // 每次都经 unwrap 读取,配置页里的改动无需重启即生效
60
+ const minChars = readNumber(config, 'autoMinChars', 0)
61
+ const routeFloor = readNumber(config, 'autoRouteMinConfidence', 0)
62
+
63
+ const log = (level, message) => {
64
+ try {
65
+ const fn = logger?.[level]
66
+ if (typeof fn === 'function') fn.call(logger, `@master0071/dsh-systemone[auto]: ${message}`)
67
+ } catch {
68
+ // 日志失败不影响会话
69
+ }
70
+ }
71
+
72
+ // agent 销毁时清理状态,避免长会话内存泄漏
73
+ disposers.push(ctx.on('agent/disposed', (payload) => {
74
+ const id = payload?.agent?.id
75
+ if (id !== undefined) {
76
+ perAgent.delete(id)
77
+ lastInjected.delete(id)
78
+ }
79
+ }))
80
+
81
+ // ── context 通道:入箱预计算 + 动态上下文渲染(不写历史,第二步起稳定可见) ──
82
+ if (readString(config, 'autoInject', 'message') === 'context') {
83
+ disposers.push(ctx.on('agent/created', (payload) => {
84
+ const agent = payload?.agent
85
+ const agentCtx = agent?.ctx
86
+ if (!agentCtx) return
87
+ // ① 消息一入箱就异步预计算,让决策尽可能在 assembly 之前就绪
88
+ if (typeof agentCtx.on === 'function') {
89
+ try {
90
+ disposers.push(agentCtx.on('agent/inbox/inserted', (event) => {
91
+ const newText = extractUserText([event?.message])
92
+ if (!newText) return
93
+ if (newText.trimStart().startsWith('/')) return
94
+ if (isTrivialInput(newText, minChars)) return
95
+ // 返回 promise 便于测试等待;宿主事件总线忽略返回值
96
+ return computeAndStore(agent, newText)
97
+ }))
98
+ } catch (error) {
99
+ log('warn', `注册入箱预计算失败:${messageOf(error)}`)
100
+ }
101
+ }
102
+ // ② 把最新决策渲染进作用域动态上下文
103
+ if (agentCtx?.systemPrompt?.context) {
104
+ try {
105
+ disposers.push(agentCtx.systemPrompt.context({
106
+ name: 'systemone-auto-decision',
107
+ order: 900,
108
+ text: () => perAgent.get(agent?.id)?.text || '',
109
+ }))
110
+ } catch (error) {
111
+ log('warn', `注册动态上下文失败:${messageOf(error)}`)
112
+ }
113
+ }
114
+ }))
115
+ }
116
+
117
+ // ── pre-step:同一步生效(仅 message 通道) ──
118
+ disposers.push(ctx.on('agent/pre-step', async (payload, next) => {
119
+ let decision
120
+ try {
121
+ decision = await next()
122
+ } catch (error) {
123
+ // next() 自身失败时交回宿主处理,本插件不介入
124
+ throw error
125
+ }
126
+
127
+ // context 通道:不在这里计算(会晚于 assembly),由入箱预计算 + 动态上下文负责
128
+ if (readString(config, 'autoInject', 'message') !== 'message') return decision
129
+
130
+ const agent = payload?.agent
131
+ const claimed = Array.isArray(payload?.messages) ? payload.messages : []
132
+ const newText = extractUserText(claimed)
133
+ if (!newText) return decision // 工具结果步骤 / 无新用户消息
134
+ if (newText.trimStart().startsWith('/')) return decision // 斜杠命令
135
+ if (isTrivialInput(newText, minChars)) return decision // 寒暄/致谢等无业务含义的输入
136
+
137
+ let injected = null
138
+ try {
139
+ // 双保险:既给上游传 abort signal,也用 race 兜底,
140
+ // 即使提供商忽略 signal 也不会让这一步的推理被无限阻塞。
141
+ const timeout = combineSignal(payload?.signal, readNumber(config, 'autoTimeoutMs', 8000))
142
+ try {
143
+ injected = await raceTimeout(
144
+ computeDecision({
145
+ agent,
146
+ newText,
147
+ provider,
148
+ config,
149
+ scenarios,
150
+ cache,
151
+ inFlight,
152
+ signal: timeout.signal,
153
+ log,
154
+ }),
155
+ readNumber(config, 'autoTimeoutMs', 8000),
156
+ )
157
+ } finally {
158
+ timeout.dispose()
159
+ }
160
+ } catch (error) {
161
+ log('warn', `自动决策失败,已放行:${messageOf(error)}`)
162
+ return decision
163
+ }
164
+ if (!injected) return decision
165
+
166
+ perAgent.set(agent?.id ?? 'unknown', injected)
167
+ log('info', `自动决策完成(${injected.scenarioId},${injected.source}):${injected.headline}`)
168
+
169
+ // 别的监听器否决了这一步:绝不把 reject 改写成 enter
170
+ if (decision?.kind === 'reject') return decision
171
+
172
+ // 去重:同一决策文本只注入一次。决策不变时不再重复追加,历史里
173
+ // 每个不同决策只出现一条,把"每步一条"的污染降为"每个决策一条"。
174
+ const agentId = agent?.id ?? 'unknown'
175
+ if (lastInjected.get(agentId) === injected.text) return decision
176
+ lastInjected.set(agentId, injected.text)
177
+
178
+ // 同一步注入:保留本轮消息,追加一条运行时上下文消息
179
+ const messages = Array.isArray(decision?.messages) ? decision.messages : claimed
180
+ return {
181
+ kind: 'enter',
182
+ messages: [...messages, buildContextMessage(injected.text)],
183
+ ...(decision?.startsRequestSeries ? { startsRequestSeries: true } : {}),
184
+ }
185
+ }))
186
+
187
+ /** 入箱预计算:异步算好并存进 perAgent,供 context 通道渲染。 */
188
+ async function computeAndStore(agent, newText) {
189
+ try {
190
+ const timeout = combineSignal(undefined, readNumber(config, 'autoTimeoutMs', 8000))
191
+ try {
192
+ const injected = await raceTimeout(
193
+ computeDecision({
194
+ agent,
195
+ newText,
196
+ provider,
197
+ config,
198
+ scenarios,
199
+ cache,
200
+ inFlight,
201
+ signal: timeout.signal,
202
+ log,
203
+ }),
204
+ readNumber(config, 'autoTimeoutMs', 8000),
205
+ )
206
+ if (injected) perAgent.set(agent?.id ?? 'unknown', injected)
207
+ } finally {
208
+ timeout.dispose()
209
+ }
210
+ } catch (error) {
211
+ log('warn', `入箱预计算失败(忽略):${messageOf(error)}`)
212
+ }
213
+ }
214
+
215
+ return () => {
216
+ for (const dispose of disposers) {
217
+ try {
218
+ dispose()
219
+ } catch {
220
+ // 卸载竞态
221
+ }
222
+ }
223
+ perAgent.clear()
224
+ lastInjected.clear()
225
+ cache.clear()
226
+ inFlight.clear()
227
+ }
228
+ }
229
+
230
+ /* ─── 决策流程 ─────────────────────────────────────────────────────────────── */
231
+
232
+ /**
233
+ * 计算决策(带并发去重)。相同 state 只发一次上游请求。
234
+ */
235
+ async function computeDecision({ agent, newText, provider, config, scenarios, cache, inFlight, signal, log }) {
236
+ const state = captureState(agent, newText, readNumber(config, 'autoMaxMessages', 6))
237
+ const key = hashString(state)
238
+ const existing = inFlight.get(key)
239
+ if (existing) return existing
240
+ const promise = decide({
241
+ agent,
242
+ newText,
243
+ state,
244
+ key,
245
+ provider,
246
+ config,
247
+ scenarios,
248
+ cache,
249
+ signal,
250
+ log,
251
+ }).finally(() => {
252
+ if (inFlight.get(key) === promise) inFlight.delete(key)
253
+ })
254
+ inFlight.set(key, promise)
255
+ return promise
256
+ }
257
+
258
+ async function decide({ agent, newText, state, key, provider, config, scenarios, cache, signal, log }) {
259
+ const ttl = readNumber(config, 'autoCacheTtlMs', 0) || 0
260
+ if (ttl > 0) {
261
+ const hit = cache.get(key)
262
+ if (hit && Date.now() - hit.at < ttl) return hit.result
263
+ }
264
+
265
+ // 1) 选场景:固定绑定,或让模型做一次自动路由
266
+ const routed = await resolveScenario({ state, provider, config, scenarios, signal })
267
+ if (!routed) return null
268
+
269
+ // 路由置信度不足:宁可不注入,也不要塞一个瞎猜的结论
270
+ const floor = readNumber(config, 'autoRouteMinConfidence', 0)
271
+ if (Number.isFinite(floor) && floor > 0
272
+ && Number.isFinite(routed.confidence) && routed.confidence < floor) {
273
+ log?.('info', `路由置信度 ${(routed.confidence * 100).toFixed(1)}% 低于阈值 ${(floor * 100).toFixed(0)}%,跳过注入`)
274
+ return null
275
+ }
276
+
277
+ // 2) 执行目标场景
278
+ const result = await runScenarioDecision({
279
+ provider,
280
+ config,
281
+ scenarios,
282
+ scenario: routed.scenario.id,
283
+ state,
284
+ exec: { signal },
285
+ minConfidence: readNumber(config, 'autoMinConfidence', 0.6),
286
+ })
287
+ if (!result?.ok) {
288
+ log?.('warn', `场景 ${routed.scenario.id} 执行失败,已放行:${result?.error || '未知错误'}`)
289
+ return null
290
+ }
291
+
292
+ const injected = {
293
+ stateKey: key,
294
+ scenarioId: routed.scenario.id,
295
+ source: routed.source,
296
+ text: renderInjection(result, routed, config),
297
+ headline: result.recommendation || routed.scenario.title,
298
+ at: Date.now(),
299
+ }
300
+ if (ttl > 0) cache.set(key, { at: Date.now(), result: injected })
301
+ return injected
302
+ }
303
+
304
+ /** 选择场景:固定绑定优先,否则用一次 choice 问句自动路由。 */
305
+ async function resolveScenario({ state, provider, config, scenarios, signal }) {
306
+ const fixed = readString(config, 'autoScenario', '').trim()
307
+ if (fixed) {
308
+ const scenario = findScenario(scenarios, fixed)
309
+ if (!scenario) return null
310
+ return { scenario, source: '固定绑定', confidence: null }
311
+ }
312
+ if (scenarios.length === 0) return null
313
+ if (scenarios.length === 1) return { scenario: scenarios[0], source: '唯一场景', confidence: null }
314
+
315
+ const response = await provider.decide({
316
+ state,
317
+ questions: {
318
+ scenario: {
319
+ type: 'choice',
320
+ instructions: '这段内容最需要哪一类业务判断?',
321
+ criteria: Object.fromEntries(scenarios.map((s) => [s.id, s.title])),
322
+ },
323
+ },
324
+ model: readString(config, 'model', ''),
325
+ signal,
326
+ })
327
+ const answer = response?.answers?.scenario
328
+ const scenario = answer?.choice ? findScenario(scenarios, answer.choice) : undefined
329
+ if (!scenario) return null
330
+ return { scenario, source: '自动路由', confidence: Number(answer?.confidence) }
331
+ }
332
+
333
+ /** 把决策结果渲染成注入文本。 */
334
+ function renderInjection(result, routed, config) {
335
+ const rationale = [
336
+ `来源:${routed.source}`,
337
+ Number.isFinite(routed.confidence) ? `路由置信度 ${pct(routed.confidence)}` : null,
338
+ ].filter(Boolean).join(',')
339
+
340
+ const lines = [
341
+ '[SystemOne 自动决策 · 仅供参考,如有冲突以用户原始要求为准]',
342
+ `场景:${routed.scenario.title}(${rationale})`,
343
+ ]
344
+
345
+ const facts = Object.entries(result.labels || {})
346
+ .map(([qid, label]) => `${questionLabel(routed.scenario, qid)}=${label}`)
347
+ .join(';')
348
+ if (facts) lines.push(`结论:${facts}`)
349
+
350
+ const derived = Object.entries(result.derived || {}).map(([k, v]) => `${k}=${v}`).join(';')
351
+ if (derived) lines.push(`派生:${derived}`)
352
+
353
+ if (result.recommendation) lines.push(`建议:${result.recommendation}`)
354
+ if (result.needs_human_review) lines.push('注意:本次判断置信度偏低,请人工确认后再执行。')
355
+
356
+ return lines.join('\n')
357
+ }
358
+
359
+ function questionLabel(scenario, qid) {
360
+ return scenario?.questions?.[qid]?.label || qid
361
+ }
362
+
363
+ /* ─── 上下文捕获 ───────────────────────────────────────────────────────────── */
364
+
365
+ /**
366
+ * 判断是否为"无业务含义"的输入,用于跳过自动决策。
367
+ *
368
+ * 只用长度阈值会误伤真实请求("退款失败" 只有 4 个字),所以改成
369
+ * 「长度阈值 + 寒暄黑名单」:短且整体就是寒暄才跳过。
370
+ */
371
+ const TRIVIAL_PATTERN = /^(你好|您好|哈喽|在吗|hi|hello|hey|ok|okay|好的|好嘞|收到|明白|嗯+|谢谢|多谢|感谢|thanks|thank you|thx|再见|拜拜|bye|测试|test)[\s!!。.~~,,、??]*$/i
372
+
373
+ function isTrivialInput(text, minChars) {
374
+ const trimmed = text.trim()
375
+ if (Number.isFinite(minChars) && minChars > 0 && trimmed.length < minChars) return true
376
+ return TRIVIAL_PATTERN.test(trimmed)
377
+ }
378
+
379
+ /**
380
+ * 组合 state:会话历史(倒序取最近 N 条)+ 本轮新消息。
381
+ * 本插件自己注入的运行时上下文会被排除,避免自我强化。
382
+ */
383
+ function captureState(agent, newText, maxMessages) {
384
+ const limit = Number.isFinite(Number(maxMessages)) && Number(maxMessages) > 0
385
+ ? Math.floor(Number(maxMessages))
386
+ : 6
387
+ const history = readHistory(agent, limit)
388
+ const parts = []
389
+ if (history.length > 0) parts.push(`【近期对话】\n${history.join('\n')}`)
390
+ parts.push(`【当前请求】\n${newText}`)
391
+ return parts.join('\n\n')
392
+ }
393
+
394
+ function readHistory(agent, limit) {
395
+ const events = agent?.session?.log
396
+ if (!Array.isArray(events)) return []
397
+ const picked = []
398
+ for (let i = events.length - 1; i >= 0 && picked.length < limit; i--) {
399
+ const event = events[i]
400
+ let message
401
+ if (event?.type === 'user/message') message = event.data
402
+ else if (event?.type === 'assistant/message') message = event.data?.message
403
+ else continue
404
+
405
+ if (event.type === 'user/message' && message?.source?.kind === RUNTIME_CONTEXT_SOURCE) continue
406
+ const text = textOfMessage(message)
407
+ if (text) picked.unshift(`- ${event.type === 'user/message' ? '用户' : '助手'}:${truncate(text, 2000)}`)
408
+ }
409
+ return picked
410
+ }
411
+
412
+ function extractUserText(messages) {
413
+ const texts = []
414
+ for (const message of messages || []) {
415
+ if (message?.role !== 'user') continue
416
+ if (message?.source?.kind === RUNTIME_CONTEXT_SOURCE) continue
417
+ const text = textOfMessage(message)
418
+ if (text) texts.push(text)
419
+ }
420
+ return texts.join('\n\n').trim()
421
+ }
422
+
423
+ function textOfMessage(message) {
424
+ if (!message) return ''
425
+ if (typeof message.text === 'string') return message.text
426
+ if (typeof message.content === 'string') return message.content
427
+ const blocks = Array.isArray(message.content) ? message.content : Array.isArray(message.blocks) ? message.blocks : []
428
+ return blocks
429
+ .filter((block) => block?.type === 'text' && typeof block.text === 'string')
430
+ .map((block) => block.text)
431
+ .join('\n')
432
+ .trim()
433
+ }
434
+
435
+ /** 构造与宿主 runtime-context 同形的消息(UserMessage)。 */
436
+ function buildContextMessage(text) {
437
+ return {
438
+ role: 'user',
439
+ content: [{ type: 'text', text }],
440
+ source: { kind: RUNTIME_CONTEXT_SOURCE },
441
+ }
442
+ }
443
+
444
+ /* ─── 工具函数 ─────────────────────────────────────────────────────────────── */
445
+
446
+ /**
447
+ * 把步骤 signal 与超时合并:任一触发即中止上游请求。
448
+ * 返回 { signal, dispose },dispose 用于在正常完成后清理定时器与监听器。
449
+ */
450
+ function combineSignal(signal, timeoutMs) {
451
+ const ms = Number(timeoutMs)
452
+ const controller = new AbortController()
453
+ let timer = null
454
+ let onOuterAbort = null
455
+
456
+ const dispose = () => {
457
+ if (timer !== null) {
458
+ clearTimeout(timer)
459
+ timer = null
460
+ }
461
+ if (onOuterAbort && typeof signal?.removeEventListener === 'function') {
462
+ signal.removeEventListener('abort', onOuterAbort)
463
+ onOuterAbort = null
464
+ }
465
+ }
466
+
467
+ if (Number.isFinite(ms) && ms > 0) {
468
+ timer = setTimeout(() => {
469
+ timer = null
470
+ controller.abort(new Error(`自动决策超时(${ms}ms)`))
471
+ }, ms)
472
+ timer.unref?.()
473
+ }
474
+ if (signal && typeof signal.addEventListener === 'function') {
475
+ if (signal.aborted) controller.abort()
476
+ else {
477
+ onOuterAbort = () => controller.abort()
478
+ signal.addEventListener('abort', onOuterAbort, { once: true })
479
+ }
480
+ }
481
+ return { signal: controller.signal, dispose }
482
+ }
483
+
484
+ /**
485
+ * 给 promise 加硬超时。即使被调用方忽略 abort signal,也不会无限等待。
486
+ */
487
+ function raceTimeout(promise, timeoutMs) {
488
+ const ms = Number(timeoutMs)
489
+ if (!Number.isFinite(ms) || ms <= 0) return promise
490
+ return new Promise((resolve, reject) => {
491
+ const timer = setTimeout(() => reject(new Error(`自动决策超时(${ms}ms)`)), ms)
492
+ timer.unref?.()
493
+ promise.then(
494
+ (value) => {
495
+ clearTimeout(timer)
496
+ resolve(value)
497
+ },
498
+ (error) => {
499
+ clearTimeout(timer)
500
+ reject(error)
501
+ },
502
+ )
503
+ })
504
+ }
505
+
506
+ function truncate(text, max) {
507
+ const value = String(text)
508
+ return value.length <= max ? value : `${value.slice(0, max)}…(已截断)`
509
+ }
510
+
511
+ function pct(value) {
512
+ return Number.isFinite(value) ? `${(value * 100).toFixed(1)}%` : '—'
513
+ }
514
+
515
+ function hashString(str) {
516
+ let h = 2166136261 >>> 0
517
+ for (let i = 0; i < str.length; i++) {
518
+ h ^= str.charCodeAt(i)
519
+ h = Math.imul(h, 16777619)
520
+ }
521
+ return (h >>> 0).toString(16)
522
+ }
523
+
524
+ function messageOf(error) {
525
+ if (error !== null && typeof error === 'object' && typeof error.message === 'string') return error.message
526
+ return String(error)
527
+ }