dsh-client-auto-continue 0.11.3 → 0.11.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js.map +2 -2
- package/lib/index.js +452 -118
- package/lib/types/host/engine.d.ts +2 -2
- package/lib/types/shared/core.d.ts +83 -27
- package/package.json +1 -1
- package/src/host/engine.ts +178 -117
- package/src/shared/core.ts +436 -45
|
@@ -74,8 +74,8 @@ export declare class AutoContinueRunner {
|
|
|
74
74
|
private checkLoop;
|
|
75
75
|
/**
|
|
76
76
|
* 打断运行中的回合: cancel(带来源标记)+ 进冷却。
|
|
77
|
-
*
|
|
78
|
-
*
|
|
77
|
+
* 只有随后持久化的 turn/end 精确携带专属 hook cause 时,
|
|
78
|
+
* 才会用 loopText 重启回合——DSH 的 first-cause 语义保证用户 Stop 优先。
|
|
79
79
|
*/
|
|
80
80
|
private interruptLoop;
|
|
81
81
|
private onSessionEvent;
|
|
@@ -152,21 +152,92 @@ export interface ToolResultFacts {
|
|
|
152
152
|
ok: boolean;
|
|
153
153
|
/** 工具输出的文本摘要(截断)。 */
|
|
154
154
|
excerpt: string;
|
|
155
|
+
/** 完整模型可见内容 + 错误状态的定长稳定指纹(loop guard 比较用)。 */
|
|
156
|
+
identity: string;
|
|
155
157
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
+
interface ToolResultData {
|
|
159
|
+
turn?: unknown;
|
|
160
|
+
step?: unknown;
|
|
158
161
|
error?: {
|
|
159
162
|
name?: string;
|
|
160
163
|
code?: string;
|
|
161
164
|
};
|
|
162
165
|
message?: {
|
|
166
|
+
source?: {
|
|
167
|
+
kind?: string;
|
|
168
|
+
callId?: unknown;
|
|
169
|
+
};
|
|
163
170
|
content?: Array<{
|
|
164
171
|
type?: string;
|
|
172
|
+
toolCallId?: unknown;
|
|
165
173
|
content?: unknown;
|
|
166
174
|
isError?: boolean;
|
|
167
175
|
}>;
|
|
168
176
|
};
|
|
169
|
-
}
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* 取工具结果的关联 id。新版 DSH 的权威位置是 message.source.callId,
|
|
180
|
+
* 同时接受模型可见 block 上的 toolCallId;两者冲突时宁可忽略,不猜测配对。
|
|
181
|
+
*/
|
|
182
|
+
export declare function toolResultCallId(data: ToolResultData): string | undefined;
|
|
183
|
+
/** 从 tool/result 事件载荷提取成功与否与文本摘要。 */
|
|
184
|
+
export declare function toolResultFacts(data: ToolResultData): ToolResultFacts;
|
|
185
|
+
/** loop guard 在后续 step 边界确认的连续重复信号。 */
|
|
186
|
+
export interface ToolRepeatSignal {
|
|
187
|
+
tool: string;
|
|
188
|
+
count: number;
|
|
189
|
+
}
|
|
190
|
+
export type ToolGuardState = {
|
|
191
|
+
kind: 'none';
|
|
192
|
+
} | {
|
|
193
|
+
kind: 'pending';
|
|
194
|
+
tool: string;
|
|
195
|
+
} | {
|
|
196
|
+
kind: 'done';
|
|
197
|
+
tool: string;
|
|
198
|
+
result: string;
|
|
199
|
+
} | {
|
|
200
|
+
kind: 'failed';
|
|
201
|
+
tool: string;
|
|
202
|
+
};
|
|
203
|
+
/**
|
|
204
|
+
* 每个会话的工具调用关联器。
|
|
205
|
+
*
|
|
206
|
+
* 集中封装事件关联、step 边界确认、护栏读取与重置。内部按 callId 配对,
|
|
207
|
+
* 乱序结果先缓存、再按调用顺序推进 loop 计数。队列和去重 id 都有硬上限;
|
|
208
|
+
* 超限或载荷无法关联时会打断重复计数,宁可漏报也不误杀健康回合。
|
|
209
|
+
*/
|
|
210
|
+
export declare class ToolInvocationTracker {
|
|
211
|
+
private readonly pendingById;
|
|
212
|
+
private readonly pendingInOrder;
|
|
213
|
+
private readonly seenCalls;
|
|
214
|
+
private readonly seenInOrder;
|
|
215
|
+
private latest;
|
|
216
|
+
private run;
|
|
217
|
+
private repeatSignal;
|
|
218
|
+
private lastEventSeq;
|
|
219
|
+
reset(): void;
|
|
220
|
+
/** 新回合边界:清空工具态,同时把重放水位推进到 turn/start。 */
|
|
221
|
+
startTurn(seq: number): void;
|
|
222
|
+
/** 回合已结束:保留最后一次调用的护栏,丢弃不再可用的 loop 关联态。 */
|
|
223
|
+
resetRepeat(): void;
|
|
224
|
+
recordCall(event: SessionEvent<'tool/call'>): boolean;
|
|
225
|
+
recordResult(event: SessionEvent<'tool/result'>): ToolRepeatSignal | undefined;
|
|
226
|
+
guard(): ToolGuardState;
|
|
227
|
+
lastTool(): string | undefined;
|
|
228
|
+
/** 下一模型 step 是稳定边界;此前 replacement/新调用会先清除候选。 */
|
|
229
|
+
confirmRepeatAtStep(seq: number): ToolRepeatSignal | undefined;
|
|
230
|
+
/** 非工具 surface range replacement(如 compaction summary)同样终止旧工具证据。 */
|
|
231
|
+
recordSurfaceReplacement(seq: number): void;
|
|
232
|
+
restore(events: readonly SessionEvent[], untilSeq: number): void;
|
|
233
|
+
private acceptEventSeq;
|
|
234
|
+
private breakCorrelation;
|
|
235
|
+
private invalidateRunHistory;
|
|
236
|
+
private trim;
|
|
237
|
+
private drainCompleted;
|
|
238
|
+
private advanceRun;
|
|
239
|
+
private refreshRepeatSignal;
|
|
240
|
+
}
|
|
170
241
|
/** 自适应退避: 同一会话连续失败时的有效冷却间隔。 */
|
|
171
242
|
export declare function effectiveCooldown(consecutive: number, base: number, factor: number, max: number): number;
|
|
172
243
|
export declare function sleep(ms: number): Promise<void>;
|
|
@@ -196,12 +267,10 @@ export declare function emptyDayStats(): DayStats;
|
|
|
196
267
|
export interface SessionState {
|
|
197
268
|
/** 连续自动「继续」次数; 成功回合或用户手动介入后归零。 */
|
|
198
269
|
consecutive: number;
|
|
199
|
-
/** 上次自动「继续」时间戳。 */
|
|
200
|
-
lastAutoAt: number;
|
|
201
270
|
/** 上次自动「继续」尝试(成功或失败)时间戳; 防止失败场景下的快速重试循环。 */
|
|
202
271
|
lastAttemptAt: number;
|
|
203
|
-
/**
|
|
204
|
-
|
|
272
|
+
/** 尚未回显到会话事件流的自动发送消息 ID。 */
|
|
273
|
+
pendingEchoMessageIds: Map<string, number>;
|
|
205
274
|
/** 宽限期定时器(进行中的待发送)。 */
|
|
206
275
|
pendingTimer: ReturnType<typeof setTimeout> | undefined;
|
|
207
276
|
/** 宿主权威 running 位(来自 host/session-status 与回合事件)。 */
|
|
@@ -214,10 +283,8 @@ export interface SessionState {
|
|
|
214
283
|
lastFailure: FailureFacts | undefined;
|
|
215
284
|
/** 最近一次失败的发生时间(模板 {elapsed} 与恢复统计用)。 */
|
|
216
285
|
lastFailureAt: number;
|
|
217
|
-
/**
|
|
218
|
-
|
|
219
|
-
/** 上一步工具调用的结果状态: 'pending' = 已发起未见结果(可能已部分执行)。 */
|
|
220
|
-
lastToolResult: 'pending' | ToolResultFacts | undefined;
|
|
286
|
+
/** callId 精确配对的工具调用、幂等护栏与 loop 重复态。 */
|
|
287
|
+
tools: ToolInvocationTracker;
|
|
221
288
|
/** 失败回合的编号(模板 {turn})。 */
|
|
222
289
|
lastTurn: number | undefined;
|
|
223
290
|
/** 我们最近一次自动发送的时间戳; 0 = 没有待确认的恢复。 */
|
|
@@ -230,29 +297,18 @@ export interface SessionState {
|
|
|
230
297
|
lastAssistantText: string;
|
|
231
298
|
/** 连续相同文本消息数(最强空转信号, 不限长度)。 */
|
|
232
299
|
sameTextRun: number;
|
|
233
|
-
/**
|
|
234
|
-
* 工具重复信号(loop guard 信号 2: 死循环)。
|
|
235
|
-
* 只有「同工具 + 同参数 + 同结果」的连续调用才累计; 参数或结果有变化视为有进展, 计数重置。
|
|
236
|
-
*/
|
|
237
|
-
toolRun: {
|
|
238
|
-
/** 工具名 + 参数(用于判定是否同一调用)。 */
|
|
239
|
-
key: string;
|
|
240
|
-
/** 连续相同调用数(结果确认后更新)。 */
|
|
241
|
-
count: number;
|
|
242
|
-
/** 上次该调用的结果摘要(比较用)。 */
|
|
243
|
-
lastResult: string | undefined;
|
|
244
|
-
/** 本次调用等待结果确认。 */
|
|
245
|
-
waiting: boolean;
|
|
246
|
-
} | undefined;
|
|
247
300
|
/** 本回合已触发过 loop guard(防重复打断)。 */
|
|
248
301
|
loopFired: boolean;
|
|
249
302
|
/** loop 重启的延迟定时器(冷却结束后再 schedule)。 */
|
|
250
303
|
loopRetryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
251
|
-
/** 我们主动 cancel 过本回合(区分用户停止)。 */
|
|
252
|
-
loopCancelled: boolean;
|
|
253
304
|
}
|
|
254
305
|
export declare const freshState: () => SessionState;
|
|
255
306
|
export declare const RECOVERY_WINDOW_MS: number;
|
|
256
307
|
export declare const ECHO_WINDOW_MS: number;
|
|
308
|
+
/** Track an identified plugin message before handing it to the host queue. */
|
|
309
|
+
export declare function trackPendingEcho(state: SessionState, messageId: string): void;
|
|
310
|
+
/** Roll back tracking when the host rejects a queued message. */
|
|
311
|
+
export declare function forgetPendingEcho(state: SessionState, messageId: string): void;
|
|
312
|
+
/** Match and consume one plugin-owned `user/message` event by stable message ID. */
|
|
257
313
|
export declare function isOurEcho(state: SessionState, event: SessionEvent): boolean;
|
|
258
314
|
export {};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-client-auto-continue",
|
|
3
3
|
"description": "DSH Web UI plugin: automatically sends a localized continue prompt when a request is interrupted by network errors or other non-human causes",
|
|
4
|
-
"version": "0.11.
|
|
4
|
+
"version": "0.11.4",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
package/src/host/engine.ts
CHANGED
|
@@ -19,11 +19,11 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types';
|
|
|
19
19
|
import type { Session } from '@deepseek-ai/dsh-session';
|
|
20
20
|
import {
|
|
21
21
|
DEFAULT_CONFIG,
|
|
22
|
-
ECHO_WINDOW_MS,
|
|
23
22
|
RECOVERY_WINDOW_MS,
|
|
24
23
|
effectiveCooldown,
|
|
25
24
|
emptyDayStats,
|
|
26
25
|
fillTemplate,
|
|
26
|
+
forgetPendingEcho,
|
|
27
27
|
freshState,
|
|
28
28
|
isNonHumanReason,
|
|
29
29
|
isOurEcho,
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
resolveConfig,
|
|
33
33
|
sleep,
|
|
34
34
|
todayKey,
|
|
35
|
-
|
|
35
|
+
trackPendingEcho,
|
|
36
36
|
type AutoContinueConfig,
|
|
37
37
|
type AutoContinueLocale,
|
|
38
38
|
type DayStats,
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
type NotifyAction,
|
|
42
42
|
type NotifyOptions,
|
|
43
43
|
type TemplateContext,
|
|
44
|
+
type ToolRepeatSignal,
|
|
44
45
|
} from '../shared/core.ts';
|
|
45
46
|
|
|
46
47
|
const NOTICE_COPY = {
|
|
@@ -72,6 +73,12 @@ const NOTICE_COPY = {
|
|
|
72
73
|
},
|
|
73
74
|
} as const satisfies Record<AutoContinueLocale, Record<string, unknown>>;
|
|
74
75
|
|
|
76
|
+
/** Durable cancel identity reserved for this plugin's loop guard. */
|
|
77
|
+
const LOOP_GUARD_CANCEL_CAUSE = {
|
|
78
|
+
kind: 'hook',
|
|
79
|
+
reason: 'dsh-auto-continue:loop-guard',
|
|
80
|
+
} as const;
|
|
81
|
+
|
|
75
82
|
/** 通知桥事件: host 引擎产生, browser 侧订阅展示(Notification / 动作按钮)。 */
|
|
76
83
|
export interface HostNotice {
|
|
77
84
|
/** 稳定标识(供 browser 去重)。 */
|
|
@@ -87,15 +94,57 @@ export interface HostNotice {
|
|
|
87
94
|
|
|
88
95
|
/** 自动发送后, 在该窗口内出现的回合结束才计入恢复统计。 */
|
|
89
96
|
|
|
90
|
-
/** 回显识别窗口: 排队消息可能几分钟后才被模型处理到, 窗口必须远大于排队延迟。 */
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* 判定一条 user/message 是否是我们自己自动发送的回显。
|
|
94
|
-
* 单实例引擎: 内存态即可; 排队消息可能几分钟后才被模型处理, 窗口保持 10 分钟。
|
|
95
|
-
*/
|
|
96
97
|
/** SSE 帧外壳: `{ rpcId, payload }`。 */
|
|
97
98
|
type FrameEnvelope<T> = { payload: T };
|
|
98
99
|
|
|
100
|
+
/** Session history APIs across the supported DSH host releases. */
|
|
101
|
+
type CompatibleSession = Session & {
|
|
102
|
+
readonly events?: readonly SessionEvent[];
|
|
103
|
+
snapshotEvents?: () => readonly SessionEvent[];
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** Read one stable event-log snapshot on both legacy and DSH 0.1.2 hosts. */
|
|
107
|
+
function snapshotSessionEvents(session: Session): readonly SessionEvent[] {
|
|
108
|
+
const compatible = session as CompatibleSession;
|
|
109
|
+
if (typeof compatible.snapshotEvents === 'function') return compatible.snapshotEvents();
|
|
110
|
+
if (compatible.events !== undefined) return compatible.events;
|
|
111
|
+
throw new TypeError('session exposes neither snapshotEvents() nor events');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Interpret host failure payloads without trusting persisted or plugin-provided event shapes. */
|
|
115
|
+
function parseFailureFacts(value: unknown): FailureFacts | undefined {
|
|
116
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
|
|
117
|
+
const failure = value as { code?: unknown; message?: unknown; status?: unknown };
|
|
118
|
+
const code = typeof failure.code === 'string' && failure.code.trim() !== ''
|
|
119
|
+
? failure.code
|
|
120
|
+
: undefined;
|
|
121
|
+
const message = typeof failure.message === 'string' && failure.message.trim() !== ''
|
|
122
|
+
? failure.message
|
|
123
|
+
: undefined;
|
|
124
|
+
const status = typeof failure.status === 'number' && Number.isFinite(failure.status)
|
|
125
|
+
? failure.status
|
|
126
|
+
: undefined;
|
|
127
|
+
if (code === undefined && message === undefined && status === undefined) return undefined;
|
|
128
|
+
return {
|
|
129
|
+
code: code ?? 'UNKNOWN',
|
|
130
|
+
message: message ?? code ?? `HTTP ${status}`,
|
|
131
|
+
...(status !== undefined ? { status } : {}),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Read a reason discriminator defensively because session history can outlive its schema. */
|
|
136
|
+
function readReasonKind(value: unknown): string | undefined {
|
|
137
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
|
|
138
|
+
const kind = (value as { kind?: unknown }).kind;
|
|
139
|
+
return typeof kind === 'string' && kind.trim() !== '' ? kind : undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isLoopGuardCancelReason(value: unknown): boolean {
|
|
143
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
|
144
|
+
const cause = value as { kind?: unknown; reason?: unknown };
|
|
145
|
+
return cause.kind === LOOP_GUARD_CANCEL_CAUSE.kind && cause.reason === LOOP_GUARD_CANCEL_CAUSE.reason;
|
|
146
|
+
}
|
|
147
|
+
|
|
99
148
|
/**
|
|
100
149
|
* 事件流泵: 带指数退避的 SSE 重连循环。
|
|
101
150
|
* - 从未收到任何帧(宿主未就绪): 退避重试, 不触发扫描
|
|
@@ -155,9 +204,17 @@ export class AutoContinueRunner {
|
|
|
155
204
|
private readonly getConfig: () => AutoContinueConfig,
|
|
156
205
|
) {
|
|
157
206
|
// 单实例事件源: 宿主进程内的会话事件 firehose, 天然覆盖所有会话。
|
|
158
|
-
this.disposeSessionEvents = ctx.on('session/event', (session, event) =>
|
|
159
|
-
|
|
160
|
-
|
|
207
|
+
this.disposeSessionEvents = ctx.on('session/event', (session, event) => {
|
|
208
|
+
try {
|
|
209
|
+
this.onHostEvent(session, event);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
console.error(
|
|
212
|
+
`[auto-continue] 会话事件处理异常 ${session.id}: ${
|
|
213
|
+
error instanceof Error ? error.message : String(error)
|
|
214
|
+
}`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
161
218
|
const config = this.getConfig();
|
|
162
219
|
if (config.scanOnBoot) {
|
|
163
220
|
void this.bootScanLoop();
|
|
@@ -254,42 +311,26 @@ export class AutoContinueRunner {
|
|
|
254
311
|
*/
|
|
255
312
|
private onHostEvent(session: Session, event: SessionEvent): void {
|
|
256
313
|
const sessionId = session.id;
|
|
314
|
+
if (
|
|
315
|
+
(event.type === 'user/message' || event.type === 'assistant/message') &&
|
|
316
|
+
typeof event.surfaceOp === 'object' &&
|
|
317
|
+
event.surfaceOp !== null
|
|
318
|
+
) {
|
|
319
|
+
// Compaction replacement 不是新消息,也可能 shadow 整段工具结果。
|
|
320
|
+
this.state(sessionId).tools.recordSurfaceReplacement(event.seq);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
257
323
|
if (event.type === 'tool/call') {
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
state.lastTool = name;
|
|
262
|
-
state.lastToolResult = 'pending'; // 已发起, 尚未见结果
|
|
263
|
-
// loop guard 信号 2: 同工具+同参数才可能是循环; 参数变化 = 有进展
|
|
264
|
-
// (工具调用本身也重置短句信号)。计数在结果确认后才推进。
|
|
265
|
-
state.shortRun = 0;
|
|
266
|
-
const key = `${name}\n${event.data.arguments}`;
|
|
267
|
-
if (state.toolRun?.key === key) {
|
|
268
|
-
state.toolRun.waiting = true; // 结果到达时与上次结果比较
|
|
269
|
-
} else {
|
|
270
|
-
state.toolRun = { key, count: 1, lastResult: undefined, waiting: false };
|
|
271
|
-
}
|
|
272
|
-
}
|
|
324
|
+
const state = this.state(sessionId);
|
|
325
|
+
// 任何新鲜调用都代表进展(即使缺关联 id);旧帧重放不能清短句 streak。
|
|
326
|
+
if (state.tools.recordCall(event)) state.shortRun = 0;
|
|
273
327
|
} else if (event.type === 'tool/result') {
|
|
274
328
|
const state = this.state(sessionId);
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
if (run !== undefined && run.waiting) {
|
|
281
|
-
run.waiting = false;
|
|
282
|
-
if (run.lastResult !== undefined && run.lastResult === facts.excerpt) {
|
|
283
|
-
run.count += 1;
|
|
284
|
-
this.checkLoop(sessionId, state);
|
|
285
|
-
} else {
|
|
286
|
-
run.lastResult = facts.excerpt;
|
|
287
|
-
run.count = 1;
|
|
288
|
-
}
|
|
289
|
-
} else if (run !== undefined && !run.waiting) {
|
|
290
|
-
run.lastResult = facts.excerpt;
|
|
291
|
-
}
|
|
292
|
-
}
|
|
329
|
+
state.tools.recordResult(event);
|
|
330
|
+
} else if (event.type === 'step/start') {
|
|
331
|
+
const state = this.state(sessionId);
|
|
332
|
+
const repeat = state.tools.confirmRepeatAtStep(event.seq);
|
|
333
|
+
if (repeat !== undefined) this.checkLoop(sessionId, state, repeat);
|
|
293
334
|
} else if (event.type === 'assistant/message') {
|
|
294
335
|
const state = this.state(sessionId);
|
|
295
336
|
this.onAssistantMessage(sessionId, state, event);
|
|
@@ -339,30 +380,33 @@ export class AutoContinueRunner {
|
|
|
339
380
|
}
|
|
340
381
|
|
|
341
382
|
/** 两个循环信号的公共检查; 命中且本回合未打断过则打断。 */
|
|
342
|
-
private checkLoop(
|
|
383
|
+
private checkLoop(
|
|
384
|
+
sessionId: SessionId,
|
|
385
|
+
state: SessionState,
|
|
386
|
+
toolRepeat?: ToolRepeatSignal,
|
|
387
|
+
): void {
|
|
343
388
|
if (!this.getConfig().loopGuard) return;
|
|
344
389
|
if (state.loopFired) return;
|
|
345
390
|
if (!state.running) return; // 只干预运行中的回合
|
|
346
391
|
const config = this.getConfig();
|
|
347
392
|
if (state.sameTextRun >= config.loopRepeatText) {
|
|
348
393
|
this.log(`检测到空转循环 ${sessionId}: 连续 ${state.sameTextRun} 条相同消息`);
|
|
349
|
-
|
|
394
|
+
this.interruptLoop(sessionId, state);
|
|
350
395
|
} else if (state.shortRun >= config.loopShortCount) {
|
|
351
396
|
this.log(`检测到空转循环 ${sessionId}: 连续 ${state.shortRun} 条短句且无工具调用`);
|
|
352
|
-
|
|
353
|
-
} else if (
|
|
354
|
-
|
|
355
|
-
this.
|
|
356
|
-
void this.interruptLoop(sessionId, state);
|
|
397
|
+
this.interruptLoop(sessionId, state);
|
|
398
|
+
} else if (toolRepeat !== undefined && toolRepeat.count >= config.loopToolRepeat) {
|
|
399
|
+
this.log(`检测到工具死循环 ${sessionId}: 「${toolRepeat.tool}」连续 ${toolRepeat.count} 次(同参数同结果)`);
|
|
400
|
+
this.interruptLoop(sessionId, state);
|
|
357
401
|
}
|
|
358
402
|
}
|
|
359
403
|
|
|
360
404
|
/**
|
|
361
405
|
* 打断运行中的回合: cancel(带来源标记)+ 进冷却。
|
|
362
|
-
*
|
|
363
|
-
*
|
|
406
|
+
* 只有随后持久化的 turn/end 精确携带专属 hook cause 时,
|
|
407
|
+
* 才会用 loopText 重启回合——DSH 的 first-cause 语义保证用户 Stop 优先。
|
|
364
408
|
*/
|
|
365
|
-
private
|
|
409
|
+
private interruptLoop(sessionId: SessionId, state: SessionState): void {
|
|
366
410
|
if (state.loopFired) return;
|
|
367
411
|
// 打断本身受冷却约束: 距上次打断/发送太近时不再打断, 防止反复打断刷屏
|
|
368
412
|
if (Date.now() - state.lastAttemptAt < this.cooldownFor(state)) {
|
|
@@ -370,21 +414,19 @@ export class AutoContinueRunner {
|
|
|
370
414
|
return;
|
|
371
415
|
}
|
|
372
416
|
state.loopFired = true;
|
|
373
|
-
state.loopCancelled = true;
|
|
374
417
|
state.lastAttemptAt = Date.now(); // 打断计入冷却, 防反复打断
|
|
375
|
-
this.bumpStat({ looped: 1 });
|
|
376
418
|
try {
|
|
377
419
|
const agent = this.ctx.agents.get(sessionId);
|
|
378
420
|
if (agent === undefined) {
|
|
379
421
|
this.log(`打断循环失败 ${sessionId}: 无 live agent`);
|
|
380
|
-
state.
|
|
422
|
+
state.loopFired = false;
|
|
381
423
|
return;
|
|
382
424
|
}
|
|
383
|
-
agent.cancel(
|
|
425
|
+
agent.cancel(LOOP_GUARD_CANCEL_CAUSE, { keepInbox: true });
|
|
384
426
|
this.log(`已打断循环 ${sessionId}: cancel 已受理`);
|
|
385
427
|
} catch (error) {
|
|
386
428
|
this.log(`打断循环失败 ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
387
|
-
state.
|
|
429
|
+
state.loopFired = false;
|
|
388
430
|
}
|
|
389
431
|
}
|
|
390
432
|
|
|
@@ -394,16 +436,13 @@ export class AutoContinueRunner {
|
|
|
394
436
|
case 'turn/start':
|
|
395
437
|
state.running = true;
|
|
396
438
|
// 新回合开始: 清空上一步工具调用状态, 避免跨回合误用护栏
|
|
397
|
-
state.
|
|
398
|
-
state.lastToolResult = undefined;
|
|
439
|
+
state.tools.startTurn(event.seq);
|
|
399
440
|
// loop guard 状态按回合重置
|
|
400
441
|
state.shortRun = 0;
|
|
401
442
|
state.lastShortAt = 0;
|
|
402
443
|
state.lastAssistantText = '';
|
|
403
444
|
state.sameTextRun = 0;
|
|
404
|
-
state.toolRun = undefined;
|
|
405
445
|
state.loopFired = false;
|
|
406
|
-
state.loopCancelled = false;
|
|
407
446
|
if (state.loopRetryTimer !== undefined) {
|
|
408
447
|
clearTimeout(state.loopRetryTimer);
|
|
409
448
|
state.loopRetryTimer = undefined;
|
|
@@ -412,26 +451,32 @@ export class AutoContinueRunner {
|
|
|
412
451
|
break;
|
|
413
452
|
case 'turn/end': {
|
|
414
453
|
state.running = false;
|
|
454
|
+
const loopCancelPending = state.loopFired;
|
|
455
|
+
state.loopFired = false;
|
|
415
456
|
this.cancelPending(sessionId, '收到新的 turn/end');
|
|
416
457
|
const reason = event.data.reason;
|
|
417
|
-
|
|
458
|
+
const reasonKind = readReasonKind(reason);
|
|
459
|
+
if (reasonKind === undefined) {
|
|
460
|
+
console.error(`[auto-continue] 忽略畸形 turn/end ${sessionId}: reason 无法解释`);
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
463
|
+
if (reasonKind === 'completed') {
|
|
418
464
|
// 成功回合: 恢复健康状态, 并确认上一次自动发送的效果
|
|
419
465
|
state.consecutive = 0;
|
|
420
466
|
state.lastFailure = undefined;
|
|
421
467
|
this.noteRecovery(sessionId, 'completed');
|
|
422
|
-
} else if (
|
|
423
|
-
if (
|
|
468
|
+
} else if (reasonKind === 'aborted') {
|
|
469
|
+
if (isLoopGuardCancelReason((reason as { reason?: unknown }).reason)) {
|
|
424
470
|
// 我们自己的 loop guard 打断: 视为可恢复中断, 用循环提示文本重启回合。
|
|
425
471
|
// 不清 consecutive / lastAttemptAt: 冷却与连续上限在 loop 路径同样生效,
|
|
426
472
|
// 防止无限打断重发(issue #13); 打断本身受冷却约束, 重启也要等冷却。
|
|
427
|
-
|
|
428
|
-
state.loopFired = false;
|
|
473
|
+
if (loopCancelPending) this.bumpStat({ looped: 1 });
|
|
429
474
|
state.pendingRecoveryAt = 0;
|
|
430
475
|
state.shortRun = 0;
|
|
431
476
|
state.lastShortAt = 0;
|
|
432
477
|
state.lastAssistantText = '';
|
|
433
478
|
state.sameTextRun = 0;
|
|
434
|
-
state.
|
|
479
|
+
state.tools.resetRepeat();
|
|
435
480
|
// 重启受冷却约束(防紧密打断循环): 等剩余冷却结束后再调度
|
|
436
481
|
const cooldown = this.cooldownFor(state);
|
|
437
482
|
const remaining = cooldown - (Date.now() - state.lastAttemptAt);
|
|
@@ -454,27 +499,27 @@ export class AutoContinueRunner {
|
|
|
454
499
|
state.consecutive = 0;
|
|
455
500
|
state.pendingRecoveryAt = 0;
|
|
456
501
|
}
|
|
457
|
-
} else if (
|
|
502
|
+
} else if (reasonKind === 'blocked') {
|
|
458
503
|
// 策略拒绝: 不自动继续
|
|
459
|
-
} else if (
|
|
504
|
+
} else if (reasonKind === 'interrupted') {
|
|
460
505
|
// 实时路径的 interrupted 仅来自崩溃修复重载(loop 从不实时发出);
|
|
461
506
|
// 用户手动停止在 DSH 中标记为 aborted, 不走到这里。实时流里出现
|
|
462
507
|
// interrupted 视为异常中断, 不自动继续——宿主崩溃孤儿回合由扫描恢复。
|
|
463
508
|
state.consecutive = 0;
|
|
464
509
|
state.pendingRecoveryAt = 0;
|
|
465
|
-
} else if (
|
|
510
|
+
} else if (reasonKind === 'error') {
|
|
466
511
|
// 记录失败事实(分类与模板填充用), 然后按类型处理
|
|
467
|
-
const
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
512
|
+
const failure = parseFailureFacts((reason as { error?: unknown }).error);
|
|
513
|
+
if (failure === undefined) {
|
|
514
|
+
console.error(`[auto-continue] 忽略畸形 turn/end ${sessionId}: error details 无法解释`);
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
state.lastFailure = failure;
|
|
473
518
|
state.lastTurn = event.data.turn;
|
|
474
519
|
state.lastFailureAt = Date.now();
|
|
475
520
|
this.noteRecovery(sessionId, 'error');
|
|
476
521
|
this.onTurnFailure(sessionId, 'turn/end:error', state.lastFailure);
|
|
477
|
-
} else if (
|
|
522
|
+
} else if (reasonKind === 'max-tokens') {
|
|
478
523
|
state.lastFailureAt = Date.now();
|
|
479
524
|
this.noteRecovery(sessionId, 'error');
|
|
480
525
|
this.schedule(sessionId, 'turn/end:max-tokens');
|
|
@@ -711,16 +756,20 @@ export class AutoContinueRunner {
|
|
|
711
756
|
}
|
|
712
757
|
state.lastAttemptAt = Date.now(); // 先记账: 无论成败, 本次尝试都进入冷却
|
|
713
758
|
try {
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
);
|
|
759
|
+
const message = createUserMessage({
|
|
760
|
+
content: [{ type: 'text', text }],
|
|
761
|
+
source: { kind: 'user' },
|
|
762
|
+
});
|
|
763
|
+
// `followup` may publish the matching session event synchronously.
|
|
764
|
+
trackPendingEcho(state, message.id);
|
|
765
|
+
try {
|
|
766
|
+
agent.followup(message);
|
|
767
|
+
} catch (error) {
|
|
768
|
+
forgetPendingEcho(state, message.id);
|
|
769
|
+
throw error;
|
|
770
|
+
}
|
|
720
771
|
const now = Date.now();
|
|
721
772
|
state.consecutive += 1;
|
|
722
|
-
state.lastAutoAt = now;
|
|
723
|
-
state.lastSentText = text;
|
|
724
773
|
state.pendingRecoveryAt = now; // 等待窗口内的下一个回合结束来判定恢复结果
|
|
725
774
|
this.bumpStat({ sent: 1, ...(state.lastFailure !== undefined ? { code: state.lastFailure.code } : {}) });
|
|
726
775
|
this.log(`已自动发送「${text}」到 ${sessionId}(${reason}), 第 ${state.consecutive} 次连续`);
|
|
@@ -765,7 +814,7 @@ export class AutoContinueRunner {
|
|
|
765
814
|
): string {
|
|
766
815
|
let text = fillTemplate(template, {
|
|
767
816
|
facts: state.lastFailure,
|
|
768
|
-
tool: state.lastTool,
|
|
817
|
+
tool: state.tools.lastTool(),
|
|
769
818
|
turn: state.lastTurn,
|
|
770
819
|
errorCount: state.consecutive + 1,
|
|
771
820
|
elapsedMs: state.lastFailureAt > 0 ? Date.now() - state.lastFailureAt : undefined,
|
|
@@ -786,12 +835,7 @@ export class AutoContinueRunner {
|
|
|
786
835
|
tool?: string;
|
|
787
836
|
result?: string;
|
|
788
837
|
} {
|
|
789
|
-
|
|
790
|
-
if (state.lastToolResult === 'pending') return { kind: 'pending', tool: state.lastTool };
|
|
791
|
-
if (state.lastToolResult.ok) {
|
|
792
|
-
return { kind: 'done', tool: state.lastTool, result: state.lastToolResult.excerpt };
|
|
793
|
-
}
|
|
794
|
-
return { kind: 'failed', tool: state.lastTool };
|
|
838
|
+
return state.tools.guard();
|
|
795
839
|
}
|
|
796
840
|
|
|
797
841
|
private async bootScanLoop(): Promise<void> {
|
|
@@ -827,12 +871,31 @@ export class AutoContinueRunner {
|
|
|
827
871
|
if (config.paused) return true; // 全局暂停: 不做任何扫描
|
|
828
872
|
// 只扫 live agents(host 重启后 agent-loop 会 resume 崩溃会话, 冷会话无需处理)
|
|
829
873
|
const now = Date.now();
|
|
830
|
-
const candidates: {
|
|
874
|
+
const candidates: {
|
|
875
|
+
sessionId: SessionId;
|
|
876
|
+
events: readonly SessionEvent[];
|
|
877
|
+
lastActivityAt: number;
|
|
878
|
+
listIndex: number;
|
|
879
|
+
}[] = [];
|
|
831
880
|
for (const agent of this.ctx.agents.list()) {
|
|
832
881
|
const session = agent.session;
|
|
833
882
|
if (session.header.origin === 'subagent') continue; // 子代理由父代理处理
|
|
834
|
-
|
|
835
|
-
|
|
883
|
+
const events = snapshotSessionEvents(session);
|
|
884
|
+
const lastActivityAt = events.reduce(
|
|
885
|
+
(latest, event) => Math.max(latest, event.time),
|
|
886
|
+
Number.isFinite(session.header.createdAt) ? session.header.createdAt : 0,
|
|
887
|
+
);
|
|
888
|
+
candidates.push({
|
|
889
|
+
sessionId: session.id,
|
|
890
|
+
events,
|
|
891
|
+
lastActivityAt,
|
|
892
|
+
listIndex: candidates.length,
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
candidates.sort(
|
|
896
|
+
(left, right) =>
|
|
897
|
+
right.lastActivityAt - left.lastActivityAt || left.listIndex - right.listIndex,
|
|
898
|
+
);
|
|
836
899
|
for (const candidate of candidates.slice(0, config.scanLimit)) {
|
|
837
900
|
if (this.disposed) return true;
|
|
838
901
|
const state = this.state(candidate.sessionId);
|
|
@@ -852,7 +915,8 @@ export class AutoContinueRunner {
|
|
|
852
915
|
}
|
|
853
916
|
if (lastEnd === undefined) continue;
|
|
854
917
|
const reason = lastEnd.data.reason;
|
|
855
|
-
|
|
918
|
+
const reasonKind = readReasonKind(reason);
|
|
919
|
+
if (reasonKind === undefined || !isNonHumanReason(reasonKind)) continue;
|
|
856
920
|
if (lastEnd.time < now - config.freshMs) continue; // 太久远, 不翻旧账
|
|
857
921
|
// 该 turn/end 之后不能有新回合或用户消息(说明已被处理)
|
|
858
922
|
let superseded = false;
|
|
@@ -865,8 +929,21 @@ export class AutoContinueRunner {
|
|
|
865
929
|
if (superseded) continue;
|
|
866
930
|
// 幂等护栏: 从历史事件里重建上一步工具调用的执行状态
|
|
867
931
|
this.applyGuardFromEvents(state, events, lastEnd.seq);
|
|
868
|
-
|
|
869
|
-
this.
|
|
932
|
+
const scanReason = `scan:turn/end:${reasonKind}`;
|
|
933
|
+
this.log(`扫描发现中断 ${candidate.sessionId}(turn/end:${reasonKind}), 交给恢复策略处理`);
|
|
934
|
+
if (reasonKind === 'error') {
|
|
935
|
+
const failure = parseFailureFacts((reason as { error?: unknown }).error);
|
|
936
|
+
if (failure === undefined) {
|
|
937
|
+
console.error(`[auto-continue] 忽略畸形扫描 turn/end ${candidate.sessionId}: error details 无法解释`);
|
|
938
|
+
continue;
|
|
939
|
+
}
|
|
940
|
+
state.lastFailure = failure;
|
|
941
|
+
state.lastTurn = lastEnd.data.turn;
|
|
942
|
+
state.lastFailureAt = lastEnd.time;
|
|
943
|
+
this.onTurnFailure(candidate.sessionId, scanReason, state.lastFailure);
|
|
944
|
+
} else {
|
|
945
|
+
this.schedule(candidate.sessionId, scanReason);
|
|
946
|
+
}
|
|
870
947
|
}
|
|
871
948
|
return true;
|
|
872
949
|
}
|
|
@@ -877,22 +954,6 @@ export class AutoContinueRunner {
|
|
|
877
954
|
events: readonly SessionEvent[],
|
|
878
955
|
untilSeq: number,
|
|
879
956
|
): void {
|
|
880
|
-
state.
|
|
881
|
-
state.lastToolResult = undefined;
|
|
882
|
-
let call: SessionEvent<'tool/call'> | undefined;
|
|
883
|
-
for (const event of events) {
|
|
884
|
-
if (event.seq >= untilSeq) continue;
|
|
885
|
-
if (event.type === 'tool/call') call = event;
|
|
886
|
-
}
|
|
887
|
-
if (call === undefined) return;
|
|
888
|
-
state.lastTool = call.data.name;
|
|
889
|
-
state.lastToolResult = 'pending';
|
|
890
|
-
for (const event of events) {
|
|
891
|
-
if (event.seq <= call.seq || event.seq >= untilSeq) continue;
|
|
892
|
-
if (event.type === 'tool/result') {
|
|
893
|
-
state.lastToolResult = toolResultFacts(event.data);
|
|
894
|
-
break;
|
|
895
|
-
}
|
|
896
|
-
}
|
|
957
|
+
state.tools.restore(events, untilSeq);
|
|
897
958
|
}
|
|
898
959
|
}
|