@mzzsfy/dsh-keep-working 0.0.0-stage → 0.2.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 +88 -2
- package/cordis.patch.yml +4 -0
- package/package.json +49 -4
- package/src/client.js +366 -0
- package/src/closure.mjs +174 -0
- package/src/config.mjs +103 -0
- package/src/engine.mjs +240 -0
- package/src/failure.mjs +84 -0
- package/src/index.js +259 -0
- package/src/offline.mjs +69 -0
- package/src/route-gate.mjs +29 -0
- package/src/route-guard.mjs +35 -0
- package/src/scan.mjs +462 -0
- package/src/session-kind.mjs +7 -0
- package/src/settings.mjs +59 -0
- package/test/client-id.test.mjs +34 -0
- package/test/client-panel.test.mjs +101 -0
- package/test/closure.test.mjs +316 -0
- package/test/config.test.mjs +24 -0
- package/test/engine.test.mjs +436 -0
- package/test/failure.test.mjs +81 -0
- package/test/offline.test.mjs +85 -0
- package/test/prompt-section.test.mjs +32 -0
- package/test/route-gate.test.mjs +57 -0
- package/test/route-guard.test.mjs +51 -0
- package/test/route.test.mjs +210 -0
- package/test/scan.test.mjs +787 -0
- package/test/settings.test.mjs +52 -0
package/src/closure.mjs
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 结案判定:纯函数层,不依赖宿主服务。
|
|
3
|
+
*
|
|
4
|
+
* 判定对象是一个 turn 的事件切片。结案凭据有两种:
|
|
5
|
+
* - present:本回合成功交付文件,且交付之后没有再改文件
|
|
6
|
+
* - marker:末行独占 TASK_DONE 或 TASK_BLOCKED: ...
|
|
7
|
+
*
|
|
8
|
+
* 有文件写入的回合只认 present,否则模型会退化成写一行标记就交差。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const DONE_MARKER = 'TASK_DONE'
|
|
12
|
+
export const BLOCKED_PREFIX = 'TASK_BLOCKED'
|
|
13
|
+
|
|
14
|
+
const BLOCKED_LINE = new RegExp(`^${BLOCKED_PREFIX}:\\s*\\S.*$`)
|
|
15
|
+
|
|
16
|
+
/** 宿主写文件的工具名;新增写入面时在此登记。 */
|
|
17
|
+
export const FILE_WRITE_TOOLS = new Set(['write', 'edit', 'apply_patch', 'str_replace_editor'])
|
|
18
|
+
|
|
19
|
+
/** 取 assistant 消息里的可见文本,工具块不计入。 */
|
|
20
|
+
export function assistantText(message) {
|
|
21
|
+
const content = message?.content
|
|
22
|
+
if (!Array.isArray(content)) return ''
|
|
23
|
+
return content
|
|
24
|
+
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
|
|
25
|
+
.map((block) => block.text)
|
|
26
|
+
.join('')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 本回合事件切片:从该 turn 的 turn/start 起,到同号 turn/end 止。 */
|
|
30
|
+
export function turnSlice(events, turn) {
|
|
31
|
+
if (!Array.isArray(events)) return []
|
|
32
|
+
let start = -1
|
|
33
|
+
let end = events.length
|
|
34
|
+
for (let index = 0; index < events.length; index += 1) {
|
|
35
|
+
const event = events[index]
|
|
36
|
+
if (event?.type === 'turn/start' && event.data?.turn === turn) start = index
|
|
37
|
+
if (event?.type === 'turn/end' && event.data?.turn === turn) {
|
|
38
|
+
end = index
|
|
39
|
+
break
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return events.slice(Math.max(start, 0), end)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 末行标记;严格匹配裸标记,括号、下划线、连字符等装饰都不算。 */
|
|
46
|
+
export function matchMarker(text) {
|
|
47
|
+
if (typeof text !== 'string' || text === '') return null
|
|
48
|
+
const last = (text.trimEnd().split('\n').at(-1) ?? '').trim()
|
|
49
|
+
if (last === DONE_MARKER) return 'done'
|
|
50
|
+
if (BLOCKED_LINE.test(last)) return 'blocked'
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 本回合最后一条带可见文本的 assistant 消息文本;纯工具调用步骤跳过。 */
|
|
55
|
+
export function lastAssistantText(events, turn) {
|
|
56
|
+
const slice = turnSlice(events, turn)
|
|
57
|
+
for (let index = slice.length - 1; index >= 0; index -= 1) {
|
|
58
|
+
const event = slice[index]
|
|
59
|
+
if (event?.type !== 'assistant/message') continue
|
|
60
|
+
const text = assistantText(event.data?.message)
|
|
61
|
+
if (text.trim() !== '') return text
|
|
62
|
+
}
|
|
63
|
+
return ''
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 本回合是否产生过可见输出。 */
|
|
67
|
+
export function hasVisibleOutput(events) {
|
|
68
|
+
return events.some(
|
|
69
|
+
(event) => event?.type === 'assistant/message' && assistantText(event.data?.message).trim() !== '',
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 真人消息:来源是 user,且不是本插件补发的。 */
|
|
74
|
+
const isHumanMessage = (event, excludeIds) =>
|
|
75
|
+
event?.type === 'user/message' &&
|
|
76
|
+
event.data?.source?.kind === 'user' &&
|
|
77
|
+
excludeIds?.has?.(event.data?.id) !== true
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 回合前导段里的真人发起消息。
|
|
81
|
+
*
|
|
82
|
+
* 宿主把各类上下文(指令文件、工作目录、任务事件、定时唤醒等)作为
|
|
83
|
+
* user/message 注入在真人消息之前,kind 由各生产者自定且持续增加,不能靠白名单。
|
|
84
|
+
* 这里只看 turn/start 之后的第一段:跳过非 user 来源,遇到本插件自投递或
|
|
85
|
+
* 模型、工具事件即结束。返回 undefined 表示本回合不是真人发起的。
|
|
86
|
+
*/
|
|
87
|
+
function leadingHumanMessage(events, turn, excludeIds) {
|
|
88
|
+
for (const event of turnSlice(events, turn)) {
|
|
89
|
+
if (event?.type === 'user/message') {
|
|
90
|
+
if (event.data?.source?.kind !== 'user') continue
|
|
91
|
+
// 本插件补发的消息是回合发起者,但不是真人
|
|
92
|
+
if (excludeIds?.has?.(event.data?.id) === true) return undefined
|
|
93
|
+
return event
|
|
94
|
+
}
|
|
95
|
+
if (event?.type === 'assistant/message' || event?.type === 'tool/call' || event?.type === 'tool/result') {
|
|
96
|
+
return undefined
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return undefined
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 回合发起来源;跳过注入上下文后取第一条 user 来源消息(含本插件补发)。 */
|
|
103
|
+
export function turnInitiator(events, turn) {
|
|
104
|
+
for (const event of turnSlice(events, turn)) {
|
|
105
|
+
if (event?.type === 'user/message') {
|
|
106
|
+
if (event.data?.source?.kind !== 'user') continue
|
|
107
|
+
return 'user'
|
|
108
|
+
}
|
|
109
|
+
if (event?.type === 'assistant/message' || event?.type === 'tool/call' || event?.type === 'tool/result') {
|
|
110
|
+
return undefined
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return undefined
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 回合由真人发起:第一条非注入消息是真人且不是本插件自投递。 */
|
|
117
|
+
export function humanInitiatedTurn(events, turn, excludeIds) {
|
|
118
|
+
return leadingHumanMessage(events, turn, excludeIds) !== undefined
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 用户在本回合中途插话:结案提醒让路,避免与人的意图抢方向。 */
|
|
122
|
+
export function userTookOver(events, turn, excludeIds) {
|
|
123
|
+
const humans = turnSlice(events, turn).filter((event) => isHumanMessage(event, excludeIds))
|
|
124
|
+
if (humans.length === 0) return false
|
|
125
|
+
// 发起者不是真人(插件补发的回合)时,第一条真人消息就是插话
|
|
126
|
+
return leadingHumanMessage(events, turn, excludeIds) === undefined || humans.length > 1
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const isFileWrite = (event) => event?.type === 'tool/call' && FILE_WRITE_TOOLS.has(event.data?.name)
|
|
130
|
+
|
|
131
|
+
/** 只读子命令:编辑器工具的 view 不改文件,不该逼出交付。 */
|
|
132
|
+
function isReadOnlyCall(call) {
|
|
133
|
+
if (call?.data?.name !== 'str_replace_editor') return false
|
|
134
|
+
try {
|
|
135
|
+
return JSON.parse(call.data?.arguments ?? '{}')?.command === 'view'
|
|
136
|
+
} catch {
|
|
137
|
+
return false
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 本回合真正改动过文件的调用。
|
|
143
|
+
*
|
|
144
|
+
* 失败的写(沙箱拒绝、审批驳回、参数错误)没有产生改动,按结果消息自带的 callId
|
|
145
|
+
* 精确剔除;没有 callId 的旧日志退化为不剔除,宁可多要求一次 present。
|
|
146
|
+
*/
|
|
147
|
+
function effectiveWrites(slice) {
|
|
148
|
+
const failed = new Set()
|
|
149
|
+
for (const event of slice) {
|
|
150
|
+
if (event?.type !== 'tool/result' || event.data?.message?.isError !== true) continue
|
|
151
|
+
const callId = event.data?.message?.toolCallId ?? event.data?.message?.source?.callId
|
|
152
|
+
if (callId !== undefined) failed.add(callId)
|
|
153
|
+
}
|
|
154
|
+
return slice
|
|
155
|
+
.filter(isFileWrite)
|
|
156
|
+
.filter((call) => !failed.has(call.data?.callId) && !isReadOnlyCall(call))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 结案判定;reason 供日志与提醒文案选择。 */
|
|
160
|
+
export function closureVerdict(events, turn) {
|
|
161
|
+
const slice = turnSlice(events, turn)
|
|
162
|
+
const writes = effectiveWrites(slice)
|
|
163
|
+
const presents = slice.filter(
|
|
164
|
+
(event) => event?.type === 'deliverables/presented' && event.data?.turn === turn,
|
|
165
|
+
)
|
|
166
|
+
const lastPresent = presents.at(-1)
|
|
167
|
+
const presentInvalidated =
|
|
168
|
+
lastPresent !== undefined && writes.some((event) => event.seq > lastPresent.seq)
|
|
169
|
+
|
|
170
|
+
if (lastPresent !== undefined && !presentInvalidated) return { closed: true, reason: 'present' }
|
|
171
|
+
if (writes.length > 0) return { closed: false, reason: 'file-write-requires-present' }
|
|
172
|
+
if (matchMarker(lastAssistantText(slice, turn)) !== null) return { closed: true, reason: 'marker' }
|
|
173
|
+
return { closed: false, reason: 'no-signal' }
|
|
174
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置解析:静态默认值 + 用户覆盖。
|
|
3
|
+
*
|
|
4
|
+
* 所有字段都有内置默认值,缺省即安全;数值一律 clamp 到可用区间,
|
|
5
|
+
* 避免误配把恢复节奏压成高频循环。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const DEFAULTS = Object.freeze({
|
|
9
|
+
graceMs: 3_000,
|
|
10
|
+
cooldownMs: 20_000,
|
|
11
|
+
maxConsecutive: 3,
|
|
12
|
+
backoffFactor: 2,
|
|
13
|
+
backoffMaxMs: 5 * 60_000,
|
|
14
|
+
scanOnBoot: true,
|
|
15
|
+
scanLimit: 8,
|
|
16
|
+
freshMs: 30 * 60_000,
|
|
17
|
+
resumeGoals: true,
|
|
18
|
+
resumePausedGoals: false,
|
|
19
|
+
resumeSilentTurns: true,
|
|
20
|
+
pauseWhenOffline: true,
|
|
21
|
+
offlineCheckMs: 15_000,
|
|
22
|
+
classify: true,
|
|
23
|
+
retryableErrorPatterns: '',
|
|
24
|
+
paused: false,
|
|
25
|
+
continueText: '继续。上一步结果未知,确认后继续。',
|
|
26
|
+
continueTextMaxTokens: '继续。上一轮被输出上限截断,分段接着输出。',
|
|
27
|
+
continueTextSilent: '继续。上一轮没有可见输出,直接说明结果或继续执行。',
|
|
28
|
+
continueTextInterrupted: '继续。上一步结果未知,确认后继续。',
|
|
29
|
+
closureText:
|
|
30
|
+
'[keepWork] 结束必须显式提交:没改文件末行单独写 TASK_DONE;任务阻塞末行单独写 TASK_BLOCKED: 原因;改过文件必须最后只使用 present;禁止同时 present 和 TASK_DONE',
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const numberOr = (value, fallback) =>
|
|
34
|
+
typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback
|
|
35
|
+
|
|
36
|
+
const booleanOr = (value, fallback) => (typeof value === 'boolean' ? value : fallback)
|
|
37
|
+
|
|
38
|
+
const textOr = (value, fallback) =>
|
|
39
|
+
typeof value === 'string' && value.trim() !== '' ? value : fallback
|
|
40
|
+
|
|
41
|
+
/** 行 config 的 volatile 字段是 boxed ref,逐字段解箱后再解析。 */
|
|
42
|
+
function unwrap(entry) {
|
|
43
|
+
if (entry === null || typeof entry !== 'object') return {}
|
|
44
|
+
const out = {}
|
|
45
|
+
for (const [key, field] of Object.entries(entry)) {
|
|
46
|
+
const raw = typeof field?.get === 'function' ? field.get() : field
|
|
47
|
+
out[key] = raw !== null && typeof raw === 'object' ? structuredClone(raw) : raw
|
|
48
|
+
}
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 时长上界:超出即视为误填,按上界截断而不是让恢复实际停摆。 */
|
|
53
|
+
const MAX_GRACE_MS = 5 * 60 * 1000
|
|
54
|
+
const MAX_COOLDOWN_MS = 60 * 60 * 1000
|
|
55
|
+
const MAX_BACKOFF_MS = 24 * 60 * 60 * 1000
|
|
56
|
+
const MAX_FRESH_MS = 7 * 24 * 60 * 60 * 1000
|
|
57
|
+
|
|
58
|
+
const clamp = (value, min, max) => Math.min(max, Math.max(min, value))
|
|
59
|
+
|
|
60
|
+
/** 覆盖解析;未知字段忽略,非法值回落默认。 */
|
|
61
|
+
export function resolveConfig(input = {}, overrides = undefined) {
|
|
62
|
+
const panel = unwrap(overrides ?? {})
|
|
63
|
+
const value = { ...unwrap(input), ...Object.fromEntries(Object.entries(panel).filter(([, v]) => v !== undefined)) }
|
|
64
|
+
return {
|
|
65
|
+
graceMs: clamp(numberOr(value.graceMs, DEFAULTS.graceMs), 0, MAX_GRACE_MS),
|
|
66
|
+
cooldownMs: clamp(numberOr(value.cooldownMs, DEFAULTS.cooldownMs), 0, MAX_COOLDOWN_MS),
|
|
67
|
+
maxConsecutive: Math.max(1, numberOr(value.maxConsecutive, DEFAULTS.maxConsecutive)),
|
|
68
|
+
backoffFactor: Math.max(1, numberOr(value.backoffFactor, DEFAULTS.backoffFactor)),
|
|
69
|
+
backoffMaxMs: clamp(numberOr(value.backoffMaxMs, DEFAULTS.backoffMaxMs), 0, MAX_BACKOFF_MS),
|
|
70
|
+
scanOnBoot: booleanOr(value.scanOnBoot, DEFAULTS.scanOnBoot),
|
|
71
|
+
scanLimit: Math.max(1, numberOr(value.scanLimit, DEFAULTS.scanLimit)),
|
|
72
|
+
freshMs: clamp(numberOr(value.freshMs, DEFAULTS.freshMs), 0, MAX_FRESH_MS),
|
|
73
|
+
resumeGoals: booleanOr(value.resumeGoals, DEFAULTS.resumeGoals),
|
|
74
|
+
resumePausedGoals: booleanOr(value.resumePausedGoals, DEFAULTS.resumePausedGoals),
|
|
75
|
+
resumeSilentTurns: booleanOr(value.resumeSilentTurns, DEFAULTS.resumeSilentTurns),
|
|
76
|
+
pauseWhenOffline: booleanOr(value.pauseWhenOffline, DEFAULTS.pauseWhenOffline),
|
|
77
|
+
offlineCheckMs: Math.max(1_000, numberOr(value.offlineCheckMs, DEFAULTS.offlineCheckMs)),
|
|
78
|
+
classify: booleanOr(value.classify, DEFAULTS.classify),
|
|
79
|
+
retryableErrorPatterns:
|
|
80
|
+
typeof value.retryableErrorPatterns === 'string'
|
|
81
|
+
? value.retryableErrorPatterns.trim()
|
|
82
|
+
: DEFAULTS.retryableErrorPatterns,
|
|
83
|
+
paused: booleanOr(value.paused, DEFAULTS.paused),
|
|
84
|
+
continueText: textOr(value.continueText, DEFAULTS.continueText),
|
|
85
|
+
continueTextMaxTokens: textOr(value.continueTextMaxTokens, DEFAULTS.continueTextMaxTokens),
|
|
86
|
+
continueTextSilent: textOr(value.continueTextSilent, DEFAULTS.continueTextSilent),
|
|
87
|
+
continueTextInterrupted: textOr(value.continueTextInterrupted, DEFAULTS.continueTextInterrupted),
|
|
88
|
+
closureText: textOr(value.closureText, DEFAULTS.closureText),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 模板占位符替换;缺失值替换为空串。 */
|
|
93
|
+
export function fillTemplate(template, context = {}) {
|
|
94
|
+
return String(template).replace(/\{(\w+)\}/g, (_, key) =>
|
|
95
|
+
context[key] === undefined || context[key] === null ? '' : String(context[key]),
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 连续失败后的冷却:基础冷却 × 退避因子^n,封顶。 */
|
|
100
|
+
export function cooldownFor(config, consecutive) {
|
|
101
|
+
const factor = config.backoffFactor ** Math.max(0, consecutive - 1)
|
|
102
|
+
return Math.min(config.cooldownMs * factor, config.backoffMaxMs)
|
|
103
|
+
}
|
package/src/engine.mjs
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 恢复引擎:消费会话事件,在需要续跑时按宿主节奏补一条用户消息。
|
|
3
|
+
*
|
|
4
|
+
* 判定与发送分离:decide 只读事件给出结论,schedule 管节奏,fire 才落地。
|
|
5
|
+
* 状态按会话驻留内存,进程重启清零(重启恢复由 scan.mjs 另行负责)。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
closureVerdict,
|
|
10
|
+
hasVisibleOutput,
|
|
11
|
+
humanInitiatedTurn,
|
|
12
|
+
turnInitiator,
|
|
13
|
+
turnSlice,
|
|
14
|
+
userTookOver,
|
|
15
|
+
} from './closure.mjs'
|
|
16
|
+
import { cooldownFor, fillTemplate } from './config.mjs'
|
|
17
|
+
import { isTransientFailure, parseFailureFacts } from './failure.mjs'
|
|
18
|
+
import { isDerivedSessionHeader } from './session-kind.mjs'
|
|
19
|
+
|
|
20
|
+
/** 宿主重启产生的取消原因;只有这些值得在重启后补一次。 */
|
|
21
|
+
const RESTART_CANCEL_CAUSES = new Set(['disposed', 'legacy'])
|
|
22
|
+
|
|
23
|
+
/** 自己发出的消息 id 只保留近期,避免长期驻留。 */
|
|
24
|
+
const SENT_ID_MEMORY = 50
|
|
25
|
+
|
|
26
|
+
const isSubagent = (session) => isDerivedSessionHeader(session?.header)
|
|
27
|
+
|
|
28
|
+
function snapshotEvents(session) {
|
|
29
|
+
const read = session?.snapshotEvents
|
|
30
|
+
if (typeof read === 'function') return read.call(session)
|
|
31
|
+
return Array.isArray(session?.events) ? session.events : []
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createEngine({
|
|
35
|
+
ctx,
|
|
36
|
+
config,
|
|
37
|
+
createUserMessage,
|
|
38
|
+
timers = globalThis,
|
|
39
|
+
now = Date.now,
|
|
40
|
+
probe,
|
|
41
|
+
}) {
|
|
42
|
+
const states = new Map()
|
|
43
|
+
let disposed = false
|
|
44
|
+
let unregister
|
|
45
|
+
|
|
46
|
+
const stateOf = (sessionId) => {
|
|
47
|
+
let state = states.get(sessionId)
|
|
48
|
+
if (state === undefined) {
|
|
49
|
+
state = {
|
|
50
|
+
consecutive: 0,
|
|
51
|
+
lastAttemptAt: 0,
|
|
52
|
+
pendingTimer: undefined,
|
|
53
|
+
pending: undefined,
|
|
54
|
+
sentIds: new Set(),
|
|
55
|
+
}
|
|
56
|
+
states.set(sessionId, state)
|
|
57
|
+
}
|
|
58
|
+
return state
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const log = (message) => ctx.logger?.info?.(`[keep-working] ${message}`)
|
|
62
|
+
|
|
63
|
+
function cancelPending(state, why) {
|
|
64
|
+
if (state.pendingTimer === undefined) return
|
|
65
|
+
timers.clearTimeout(state.pendingTimer)
|
|
66
|
+
state.pendingTimer = undefined
|
|
67
|
+
state.pending = undefined
|
|
68
|
+
log(`取消待发送(${why})`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function rememberSent(state, messageId) {
|
|
72
|
+
state.sentIds.add(messageId)
|
|
73
|
+
if (state.sentIds.size <= SENT_ID_MEMORY) return
|
|
74
|
+
const oldest = state.sentIds.values().next().value
|
|
75
|
+
state.sentIds.delete(oldest)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 按 turn/end 的原因给出动作;undefined 表示不恢复,{ closed } 表示正常结案。 */
|
|
79
|
+
function decide(session, event, state) {
|
|
80
|
+
const turn = event.data?.turn
|
|
81
|
+
const reason = event.data?.reason ?? {}
|
|
82
|
+
const events = snapshotEvents(session)
|
|
83
|
+
|
|
84
|
+
switch (reason.kind) {
|
|
85
|
+
case 'completed': {
|
|
86
|
+
const verdict = closureVerdict(events, turn)
|
|
87
|
+
if (verdict.closed) return { closed: true }
|
|
88
|
+
// 人已插话就让路,静的回合也不抢方向
|
|
89
|
+
if (userTookOver(events, turn, state.sentIds)) return undefined
|
|
90
|
+
// 有有效文件写入时结案规则优先:没 present 就必须提醒,不能被空回合开关跳过
|
|
91
|
+
const silent = !hasVisibleOutput(turnSlice(events, turn))
|
|
92
|
+
if (silent && verdict.reason !== 'file-write-requires-present') {
|
|
93
|
+
return config.resumeSilentTurns ? { reason: 'silent', text: config.continueTextSilent } : undefined
|
|
94
|
+
}
|
|
95
|
+
if (turnInitiator(events, turn) !== 'user') return undefined
|
|
96
|
+
return { reason: 'closure', text: config.closureText }
|
|
97
|
+
}
|
|
98
|
+
case 'error': {
|
|
99
|
+
const failure = parseFailureFacts(reason.error)
|
|
100
|
+
if (failure === undefined) return undefined
|
|
101
|
+
if (config.classify && !isTransientFailure(failure, config.retryableErrorPatterns)) {
|
|
102
|
+
log(`跳过永久错误 ${failure.code}: ${failure.message}`)
|
|
103
|
+
return undefined
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
reason: 'error',
|
|
107
|
+
text: fillTemplate(config.continueText, {
|
|
108
|
+
code: failure.code,
|
|
109
|
+
message: failure.message,
|
|
110
|
+
status: failure.status,
|
|
111
|
+
}),
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
case 'max-tokens':
|
|
115
|
+
return { reason: 'max-tokens', text: config.continueTextMaxTokens }
|
|
116
|
+
case 'interrupted':
|
|
117
|
+
return { reason: 'interrupted', text: config.continueTextInterrupted }
|
|
118
|
+
case 'aborted': {
|
|
119
|
+
if (!RESTART_CANCEL_CAUSES.has(reason.reason?.kind)) return undefined
|
|
120
|
+
return { reason: 'interrupted', text: config.continueTextInterrupted }
|
|
121
|
+
}
|
|
122
|
+
default:
|
|
123
|
+
return undefined
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function fire(session, state, decision) {
|
|
128
|
+
if (disposed || config.paused) return
|
|
129
|
+
const agent = ctx.agents.get(session.id)
|
|
130
|
+
if (agent === undefined) {
|
|
131
|
+
log(`${session.id}: 无 live agent,放弃本次恢复`)
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
const message = createUserMessage({
|
|
135
|
+
content: [{ type: 'text', text: decision.text }],
|
|
136
|
+
source: { kind: 'user' },
|
|
137
|
+
})
|
|
138
|
+
try {
|
|
139
|
+
agent.followup(message)
|
|
140
|
+
} catch (error) {
|
|
141
|
+
log(`${session.id}: 投递失败 ${error instanceof Error ? error.message : String(error)}`)
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
rememberSent(state, message.id)
|
|
145
|
+
state.lastAttemptAt = now()
|
|
146
|
+
state.consecutive += 1
|
|
147
|
+
log(`${session.id}: 已发送恢复消息(${decision.reason},连续 ${state.consecutive})`)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 离线时挂起,联网后自会重试;探测失败按在线处理,避免永久卡住。 */
|
|
151
|
+
function settle(session, state, decision) {
|
|
152
|
+
if (!config.pauseWhenOffline || probe === undefined) {
|
|
153
|
+
fire(session, state, decision)
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
probe().then(
|
|
157
|
+
(online) => {
|
|
158
|
+
if (disposed || config.paused) return
|
|
159
|
+
if (online) {
|
|
160
|
+
fire(session, state, decision)
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
state.pendingTimer = timers.setTimeout(() => {
|
|
164
|
+
state.pendingTimer = undefined
|
|
165
|
+
settle(session, state, decision)
|
|
166
|
+
}, config.offlineCheckMs)
|
|
167
|
+
},
|
|
168
|
+
() => fire(session, state, decision),
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function schedule(session, state, decision) {
|
|
173
|
+
if (disposed || config.paused) return
|
|
174
|
+
if (isSubagent(session)) return
|
|
175
|
+
if (state.pendingTimer !== undefined) return
|
|
176
|
+
if (state.consecutive >= config.maxConsecutive) {
|
|
177
|
+
log(`${session.id}: 已达连续上限 ${config.maxConsecutive},等待用户介入`)
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
const cooldown = cooldownFor(config, state.consecutive)
|
|
181
|
+
const delay = Math.max(config.graceMs, cooldown - (now() - state.lastAttemptAt))
|
|
182
|
+
state.pending = decision
|
|
183
|
+
state.pendingTimer = timers.setTimeout(() => {
|
|
184
|
+
state.pendingTimer = undefined
|
|
185
|
+
state.pending = undefined
|
|
186
|
+
settle(session, state, decision)
|
|
187
|
+
}, delay)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function onSessionEvent(session, event) {
|
|
191
|
+
if (disposed) return
|
|
192
|
+
const state = stateOf(session.id)
|
|
193
|
+
switch (event.type) {
|
|
194
|
+
case 'turn/start':
|
|
195
|
+
cancelPending(state, '新回合开始')
|
|
196
|
+
return
|
|
197
|
+
case 'turn/end':
|
|
198
|
+
return handleTurnEnd(session, state, event)
|
|
199
|
+
default:
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function handleTurnEnd(session, state, event) {
|
|
205
|
+
const turn = event.data?.turn
|
|
206
|
+
cancelPending(state, '回合结束')
|
|
207
|
+
// 真人发起的回合说明人已介入:恢复额度,封顶不再永久沉默。
|
|
208
|
+
// 判定按消息 id 排除本插件自投递的消息,不依赖"下一个 turn/start 是我们的"这一猜测。
|
|
209
|
+
if (humanInitiatedTurn(snapshotEvents(session), turn, state.sentIds)) {
|
|
210
|
+
if (state.consecutive > 0) log(`${session.id}: 用户回合,连续计数清零`)
|
|
211
|
+
state.consecutive = 0
|
|
212
|
+
state.lastAttemptAt = 0
|
|
213
|
+
}
|
|
214
|
+
const decision = decide(session, event, state)
|
|
215
|
+
// 正常结案代表恢复目的已达成:清零连续计数,后续回合重新获得额度
|
|
216
|
+
if (decision?.closed === true) {
|
|
217
|
+
state.consecutive = 0
|
|
218
|
+
state.lastAttemptAt = 0
|
|
219
|
+
log(`${session.id}: 已结案,连续计数清零`)
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
if (decision !== undefined) schedule(session, state, decision)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function attach() {
|
|
226
|
+
if (unregister !== undefined) return unregister
|
|
227
|
+
unregister = ctx.on('session/event', onSessionEvent)
|
|
228
|
+
return unregister
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function dispose() {
|
|
232
|
+
disposed = true
|
|
233
|
+
for (const state of states.values()) cancelPending(state, '插件卸载')
|
|
234
|
+
states.clear()
|
|
235
|
+
unregister?.()
|
|
236
|
+
unregister = undefined
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return { attach, dispose, onSessionEvent }
|
|
240
|
+
}
|
package/src/failure.mjs
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 失败分类:决定一次 turn/end error 是否值得自动恢复。
|
|
3
|
+
*
|
|
4
|
+
* 永久性失败(鉴权、余额、模型不存在、上下文超限、非法请求)重试必然再失败,
|
|
5
|
+
* 只会消耗额度;其余(网络、超时、5xx、429)按临时处理。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 宿主规范错误码里的永久集合。
|
|
10
|
+
*
|
|
11
|
+
* 宿主明确要求按 code 路由而非解析文案,这些码的 message 常常是中性描述
|
|
12
|
+
* (例如 "the request failed"),只看文本会把额度耗尽、缺少适配器当成
|
|
13
|
+
* 临时错误反复重试,每次都在真实计费面上再失败一次。
|
|
14
|
+
*/
|
|
15
|
+
const PERMANENT_CODES = new Set([
|
|
16
|
+
'QUOTA',
|
|
17
|
+
'ACCOUNT_QUOTA',
|
|
18
|
+
'AUTH',
|
|
19
|
+
'INVALID_CREDENTIAL',
|
|
20
|
+
'MISSING_CREDENTIAL',
|
|
21
|
+
'CONTEXT_WINDOW_EXCEEDED',
|
|
22
|
+
'NO_ADAPTER',
|
|
23
|
+
'INVALID_ARGS',
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
/** 永久失败的文本特征;命中即不重试。 */
|
|
27
|
+
const PERMANENT_PATTERNS = [
|
|
28
|
+
/unauthor|forbidden|api[\s_-]?key|\bauth\b|authentication|credential|permission denied|invalid token/i,
|
|
29
|
+
/insufficient.*(balance|quota|funds)|billing|payment required|quota exceeded/i,
|
|
30
|
+
// 各家额度耗尽的原文彼此不通用,补两条最常见的无码形态
|
|
31
|
+
/credit balance is too low|resource has been exhausted/i,
|
|
32
|
+
/model.*not\s*found|unknown model|does not support model/i,
|
|
33
|
+
/context.*(length|limit|overflow|exceeded)|token limit|max.*context/i,
|
|
34
|
+
/invalid[_-]?request|bad[_-]?request/i,
|
|
35
|
+
/prompt variable .* has no value/i,
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
/** 宿主会把 HTTP 状态并进 message 而非独立字段,文本里的 401/403 同样是永久信号。 */
|
|
39
|
+
const PERMANENT_STATUS = /(?:^|[^\d])(401|403)(?:[^\d]|$)/
|
|
40
|
+
|
|
41
|
+
const textOf = (failure) => `${failure?.code ?? ''} ${failure?.status ?? ''} ${failure?.message ?? ''}`
|
|
42
|
+
|
|
43
|
+
/** 解析 turn/end 的 error 载荷;无法解释时返回 undefined。 */
|
|
44
|
+
export function parseFailureFacts(value) {
|
|
45
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
|
|
46
|
+
const code = typeof value.code === 'string' && value.code.trim() !== '' ? value.code : undefined
|
|
47
|
+
const message =
|
|
48
|
+
typeof value.message === 'string' && value.message.trim() !== '' ? value.message : undefined
|
|
49
|
+
const status =
|
|
50
|
+
typeof value.status === 'number' && Number.isFinite(value.status) ? value.status : undefined
|
|
51
|
+
if (code === undefined && message === undefined && status === undefined) return undefined
|
|
52
|
+
return {
|
|
53
|
+
code: code ?? 'UNKNOWN',
|
|
54
|
+
message: message ?? code ?? `HTTP ${status}`,
|
|
55
|
+
...(status === undefined ? {} : { status }),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 自定义片段按行填写,命中即视为可重试。 */
|
|
60
|
+
function matchesCustomPattern(text, patterns) {
|
|
61
|
+
if (typeof patterns !== 'string' || patterns === '') return false
|
|
62
|
+
return patterns
|
|
63
|
+
.split(/\r?\n/)
|
|
64
|
+
.map((pattern) => pattern.trim().toLowerCase())
|
|
65
|
+
.filter((pattern) => pattern !== '')
|
|
66
|
+
.some((pattern) => text.includes(pattern))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 失败是否值得自动继续。
|
|
71
|
+
* 401/403 与 MALFORMED_RESPONSE 是历史踩坑点:前者恒永久,后者的消息可能
|
|
72
|
+
* 回显工具参数里的 "api_key" 之类字样,不能按文本判永久。
|
|
73
|
+
*/
|
|
74
|
+
export function isTransientFailure(failure, retryableErrorPatterns = '') {
|
|
75
|
+
const text = textOf(failure).toLowerCase()
|
|
76
|
+
if (matchesCustomPattern(text, retryableErrorPatterns)) return true
|
|
77
|
+
// 规范码优先于文本:中性描述的额度与适配器错误只有 code 认得出来
|
|
78
|
+
if (typeof failure?.code === 'string' && PERMANENT_CODES.has(failure.code)) return false
|
|
79
|
+
if (failure?.status === 401 || failure?.status === 403) return false
|
|
80
|
+
if (PERMANENT_STATUS.test(text)) return false
|
|
81
|
+
if (failure?.code === 'MALFORMED_RESPONSE') return true
|
|
82
|
+
return !PERMANENT_PATTERNS.some((pattern) => pattern.test(text))
|
|
83
|
+
}
|
|
84
|
+
|