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