dsh-client-auto-continue 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -6
- package/README.zh.md +2 -6
- package/lib/client.js +14 -46
- package/lib/client.js.map +3 -3
- package/lib/index.js +5 -11
- package/lib/types/client/dsh-store-compat.d.ts +39 -0
- package/lib/types/client/engine.d.ts +5 -250
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/locales.d.ts +0 -4
- package/lib/types/client/settings-card.d.ts +1 -3
- package/lib/types/client/settings-form.d.ts +1 -1
- package/lib/types/host/engine.d.ts +1 -141
- package/lib/types/index.d.ts +0 -8
- package/lib/types/shared/core.d.ts +234 -0
- package/package.json +2 -4
- package/src/client/dsh-store-compat.ts +57 -0
- package/src/client/engine.ts +17 -1639
- package/src/client/index.ts +1 -1
- package/src/client/locales.ts +0 -8
- package/src/client/settings-card.tsx +1 -27
- package/src/client/settings-form.ts +1 -1
- package/src/host/engine.ts +24 -457
- package/src/index.ts +2 -5
- package/src/shared/core.ts +458 -0
package/src/client/engine.ts
CHANGED
|
@@ -1,1642 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Browser half: re-exports the shared core (config, templates, guards).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* After a grace period it sends a queued prompt (default 「继续」) to that
|
|
8
|
-
* session — exactly equivalent to the user typing it manually.
|
|
9
|
-
*
|
|
10
|
-
* All behavior is driven by the `auto-continue` settings namespace (see the
|
|
11
|
-
* plugin's settings card); every knob below is user-configurable there.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import type {
|
|
15
|
-
HostFrame,
|
|
16
|
-
IApiClient,
|
|
17
|
-
MuxFrame,
|
|
18
|
-
SessionId,
|
|
19
|
-
SessionSummary,
|
|
20
|
-
} from '@deepseek-ai/dsh-client-connection/client';
|
|
21
|
-
import type { SessionEvent } from '@deepseek-ai/dsh-session/types';
|
|
22
|
-
|
|
23
|
-
/** The `auto-continue` settings section (all fields optional on the wire; the host schema carries defaults). */
|
|
24
|
-
export interface AutoContinueSettings {
|
|
25
|
-
/** Text automatically sent after an interruption. */
|
|
26
|
-
continueText?: string;
|
|
27
|
-
/** Text sent when the output token ceiling is reached (same placeholders as `continueText`). */
|
|
28
|
-
continueTextMaxTokens?: string;
|
|
29
|
-
/** Idempotency guard: inspect the last tool call before resuming and steer the model. */
|
|
30
|
-
guardTools?: boolean;
|
|
31
|
-
/** Guard text appended when the last tool call has no confirmed result (it may have partially executed). */
|
|
32
|
-
guardPendingText?: string;
|
|
33
|
-
/** Guard text appended when the last tool call completed successfully (don't rerun it). */
|
|
34
|
-
guardDoneText?: string;
|
|
35
|
-
/** Grace period after an interruption before auto-sending (ms). */
|
|
36
|
-
graceMs?: number;
|
|
37
|
-
/** Minimum interval between two auto-continues per session (ms). */
|
|
38
|
-
cooldownMs?: number;
|
|
39
|
-
/** Max consecutive auto-continues per session before stopping. */
|
|
40
|
-
maxConsecutive?: number;
|
|
41
|
-
/** Scan recently interrupted sessions on page load / reconnect. */
|
|
42
|
-
scanOnBoot?: boolean;
|
|
43
|
-
/** Max sessions the scan checks (most recently updated). */
|
|
44
|
-
scanLimit?: number;
|
|
45
|
-
/** Scan only considers interruptions inside this window (ms). */
|
|
46
|
-
freshMs?: number;
|
|
47
|
-
/** Delay before scanning after a reconnect (ms). */
|
|
48
|
-
reconnectScanDelayMs?: number;
|
|
49
|
-
/** SSE reconnect backoff (ms). */
|
|
50
|
-
reconnectBackoffMs?: number;
|
|
51
|
-
/** Log `[auto-continue]` lines to the browser console. */
|
|
52
|
-
verbose?: boolean;
|
|
53
|
-
/** Classify failures: auto-continue transient errors only; permanent ones (auth/balance/model) are skipped and notified. */
|
|
54
|
-
classify?: boolean;
|
|
55
|
-
/** Cooldown multiplier per consecutive failure (adaptive backoff). */
|
|
56
|
-
backoffFactor?: number;
|
|
57
|
-
/** Cap on the effective backoff interval (ms). */
|
|
58
|
-
backoffMaxMs?: number;
|
|
59
|
-
/** Show browser notifications for auto-continue events. */
|
|
60
|
-
notify?: boolean;
|
|
61
|
-
/** Globally pause auto-continue: no live or scan send, queued pending sends cancelled. */
|
|
62
|
-
paused?: boolean;
|
|
63
|
-
/** Loop guard: detect a running turn spinning in place (short talk without tools, or the same tool repeating) and restart it. */
|
|
64
|
-
loopGuard?: boolean;
|
|
65
|
-
/** A model message shorter than this many chars counts as a "short sentence" (loop signal). */
|
|
66
|
-
loopShortChars?: number;
|
|
67
|
-
/** Consecutive short sentences within this window (ms) with no tool call in between trip the loop guard. */
|
|
68
|
-
loopWindowMs?: number;
|
|
69
|
-
/** Consecutive short sentences trip the loop guard. */
|
|
70
|
-
loopShortCount?: number;
|
|
71
|
-
/** Consecutive identical tool calls with identical arguments AND identical results trip the loop guard. */
|
|
72
|
-
loopToolRepeat?: number;
|
|
73
|
-
/** Consecutive identical short sentences trip the loop guard (strongest spinning signal). */
|
|
74
|
-
loopRepeatText?: number;
|
|
75
|
-
/** Text sent after the loop guard cancels and restarts a turn (supports {tool}). */
|
|
76
|
-
loopText?: string;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** Fully resolved configuration (built-in defaults + user overrides). */
|
|
80
|
-
export type AutoContinueConfig = Required<AutoContinueSettings>;
|
|
81
|
-
|
|
82
|
-
/** Built-in defaults — must match the host schema defaults in src/index.ts. */
|
|
83
|
-
export const DEFAULT_CONFIG: AutoContinueConfig = {
|
|
84
|
-
continueText: '继续',
|
|
85
|
-
continueTextMaxTokens: '继续',
|
|
86
|
-
guardTools: true,
|
|
87
|
-
guardPendingText: '(上一步工具「{tool}」可能未完成, 先确认状态再继续, 不要重复执行)',
|
|
88
|
-
guardDoneText: '(上一步工具「{tool}」已完成, 结果: {result}; 不要重复执行, 直接继续)',
|
|
89
|
-
graceMs: 3000,
|
|
90
|
-
cooldownMs: 20000,
|
|
91
|
-
maxConsecutive: 3,
|
|
92
|
-
scanOnBoot: true,
|
|
93
|
-
scanLimit: 8,
|
|
94
|
-
freshMs: 15 * 60 * 1000,
|
|
95
|
-
reconnectScanDelayMs: 5000,
|
|
96
|
-
reconnectBackoffMs: 3000,
|
|
97
|
-
verbose: true,
|
|
98
|
-
classify: true,
|
|
99
|
-
backoffFactor: 2,
|
|
100
|
-
backoffMaxMs: 300000,
|
|
101
|
-
notify: false,
|
|
102
|
-
paused: false,
|
|
103
|
-
loopGuard: true,
|
|
104
|
-
loopShortChars: 40,
|
|
105
|
-
loopWindowMs: 30000,
|
|
106
|
-
loopShortCount: 12,
|
|
107
|
-
loopRepeatText: 4,
|
|
108
|
-
loopToolRepeat: 5,
|
|
109
|
-
loopText: '(检测到你可能陷入循环, 请停止重复刚才的动作, 换一种方式继续)',
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
function numberOr(value: unknown, fallback: number): number {
|
|
113
|
-
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function booleanOr(value: unknown, fallback: boolean): boolean {
|
|
117
|
-
return typeof value === 'boolean' ? value : fallback;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/** Resolve a (possibly partial / not-yet-loaded) settings section to a full config. */
|
|
121
|
-
export function resolveConfig(section: AutoContinueSettings | undefined): AutoContinueConfig {
|
|
122
|
-
const value = section ?? {};
|
|
123
|
-
const text =
|
|
124
|
-
typeof value.continueText === 'string' && value.continueText.trim() !== ''
|
|
125
|
-
? value.continueText
|
|
126
|
-
: DEFAULT_CONFIG.continueText;
|
|
127
|
-
const maxTokensText =
|
|
128
|
-
typeof value.continueTextMaxTokens === 'string' && value.continueTextMaxTokens.trim() !== ''
|
|
129
|
-
? value.continueTextMaxTokens
|
|
130
|
-
: DEFAULT_CONFIG.continueTextMaxTokens;
|
|
131
|
-
const guardPendingText =
|
|
132
|
-
typeof value.guardPendingText === 'string' && value.guardPendingText.trim() !== ''
|
|
133
|
-
? value.guardPendingText
|
|
134
|
-
: DEFAULT_CONFIG.guardPendingText;
|
|
135
|
-
const guardDoneText =
|
|
136
|
-
typeof value.guardDoneText === 'string' && value.guardDoneText.trim() !== ''
|
|
137
|
-
? value.guardDoneText
|
|
138
|
-
: DEFAULT_CONFIG.guardDoneText;
|
|
139
|
-
return {
|
|
140
|
-
continueText: text,
|
|
141
|
-
continueTextMaxTokens: maxTokensText,
|
|
142
|
-
guardTools: booleanOr(value.guardTools, DEFAULT_CONFIG.guardTools),
|
|
143
|
-
guardPendingText,
|
|
144
|
-
guardDoneText,
|
|
145
|
-
graceMs: numberOr(value.graceMs, DEFAULT_CONFIG.graceMs),
|
|
146
|
-
cooldownMs: numberOr(value.cooldownMs, DEFAULT_CONFIG.cooldownMs),
|
|
147
|
-
maxConsecutive: Math.max(1, numberOr(value.maxConsecutive, DEFAULT_CONFIG.maxConsecutive)),
|
|
148
|
-
scanOnBoot: booleanOr(value.scanOnBoot, DEFAULT_CONFIG.scanOnBoot),
|
|
149
|
-
scanLimit: Math.max(1, numberOr(value.scanLimit, DEFAULT_CONFIG.scanLimit)),
|
|
150
|
-
freshMs: numberOr(value.freshMs, DEFAULT_CONFIG.freshMs),
|
|
151
|
-
reconnectScanDelayMs: numberOr(value.reconnectScanDelayMs, DEFAULT_CONFIG.reconnectScanDelayMs),
|
|
152
|
-
reconnectBackoffMs: numberOr(value.reconnectBackoffMs, DEFAULT_CONFIG.reconnectBackoffMs),
|
|
153
|
-
verbose: booleanOr(value.verbose, DEFAULT_CONFIG.verbose),
|
|
154
|
-
classify: booleanOr(value.classify, DEFAULT_CONFIG.classify),
|
|
155
|
-
backoffFactor: Math.max(1, numberOr(value.backoffFactor, DEFAULT_CONFIG.backoffFactor)),
|
|
156
|
-
backoffMaxMs: numberOr(value.backoffMaxMs, DEFAULT_CONFIG.backoffMaxMs),
|
|
157
|
-
notify: booleanOr(value.notify, DEFAULT_CONFIG.notify),
|
|
158
|
-
paused: booleanOr(value.paused, DEFAULT_CONFIG.paused),
|
|
159
|
-
loopGuard: booleanOr(value.loopGuard, DEFAULT_CONFIG.loopGuard),
|
|
160
|
-
loopShortChars: Math.max(1, numberOr(value.loopShortChars, DEFAULT_CONFIG.loopShortChars)),
|
|
161
|
-
loopWindowMs: Math.max(1000, numberOr(value.loopWindowMs, DEFAULT_CONFIG.loopWindowMs)),
|
|
162
|
-
loopShortCount: Math.max(2, numberOr(value.loopShortCount, DEFAULT_CONFIG.loopShortCount)),
|
|
163
|
-
loopRepeatText: Math.max(2, numberOr(value.loopRepeatText, DEFAULT_CONFIG.loopRepeatText)),
|
|
164
|
-
loopToolRepeat: Math.max(2, numberOr(value.loopToolRepeat, DEFAULT_CONFIG.loopToolRepeat)),
|
|
165
|
-
loopText:
|
|
166
|
-
typeof value.loopText === 'string' && value.loopText.trim() !== ''
|
|
167
|
-
? value.loopText
|
|
168
|
-
: DEFAULT_CONFIG.loopText,
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* 视为「非人为中断」的回合结束原因, 用于启动/重连扫描。
|
|
174
|
-
* - `interrupted` 只由崩溃修复在宿主重载时写入(loop 永不实时发出), 因此仅在扫描路径处理;
|
|
175
|
-
* - 实时事件路径只对 `error` / `max-tokens` 自动续跑;
|
|
176
|
-
* - `aborted`(用户停止)与 `blocked`(策略拒绝)永不自动继续。
|
|
177
|
-
*/
|
|
178
|
-
type NonHumanReason = 'error' | 'interrupted' | 'max-tokens';
|
|
179
|
-
|
|
180
|
-
function isNonHumanReason(kind: string): kind is NonHumanReason {
|
|
181
|
-
return kind === 'error' || kind === 'interrupted' || kind === 'max-tokens';
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/** 一次回合失败的机器可读事实(turn/end error 的 LlmFailure 载荷)。 */
|
|
185
|
-
export interface FailureFacts {
|
|
186
|
-
/** 稳定机器路由码(如 UPSTREAM、RATE_LIMIT_EXCEEDED、INVALID_API_KEY)。 */
|
|
187
|
-
code: string;
|
|
188
|
-
/** 人类可读的失败描述。 */
|
|
189
|
-
message: string;
|
|
190
|
-
/** 供应商 HTTP 状态码(可用时)。 */
|
|
191
|
-
status?: number;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* 错误分类: 该失败是否值得自动继续。
|
|
196
|
-
* 永久性失败(认证/余额/模型不存在/上下文超限等)重试也不会成功, 应跳过并通知用户;
|
|
197
|
-
* 其余(网络、超时、5xx、429 等)视为临时性失败, 允许自动恢复。
|
|
198
|
-
*/
|
|
199
|
-
export function isTransientFailure(failure: FailureFacts): boolean {
|
|
200
|
-
const haystack = `${failure.code} ${failure.message}`.toLowerCase();
|
|
201
|
-
const status = failure.status;
|
|
202
|
-
if (status !== undefined && (status === 401 || status === 403)) return false;
|
|
203
|
-
const permanent =
|
|
204
|
-
/auth|unauthor|forbidden|credential|api[_-]?key|permission/i.test(haystack) ||
|
|
205
|
-
/insufficient.*(balance|quota)|billing|payment|quota.*exceeded.*(?!retry)/i.test(haystack) ||
|
|
206
|
-
/model.*not[_-]?found|unknown[_-]?model|model[_-]?not[_-]?found|not.*support.*model/i.test(haystack) ||
|
|
207
|
-
/context.*(length|limit|overflow|exceed)|token.*limit|max.*context/i.test(haystack) ||
|
|
208
|
-
/invalid[_-]?request|bad[_-]?request/i.test(haystack);
|
|
209
|
-
return !permanent;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* host/agent-error 消息分类: 仅明确属于网络/传输类的临时错误才自动继续。
|
|
214
|
-
* 其余(序列化失败、配置/宿主内部错误等)视为永久性——重试无益, 且用户停止导致的
|
|
215
|
-
* 序列化失败(如 Windows 下 abort 的 DOMException reason)绝不能自动续跑。
|
|
216
|
-
*/
|
|
217
|
-
export function isTransientAgentError(message: string): boolean {
|
|
218
|
-
return /network|timeout|timed ?out|econn|etimedout|socket|5\d\d|\b429\b|upstream|temporar/i.test(message);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** 通知上的一个操作按钮(action 标识 + 显示文案)。 */
|
|
222
|
-
export interface NotifyAction {
|
|
223
|
-
/** 稳定动作标识, 点击时经 onAction 回调传出。 */
|
|
224
|
-
action: string;
|
|
225
|
-
/** 按钮显示文案。 */
|
|
226
|
-
title: string;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/** 通知的可选行为: 操作按钮列表与点击回调。 */
|
|
230
|
-
export interface NotifyOptions {
|
|
231
|
-
actions?: NotifyAction[];
|
|
232
|
-
onAction?: (action: string) => void;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/** 浏览器通知(不可用时静默跳过); 点击通知聚焦窗口, 操作按钮走 onAction。 */
|
|
236
|
-
function notify(title: string, body: string, options?: NotifyOptions): void {
|
|
237
|
-
try {
|
|
238
|
-
const N = (globalThis as { Notification?: unknown }).Notification as
|
|
239
|
-
| (new (t: string, o: { body: string; actions?: NotifyAction[] }) => unknown)
|
|
240
|
-
| undefined;
|
|
241
|
-
if (typeof N === 'undefined') return;
|
|
242
|
-
const permission = (N as unknown as { permission?: string }).permission;
|
|
243
|
-
const create = (): void => {
|
|
244
|
-
const instance = new N(title, {
|
|
245
|
-
body,
|
|
246
|
-
...(options?.actions !== undefined && options.actions.length > 0
|
|
247
|
-
? { actions: options.actions }
|
|
248
|
-
: {}),
|
|
249
|
-
});
|
|
250
|
-
const target = instance as {
|
|
251
|
-
onclick?: (() => void) | null;
|
|
252
|
-
onaction?: ((event: { action: string }) => void) | null;
|
|
253
|
-
};
|
|
254
|
-
target.onclick = () => {
|
|
255
|
-
try {
|
|
256
|
-
(globalThis as { focus?: () => void }).focus?.();
|
|
257
|
-
} catch {
|
|
258
|
-
/* ignore */
|
|
259
|
-
}
|
|
260
|
-
};
|
|
261
|
-
if (options?.onAction !== undefined) {
|
|
262
|
-
target.onaction = (event) => options.onAction?.(event.action);
|
|
263
|
-
}
|
|
264
|
-
};
|
|
265
|
-
if (permission === 'granted') {
|
|
266
|
-
create();
|
|
267
|
-
} else if (permission === 'default') {
|
|
268
|
-
// 首次使用时请求一次权限, 用户拒绝后不再打扰。
|
|
269
|
-
void (N as unknown as { requestPermission?: () => Promise<string> }).requestPermission?.()
|
|
270
|
-
.then((result) => {
|
|
271
|
-
if (result === 'granted') create();
|
|
272
|
-
})
|
|
273
|
-
.catch(() => {});
|
|
274
|
-
}
|
|
275
|
-
} catch {
|
|
276
|
-
/* 通知失败不影响核心逻辑 */
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/** 把毫秒格式化为人类可读的经过时长(如 65s → 1m5s)。 */
|
|
281
|
-
function formatElapsed(ms: number | undefined): string {
|
|
282
|
-
if (ms === undefined || !Number.isFinite(ms) || ms < 0) return '';
|
|
283
|
-
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
284
|
-
const s = Math.round(ms / 1000);
|
|
285
|
-
if (s < 60) return `${s}s`;
|
|
286
|
-
return `${Math.floor(s / 60)}m${s % 60 > 0 ? `${s % 60}s` : ''}`;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/** 模板填充所需的上下文(全部可选, 缺失的占位符填为空串)。 */
|
|
290
|
-
export interface TemplateContext {
|
|
291
|
-
/** 失败事实(错误码/消息/HTTP 状态), 对应 {code}/{message}/{status}。 */
|
|
292
|
-
facts?: FailureFacts;
|
|
293
|
-
/** 失败前最后一次工具调用的名称, 对应 {tool}。 */
|
|
294
|
-
tool?: string;
|
|
295
|
-
/** 失败回合的编号, 对应 {turn}。 */
|
|
296
|
-
turn?: number;
|
|
297
|
-
/** 连续失败次数(含本次), 对应 {errorCount}。 */
|
|
298
|
-
errorCount?: number;
|
|
299
|
-
/** 会话标题(来自 session.list 投影, 可用时), 对应 {sessionTitle}。 */
|
|
300
|
-
sessionTitle?: string;
|
|
301
|
-
/** 自失败发生以来的毫秒数, 对应 {elapsed}。 */
|
|
302
|
-
elapsedMs?: number;
|
|
303
|
-
/** 上一步工具结果摘要(截断), 对应 {result}(护栏模板用)。 */
|
|
304
|
-
result?: string;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/** 用失败事实与回合信息填充 continueText 模板占位符({code}/{message}/{status}/{tool}/{turn}/{errorCount}/{sessionTitle}/{elapsed}/{result})。 */
|
|
308
|
-
export function fillTemplate(template: string, ctx: TemplateContext): string {
|
|
309
|
-
return template
|
|
310
|
-
.replace(/\{code\}/g, ctx.facts?.code ?? '')
|
|
311
|
-
.replace(/\{message\}/g, ctx.facts?.message ?? '')
|
|
312
|
-
.replace(/\{status\}/g, ctx.facts?.status !== undefined ? String(ctx.facts.status) : '')
|
|
313
|
-
.replace(/\{tool\}/g, ctx.tool ?? '')
|
|
314
|
-
.replace(/\{turn\}/g, ctx.turn !== undefined ? String(ctx.turn) : '')
|
|
315
|
-
.replace(/\{errorCount\}/g, ctx.errorCount !== undefined ? String(ctx.errorCount) : '')
|
|
316
|
-
.replace(/\{sessionTitle\}/g, ctx.sessionTitle ?? '')
|
|
317
|
-
.replace(/\{elapsed\}/g, formatElapsed(ctx.elapsedMs))
|
|
318
|
-
.replace(/\{result\}/g, ctx.result ?? '');
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// ---------- 幂等护栏: 上一步工具调用的执行状态 ----------
|
|
322
|
-
|
|
323
|
-
/** 工具结果摘要的最大长度(护栏模板 {result} 用)。 */
|
|
324
|
-
const TOOL_RESULT_CAP = 160;
|
|
325
|
-
|
|
326
|
-
/** 从任意内容块里递归收集文本(结果为模型可见的工具输出)。 */
|
|
327
|
-
function extractText(blocks: unknown, cap: number): string {
|
|
328
|
-
let out = '';
|
|
329
|
-
const walk = (value: unknown): void => {
|
|
330
|
-
if (out.length >= cap) return;
|
|
331
|
-
if (Array.isArray(value)) {
|
|
332
|
-
for (const item of value) walk(item);
|
|
333
|
-
return;
|
|
334
|
-
}
|
|
335
|
-
if (typeof value !== 'object' || value === null) return;
|
|
336
|
-
const record = value as Record<string, unknown>;
|
|
337
|
-
if (record['type'] === 'text' && typeof record['text'] === 'string') {
|
|
338
|
-
out += record['text'];
|
|
339
|
-
return;
|
|
340
|
-
}
|
|
341
|
-
for (const child of Object.values(record)) walk(child);
|
|
342
|
-
};
|
|
343
|
-
walk(blocks);
|
|
344
|
-
return out.slice(0, cap);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/** 上一步工具调用的判定结果: 是否已确认完成, 以及文本摘要。 */
|
|
348
|
-
export interface ToolResultFacts {
|
|
349
|
-
/** 工具是否成功完成(内部失败或 isError 视为未成功)。 */
|
|
350
|
-
ok: boolean;
|
|
351
|
-
/** 工具输出的文本摘要(截断)。 */
|
|
352
|
-
excerpt: string;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/** 从 tool/result 事件载荷提取成功与否与文本摘要。 */
|
|
356
|
-
function toolResultFacts(data: {
|
|
357
|
-
error?: { name?: string; code?: string };
|
|
358
|
-
message?: { content?: Array<{ type?: string; content?: unknown; isError?: boolean }> };
|
|
359
|
-
}): ToolResultFacts {
|
|
360
|
-
const failed = data.error !== undefined || data.message?.content?.[0]?.isError === true;
|
|
361
|
-
return { ok: !failed, excerpt: extractText(data.message?.content?.[0]?.content, TOOL_RESULT_CAP) };
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
/** 自适应退避: 同一会话连续失败时的有效冷却间隔。 */
|
|
365
|
-
export function effectiveCooldown(
|
|
366
|
-
consecutive: number,
|
|
367
|
-
base: number,
|
|
368
|
-
factor: number,
|
|
369
|
-
max: number,
|
|
370
|
-
): number {
|
|
371
|
-
// consecutive = 已连续自动继续的次数; 第 1 次后开始按 factor 递增
|
|
372
|
-
const multiplier = Math.pow(factor, consecutive);
|
|
373
|
-
return Math.min(Math.max(base, base * multiplier), Math.max(base, max));
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
function sleep(ms: number): Promise<void> {
|
|
377
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/** 浏览器当前 IANA 时区; 不可用时省略(宿主允许省略)。 */
|
|
381
|
-
function clientTimeZone(): string | undefined {
|
|
382
|
-
try {
|
|
383
|
-
return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
|
|
384
|
-
} catch {
|
|
385
|
-
return undefined;
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/** 跨标签页互斥与冷却记录(仅浏览器本地, 不落盘到宿主)。 */
|
|
390
|
-
const lockPrefix = 'dsh-auto-continue:';
|
|
391
|
-
const lockKey = (sessionId: SessionId) => `${lockPrefix}lock:${sessionId}`;
|
|
392
|
-
const stampKey = (sessionId: SessionId) => `${lockPrefix}last:${sessionId}`;
|
|
393
|
-
const countKey = (sessionId: SessionId) => `${lockPrefix}count:${sessionId}`;
|
|
394
|
-
|
|
395
|
-
/**
|
|
396
|
-
* 上次自动发送的完整记录(跨标签页): 时间戳 + 文本。
|
|
397
|
-
* 回显识别用它而不是单 runner 内存, 所以任何标签页发出的消息都能被所有标签页认出。
|
|
398
|
-
*/
|
|
399
|
-
function readLastSent(sessionId: SessionId): { at: number; text: string } {
|
|
400
|
-
try {
|
|
401
|
-
const raw = localStorage.getItem(stampKey(sessionId));
|
|
402
|
-
if (raw === null) return { at: 0, text: '' };
|
|
403
|
-
const parsed = JSON.parse(raw);
|
|
404
|
-
if (typeof parsed === 'object' && parsed !== null && typeof parsed.text === 'string') {
|
|
405
|
-
return { at: Number(parsed.at) || 0, text: parsed.text };
|
|
406
|
-
}
|
|
407
|
-
return { at: Number(raw) || 0, text: '' }; // 兼容旧格式(纯时间戳)
|
|
408
|
-
} catch {
|
|
409
|
-
return { at: 0, text: '' };
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
/** 读「上次自动发送」时间戳(跨标签页冷却)。 */
|
|
414
|
-
function readLastSend(sessionId: SessionId): number {
|
|
415
|
-
return readLastSent(sessionId).at;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
function writeLastSend(sessionId: SessionId, at: number, text: string): void {
|
|
419
|
-
try {
|
|
420
|
-
localStorage.setItem(stampKey(sessionId), JSON.stringify({ at, text }));
|
|
421
|
-
} catch {
|
|
422
|
-
/* ignore */
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
// ---------- 跨标签页发送计数(硬上限, 不依赖回显识别) ----------
|
|
427
|
-
|
|
428
|
-
/** 发送计数窗口: 超过该时长无新发送, 计数自动失效。 */
|
|
429
|
-
const SEND_COUNT_WINDOW_MS = 10 * 60 * 1000;
|
|
430
|
-
|
|
431
|
-
interface SendCount {
|
|
432
|
-
at: number;
|
|
433
|
-
count: number;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
function readSendCount(sessionId: SessionId): SendCount {
|
|
437
|
-
try {
|
|
438
|
-
const raw = localStorage.getItem(countKey(sessionId));
|
|
439
|
-
if (raw === null) return { at: 0, count: 0 };
|
|
440
|
-
const parsed = JSON.parse(raw);
|
|
441
|
-
if (typeof parsed === 'object' && parsed !== null && typeof parsed.count === 'number') {
|
|
442
|
-
const at = Number(parsed.at) || 0;
|
|
443
|
-
if (Date.now() - at > SEND_COUNT_WINDOW_MS) return { at: 0, count: 0 }; // 窗口过期
|
|
444
|
-
return { at, count: parsed.count };
|
|
445
|
-
}
|
|
446
|
-
} catch {
|
|
447
|
-
/* ignore */
|
|
448
|
-
}
|
|
449
|
-
return { at: 0, count: 0 };
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
function bumpSendCount(sessionId: SessionId): void {
|
|
453
|
-
try {
|
|
454
|
-
const current = readSendCount(sessionId);
|
|
455
|
-
localStorage.setItem(
|
|
456
|
-
countKey(sessionId),
|
|
457
|
-
JSON.stringify({ at: Date.now(), count: current.count + 1 }),
|
|
458
|
-
);
|
|
459
|
-
} catch {
|
|
460
|
-
/* ignore */
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
/** 用户介入或成功回合后清零发送计数(跨标签页共享)。 */
|
|
465
|
-
function clearSendCount(sessionId: SessionId): void {
|
|
466
|
-
try {
|
|
467
|
-
localStorage.removeItem(countKey(sessionId));
|
|
468
|
-
} catch {
|
|
469
|
-
/* ignore */
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
/** Web Locks: 跨标签页原子发送锁; 不可用(旧浏览器/测试环境)时回退到互斥戳。 */
|
|
474
|
-
async function withSendLock(sessionId: SessionId, body: () => Promise<void>): Promise<void> {
|
|
475
|
-
const nav = (globalThis as { navigator?: unknown }).navigator as
|
|
476
|
-
| { locks?: { request: (name: string, cb: () => Promise<void>) => Promise<void> } }
|
|
477
|
-
| undefined;
|
|
478
|
-
if (nav?.locks !== undefined) {
|
|
479
|
-
await nav.locks.request(`dsh-auto-continue:send:${sessionId}`, body);
|
|
480
|
-
return;
|
|
481
|
-
}
|
|
482
|
-
// 回退: 尽力互斥(单标签页下可靠; 旧浏览器无 Web Locks)
|
|
483
|
-
if (!claimSend(sessionId)) return;
|
|
484
|
-
try {
|
|
485
|
-
await body();
|
|
486
|
-
} finally {
|
|
487
|
-
releaseSend(sessionId);
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
/** 尝试独占本次发送: 两个标签页同时触发时只有一个成功(Web Locks 的回退方案)。 */
|
|
492
|
-
function claimSend(sessionId: SessionId): boolean {
|
|
493
|
-
try {
|
|
494
|
-
const token = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
495
|
-
localStorage.setItem(lockKey(sessionId), token);
|
|
496
|
-
return localStorage.getItem(lockKey(sessionId)) === token;
|
|
497
|
-
} catch {
|
|
498
|
-
return true; // 存储不可用(隐私模式等)时放行
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
function releaseSend(sessionId: SessionId): void {
|
|
503
|
-
try {
|
|
504
|
-
localStorage.removeItem(lockKey(sessionId));
|
|
505
|
-
} catch {
|
|
506
|
-
/* ignore */
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
// ---------- 会话级暂停(仅浏览器本地, 跨标签页共享) ----------
|
|
511
|
-
|
|
512
|
-
const pauseKey = (sessionId: SessionId) => `${lockPrefix}pause:${sessionId}`;
|
|
513
|
-
|
|
514
|
-
/** 暂停某会话: 到 `until` 之前, 引擎不会为该会话自动继续(通知按钮等调用)。 */
|
|
515
|
-
export function pauseSession(sessionId: SessionId, ms: number): void {
|
|
516
|
-
try {
|
|
517
|
-
localStorage.setItem(pauseKey(sessionId), String(Date.now() + ms));
|
|
518
|
-
} catch {
|
|
519
|
-
/* ignore */
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
/** 解除某会话的暂停。 */
|
|
524
|
-
export function unpauseSession(sessionId: SessionId): void {
|
|
525
|
-
try {
|
|
526
|
-
localStorage.removeItem(pauseKey(sessionId));
|
|
527
|
-
} catch {
|
|
528
|
-
/* ignore */
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
/** 会话暂停的截止时间戳; 0 表示未暂停。 */
|
|
533
|
-
export function sessionPauseUntil(sessionId: SessionId): number {
|
|
534
|
-
try {
|
|
535
|
-
return Number(localStorage.getItem(pauseKey(sessionId)) ?? 0) || 0;
|
|
536
|
-
} catch {
|
|
537
|
-
return 0;
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
/** 当前生效(未过期)的暂停会话列表; 顺带清理过期条目。 */
|
|
542
|
-
export function pausedSessions(): { sessionId: SessionId; until: number }[] {
|
|
543
|
-
const out: { sessionId: SessionId; until: number }[] = [];
|
|
544
|
-
const now = Date.now();
|
|
545
|
-
try {
|
|
546
|
-
for (let i = 0; i < localStorage.length; i += 1) {
|
|
547
|
-
const key = localStorage.key(i);
|
|
548
|
-
if (key === null || !key.startsWith(`${lockPrefix}pause:`)) continue;
|
|
549
|
-
const sessionId = key.slice(lockPrefix.length + 'pause:'.length) as SessionId;
|
|
550
|
-
const until = Number(localStorage.getItem(key) ?? 0) || 0;
|
|
551
|
-
if (until > now) out.push({ sessionId, until });
|
|
552
|
-
else localStorage.removeItem(key);
|
|
553
|
-
}
|
|
554
|
-
} catch {
|
|
555
|
-
/* ignore */
|
|
556
|
-
}
|
|
557
|
-
return out;
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
// ---------- 统计(仅浏览器本地; 按本地日期分桶, 最多保留 90 天) ----------
|
|
561
|
-
|
|
562
|
-
/** 一天的自动继续统计。 */
|
|
563
|
-
export interface DayStats {
|
|
564
|
-
/** 本地日期 YYYY-MM-DD。 */
|
|
565
|
-
date: string;
|
|
566
|
-
/** 自动发送次数。 */
|
|
567
|
-
sent: number;
|
|
568
|
-
/** 因永久性错误跳过的次数。 */
|
|
569
|
-
skipped: number;
|
|
570
|
-
/** 发送后回合成功完成(恢复成功)的次数。 */
|
|
571
|
-
recovered: number;
|
|
572
|
-
/** 发送后再次失败的次数。 */
|
|
573
|
-
failed: number;
|
|
574
|
-
/** 达到连续上限而停止的次数(按停止事件计)。 */
|
|
575
|
-
gaveUp: number;
|
|
576
|
-
/** loop guard 打断并重启回合的次数。 */
|
|
577
|
-
looped: number;
|
|
578
|
-
/** 按错误码计数的失败分布。 */
|
|
579
|
-
byCode: Record<string, number>;
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
const statsKey = `${lockPrefix}stats`;
|
|
583
|
-
const STATS_MAX_DAYS = 90;
|
|
584
|
-
|
|
585
|
-
function todayKey(): string {
|
|
586
|
-
const d = new Date();
|
|
587
|
-
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
588
|
-
const dd = String(d.getDate()).padStart(2, '0');
|
|
589
|
-
return `${d.getFullYear()}-${mm}-${dd}`;
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
function readStats(): DayStats[] {
|
|
593
|
-
try {
|
|
594
|
-
const raw = localStorage.getItem(statsKey);
|
|
595
|
-
if (raw === null) return [];
|
|
596
|
-
const parsed = JSON.parse(raw);
|
|
597
|
-
if (!Array.isArray(parsed)) return [];
|
|
598
|
-
return parsed.filter(
|
|
599
|
-
(item): item is DayStats =>
|
|
600
|
-
typeof item === 'object' && item !== null && typeof item.date === 'string',
|
|
601
|
-
);
|
|
602
|
-
} catch {
|
|
603
|
-
return [];
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
function writeStats(list: DayStats[]): void {
|
|
608
|
-
try {
|
|
609
|
-
localStorage.setItem(statsKey, JSON.stringify(list));
|
|
610
|
-
} catch {
|
|
611
|
-
/* ignore */
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
/** 累加今日统计(引擎内部记账)。 */
|
|
616
|
-
function bumpStat(delta: {
|
|
617
|
-
sent?: number;
|
|
618
|
-
skipped?: number;
|
|
619
|
-
recovered?: number;
|
|
620
|
-
failed?: number;
|
|
621
|
-
gaveUp?: number;
|
|
622
|
-
looped?: number;
|
|
623
|
-
code?: string;
|
|
624
|
-
}): void {
|
|
625
|
-
const list = readStats();
|
|
626
|
-
let day = list.find((item) => item.date === todayKey());
|
|
627
|
-
if (day === undefined) {
|
|
628
|
-
day = { date: todayKey(), sent: 0, skipped: 0, recovered: 0, failed: 0, gaveUp: 0, looped: 0, byCode: {} };
|
|
629
|
-
list.unshift(day);
|
|
630
|
-
}
|
|
631
|
-
if (delta.sent !== undefined) day.sent += delta.sent;
|
|
632
|
-
if (delta.skipped !== undefined) day.skipped += delta.skipped;
|
|
633
|
-
if (delta.recovered !== undefined) day.recovered += delta.recovered;
|
|
634
|
-
if (delta.failed !== undefined) day.failed += delta.failed;
|
|
635
|
-
if (delta.gaveUp !== undefined) day.gaveUp += delta.gaveUp;
|
|
636
|
-
if (delta.looped !== undefined) day.looped += delta.looped;
|
|
637
|
-
if (delta.code !== undefined) day.byCode[delta.code] = (day.byCode[delta.code] ?? 0) + 1;
|
|
638
|
-
writeStats(list.slice(0, STATS_MAX_DAYS));
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
/** 今日统计(设置卡片展示用)。 */
|
|
642
|
-
export function readTodayStats(): DayStats {
|
|
643
|
-
const today = todayKey();
|
|
644
|
-
const found = readStats().find((item) => item.date === today);
|
|
645
|
-
return (
|
|
646
|
-
found ?? { date: today, sent: 0, skipped: 0, recovered: 0, failed: 0, gaveUp: 0, looped: 0, byCode: {} }
|
|
647
|
-
);
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
/** 清零今日统计。 */
|
|
651
|
-
export function resetTodayStats(): void {
|
|
652
|
-
writeStats(readStats().filter((item) => item.date !== todayKey()));
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
/** 每会话运行时状态。 */
|
|
656
|
-
interface SessionState {
|
|
657
|
-
/** 连续自动「继续」次数; 成功回合或用户手动介入后归零。 */
|
|
658
|
-
consecutive: number;
|
|
659
|
-
/** 上次自动「继续」时间戳。 */
|
|
660
|
-
lastAutoAt: number;
|
|
661
|
-
/** 上次自动「继续」尝试(成功或失败)时间戳; 防止失败场景下的快速重试循环。 */
|
|
662
|
-
lastAttemptAt: number;
|
|
663
|
-
/** 我们上次自动发送的文本(用于识别自己的回显)。 */
|
|
664
|
-
lastSentText: string;
|
|
665
|
-
/** 宽限期定时器(进行中的待发送)。 */
|
|
666
|
-
pendingTimer: ReturnType<typeof setTimeout> | undefined;
|
|
667
|
-
/** 宿主权威 running 位(来自 host/session-status 与回合事件)。 */
|
|
668
|
-
running: boolean | undefined;
|
|
669
|
-
/** 当前排队消息数(来自 session/queue 帧)。 */
|
|
670
|
-
queued: number;
|
|
671
|
-
/** 子代理会话(host/session-added 带 parentSessionId)。 */
|
|
672
|
-
subagent: boolean;
|
|
673
|
-
/** 最近一次回合失败的事实(用于分类与模板填充)。 */
|
|
674
|
-
lastFailure: FailureFacts | undefined;
|
|
675
|
-
/** 最近一次失败的发生时间(模板 {elapsed} 与恢复统计用)。 */
|
|
676
|
-
lastFailureAt: number;
|
|
677
|
-
/** 失败前最后一次工具调用的名称(模板 {tool} 与幂等护栏用)。 */
|
|
678
|
-
lastTool: string | undefined;
|
|
679
|
-
/** 上一步工具调用的结果状态: 'pending' = 已发起未见结果(可能已部分执行)。 */
|
|
680
|
-
lastToolResult: 'pending' | ToolResultFacts | undefined;
|
|
681
|
-
/** 失败回合的编号(模板 {turn})。 */
|
|
682
|
-
lastTurn: number | undefined;
|
|
683
|
-
/** 我们最近一次自动发送的时间戳; 0 = 没有待确认的恢复。 */
|
|
684
|
-
pendingRecoveryAt: number;
|
|
685
|
-
/** 当前连续短句数(loop guard 信号 1: 空转)。 */
|
|
686
|
-
shortRun: number;
|
|
687
|
-
/** 最后一条短句的时间(时间窗判定用)。 */
|
|
688
|
-
lastShortAt: number;
|
|
689
|
-
/** 最后一条模型消息的文本(相同文本重复判定用)。 */
|
|
690
|
-
lastAssistantText: string;
|
|
691
|
-
/** 连续相同文本消息数(最强空转信号, 不限长度)。 */
|
|
692
|
-
sameTextRun: number;
|
|
693
|
-
/**
|
|
694
|
-
* 工具重复信号(loop guard 信号 2: 死循环)。
|
|
695
|
-
* 只有「同工具 + 同参数 + 同结果」的连续调用才累计; 参数或结果有变化视为有进展, 计数重置。
|
|
696
|
-
*/
|
|
697
|
-
toolRun:
|
|
698
|
-
| {
|
|
699
|
-
/** 工具名 + 参数(用于判定是否同一调用)。 */
|
|
700
|
-
key: string;
|
|
701
|
-
/** 连续相同调用数(结果确认后更新)。 */
|
|
702
|
-
count: number;
|
|
703
|
-
/** 上次该调用的结果摘要(比较用)。 */
|
|
704
|
-
lastResult: string | undefined;
|
|
705
|
-
/** 本次调用等待结果确认。 */
|
|
706
|
-
waiting: boolean;
|
|
707
|
-
}
|
|
708
|
-
| undefined;
|
|
709
|
-
/** 本回合已触发过 loop guard(防重复打断)。 */
|
|
710
|
-
loopFired: boolean;
|
|
711
|
-
/** loop 重启的延迟定时器(冷却结束后再 schedule)。 */
|
|
712
|
-
loopRetryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
713
|
-
/** 我们主动 cancel 过本回合(区分用户停止)。 */
|
|
714
|
-
loopCancelled: boolean;
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
const freshState = (): SessionState => ({
|
|
718
|
-
consecutive: 0,
|
|
719
|
-
lastAutoAt: 0,
|
|
720
|
-
lastAttemptAt: 0,
|
|
721
|
-
lastSentText: '',
|
|
722
|
-
pendingTimer: undefined,
|
|
723
|
-
running: undefined,
|
|
724
|
-
queued: 0,
|
|
725
|
-
subagent: false,
|
|
726
|
-
lastFailure: undefined,
|
|
727
|
-
lastFailureAt: 0,
|
|
728
|
-
lastTool: undefined,
|
|
729
|
-
lastToolResult: undefined,
|
|
730
|
-
lastTurn: undefined,
|
|
731
|
-
pendingRecoveryAt: 0,
|
|
732
|
-
shortRun: 0,
|
|
733
|
-
lastShortAt: 0,
|
|
734
|
-
lastAssistantText: '',
|
|
735
|
-
sameTextRun: 0,
|
|
736
|
-
toolRun: undefined,
|
|
737
|
-
loopFired: false,
|
|
738
|
-
loopCancelled: false,
|
|
739
|
-
loopRetryTimer: undefined,
|
|
740
|
-
});
|
|
741
|
-
|
|
742
|
-
/** 自动发送后, 在该窗口内出现的回合结束才计入恢复统计。 */
|
|
743
|
-
const RECOVERY_WINDOW_MS = 10 * 60 * 1000;
|
|
744
|
-
|
|
745
|
-
/** 回显识别窗口: 排队消息可能几分钟后才被模型处理到, 窗口必须远大于排队延迟。 */
|
|
746
|
-
const ECHO_WINDOW_MS = 10 * 60 * 1000;
|
|
747
|
-
|
|
748
|
-
/**
|
|
749
|
-
* 判定一条 user/message 是否是我们自己自动发送的回显。
|
|
750
|
-
* 用 localStorage 里的上次发送记录(跨标签页): 任何标签页发出的消息,
|
|
751
|
-
* 所有标签页都能认出——排队回显不会误判为「用户介入」而清零上限。
|
|
752
|
-
*/
|
|
753
|
-
function isOurEcho(state: SessionState, sessionId: SessionId, event: SessionEvent): boolean {
|
|
754
|
-
if (event.type !== 'user/message') return false;
|
|
755
|
-
const message = event.data;
|
|
756
|
-
if (message.source.kind !== 'user') return false;
|
|
757
|
-
const last = readLastSent(sessionId);
|
|
758
|
-
if (last.at === 0 || last.text === '') return false;
|
|
759
|
-
if (Date.now() - last.at > ECHO_WINDOW_MS) return false;
|
|
760
|
-
const text = message.content
|
|
761
|
-
.filter((part): part is { type: 'text'; text: string } => part.type === 'text')
|
|
762
|
-
.map((part) => part.text)
|
|
763
|
-
.join('');
|
|
764
|
-
return text === last.text;
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
/** SSE 帧外壳: `{ rpcId, payload }`。 */
|
|
768
|
-
type FrameEnvelope<T> = { payload: T };
|
|
769
|
-
|
|
770
|
-
/**
|
|
771
|
-
* 事件流泵: 带指数退避的 SSE 重连循环。
|
|
772
|
-
* - 从未收到任何帧(宿主未就绪): 退避重试, 不触发扫描
|
|
773
|
-
* - 曾连上后断开: 重连, 并通过 onReconnect 通知外层(宿主可能崩溃重启过)
|
|
4
|
+
* The engine itself moved into the host process in 0.8.0
|
|
5
|
+
* (see src/host/engine.ts); this module only serves the settings card and
|
|
6
|
+
* anything else the thin browser half still needs.
|
|
774
7
|
*/
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
connected = true;
|
|
789
|
-
onFrame(envelope.payload);
|
|
790
|
-
}
|
|
791
|
-
if (signal.aborted) return;
|
|
792
|
-
} catch (error) {
|
|
793
|
-
if (signal.aborted) return;
|
|
794
|
-
log(`stream error: ${error instanceof Error ? error.message : String(error)}`);
|
|
795
|
-
}
|
|
796
|
-
if (!connected) {
|
|
797
|
-
// 从未连上(宿主未就绪): 指数退避重试
|
|
798
|
-
await sleep(backoff);
|
|
799
|
-
backoff = Math.min(backoff * 2, 15000);
|
|
800
|
-
continue;
|
|
801
|
-
}
|
|
802
|
-
// 曾连上后断开 → 重连并触发外层扫描
|
|
803
|
-
backoff = getBackoff();
|
|
804
|
-
onReconnect();
|
|
805
|
-
await sleep(backoff);
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
/** 插件主体: 一条 mux 流 + 一条 host 流 + 启动/重连扫描。 */
|
|
810
|
-
export class AutoContinueRunner {
|
|
811
|
-
private readonly states = new Map<SessionId, SessionState>();
|
|
812
|
-
private readonly muxAbort = new AbortController();
|
|
813
|
-
private readonly hostAbort = new AbortController();
|
|
814
|
-
private disposed = false;
|
|
815
|
-
private reconnectScans = 0;
|
|
816
|
-
|
|
817
|
-
/**
|
|
818
|
-
* @param api - shared wire client (ctx.connection.api).
|
|
819
|
-
* @param getConfig - read the current resolved configuration (settings scope).
|
|
820
|
-
*/
|
|
821
|
-
constructor(
|
|
822
|
-
private readonly api: IApiClient,
|
|
823
|
-
private readonly getConfig: () => AutoContinueConfig,
|
|
824
|
-
) {
|
|
825
|
-
const config = this.getConfig();
|
|
826
|
-
void this.runMux();
|
|
827
|
-
void this.runHost();
|
|
828
|
-
if (config.scanOnBoot) {
|
|
829
|
-
// 启动时连接可能尚未建立, 循环重试直到成功。
|
|
830
|
-
void this.bootScanLoop();
|
|
831
|
-
}
|
|
832
|
-
this.log(
|
|
833
|
-
`已启动(文本="${config.continueText}", 宽限 ${config.graceMs}ms, ` +
|
|
834
|
-
`冷却 ${config.cooldownMs}ms, 最多连续 ${config.maxConsecutive} 次)`,
|
|
835
|
-
);
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
private log(message: string): void {
|
|
839
|
-
if (this.getConfig().verbose) console.info(`[auto-continue] ${message}`);
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
dispose(): void {
|
|
843
|
-
this.disposed = true;
|
|
844
|
-
this.muxAbort.abort();
|
|
845
|
-
this.hostAbort.abort();
|
|
846
|
-
for (const state of this.states.values()) {
|
|
847
|
-
if (state.pendingTimer !== undefined) clearTimeout(state.pendingTimer);
|
|
848
|
-
if (state.loopRetryTimer !== undefined) clearTimeout(state.loopRetryTimer);
|
|
849
|
-
}
|
|
850
|
-
this.states.clear();
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
private state(sessionId: SessionId): SessionState {
|
|
854
|
-
let state = this.states.get(sessionId);
|
|
855
|
-
if (state === undefined) {
|
|
856
|
-
state = freshState();
|
|
857
|
-
this.states.set(sessionId, state);
|
|
858
|
-
}
|
|
859
|
-
return state;
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
private runMux(): Promise<void> {
|
|
863
|
-
return pumpStream<MuxFrame>(
|
|
864
|
-
(signal) => this.api.events.mux({}, signal),
|
|
865
|
-
(payload) => this.onMuxFrame(payload),
|
|
866
|
-
() => this.scheduleReconnectScan(),
|
|
867
|
-
() => this.getConfig().reconnectBackoffMs,
|
|
868
|
-
(m) => this.log(m),
|
|
869
|
-
this.muxAbort.signal,
|
|
870
|
-
);
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
private runHost(): Promise<void> {
|
|
874
|
-
return pumpStream<HostFrame>(
|
|
875
|
-
(signal) => this.api.events.host({}, signal),
|
|
876
|
-
(payload) => this.onHostFrame(payload),
|
|
877
|
-
() => this.scheduleReconnectScan(),
|
|
878
|
-
() => this.getConfig().reconnectBackoffMs,
|
|
879
|
-
(m) => this.log(m),
|
|
880
|
-
this.hostAbort.signal,
|
|
881
|
-
);
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
// ---------- mux 帧 ----------
|
|
885
|
-
|
|
886
|
-
private onMuxFrame(frame: MuxFrame): void {
|
|
887
|
-
switch (frame.type) {
|
|
888
|
-
case 'session/event':
|
|
889
|
-
if (frame.event.type === 'tool/call') {
|
|
890
|
-
const name = frame.event.data.name;
|
|
891
|
-
if (typeof name === 'string') {
|
|
892
|
-
const state = this.state(frame.sessionId);
|
|
893
|
-
state.lastTool = name;
|
|
894
|
-
state.lastToolResult = 'pending'; // 已发起, 尚未见结果
|
|
895
|
-
// loop guard 信号 2: 同工具+同参数才可能是循环; 参数变化 = 有进展
|
|
896
|
-
// (工具调用本身也重置短句信号)。计数在结果确认后才推进。
|
|
897
|
-
state.shortRun = 0;
|
|
898
|
-
const key = `${name}\n${frame.event.data.arguments}`;
|
|
899
|
-
if (state.toolRun?.key === key) {
|
|
900
|
-
state.toolRun.waiting = true; // 结果到达时与上次结果比较
|
|
901
|
-
} else {
|
|
902
|
-
state.toolRun = { key, count: 1, lastResult: undefined, waiting: false };
|
|
903
|
-
}
|
|
904
|
-
}
|
|
905
|
-
} else if (frame.event.type === 'tool/result') {
|
|
906
|
-
const state = this.state(frame.sessionId);
|
|
907
|
-
if (state.lastToolResult === 'pending') {
|
|
908
|
-
const facts = toolResultFacts(frame.event.data);
|
|
909
|
-
state.lastToolResult = facts;
|
|
910
|
-
// 结果确认: 与上次相同 → 计数推进; 不同 → 有进展, 重置
|
|
911
|
-
const run = state.toolRun;
|
|
912
|
-
if (run !== undefined && run.waiting) {
|
|
913
|
-
run.waiting = false;
|
|
914
|
-
if (run.lastResult !== undefined && run.lastResult === facts.excerpt) {
|
|
915
|
-
run.count += 1;
|
|
916
|
-
this.checkLoop(frame.sessionId, state);
|
|
917
|
-
} else {
|
|
918
|
-
run.lastResult = facts.excerpt;
|
|
919
|
-
run.count = 1;
|
|
920
|
-
}
|
|
921
|
-
} else if (run !== undefined && !run.waiting) {
|
|
922
|
-
run.lastResult = facts.excerpt;
|
|
923
|
-
}
|
|
924
|
-
}
|
|
925
|
-
} else if (frame.event.type === 'assistant/message') {
|
|
926
|
-
const state = this.state(frame.sessionId);
|
|
927
|
-
this.onAssistantMessage(frame.sessionId, state, frame.event);
|
|
928
|
-
}
|
|
929
|
-
this.onSessionEvent(frame.sessionId, frame.event);
|
|
930
|
-
break;
|
|
931
|
-
case 'session/queue':
|
|
932
|
-
this.state(frame.sessionId).queued = frame.items.length;
|
|
933
|
-
if (frame.items.length > 0) this.cancelPending(frame.sessionId, '出现排队消息');
|
|
934
|
-
break;
|
|
935
|
-
case 'stream/error':
|
|
936
|
-
this.log(`mux stream/error: ${frame.error.code} ${frame.error.message}`);
|
|
937
|
-
break;
|
|
938
|
-
default:
|
|
939
|
-
break; // session/subscribed、approval/*、question/*、session/jobs、session/projection 与本插件无关
|
|
940
|
-
}
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
/** 从 assistant/message 事件提取纯文本。 */
|
|
944
|
-
private assistantText(event: SessionEvent<'assistant/message'>): string {
|
|
945
|
-
const content = event.data.message.content;
|
|
946
|
-
if (!Array.isArray(content)) return '';
|
|
947
|
-
return content
|
|
948
|
-
.filter((part): part is { type: 'text'; text: string } => part.type === 'text')
|
|
949
|
-
.map((part) => part.text)
|
|
950
|
-
.join('');
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
/**
|
|
954
|
-
* loop guard 信号 1(空转): 时间窗内连续短句且期间无工具调用。
|
|
955
|
-
* 短句 = 模型消息文本短于 loopShortChars; 长句、工具调用、或短句间隔超过
|
|
956
|
-
* loopWindowMs(正常思考的短文本散布在长时间里)都会重置计数。
|
|
957
|
-
*/
|
|
958
|
-
private onAssistantMessage(
|
|
959
|
-
sessionId: SessionId,
|
|
960
|
-
state: SessionState,
|
|
961
|
-
event: SessionEvent<'assistant/message'>,
|
|
962
|
-
): void {
|
|
963
|
-
if (!this.getConfig().loopGuard) return;
|
|
964
|
-
const text = this.assistantText(event);
|
|
965
|
-
const trimmed = text.trim();
|
|
966
|
-
// 相同文本重复(不限长度): 模型反复输出完全相同的消息是最强的循环信号,
|
|
967
|
-
// 例如 "Let me test variants of the regex..." 连续 7 遍
|
|
968
|
-
if (trimmed !== '' && trimmed === state.lastAssistantText) {
|
|
969
|
-
state.sameTextRun += 1;
|
|
970
|
-
} else {
|
|
971
|
-
state.lastAssistantText = trimmed;
|
|
972
|
-
state.sameTextRun = 1;
|
|
973
|
-
}
|
|
974
|
-
// 短句计数(长度 < loopShortChars 且落在时间窗内): 空转信号
|
|
975
|
-
if (trimmed.length < this.getConfig().loopShortChars) {
|
|
976
|
-
const now = Date.now();
|
|
977
|
-
if (now - state.lastShortAt > this.getConfig().loopWindowMs) {
|
|
978
|
-
state.shortRun = 0; // 超过时间窗: 上一次短句太久远, 不算连续
|
|
979
|
-
}
|
|
980
|
-
state.shortRun += 1;
|
|
981
|
-
state.lastShortAt = now;
|
|
982
|
-
} else {
|
|
983
|
-
state.shortRun = 0; // 长句 = 有实际输出, 重置
|
|
984
|
-
state.lastShortAt = 0;
|
|
985
|
-
}
|
|
986
|
-
this.checkLoop(sessionId, state);
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
/** 两个循环信号的公共检查; 命中且本回合未打断过则打断。 */
|
|
990
|
-
private checkLoop(sessionId: SessionId, state: SessionState): void {
|
|
991
|
-
if (!this.getConfig().loopGuard) return;
|
|
992
|
-
if (state.loopFired) return;
|
|
993
|
-
if (!state.running) return; // 只干预运行中的回合
|
|
994
|
-
const config = this.getConfig();
|
|
995
|
-
if (state.sameTextRun >= config.loopRepeatText) {
|
|
996
|
-
this.log(`检测到空转循环 ${sessionId}: 连续 ${state.sameTextRun} 条相同消息`);
|
|
997
|
-
void this.interruptLoop(sessionId, state);
|
|
998
|
-
} else if (state.shortRun >= config.loopShortCount) {
|
|
999
|
-
this.log(`检测到空转循环 ${sessionId}: 连续 ${state.shortRun} 条短句且无工具调用`);
|
|
1000
|
-
void this.interruptLoop(sessionId, state);
|
|
1001
|
-
} else if (state.toolRun !== undefined && state.toolRun.count >= config.loopToolRepeat) {
|
|
1002
|
-
const toolName = state.toolRun.key.split('\n')[0] ?? '?';
|
|
1003
|
-
this.log(`检测到工具死循环 ${sessionId}: 「${toolName}」连续 ${state.toolRun.count} 次(同参数同结果)`);
|
|
1004
|
-
void this.interruptLoop(sessionId, state);
|
|
1005
|
-
}
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
/**
|
|
1009
|
-
* 打断运行中的回合: cancel(带来源标记)+ 进冷却。
|
|
1010
|
-
* 随后的 turn/end aborted 会因 loopCancelled 走「可恢复中断」路径,
|
|
1011
|
-
* 用 loopText 重启回合——不会与用户手动停止混淆。
|
|
1012
|
-
*/
|
|
1013
|
-
private async interruptLoop(sessionId: SessionId, state: SessionState): Promise<void> {
|
|
1014
|
-
if (state.loopFired) return;
|
|
1015
|
-
// 打断本身受冷却约束: 距上次打断/发送太近时不再打断, 防止反复打断刷屏
|
|
1016
|
-
if (Date.now() - state.lastAttemptAt < this.cooldownFor(state)) {
|
|
1017
|
-
this.log(`跳过循环打断 ${sessionId}: 处于冷却期`);
|
|
1018
|
-
return;
|
|
1019
|
-
}
|
|
1020
|
-
state.loopFired = true;
|
|
1021
|
-
state.loopCancelled = true;
|
|
1022
|
-
state.lastAttemptAt = Date.now(); // 打断计入冷却, 防反复打断
|
|
1023
|
-
bumpStat({ looped: 1 });
|
|
1024
|
-
try {
|
|
1025
|
-
const response = await this.api.sessions.cancel({ sessionId });
|
|
1026
|
-
this.log(
|
|
1027
|
-
`已打断循环 ${sessionId}: ${response.result.ok ? 'cancel 已受理' : 'cancel 被拒绝'}`,
|
|
1028
|
-
);
|
|
1029
|
-
} catch (error) {
|
|
1030
|
-
this.log(`打断循环失败 ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1031
|
-
state.loopCancelled = false;
|
|
1032
|
-
}
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
private onSessionEvent(sessionId: SessionId, event: SessionEvent): void {
|
|
1036
|
-
const state = this.state(sessionId);
|
|
1037
|
-
switch (event.type) {
|
|
1038
|
-
case 'turn/start':
|
|
1039
|
-
state.running = true;
|
|
1040
|
-
// 新回合开始: 清空上一步工具调用状态, 避免跨回合误用护栏
|
|
1041
|
-
state.lastTool = undefined;
|
|
1042
|
-
state.lastToolResult = undefined;
|
|
1043
|
-
// loop guard 状态按回合重置
|
|
1044
|
-
state.shortRun = 0;
|
|
1045
|
-
state.lastShortAt = 0;
|
|
1046
|
-
state.lastAssistantText = '';
|
|
1047
|
-
state.sameTextRun = 0;
|
|
1048
|
-
state.toolRun = undefined;
|
|
1049
|
-
state.loopFired = false;
|
|
1050
|
-
state.loopCancelled = false;
|
|
1051
|
-
if (state.loopRetryTimer !== undefined) {
|
|
1052
|
-
clearTimeout(state.loopRetryTimer);
|
|
1053
|
-
state.loopRetryTimer = undefined;
|
|
1054
|
-
}
|
|
1055
|
-
this.cancelPending(sessionId, '宿主自行开启新回合');
|
|
1056
|
-
break;
|
|
1057
|
-
case 'turn/end': {
|
|
1058
|
-
state.running = false;
|
|
1059
|
-
this.cancelPending(sessionId, '收到新的 turn/end');
|
|
1060
|
-
const reason = event.data.reason;
|
|
1061
|
-
if (reason.kind === 'completed') {
|
|
1062
|
-
// 成功回合: 恢复健康状态, 并确认上一次自动发送的效果
|
|
1063
|
-
state.consecutive = 0;
|
|
1064
|
-
state.lastFailure = undefined;
|
|
1065
|
-
clearSendCount(sessionId);
|
|
1066
|
-
this.noteRecovery(sessionId, 'completed');
|
|
1067
|
-
} else if (reason.kind === 'aborted') {
|
|
1068
|
-
if (state.loopCancelled) {
|
|
1069
|
-
// 我们自己的 loop guard 打断: 视为可恢复中断, 用循环提示文本重启回合。
|
|
1070
|
-
// 不清 consecutive / lastAttemptAt: 冷却与连续上限在 loop 路径同样生效,
|
|
1071
|
-
// 防止无限打断重发(issue #13); 打断本身受冷却约束, 重启也要等冷却。
|
|
1072
|
-
state.loopCancelled = false;
|
|
1073
|
-
state.loopFired = false;
|
|
1074
|
-
state.pendingRecoveryAt = 0;
|
|
1075
|
-
state.shortRun = 0;
|
|
1076
|
-
state.lastShortAt = 0;
|
|
1077
|
-
state.lastAssistantText = '';
|
|
1078
|
-
state.sameTextRun = 0;
|
|
1079
|
-
state.toolRun = undefined;
|
|
1080
|
-
// 重启受冷却约束(防紧密打断循环): 等剩余冷却结束后再调度
|
|
1081
|
-
const cooldown = this.cooldownFor(state);
|
|
1082
|
-
const remaining = cooldown - (Date.now() - state.lastAttemptAt);
|
|
1083
|
-
if (remaining > 0) {
|
|
1084
|
-
if (state.loopRetryTimer !== undefined) clearTimeout(state.loopRetryTimer);
|
|
1085
|
-
state.loopRetryTimer = setTimeout(() => {
|
|
1086
|
-
state.loopRetryTimer = undefined;
|
|
1087
|
-
this.schedule(sessionId, 'loop:aborted');
|
|
1088
|
-
}, remaining);
|
|
1089
|
-
this.log(`loop 重启延迟 ${remaining}ms(冷却期) ${sessionId}`);
|
|
1090
|
-
} else {
|
|
1091
|
-
this.schedule(sessionId, 'loop:aborted');
|
|
1092
|
-
}
|
|
1093
|
-
} else {
|
|
1094
|
-
// 用户主动停止: 不自动继续, 视为用户介入
|
|
1095
|
-
state.consecutive = 0;
|
|
1096
|
-
state.pendingRecoveryAt = 0;
|
|
1097
|
-
clearSendCount(sessionId);
|
|
1098
|
-
}
|
|
1099
|
-
} else if (reason.kind === 'blocked') {
|
|
1100
|
-
// 策略拒绝: 不自动继续
|
|
1101
|
-
} else if (reason.kind === 'interrupted') {
|
|
1102
|
-
// 实时路径的 interrupted 仅来自崩溃修复重载(loop 从不实时发出);
|
|
1103
|
-
// 用户手动停止在 DSH 中标记为 aborted, 不走到这里。实时流里出现
|
|
1104
|
-
// interrupted 视为异常中断, 不自动继续——宿主崩溃孤儿回合由扫描恢复。
|
|
1105
|
-
state.consecutive = 0;
|
|
1106
|
-
state.pendingRecoveryAt = 0;
|
|
1107
|
-
} else if (reason.kind === 'error') {
|
|
1108
|
-
// 记录失败事实(分类与模板填充用), 然后按类型处理
|
|
1109
|
-
const error = reason.error;
|
|
1110
|
-
state.lastFailure = {
|
|
1111
|
-
code: typeof error.code === 'string' ? error.code : 'UNKNOWN',
|
|
1112
|
-
message: typeof error.message === 'string' ? error.message : String(error),
|
|
1113
|
-
...(typeof error.status === 'number' ? { status: error.status } : {}),
|
|
1114
|
-
};
|
|
1115
|
-
state.lastTurn = event.data.turn;
|
|
1116
|
-
state.lastFailureAt = Date.now();
|
|
1117
|
-
this.noteRecovery(sessionId, 'error');
|
|
1118
|
-
this.onTurnFailure(sessionId, 'turn/end:error', state.lastFailure);
|
|
1119
|
-
} else if (reason.kind === 'max-tokens') {
|
|
1120
|
-
state.lastFailureAt = Date.now();
|
|
1121
|
-
this.noteRecovery(sessionId, 'error');
|
|
1122
|
-
this.schedule(sessionId, 'turn/end:max-tokens');
|
|
1123
|
-
}
|
|
1124
|
-
break;
|
|
1125
|
-
}
|
|
1126
|
-
case 'user/message':
|
|
1127
|
-
if (isOurEcho(state, sessionId, event)) break; // 我们自己的回显(跨标签页识别)
|
|
1128
|
-
if (event.data.source.kind === 'user') {
|
|
1129
|
-
// 用户手动介入: 清零上限与跨标签页发送计数
|
|
1130
|
-
state.consecutive = 0;
|
|
1131
|
-
clearSendCount(sessionId);
|
|
1132
|
-
this.cancelPending(sessionId, '用户手动发送消息');
|
|
1133
|
-
}
|
|
1134
|
-
break;
|
|
1135
|
-
default:
|
|
1136
|
-
break;
|
|
1137
|
-
}
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
// ---------- host 帧 ----------
|
|
1141
|
-
|
|
1142
|
-
private onHostFrame(frame: HostFrame): void {
|
|
1143
|
-
switch (frame.type) {
|
|
1144
|
-
case 'host/session-status':
|
|
1145
|
-
this.state(frame.sessionId).running = frame.running;
|
|
1146
|
-
if (frame.running) this.cancelPending(frame.sessionId, '宿主报告会话开始运行');
|
|
1147
|
-
break;
|
|
1148
|
-
case 'host/session-added':
|
|
1149
|
-
this.state(frame.sessionId).subagent = frame.parentSessionId !== undefined;
|
|
1150
|
-
break;
|
|
1151
|
-
case 'host/agent-error':
|
|
1152
|
-
if (this.state(frame.sessionId).subagent) break;
|
|
1153
|
-
this.log(`host/agent-error(${frame.sessionId}): ${frame.message}`);
|
|
1154
|
-
// agent-error 的「仅网络/超时类自动续跑」是无条件安全承诺(与 classify 开关无关):
|
|
1155
|
-
// 序列化失败等永久性 agent 错误(包括用户停止的连带 DOMException)绝不能自动续跑,
|
|
1156
|
-
// 否则会退回「用户停止被误续跑」的场景(issue #2)。
|
|
1157
|
-
if (!isTransientAgentError(frame.message)) {
|
|
1158
|
-
// 永久性 agent 错误(序列化失败/配置错误等): 跳过并通知, 避免把用户停止等
|
|
1159
|
-
// 场景误判为可恢复中断后自动续跑。
|
|
1160
|
-
this.log(`跳过 ${frame.sessionId}: 永久性 agent 错误 — ${frame.message}`);
|
|
1161
|
-
bumpStat({ skipped: 1 });
|
|
1162
|
-
if (this.getConfig().notify) {
|
|
1163
|
-
notify(
|
|
1164
|
-
'dsh-auto-continue: 未自动继续',
|
|
1165
|
-
`${frame.sessionId}: 永久性 agent 错误 ${frame.message.slice(0, 120)}`,
|
|
1166
|
-
this.notifyOptions(frame.sessionId),
|
|
1167
|
-
);
|
|
1168
|
-
}
|
|
1169
|
-
break;
|
|
1170
|
-
}
|
|
1171
|
-
this.schedule(frame.sessionId, 'host/agent-error');
|
|
1172
|
-
break;
|
|
1173
|
-
case 'host/session-removed':
|
|
1174
|
-
this.cancelPending(frame.sessionId, '会话已移除');
|
|
1175
|
-
this.states.delete(frame.sessionId);
|
|
1176
|
-
break;
|
|
1177
|
-
default:
|
|
1178
|
-
break;
|
|
1179
|
-
}
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
|
-
// ---------- 调度 ----------
|
|
1183
|
-
|
|
1184
|
-
/** 回合失败入口: 先做错误分类, 永久性失败跳过并通知, 临时性失败走正常调度。 */
|
|
1185
|
-
private onTurnFailure(sessionId: SessionId, reason: string, failure: FailureFacts): void {
|
|
1186
|
-
const config = this.getConfig();
|
|
1187
|
-
if (config.classify && !isTransientFailure(failure)) {
|
|
1188
|
-
const summary = `${failure.code}${failure.status !== undefined ? ` (HTTP ${failure.status})` : ''}`;
|
|
1189
|
-
this.log(`跳过 ${sessionId}(${reason}): 永久性失败 ${summary} — ${failure.message}`);
|
|
1190
|
-
bumpStat({ skipped: 1, code: failure.code });
|
|
1191
|
-
if (config.notify) {
|
|
1192
|
-
notify(
|
|
1193
|
-
'dsh-auto-continue: 未自动继续',
|
|
1194
|
-
`${sessionId}: 永久性错误 ${summary},需要人工处理`,
|
|
1195
|
-
this.notifyOptions(sessionId),
|
|
1196
|
-
);
|
|
1197
|
-
}
|
|
1198
|
-
return;
|
|
1199
|
-
}
|
|
1200
|
-
this.schedule(sessionId, reason);
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
|
-
/** 通知操作按钮与回调(「立即续跑」/「暂停该会话 1 小时」)。 */
|
|
1204
|
-
private notifyOptions(sessionId: SessionId): NotifyOptions {
|
|
1205
|
-
return {
|
|
1206
|
-
actions: [
|
|
1207
|
-
{ action: 'resume', title: '立即续跑' },
|
|
1208
|
-
{ action: 'pause1h', title: '暂停该会话 1 小时' },
|
|
1209
|
-
],
|
|
1210
|
-
onAction: (action) => this.onNotifyAction(sessionId, action),
|
|
1211
|
-
};
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
private onNotifyAction(sessionId: SessionId, action: string): void {
|
|
1215
|
-
if (action === 'resume') {
|
|
1216
|
-
this.log(`通知按钮: 立即续跑 ${sessionId}`);
|
|
1217
|
-
void this.resumeNow(sessionId);
|
|
1218
|
-
} else if (action === 'pause1h') {
|
|
1219
|
-
this.log(`通知按钮: 暂停 ${sessionId} 1 小时`);
|
|
1220
|
-
pauseSession(sessionId, 60 * 60 * 1000);
|
|
1221
|
-
this.cancelPending(sessionId, '通知按钮暂停该会话');
|
|
1222
|
-
}
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
/** 恢复结果记账: 自动发送后窗口内的回合结束, 判定恢复成功或失败。 */
|
|
1226
|
-
private noteRecovery(sessionId: SessionId, outcome: 'completed' | 'error'): void {
|
|
1227
|
-
const state = this.state(sessionId);
|
|
1228
|
-
if (state.pendingRecoveryAt === 0) return;
|
|
1229
|
-
if (Date.now() - state.pendingRecoveryAt > RECOVERY_WINDOW_MS) {
|
|
1230
|
-
state.pendingRecoveryAt = 0; // 窗口过期, 不再归属这次发送
|
|
1231
|
-
return;
|
|
1232
|
-
}
|
|
1233
|
-
state.pendingRecoveryAt = 0;
|
|
1234
|
-
bumpStat(outcome === 'completed' ? { recovered: 1 } : { failed: 1 });
|
|
1235
|
-
this.log(`恢复结果(${sessionId}): ${outcome === 'completed' ? '成功' : '失败'}`);
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
/** 立即为该会话发送一次自动继续(无视冷却与连续上限; 由通知按钮触发)。 */
|
|
1239
|
-
async resumeNow(sessionId: SessionId): Promise<void> {
|
|
1240
|
-
if (this.disposed) return;
|
|
1241
|
-
const state = this.state(sessionId);
|
|
1242
|
-
if (state.subagent) return;
|
|
1243
|
-
if (state.pendingTimer !== undefined) {
|
|
1244
|
-
clearTimeout(state.pendingTimer);
|
|
1245
|
-
state.pendingTimer = undefined;
|
|
1246
|
-
}
|
|
1247
|
-
await this.fire(sessionId, 'manual:notification', true);
|
|
1248
|
-
}
|
|
1249
|
-
|
|
1250
|
-
/** 本会话当前生效的冷却间隔(自适应退避)。 */
|
|
1251
|
-
private cooldownFor(state: SessionState): number {
|
|
1252
|
-
const config = this.getConfig();
|
|
1253
|
-
return effectiveCooldown(
|
|
1254
|
-
state.consecutive,
|
|
1255
|
-
config.cooldownMs,
|
|
1256
|
-
config.backoffFactor,
|
|
1257
|
-
config.backoffMaxMs,
|
|
1258
|
-
);
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
private schedule(sessionId: SessionId, reason: string): void {
|
|
1262
|
-
const state = this.state(sessionId);
|
|
1263
|
-
const config = this.getConfig();
|
|
1264
|
-
if (state.subagent) return; // 子代理会话由父代理处理, 不抢跑
|
|
1265
|
-
if (config.paused) {
|
|
1266
|
-
this.log(`跳过 ${sessionId}(${reason}): 全局暂停中`);
|
|
1267
|
-
return;
|
|
1268
|
-
}
|
|
1269
|
-
if (Date.now() < sessionPauseUntil(sessionId)) {
|
|
1270
|
-
this.log(`跳过 ${sessionId}(${reason}): 会话暂停中`);
|
|
1271
|
-
return;
|
|
1272
|
-
}
|
|
1273
|
-
if (state.pendingTimer !== undefined) return; // 已有待发送
|
|
1274
|
-
if (Date.now() - state.lastAttemptAt < this.cooldownFor(state)) return; // 冷却期(含失败尝试, 自适应退避)
|
|
1275
|
-
if (state.consecutive >= config.maxConsecutive) {
|
|
1276
|
-
this.log(
|
|
1277
|
-
`跳过 ${sessionId}(${reason}): 已连续自动继续 ${state.consecutive} 次, 等待用户介入或成功回合`,
|
|
1278
|
-
);
|
|
1279
|
-
return;
|
|
1280
|
-
}
|
|
1281
|
-
if (state.queued > 0) return; // 已有排队消息, 宿主会自行唤醒
|
|
1282
|
-
const timer = setTimeout(() => {
|
|
1283
|
-
if (state.pendingTimer !== timer) return;
|
|
1284
|
-
state.pendingTimer = undefined;
|
|
1285
|
-
void this.fire(sessionId, reason);
|
|
1286
|
-
}, config.graceMs);
|
|
1287
|
-
state.pendingTimer = timer;
|
|
1288
|
-
const template = reason.startsWith('loop:')
|
|
1289
|
-
? config.loopText
|
|
1290
|
-
: reason.includes('max-tokens')
|
|
1291
|
-
? config.continueTextMaxTokens
|
|
1292
|
-
: config.continueText;
|
|
1293
|
-
this.log(
|
|
1294
|
-
`检测到非人为中断 ${sessionId}(${reason}), ${config.graceMs}ms 后自动发送「${template}」`,
|
|
1295
|
-
);
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
|
-
private cancelPending(sessionId: SessionId, why: string): void {
|
|
1299
|
-
const state = this.state(sessionId);
|
|
1300
|
-
if (state.pendingTimer === undefined) return;
|
|
1301
|
-
clearTimeout(state.pendingTimer);
|
|
1302
|
-
state.pendingTimer = undefined;
|
|
1303
|
-
this.log(`取消 ${sessionId} 的自动继续(${why})`);
|
|
1304
|
-
}
|
|
1305
|
-
|
|
1306
|
-
private async fire(sessionId: SessionId, reason: string, force = false): Promise<void> {
|
|
1307
|
-
if (this.disposed) return;
|
|
1308
|
-
const state = this.state(sessionId);
|
|
1309
|
-
const config = this.getConfig();
|
|
1310
|
-
// 权威 running 检查: 优先用 host 帧, 未知时回退到 session.list
|
|
1311
|
-
if (state.running === undefined) {
|
|
1312
|
-
const running = await this.runningViaList(sessionId);
|
|
1313
|
-
if (running === undefined || running) {
|
|
1314
|
-
this.log(`跳过 ${sessionId}: 无法确认空闲(${running === undefined ? '未知' : '运行中'})`);
|
|
1315
|
-
return;
|
|
1316
|
-
}
|
|
1317
|
-
} else if (state.running) {
|
|
1318
|
-
this.log(`跳过 ${sessionId}: 会话仍在运行`);
|
|
1319
|
-
return;
|
|
1320
|
-
}
|
|
1321
|
-
if (state.queued > 0) {
|
|
1322
|
-
this.log(`跳过 ${sessionId}: 已有排队消息`);
|
|
1323
|
-
return;
|
|
1324
|
-
}
|
|
1325
|
-
// 跨标签页持久化发送计数: 窗口内达到 maxConsecutive 即硬抑制,
|
|
1326
|
-
// 不依赖回显识别与单 runner 内存(issue #13 的多标签页刷屏防线)。
|
|
1327
|
-
if (!force && readSendCount(sessionId).count >= config.maxConsecutive) {
|
|
1328
|
-
this.log(`跳过 ${sessionId}: 发送计数已达上限 ${config.maxConsecutive}, 等待用户介入或成功回合`);
|
|
1329
|
-
return;
|
|
1330
|
-
}
|
|
1331
|
-
// 模板填充: continueText 可含 {code}/{message}/{status}/{tool}/{turn}/{errorCount}/{sessionTitle}/{elapsed} 占位符
|
|
1332
|
-
const template = reason.startsWith('loop:')
|
|
1333
|
-
? config.loopText
|
|
1334
|
-
: reason.includes('max-tokens')
|
|
1335
|
-
? config.continueTextMaxTokens
|
|
1336
|
-
: config.continueText;
|
|
1337
|
-
let sessionTitle: string | undefined;
|
|
1338
|
-
if (template.includes('{sessionTitle}')) {
|
|
1339
|
-
sessionTitle = this.titles.get(sessionId);
|
|
1340
|
-
if (sessionTitle === undefined) {
|
|
1341
|
-
const info = await this.fetchSessionInfo(sessionId);
|
|
1342
|
-
sessionTitle = info?.title;
|
|
1343
|
-
}
|
|
1344
|
-
}
|
|
1345
|
-
const text = this.buildContinueText(config, state, template, sessionTitle);
|
|
1346
|
-
const zone = clientTimeZone();
|
|
1347
|
-
// 跨标签页原子发送锁(Web Locks; 回退到互斥戳):
|
|
1348
|
-
// 冷却检查、发送、计数、时间戳全部在锁内, 两个标签页不会同时放行。
|
|
1349
|
-
await withSendLock(sessionId, async () => {
|
|
1350
|
-
if (this.disposed) return;
|
|
1351
|
-
if (state.queued > 0) {
|
|
1352
|
-
this.log(`跳过 ${sessionId}: 已有排队消息`);
|
|
1353
|
-
return;
|
|
1354
|
-
}
|
|
1355
|
-
// 跨标签页冷却(自适应退避); 通知按钮的强制续跑不受冷却约束
|
|
1356
|
-
if (!force && Date.now() - readLastSend(sessionId) < this.cooldownFor(state)) {
|
|
1357
|
-
this.log(`跳过 ${sessionId}: 其他标签页刚发送过`);
|
|
1358
|
-
return;
|
|
1359
|
-
}
|
|
1360
|
-
if (!force && readSendCount(sessionId).count >= config.maxConsecutive) {
|
|
1361
|
-
this.log(`跳过 ${sessionId}: 发送计数已达上限 ${config.maxConsecutive}, 等待用户介入或成功回合`);
|
|
1362
|
-
return;
|
|
1363
|
-
}
|
|
1364
|
-
// 宿主权威兜底: 历史里最后一条事件若正是同一文本的 user 消息, 说明它还在
|
|
1365
|
-
// 排队未被处理——不再叠加发送(issue #13 的 13 条排队场景)。
|
|
1366
|
-
// 若最后一条是回合结束等其他事件, 说明之前的同文本消息已被处理, 正常放行
|
|
1367
|
-
// (连续续跑不被误挡)。查询失败时放行(本地防线仍在)。
|
|
1368
|
-
if (!force && (await this.hostHasPendingSameText(sessionId, text))) {
|
|
1369
|
-
this.log(`跳过 ${sessionId}: 宿主队列里已有相同文本消息在排队`);
|
|
1370
|
-
return;
|
|
1371
|
-
}
|
|
1372
|
-
state.lastAttemptAt = Date.now(); // 先记账: 无论成败, 本次尝试都进入冷却
|
|
1373
|
-
try {
|
|
1374
|
-
const response = await this.api.sessions.prompt({
|
|
1375
|
-
sessionId,
|
|
1376
|
-
mode: 'queue',
|
|
1377
|
-
content: [{ type: 'text', text }],
|
|
1378
|
-
...(zone === undefined ? {} : { clientTimeZone: zone }),
|
|
1379
|
-
});
|
|
1380
|
-
if (response.result.ok) {
|
|
1381
|
-
const now = Date.now();
|
|
1382
|
-
state.consecutive += 1;
|
|
1383
|
-
state.lastAutoAt = now;
|
|
1384
|
-
state.lastSentText = text;
|
|
1385
|
-
state.pendingRecoveryAt = now; // 等待窗口内的下一个回合结束来判定恢复结果
|
|
1386
|
-
writeLastSend(sessionId, now, text); // 记录文本: 跨标签页回显识别
|
|
1387
|
-
bumpSendCount(sessionId); // 跨标签页持久化计数(硬上限)
|
|
1388
|
-
bumpStat({ sent: 1, ...(state.lastFailure !== undefined ? { code: state.lastFailure.code } : {}) });
|
|
1389
|
-
this.log(`已自动发送「${text}」到 ${sessionId}(${reason}), 第 ${state.consecutive} 次连续`);
|
|
1390
|
-
if (config.notify) {
|
|
1391
|
-
notify(
|
|
1392
|
-
'dsh-auto-continue: 已自动继续',
|
|
1393
|
-
`${sessionId}: 已发送「${text}」(第 ${state.consecutive} 次连续)`,
|
|
1394
|
-
this.notifyOptions(sessionId),
|
|
1395
|
-
);
|
|
1396
|
-
}
|
|
1397
|
-
if (state.consecutive >= config.maxConsecutive) {
|
|
1398
|
-
bumpStat({ gaveUp: 1 });
|
|
1399
|
-
this.log(`达到连续上限 ${config.maxConsecutive} 次, 停止自动继续 ${sessionId}`);
|
|
1400
|
-
if (config.notify) {
|
|
1401
|
-
notify(
|
|
1402
|
-
'dsh-auto-continue: 已停止自动继续',
|
|
1403
|
-
`${sessionId}: 连续失败 ${state.consecutive} 次, 需要人工介入`,
|
|
1404
|
-
this.notifyOptions(sessionId),
|
|
1405
|
-
);
|
|
1406
|
-
}
|
|
1407
|
-
}
|
|
1408
|
-
} else {
|
|
1409
|
-
this.log(
|
|
1410
|
-
`发送失败 ${sessionId}: ${response.result.error.code} ${response.result.error.message}`,
|
|
1411
|
-
);
|
|
1412
|
-
}
|
|
1413
|
-
} catch (error) {
|
|
1414
|
-
this.log(`发送异常 ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1415
|
-
}
|
|
1416
|
-
});
|
|
1417
|
-
}
|
|
1418
|
-
|
|
1419
|
-
/**
|
|
1420
|
-
* 组装本次续跑消息: 模板填充 + 幂等护栏。
|
|
1421
|
-
* 护栏依据上一步工具调用的执行状态附加指引, 防止重跑副作用操作:
|
|
1422
|
-
* - 结果未确认(可能已部分执行)→ 提示先确认状态、不要重复执行
|
|
1423
|
-
* - 已确认成功 → 提示已完成、不要重复执行
|
|
1424
|
-
* - 已失败 → 不加护栏(重试工具本来就是目的)
|
|
1425
|
-
*/
|
|
1426
|
-
private buildContinueText(
|
|
1427
|
-
config: AutoContinueConfig,
|
|
1428
|
-
state: SessionState,
|
|
1429
|
-
template: string,
|
|
1430
|
-
sessionTitle: string | undefined,
|
|
1431
|
-
): string {
|
|
1432
|
-
let text = fillTemplate(template, {
|
|
1433
|
-
facts: state.lastFailure,
|
|
1434
|
-
tool: state.lastTool,
|
|
1435
|
-
turn: state.lastTurn,
|
|
1436
|
-
errorCount: state.consecutive + 1,
|
|
1437
|
-
sessionTitle,
|
|
1438
|
-
elapsedMs: state.lastFailureAt > 0 ? Date.now() - state.lastFailureAt : undefined,
|
|
1439
|
-
});
|
|
1440
|
-
if (!config.guardTools) return text;
|
|
1441
|
-
const guard = this.currentGuard(state);
|
|
1442
|
-
if (guard.kind === 'pending') {
|
|
1443
|
-
text += ` ${fillTemplate(config.guardPendingText, { tool: guard.tool, result: guard.result })}`;
|
|
1444
|
-
} else if (guard.kind === 'done') {
|
|
1445
|
-
text += ` ${fillTemplate(config.guardDoneText, { tool: guard.tool, result: guard.result })}`;
|
|
1446
|
-
}
|
|
1447
|
-
return text;
|
|
1448
|
-
}
|
|
1449
|
-
|
|
1450
|
-
/** 上一步工具调用的护栏状态(实时路径, 由 mux 帧维护)。 */
|
|
1451
|
-
private currentGuard(state: SessionState): {
|
|
1452
|
-
kind: 'none' | 'pending' | 'done' | 'failed';
|
|
1453
|
-
tool?: string;
|
|
1454
|
-
result?: string;
|
|
1455
|
-
} {
|
|
1456
|
-
if (state.lastTool === undefined || state.lastToolResult === undefined) return { kind: 'none' };
|
|
1457
|
-
if (state.lastToolResult === 'pending') return { kind: 'pending', tool: state.lastTool };
|
|
1458
|
-
if (state.lastToolResult.ok) {
|
|
1459
|
-
return { kind: 'done', tool: state.lastTool, result: state.lastToolResult.excerpt };
|
|
1460
|
-
}
|
|
1461
|
-
return { kind: 'failed', tool: state.lastTool };
|
|
1462
|
-
}
|
|
1463
|
-
|
|
1464
|
-
/**
|
|
1465
|
-
* 宿主权威兜底: 历史里最后一条事件是否就是同一文本的 user 消息。
|
|
1466
|
-
* 是 = 它还在排队未被处理, 不应再叠加发送; 否(回合结束等其他事件)= 放行。
|
|
1467
|
-
* 查询失败时返回 false(放行, 本地防线仍在)。
|
|
1468
|
-
*/
|
|
1469
|
-
private async hostHasPendingSameText(sessionId: SessionId, text: string): Promise<boolean> {
|
|
1470
|
-
try {
|
|
1471
|
-
const response = await this.api.sessions.history({ sessionId, maxMessages: 10 });
|
|
1472
|
-
if (!response.result.ok) return false;
|
|
1473
|
-
const events = response.result.value.events;
|
|
1474
|
-
const last = events[events.length - 1]?.event;
|
|
1475
|
-
if (last === undefined || last.type !== 'user/message') return false;
|
|
1476
|
-
if (last.data.source?.kind !== 'user') return false;
|
|
1477
|
-
const lastText = (last.data.content ?? [])
|
|
1478
|
-
.filter((part): part is { type: 'text'; text: string } => part.type === 'text')
|
|
1479
|
-
.map((part) => part.text)
|
|
1480
|
-
.join('');
|
|
1481
|
-
return lastText === text;
|
|
1482
|
-
} catch {
|
|
1483
|
-
return false;
|
|
1484
|
-
}
|
|
1485
|
-
}
|
|
1486
|
-
|
|
1487
|
-
/** 会话标题缓存(来自 session.list 投影, {sessionTitle} 占位符用)。 */ private readonly titles = new Map<SessionId, string>();
|
|
1488
|
-
|
|
1489
|
-
/** 查一次 session.list, 顺带缓存该会话的标题。 */
|
|
1490
|
-
private async fetchSessionInfo(
|
|
1491
|
-
sessionId: SessionId,
|
|
1492
|
-
): Promise<{ running: boolean | undefined; title: string | undefined } | undefined> {
|
|
1493
|
-
try {
|
|
1494
|
-
const response = await this.api.sessions.list({});
|
|
1495
|
-
if (!response.result.ok) return undefined;
|
|
1496
|
-
const item = response.result.value.items.find(
|
|
1497
|
-
(summary: SessionSummary) => summary.sessionId === sessionId,
|
|
1498
|
-
);
|
|
1499
|
-
if (item === undefined) return undefined;
|
|
1500
|
-
// `title` 投影由 @deepseek-ai/dsh-session-title 声明; 此处用局部断言避免引入额外依赖。
|
|
1501
|
-
const title = (item.projections?.values as { title?: string | null } | undefined)?.title;
|
|
1502
|
-
if (typeof title === 'string' && title !== '') this.titles.set(sessionId, title);
|
|
1503
|
-
return { running: item.running, title: typeof title === 'string' ? title : undefined };
|
|
1504
|
-
} catch {
|
|
1505
|
-
return undefined;
|
|
1506
|
-
}
|
|
1507
|
-
}
|
|
1508
|
-
|
|
1509
|
-
private async runningViaList(sessionId: SessionId): Promise<boolean | undefined> {
|
|
1510
|
-
const info = await this.fetchSessionInfo(sessionId);
|
|
1511
|
-
return info?.running;
|
|
1512
|
-
}
|
|
1513
|
-
|
|
1514
|
-
// ---------- 启动/重连扫描 ----------
|
|
1515
|
-
|
|
1516
|
-
private scheduleReconnectScan(): void {
|
|
1517
|
-
this.reconnectScans += 1;
|
|
1518
|
-
const scan = this.reconnectScans;
|
|
1519
|
-
setTimeout(() => {
|
|
1520
|
-
if (scan !== this.reconnectScans || this.disposed) return;
|
|
1521
|
-
void this.scanLoop(6, this.getConfig().reconnectScanDelayMs);
|
|
1522
|
-
}, this.getConfig().reconnectScanDelayMs);
|
|
1523
|
-
}
|
|
1524
|
-
|
|
1525
|
-
private async bootScanLoop(): Promise<void> {
|
|
1526
|
-
await this.scanLoop(Infinity, 3000);
|
|
1527
|
-
}
|
|
1528
|
-
|
|
1529
|
-
/** 反复尝试扫描, 直到成功(宿主就绪)或达到次数上限。 */
|
|
1530
|
-
private async scanLoop(attempts: number, delayMs: number): Promise<void> {
|
|
1531
|
-
for (let attempt = 0; attempt < attempts && !this.disposed; attempt += 1) {
|
|
1532
|
-
try {
|
|
1533
|
-
if (await this.scanInterrupted()) return;
|
|
1534
|
-
} catch (error) {
|
|
1535
|
-
if (this.disposed) return;
|
|
1536
|
-
// 宿主未就绪时每 3s 重试; 只节流记录日志, 避免刷屏。
|
|
1537
|
-
if (attempt % 10 === 0) {
|
|
1538
|
-
this.log(
|
|
1539
|
-
`扫描失败(${attempt + 1}/${attempts === Infinity ? '∞' : attempts}): ${
|
|
1540
|
-
error instanceof Error ? error.message : String(error)
|
|
1541
|
-
}`,
|
|
1542
|
-
);
|
|
1543
|
-
}
|
|
1544
|
-
}
|
|
1545
|
-
if (attempt + 1 < attempts) await sleep(delayMs);
|
|
1546
|
-
}
|
|
1547
|
-
}
|
|
1548
|
-
|
|
1549
|
-
/**
|
|
1550
|
-
* 扫描最近中断过的会话: 最后回合以非人为原因结束, 且其后没有新回合或用户消息。
|
|
1551
|
-
* @returns 是否成功完成一次扫描(宿主就绪)。
|
|
1552
|
-
*/
|
|
1553
|
-
private async scanInterrupted(): Promise<boolean> {
|
|
1554
|
-
const config = this.getConfig();
|
|
1555
|
-
if (config.paused) return true; // 全局暂停: 不做任何扫描
|
|
1556
|
-
const response = await this.api.sessions.list({});
|
|
1557
|
-
if (!response.result.ok) return false;
|
|
1558
|
-
const items = response.result.value.items;
|
|
1559
|
-
for (const summary of items) {
|
|
1560
|
-
const title = (summary.projections?.values as { title?: string | null } | undefined)?.title;
|
|
1561
|
-
if (typeof title === 'string' && title !== '') this.titles.set(summary.sessionId, title);
|
|
1562
|
-
}
|
|
1563
|
-
const candidates = items
|
|
1564
|
-
.filter((summary) => !summary.running && summary.parentSessionId === undefined)
|
|
1565
|
-
.slice(0, config.scanLimit);
|
|
1566
|
-
const now = Date.now();
|
|
1567
|
-
for (const summary of candidates) {
|
|
1568
|
-
if (this.disposed) return true;
|
|
1569
|
-
const state = this.state(summary.sessionId);
|
|
1570
|
-
if (state.pendingTimer !== undefined) continue;
|
|
1571
|
-
if (state.consecutive >= config.maxConsecutive) continue;
|
|
1572
|
-
if (now - state.lastAttemptAt < this.cooldownFor(state)) continue;
|
|
1573
|
-
if (now < sessionPauseUntil(summary.sessionId)) continue; // 会话暂停中
|
|
1574
|
-
let events;
|
|
1575
|
-
try {
|
|
1576
|
-
const page = await this.api.sessions.history({
|
|
1577
|
-
sessionId: summary.sessionId,
|
|
1578
|
-
maxMessages: 30,
|
|
1579
|
-
});
|
|
1580
|
-
if (!page.result.ok) continue;
|
|
1581
|
-
events = page.result.value.events;
|
|
1582
|
-
} catch {
|
|
1583
|
-
continue; // 会话可能刚被移除
|
|
1584
|
-
}
|
|
1585
|
-
// 从尾部找最后一个 turn/end(在分支内完成收窄)
|
|
1586
|
-
let lastEnd: SessionEvent<'turn/end'> | undefined;
|
|
1587
|
-
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
1588
|
-
const event = events[i]?.event;
|
|
1589
|
-
if (event !== undefined && event.type === 'turn/end') {
|
|
1590
|
-
lastEnd = event;
|
|
1591
|
-
break;
|
|
1592
|
-
}
|
|
1593
|
-
}
|
|
1594
|
-
if (lastEnd === undefined) continue;
|
|
1595
|
-
const reason = lastEnd.data.reason;
|
|
1596
|
-
if (!isNonHumanReason(reason.kind)) continue;
|
|
1597
|
-
if (lastEnd.time < now - config.freshMs) continue; // 太久远, 不翻旧账
|
|
1598
|
-
// 该 turn/end 之后不能有新回合或用户消息(说明已被处理)
|
|
1599
|
-
let superseded = false;
|
|
1600
|
-
for (const entry of events) {
|
|
1601
|
-
const event = entry.event;
|
|
1602
|
-
if (event.seq <= lastEnd.seq) continue;
|
|
1603
|
-
if (event.type === 'turn/start') superseded = true;
|
|
1604
|
-
if (event.type === 'user/message' && event.data.source.kind === 'user') superseded = true;
|
|
1605
|
-
if (superseded) break;
|
|
1606
|
-
}
|
|
1607
|
-
if (superseded) continue;
|
|
1608
|
-
// 幂等护栏: 从历史事件里重建上一步工具调用的执行状态
|
|
1609
|
-
this.applyGuardFromEvents(state, events, lastEnd.seq);
|
|
1610
|
-
this.log(`扫描发现中断 ${summary.sessionId}(turn/end:${reason.kind}), 安排自动继续`);
|
|
1611
|
-
this.schedule(summary.sessionId, `scan:turn/end:${reason.kind}`);
|
|
1612
|
-
}
|
|
1613
|
-
return true;
|
|
1614
|
-
}
|
|
1615
|
-
|
|
1616
|
-
/** 从历史事件恢复上一步工具调用状态(扫描路径的幂等护栏)。 */
|
|
1617
|
-
private applyGuardFromEvents(
|
|
1618
|
-
state: SessionState,
|
|
1619
|
-
events: { event: SessionEvent }[],
|
|
1620
|
-
untilSeq: number,
|
|
1621
|
-
): void {
|
|
1622
|
-
state.lastTool = undefined;
|
|
1623
|
-
state.lastToolResult = undefined;
|
|
1624
|
-
let call: SessionEvent<'tool/call'> | undefined;
|
|
1625
|
-
for (const entry of events) {
|
|
1626
|
-
const event = entry.event;
|
|
1627
|
-
if (event.seq >= untilSeq) continue;
|
|
1628
|
-
if (event.type === 'tool/call') call = event;
|
|
1629
|
-
}
|
|
1630
|
-
if (call === undefined) return;
|
|
1631
|
-
state.lastTool = call.data.name;
|
|
1632
|
-
state.lastToolResult = 'pending';
|
|
1633
|
-
for (const entry of events) {
|
|
1634
|
-
const event = entry.event;
|
|
1635
|
-
if (event.seq <= call.seq || event.seq >= untilSeq) continue;
|
|
1636
|
-
if (event.type === 'tool/result') {
|
|
1637
|
-
state.lastToolResult = toolResultFacts(event.data);
|
|
1638
|
-
break;
|
|
1639
|
-
}
|
|
1640
|
-
}
|
|
1641
|
-
}
|
|
1642
|
-
}
|
|
8
|
+
export {
|
|
9
|
+
DEFAULT_CONFIG,
|
|
10
|
+
effectiveCooldown,
|
|
11
|
+
fillTemplate,
|
|
12
|
+
isTransientAgentError,
|
|
13
|
+
isTransientFailure,
|
|
14
|
+
resolveConfig,
|
|
15
|
+
type AutoContinueConfig,
|
|
16
|
+
type AutoContinueSettings,
|
|
17
|
+
type DayStats,
|
|
18
|
+
type FailureFacts,
|
|
19
|
+
type TemplateContext,
|
|
20
|
+
} from '../shared/core.ts';
|