dsh-client-auto-continue 0.7.4 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -12
- package/README.zh.md +8 -12
- package/lib/client.js +208 -1134
- package/lib/client.js.map +4 -4
- package/lib/index.js +993 -28
- package/lib/types/client/bridge.d.ts +39 -0
- package/lib/types/client/engine.d.ts +7 -2
- package/lib/types/client/index.d.ts +11 -14
- package/lib/types/host/engine.d.ts +258 -0
- package/lib/types/index.d.ts +11 -5
- package/package.json +4 -3
- package/src/client/bridge.ts +202 -0
- package/src/client/engine.ts +34 -4
- package/src/client/index.ts +19 -29
- package/src/client/settings-card.tsx +9 -4
- package/src/host/engine.ts +1270 -0
- package/src/index.ts +97 -5
- package/tsconfig.json +2 -1
package/lib/index.js
CHANGED
|
@@ -1,60 +1,946 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import
|
|
2
|
+
import z2 from "@deepseek-ai/schemastery";
|
|
3
3
|
import { settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
4
|
+
|
|
5
|
+
// node_modules/@deepseek-ai/dsh-llm/lib/index.js
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
8
|
+
import z from "@deepseek-ai/schemastery";
|
|
9
|
+
|
|
10
|
+
// node_modules/@deepseek-ai/dsh-timeout/lib/index.js
|
|
11
|
+
var MAX_TIMER_DELAY_MS = 2147483647;
|
|
12
|
+
|
|
13
|
+
// node_modules/@deepseek-ai/dsh-llm/lib/index.js
|
|
14
|
+
function MessageId(id) {
|
|
15
|
+
return id;
|
|
16
|
+
}
|
|
17
|
+
function deepFreeze(value) {
|
|
18
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
19
|
+
const pending = [{
|
|
20
|
+
kind: "visit",
|
|
21
|
+
node: value
|
|
22
|
+
}];
|
|
23
|
+
while (pending.length > 0) {
|
|
24
|
+
const task = pending.pop();
|
|
25
|
+
if (task === void 0) continue;
|
|
26
|
+
if (task.kind === "property") {
|
|
27
|
+
pending.push({
|
|
28
|
+
kind: "visit",
|
|
29
|
+
node: task.source[task.key]
|
|
30
|
+
});
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const node = task.node;
|
|
34
|
+
if (node === null || typeof node !== "object") continue;
|
|
35
|
+
if (node instanceof AbortSignal) continue;
|
|
36
|
+
if (seen.has(node)) continue;
|
|
37
|
+
seen.add(node);
|
|
38
|
+
Object.freeze(node);
|
|
39
|
+
const keys = Object.keys(node);
|
|
40
|
+
for (let index = keys.length - 1; index >= 0; index--) {
|
|
41
|
+
const key = keys[index];
|
|
42
|
+
if (key === void 0) continue;
|
|
43
|
+
pending.push({
|
|
44
|
+
kind: "property",
|
|
45
|
+
source: node,
|
|
46
|
+
key
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
function freezeMessage(message) {
|
|
53
|
+
return deepFreeze(structuredClone(message));
|
|
54
|
+
}
|
|
55
|
+
function createMessage(input) {
|
|
56
|
+
return freezeMessage({
|
|
57
|
+
...input,
|
|
58
|
+
id: MessageId(crypto.randomUUID())
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function createUserMessage(input) {
|
|
62
|
+
return createMessage({
|
|
63
|
+
...input,
|
|
64
|
+
role: "user"
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
var EMPTY_RESPONSE_CODE = "EMPTY_RESPONSE";
|
|
68
|
+
var STRUCTURED_CONTEXT_OVERFLOW = new RegExp(String.raw`(?:^|[^a-z0-9])context[\s_-](?:length|window)[\s_-]` + String.raw`(?:exceed(?:ed|s)?|overflow(?:ed)?|limit[\s_-]exceeded)(?:$|[^a-z0-9])`, "i");
|
|
69
|
+
var TOO_LARGE_FOR_CONTEXT = new RegExp(String.raw`\b(?:request|prompt|input|messages?)\s+(?:is\s+|are\s+)?` + String.raw`too\s+(?:large|long)\s+for\s+(?:(?:this|the)\s+)?` + String.raw`(?:model(?:'s)?\s+)?context(?:\s+window)?\b`, "i");
|
|
70
|
+
var EXCEEDS_MODEL_CONTEXT = new RegExp(String.raw`\b(?:input|prompt|request|messages?)\b.{0,40}` + String.raw`\b(?:exceed(?:s|ed)?|overflows?|is\s+larger\s+than)\b.{0,40}` + String.raw`\b(?:the\s+)?(?:model(?:'s)?\s+)?context(?:\s+(?:length|window))?\b`, "i");
|
|
71
|
+
var DEFAULT_MAX_RETRIES = 2;
|
|
72
|
+
var DEFAULT_INITIAL_DELAY_MS = 500;
|
|
73
|
+
var DEFAULT_MAX_DELAY_MS = 1e4;
|
|
74
|
+
var DEFAULT_JITTER_RATIO = 0.1;
|
|
75
|
+
var DEFAULT_RETRYABLE_CODES = Object.freeze([
|
|
76
|
+
EMPTY_RESPONSE_CODE,
|
|
77
|
+
"RATE_LIMIT",
|
|
78
|
+
"SERVER",
|
|
79
|
+
"TIMEOUT",
|
|
80
|
+
"TRANSPORT"
|
|
81
|
+
]);
|
|
82
|
+
var backoffSchema = z.object({
|
|
83
|
+
initialDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_INITIAL_DELAY_MS),
|
|
84
|
+
maxDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_MAX_DELAY_MS),
|
|
85
|
+
jitterRatio: z.number().min(0).max(1).default(DEFAULT_JITTER_RATIO)
|
|
86
|
+
});
|
|
87
|
+
var normalPolicySchema = z.object({
|
|
88
|
+
mode: z.const("normal").required(),
|
|
89
|
+
maxRetries: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_RETRIES),
|
|
90
|
+
retryableCodes: z.array(z.string()).default([...DEFAULT_RETRYABLE_CODES]),
|
|
91
|
+
backoff: backoffSchema
|
|
92
|
+
});
|
|
93
|
+
var alwaysPolicySchema = z.object({
|
|
94
|
+
mode: z.const("always").required(),
|
|
95
|
+
backoff: backoffSchema
|
|
96
|
+
});
|
|
97
|
+
var RetryPolicySchema = z.union([normalPolicySchema, alwaysPolicySchema]);
|
|
98
|
+
var { version } = createRequire(import.meta.url)("../package.json");
|
|
99
|
+
|
|
100
|
+
// src/host/engine.ts
|
|
101
|
+
var DEFAULT_CONFIG = {
|
|
102
|
+
continueText: "继续",
|
|
103
|
+
continueTextMaxTokens: "继续",
|
|
104
|
+
guardTools: true,
|
|
105
|
+
guardPendingText: "(上一步工具「{tool}」可能未完成, 先确认状态再继续, 不要重复执行)",
|
|
106
|
+
guardDoneText: "(上一步工具「{tool}」已完成, 结果: {result}; 不要重复执行, 直接继续)",
|
|
107
|
+
graceMs: 3e3,
|
|
108
|
+
cooldownMs: 2e4,
|
|
109
|
+
maxConsecutive: 3,
|
|
110
|
+
scanOnBoot: true,
|
|
111
|
+
scanLimit: 8,
|
|
112
|
+
freshMs: 15 * 60 * 1e3,
|
|
113
|
+
reconnectScanDelayMs: 5e3,
|
|
114
|
+
reconnectBackoffMs: 3e3,
|
|
115
|
+
verbose: true,
|
|
116
|
+
classify: true,
|
|
117
|
+
backoffFactor: 2,
|
|
118
|
+
backoffMaxMs: 3e5,
|
|
119
|
+
notify: false,
|
|
120
|
+
paused: false,
|
|
121
|
+
loopGuard: true,
|
|
122
|
+
loopShortChars: 40,
|
|
123
|
+
loopWindowMs: 3e4,
|
|
124
|
+
loopShortCount: 12,
|
|
125
|
+
loopRepeatText: 4,
|
|
126
|
+
loopToolRepeat: 5,
|
|
127
|
+
loopText: "(检测到你可能陷入循环, 请停止重复刚才的动作, 换一种方式继续)"
|
|
128
|
+
};
|
|
129
|
+
function numberOr(value, fallback) {
|
|
130
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
131
|
+
}
|
|
132
|
+
function booleanOr(value, fallback) {
|
|
133
|
+
return typeof value === "boolean" ? value : fallback;
|
|
134
|
+
}
|
|
135
|
+
function resolveConfig(section) {
|
|
136
|
+
const value = section ?? {};
|
|
137
|
+
const text = typeof value.continueText === "string" && value.continueText.trim() !== "" ? value.continueText : DEFAULT_CONFIG.continueText;
|
|
138
|
+
const maxTokensText = typeof value.continueTextMaxTokens === "string" && value.continueTextMaxTokens.trim() !== "" ? value.continueTextMaxTokens : DEFAULT_CONFIG.continueTextMaxTokens;
|
|
139
|
+
const guardPendingText = typeof value.guardPendingText === "string" && value.guardPendingText.trim() !== "" ? value.guardPendingText : DEFAULT_CONFIG.guardPendingText;
|
|
140
|
+
const guardDoneText = typeof value.guardDoneText === "string" && value.guardDoneText.trim() !== "" ? value.guardDoneText : DEFAULT_CONFIG.guardDoneText;
|
|
141
|
+
return {
|
|
142
|
+
continueText: text,
|
|
143
|
+
continueTextMaxTokens: maxTokensText,
|
|
144
|
+
guardTools: booleanOr(value.guardTools, DEFAULT_CONFIG.guardTools),
|
|
145
|
+
guardPendingText,
|
|
146
|
+
guardDoneText,
|
|
147
|
+
graceMs: numberOr(value.graceMs, DEFAULT_CONFIG.graceMs),
|
|
148
|
+
cooldownMs: numberOr(value.cooldownMs, DEFAULT_CONFIG.cooldownMs),
|
|
149
|
+
maxConsecutive: Math.max(1, numberOr(value.maxConsecutive, DEFAULT_CONFIG.maxConsecutive)),
|
|
150
|
+
scanOnBoot: booleanOr(value.scanOnBoot, DEFAULT_CONFIG.scanOnBoot),
|
|
151
|
+
scanLimit: Math.max(1, numberOr(value.scanLimit, DEFAULT_CONFIG.scanLimit)),
|
|
152
|
+
freshMs: numberOr(value.freshMs, DEFAULT_CONFIG.freshMs),
|
|
153
|
+
reconnectScanDelayMs: numberOr(value.reconnectScanDelayMs, DEFAULT_CONFIG.reconnectScanDelayMs),
|
|
154
|
+
reconnectBackoffMs: numberOr(value.reconnectBackoffMs, DEFAULT_CONFIG.reconnectBackoffMs),
|
|
155
|
+
verbose: booleanOr(value.verbose, DEFAULT_CONFIG.verbose),
|
|
156
|
+
classify: booleanOr(value.classify, DEFAULT_CONFIG.classify),
|
|
157
|
+
backoffFactor: Math.max(1, numberOr(value.backoffFactor, DEFAULT_CONFIG.backoffFactor)),
|
|
158
|
+
backoffMaxMs: numberOr(value.backoffMaxMs, DEFAULT_CONFIG.backoffMaxMs),
|
|
159
|
+
notify: booleanOr(value.notify, DEFAULT_CONFIG.notify),
|
|
160
|
+
paused: booleanOr(value.paused, DEFAULT_CONFIG.paused),
|
|
161
|
+
loopGuard: booleanOr(value.loopGuard, DEFAULT_CONFIG.loopGuard),
|
|
162
|
+
loopShortChars: Math.max(1, numberOr(value.loopShortChars, DEFAULT_CONFIG.loopShortChars)),
|
|
163
|
+
loopWindowMs: Math.max(1e3, numberOr(value.loopWindowMs, DEFAULT_CONFIG.loopWindowMs)),
|
|
164
|
+
loopShortCount: Math.max(2, numberOr(value.loopShortCount, DEFAULT_CONFIG.loopShortCount)),
|
|
165
|
+
loopRepeatText: Math.max(2, numberOr(value.loopRepeatText, DEFAULT_CONFIG.loopRepeatText)),
|
|
166
|
+
loopToolRepeat: Math.max(2, numberOr(value.loopToolRepeat, DEFAULT_CONFIG.loopToolRepeat)),
|
|
167
|
+
loopText: typeof value.loopText === "string" && value.loopText.trim() !== "" ? value.loopText : DEFAULT_CONFIG.loopText
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
function isNonHumanReason(kind) {
|
|
171
|
+
return kind === "error" || kind === "interrupted" || kind === "max-tokens";
|
|
172
|
+
}
|
|
173
|
+
function isTransientFailure(failure) {
|
|
174
|
+
const haystack = `${failure.code} ${failure.message}`.toLowerCase();
|
|
175
|
+
const status = failure.status;
|
|
176
|
+
if (status !== void 0 && (status === 401 || status === 403)) return false;
|
|
177
|
+
const permanent = /auth|unauthor|forbidden|credential|api[_-]?key|permission/i.test(haystack) || /insufficient.*(balance|quota)|billing|payment|quota.*exceeded.*(?!retry)/i.test(haystack) || /model.*not[_-]?found|unknown[_-]?model|model[_-]?not[_-]?found|not.*support.*model/i.test(haystack) || /context.*(length|limit|overflow|exceed)|token.*limit|max.*context/i.test(haystack) || /invalid[_-]?request|bad[_-]?request/i.test(haystack);
|
|
178
|
+
return !permanent;
|
|
179
|
+
}
|
|
180
|
+
function formatElapsed(ms) {
|
|
181
|
+
if (ms === void 0 || !Number.isFinite(ms) || ms < 0) return "";
|
|
182
|
+
if (ms < 1e3) return `${Math.round(ms)}ms`;
|
|
183
|
+
const s = Math.round(ms / 1e3);
|
|
184
|
+
if (s < 60) return `${s}s`;
|
|
185
|
+
return `${Math.floor(s / 60)}m${s % 60 > 0 ? `${s % 60}s` : ""}`;
|
|
186
|
+
}
|
|
187
|
+
function fillTemplate(template, ctx) {
|
|
188
|
+
return template.replace(/\{code\}/g, ctx.facts?.code ?? "").replace(/\{message\}/g, ctx.facts?.message ?? "").replace(/\{status\}/g, ctx.facts?.status !== void 0 ? String(ctx.facts.status) : "").replace(/\{tool\}/g, ctx.tool ?? "").replace(/\{turn\}/g, ctx.turn !== void 0 ? String(ctx.turn) : "").replace(/\{errorCount\}/g, ctx.errorCount !== void 0 ? String(ctx.errorCount) : "").replace(/\{sessionTitle\}/g, ctx.sessionTitle ?? "").replace(/\{elapsed\}/g, formatElapsed(ctx.elapsedMs)).replace(/\{result\}/g, ctx.result ?? "");
|
|
189
|
+
}
|
|
190
|
+
var TOOL_RESULT_CAP = 160;
|
|
191
|
+
function extractText(blocks, cap) {
|
|
192
|
+
let out = "";
|
|
193
|
+
const walk = (value) => {
|
|
194
|
+
if (out.length >= cap) return;
|
|
195
|
+
if (Array.isArray(value)) {
|
|
196
|
+
for (const item of value) walk(item);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (typeof value !== "object" || value === null) return;
|
|
200
|
+
const record = value;
|
|
201
|
+
if (record["type"] === "text" && typeof record["text"] === "string") {
|
|
202
|
+
out += record["text"];
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
for (const child of Object.values(record)) walk(child);
|
|
206
|
+
};
|
|
207
|
+
walk(blocks);
|
|
208
|
+
return out.slice(0, cap);
|
|
209
|
+
}
|
|
210
|
+
function toolResultFacts(data) {
|
|
211
|
+
const failed = data.error !== void 0 || data.message?.content?.[0]?.isError === true;
|
|
212
|
+
return { ok: !failed, excerpt: extractText(data.message?.content?.[0]?.content, TOOL_RESULT_CAP) };
|
|
213
|
+
}
|
|
214
|
+
function effectiveCooldown(consecutive, base, factor, max) {
|
|
215
|
+
const multiplier = Math.pow(factor, consecutive);
|
|
216
|
+
return Math.min(Math.max(base, base * multiplier), Math.max(base, max));
|
|
217
|
+
}
|
|
218
|
+
function sleep(ms) {
|
|
219
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
220
|
+
}
|
|
221
|
+
function todayKey() {
|
|
222
|
+
const d = /* @__PURE__ */ new Date();
|
|
223
|
+
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
224
|
+
const dd = String(d.getDate()).padStart(2, "0");
|
|
225
|
+
return `${d.getFullYear()}-${mm}-${dd}`;
|
|
226
|
+
}
|
|
227
|
+
function emptyDayStats() {
|
|
228
|
+
return { date: todayKey(), sent: 0, skipped: 0, recovered: 0, failed: 0, gaveUp: 0, looped: 0, byCode: {} };
|
|
229
|
+
}
|
|
230
|
+
var RECOVERY_WINDOW_MS = 10 * 60 * 1e3;
|
|
231
|
+
var ECHO_WINDOW_MS = 10 * 60 * 1e3;
|
|
232
|
+
var freshState = () => ({
|
|
233
|
+
consecutive: 0,
|
|
234
|
+
lastAutoAt: 0,
|
|
235
|
+
lastAttemptAt: 0,
|
|
236
|
+
lastSentText: "",
|
|
237
|
+
pendingTimer: void 0,
|
|
238
|
+
running: void 0,
|
|
239
|
+
queued: 0,
|
|
240
|
+
subagent: false,
|
|
241
|
+
lastFailure: void 0,
|
|
242
|
+
lastFailureAt: 0,
|
|
243
|
+
lastTool: void 0,
|
|
244
|
+
lastToolResult: void 0,
|
|
245
|
+
lastTurn: void 0,
|
|
246
|
+
pendingRecoveryAt: 0,
|
|
247
|
+
shortRun: 0,
|
|
248
|
+
lastShortAt: 0,
|
|
249
|
+
lastAssistantText: "",
|
|
250
|
+
sameTextRun: 0,
|
|
251
|
+
toolRun: void 0,
|
|
252
|
+
loopFired: false,
|
|
253
|
+
loopCancelled: false,
|
|
254
|
+
loopRetryTimer: void 0
|
|
255
|
+
});
|
|
256
|
+
function isOurEcho(state, event) {
|
|
257
|
+
if (event.type !== "user/message") return false;
|
|
258
|
+
const message = event.data;
|
|
259
|
+
if (message.source.kind !== "user") return false;
|
|
260
|
+
if (state.lastSentText === "") return false;
|
|
261
|
+
if (Date.now() - state.lastAutoAt > ECHO_WINDOW_MS) return false;
|
|
262
|
+
const text = message.content.filter((part) => part.type === "text").map((part) => part.text).join("");
|
|
263
|
+
return text === state.lastSentText;
|
|
264
|
+
}
|
|
265
|
+
var AutoContinueRunner = class {
|
|
266
|
+
/**
|
|
267
|
+
* @param ctx - host plugin context (agents registry, session events, settings).
|
|
268
|
+
* @param getConfig - read the current resolved configuration (settings service).
|
|
269
|
+
*/
|
|
270
|
+
constructor(ctx, getConfig) {
|
|
271
|
+
this.ctx = ctx;
|
|
272
|
+
this.getConfig = getConfig;
|
|
273
|
+
this.states = /* @__PURE__ */ new Map();
|
|
274
|
+
this.pauseUntil = /* @__PURE__ */ new Map();
|
|
275
|
+
this.dayStats = emptyDayStats();
|
|
276
|
+
this.notices = [];
|
|
277
|
+
this.noticeListeners = /* @__PURE__ */ new Set();
|
|
278
|
+
this.stateListeners = /* @__PURE__ */ new Set();
|
|
279
|
+
this.disposed = false;
|
|
280
|
+
ctx.on("session/event", (session, event) => this.onHostEvent(session, event));
|
|
281
|
+
const config = this.getConfig();
|
|
282
|
+
if (config.scanOnBoot) {
|
|
283
|
+
void this.bootScanLoop();
|
|
284
|
+
}
|
|
285
|
+
this.log(
|
|
286
|
+
`已启动(host 单实例, 文本="${config.continueText}", 宽限 ${config.graceMs}ms, 冷却 ${config.cooldownMs}ms, 最多连续 ${config.maxConsecutive} 次)`
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
log(message) {
|
|
290
|
+
if (this.getConfig().verbose) console.info(`[auto-continue] ${message}`);
|
|
291
|
+
}
|
|
292
|
+
/** 对外(状态桥): 今日统计快照。 */
|
|
293
|
+
todayStats() {
|
|
294
|
+
const today = todayKey();
|
|
295
|
+
if (this.dayStats.date !== today) this.dayStats = emptyDayStats();
|
|
296
|
+
return { ...this.dayStats, byCode: { ...this.dayStats.byCode } };
|
|
297
|
+
}
|
|
298
|
+
/** 对外(状态桥): 当前生效的会话级暂停列表。 */
|
|
299
|
+
activePauses() {
|
|
300
|
+
const now = Date.now();
|
|
301
|
+
const out = [];
|
|
302
|
+
for (const [sessionId, until] of this.pauseUntil) {
|
|
303
|
+
if (until > now) out.push({ sessionId, until });
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
/** 对外(状态桥): 订阅通知事件(SSE 端点推送)。 */
|
|
308
|
+
subscribeNotices(listener) {
|
|
309
|
+
this.noticeListeners.add(listener);
|
|
310
|
+
return () => {
|
|
311
|
+
this.noticeListeners.delete(listener);
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
/** 对外(状态桥): 订阅运行时状态变化(统计/暂停列表)。 */
|
|
315
|
+
subscribeState(listener) {
|
|
316
|
+
this.stateListeners.add(listener);
|
|
317
|
+
return () => {
|
|
318
|
+
this.stateListeners.delete(listener);
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
emitState() {
|
|
322
|
+
for (const listener of this.stateListeners) listener();
|
|
323
|
+
}
|
|
324
|
+
/** 对外(状态桥): 消费待展示的通知。 */
|
|
325
|
+
drainNotices() {
|
|
326
|
+
return this.notices.splice(0, this.notices.length);
|
|
327
|
+
}
|
|
328
|
+
/** 通知动作(browser 通知按钮回传): 立即续跑 / 暂停该会话 / 解除暂停 / 清零统计。 */
|
|
329
|
+
handleNoticeAction(sessionId, action) {
|
|
330
|
+
if (action === "unpause") {
|
|
331
|
+
if (sessionId !== void 0) this.pauseUntil.delete(sessionId);
|
|
332
|
+
this.log(`解除暂停 ${sessionId ?? "?"}`);
|
|
333
|
+
} else if (action === "reset-stats") {
|
|
334
|
+
this.dayStats = emptyDayStats();
|
|
335
|
+
this.log("清零今日统计");
|
|
336
|
+
} else if (sessionId !== void 0) {
|
|
337
|
+
this.onNotifyAction(sessionId, action);
|
|
338
|
+
}
|
|
339
|
+
this.emitState();
|
|
340
|
+
}
|
|
341
|
+
dispose() {
|
|
342
|
+
this.disposed = true;
|
|
343
|
+
for (const state of this.states.values()) {
|
|
344
|
+
if (state.pendingTimer !== void 0) clearTimeout(state.pendingTimer);
|
|
345
|
+
if (state.loopRetryTimer !== void 0) clearTimeout(state.loopRetryTimer);
|
|
346
|
+
}
|
|
347
|
+
this.states.clear();
|
|
348
|
+
}
|
|
349
|
+
state(sessionId) {
|
|
350
|
+
let state = this.states.get(sessionId);
|
|
351
|
+
if (state === void 0) {
|
|
352
|
+
state = freshState();
|
|
353
|
+
this.states.set(sessionId, state);
|
|
354
|
+
}
|
|
355
|
+
return state;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* 事件入口(host 单实例): 预处理工具调用/结果/模型消息(护栏与循环信号),
|
|
359
|
+
* 然后交给回合状态机。
|
|
360
|
+
*/
|
|
361
|
+
onHostEvent(session, event) {
|
|
362
|
+
const sessionId = session.id;
|
|
363
|
+
if (event.type === "tool/call") {
|
|
364
|
+
const name = event.data.name;
|
|
365
|
+
if (typeof name === "string") {
|
|
366
|
+
const state = this.state(sessionId);
|
|
367
|
+
state.lastTool = name;
|
|
368
|
+
state.lastToolResult = "pending";
|
|
369
|
+
state.shortRun = 0;
|
|
370
|
+
const key = `${name}
|
|
371
|
+
${event.data.arguments}`;
|
|
372
|
+
if (state.toolRun?.key === key) {
|
|
373
|
+
state.toolRun.waiting = true;
|
|
374
|
+
} else {
|
|
375
|
+
state.toolRun = { key, count: 1, lastResult: void 0, waiting: false };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
} else if (event.type === "tool/result") {
|
|
379
|
+
const state = this.state(sessionId);
|
|
380
|
+
if (state.lastToolResult === "pending") {
|
|
381
|
+
const facts = toolResultFacts(event.data);
|
|
382
|
+
state.lastToolResult = facts;
|
|
383
|
+
const run = state.toolRun;
|
|
384
|
+
if (run !== void 0 && run.waiting) {
|
|
385
|
+
run.waiting = false;
|
|
386
|
+
if (run.lastResult !== void 0 && run.lastResult === facts.excerpt) {
|
|
387
|
+
run.count += 1;
|
|
388
|
+
this.checkLoop(sessionId, state);
|
|
389
|
+
} else {
|
|
390
|
+
run.lastResult = facts.excerpt;
|
|
391
|
+
run.count = 1;
|
|
392
|
+
}
|
|
393
|
+
} else if (run !== void 0 && !run.waiting) {
|
|
394
|
+
run.lastResult = facts.excerpt;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
} else if (event.type === "assistant/message") {
|
|
398
|
+
const state = this.state(sessionId);
|
|
399
|
+
this.onAssistantMessage(sessionId, state, event);
|
|
400
|
+
}
|
|
401
|
+
this.onSessionEvent(sessionId, event);
|
|
402
|
+
}
|
|
403
|
+
/** 从 assistant/message 事件提取纯文本。 */
|
|
404
|
+
assistantText(event) {
|
|
405
|
+
const content = event.data.message.content;
|
|
406
|
+
if (!Array.isArray(content)) return "";
|
|
407
|
+
return content.filter((part) => part.type === "text").map((part) => part.text).join("");
|
|
408
|
+
}
|
|
409
|
+
onAssistantMessage(sessionId, state, event) {
|
|
410
|
+
if (!this.getConfig().loopGuard) return;
|
|
411
|
+
const text = this.assistantText(event);
|
|
412
|
+
const trimmed = text.trim();
|
|
413
|
+
if (trimmed !== "" && trimmed === state.lastAssistantText) {
|
|
414
|
+
state.sameTextRun += 1;
|
|
415
|
+
} else {
|
|
416
|
+
state.lastAssistantText = trimmed;
|
|
417
|
+
state.sameTextRun = 1;
|
|
418
|
+
}
|
|
419
|
+
if (trimmed.length < this.getConfig().loopShortChars) {
|
|
420
|
+
const now = Date.now();
|
|
421
|
+
if (now - state.lastShortAt > this.getConfig().loopWindowMs) {
|
|
422
|
+
state.shortRun = 0;
|
|
423
|
+
}
|
|
424
|
+
state.shortRun += 1;
|
|
425
|
+
state.lastShortAt = now;
|
|
426
|
+
} else {
|
|
427
|
+
state.shortRun = 0;
|
|
428
|
+
state.lastShortAt = 0;
|
|
429
|
+
}
|
|
430
|
+
this.checkLoop(sessionId, state);
|
|
431
|
+
}
|
|
432
|
+
/** 两个循环信号的公共检查; 命中且本回合未打断过则打断。 */
|
|
433
|
+
checkLoop(sessionId, state) {
|
|
434
|
+
if (!this.getConfig().loopGuard) return;
|
|
435
|
+
if (state.loopFired) return;
|
|
436
|
+
if (!state.running) return;
|
|
437
|
+
const config = this.getConfig();
|
|
438
|
+
if (state.sameTextRun >= config.loopRepeatText) {
|
|
439
|
+
this.log(`检测到空转循环 ${sessionId}: 连续 ${state.sameTextRun} 条相同消息`);
|
|
440
|
+
void this.interruptLoop(sessionId, state);
|
|
441
|
+
} else if (state.shortRun >= config.loopShortCount) {
|
|
442
|
+
this.log(`检测到空转循环 ${sessionId}: 连续 ${state.shortRun} 条短句且无工具调用`);
|
|
443
|
+
void this.interruptLoop(sessionId, state);
|
|
444
|
+
} else if (state.toolRun !== void 0 && state.toolRun.count >= config.loopToolRepeat) {
|
|
445
|
+
const toolName = state.toolRun.key.split("\n")[0] ?? "?";
|
|
446
|
+
this.log(`检测到工具死循环 ${sessionId}: 「${toolName}」连续 ${state.toolRun.count} 次(同参数同结果)`);
|
|
447
|
+
void this.interruptLoop(sessionId, state);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* 打断运行中的回合: cancel(带来源标记)+ 进冷却。
|
|
452
|
+
* 随后的 turn/end aborted 会因 loopCancelled 走「可恢复中断」路径,
|
|
453
|
+
* 用 loopText 重启回合——不会与用户手动停止混淆。
|
|
454
|
+
*/
|
|
455
|
+
async interruptLoop(sessionId, state) {
|
|
456
|
+
if (state.loopFired) return;
|
|
457
|
+
if (Date.now() - state.lastAttemptAt < this.cooldownFor(state)) {
|
|
458
|
+
this.log(`跳过循环打断 ${sessionId}: 处于冷却期`);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
state.loopFired = true;
|
|
462
|
+
state.loopCancelled = true;
|
|
463
|
+
state.lastAttemptAt = Date.now();
|
|
464
|
+
this.bumpStat({ looped: 1 });
|
|
465
|
+
try {
|
|
466
|
+
const agent = this.ctx.agents.get(sessionId);
|
|
467
|
+
if (agent === void 0) {
|
|
468
|
+
this.log(`打断循环失败 ${sessionId}: 无 live agent`);
|
|
469
|
+
state.loopCancelled = false;
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
agent.cancel({ kind: "user" }, { keepInbox: true });
|
|
473
|
+
this.log(`已打断循环 ${sessionId}: cancel 已受理`);
|
|
474
|
+
} catch (error) {
|
|
475
|
+
this.log(`打断循环失败 ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
476
|
+
state.loopCancelled = false;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
onSessionEvent(sessionId, event) {
|
|
480
|
+
const state = this.state(sessionId);
|
|
481
|
+
switch (event.type) {
|
|
482
|
+
case "turn/start":
|
|
483
|
+
state.running = true;
|
|
484
|
+
state.lastTool = void 0;
|
|
485
|
+
state.lastToolResult = void 0;
|
|
486
|
+
state.shortRun = 0;
|
|
487
|
+
state.lastShortAt = 0;
|
|
488
|
+
state.lastAssistantText = "";
|
|
489
|
+
state.sameTextRun = 0;
|
|
490
|
+
state.toolRun = void 0;
|
|
491
|
+
state.loopFired = false;
|
|
492
|
+
state.loopCancelled = false;
|
|
493
|
+
if (state.loopRetryTimer !== void 0) {
|
|
494
|
+
clearTimeout(state.loopRetryTimer);
|
|
495
|
+
state.loopRetryTimer = void 0;
|
|
496
|
+
}
|
|
497
|
+
this.cancelPending(sessionId, "宿主自行开启新回合");
|
|
498
|
+
break;
|
|
499
|
+
case "turn/end": {
|
|
500
|
+
state.running = false;
|
|
501
|
+
this.cancelPending(sessionId, "收到新的 turn/end");
|
|
502
|
+
const reason = event.data.reason;
|
|
503
|
+
if (reason.kind === "completed") {
|
|
504
|
+
state.consecutive = 0;
|
|
505
|
+
state.lastFailure = void 0;
|
|
506
|
+
this.noteRecovery(sessionId, "completed");
|
|
507
|
+
} else if (reason.kind === "aborted") {
|
|
508
|
+
if (state.loopCancelled) {
|
|
509
|
+
state.loopCancelled = false;
|
|
510
|
+
state.loopFired = false;
|
|
511
|
+
state.pendingRecoveryAt = 0;
|
|
512
|
+
state.shortRun = 0;
|
|
513
|
+
state.lastShortAt = 0;
|
|
514
|
+
state.lastAssistantText = "";
|
|
515
|
+
state.sameTextRun = 0;
|
|
516
|
+
state.toolRun = void 0;
|
|
517
|
+
const cooldown = this.cooldownFor(state);
|
|
518
|
+
const remaining = cooldown - (Date.now() - state.lastAttemptAt);
|
|
519
|
+
if (remaining > 0) {
|
|
520
|
+
if (state.loopRetryTimer !== void 0) clearTimeout(state.loopRetryTimer);
|
|
521
|
+
state.loopRetryTimer = setTimeout(() => {
|
|
522
|
+
state.loopRetryTimer = void 0;
|
|
523
|
+
this.schedule(sessionId, "loop:aborted");
|
|
524
|
+
}, remaining);
|
|
525
|
+
this.log(`loop 重启延迟 ${remaining}ms(冷却期) ${sessionId}`);
|
|
526
|
+
} else {
|
|
527
|
+
this.schedule(sessionId, "loop:aborted");
|
|
528
|
+
}
|
|
529
|
+
} else {
|
|
530
|
+
state.consecutive = 0;
|
|
531
|
+
state.pendingRecoveryAt = 0;
|
|
532
|
+
}
|
|
533
|
+
} else if (reason.kind === "blocked") {
|
|
534
|
+
} else if (reason.kind === "interrupted") {
|
|
535
|
+
state.consecutive = 0;
|
|
536
|
+
state.pendingRecoveryAt = 0;
|
|
537
|
+
} else if (reason.kind === "error") {
|
|
538
|
+
const error = reason.error;
|
|
539
|
+
state.lastFailure = {
|
|
540
|
+
code: typeof error.code === "string" ? error.code : "UNKNOWN",
|
|
541
|
+
message: typeof error.message === "string" ? error.message : String(error),
|
|
542
|
+
...typeof error.status === "number" ? { status: error.status } : {}
|
|
543
|
+
};
|
|
544
|
+
state.lastTurn = event.data.turn;
|
|
545
|
+
state.lastFailureAt = Date.now();
|
|
546
|
+
this.noteRecovery(sessionId, "error");
|
|
547
|
+
this.onTurnFailure(sessionId, "turn/end:error", state.lastFailure);
|
|
548
|
+
} else if (reason.kind === "max-tokens") {
|
|
549
|
+
state.lastFailureAt = Date.now();
|
|
550
|
+
this.noteRecovery(sessionId, "error");
|
|
551
|
+
this.schedule(sessionId, "turn/end:max-tokens");
|
|
552
|
+
}
|
|
553
|
+
break;
|
|
554
|
+
}
|
|
555
|
+
case "user/message":
|
|
556
|
+
if (isOurEcho(state, event)) break;
|
|
557
|
+
if (event.data.source.kind === "user") {
|
|
558
|
+
state.consecutive = 0;
|
|
559
|
+
this.cancelPending(sessionId, "用户手动发送消息");
|
|
560
|
+
}
|
|
561
|
+
break;
|
|
562
|
+
default:
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
// ---------- host 帧 ----------
|
|
567
|
+
onTurnFailure(sessionId, reason, failure) {
|
|
568
|
+
const config = this.getConfig();
|
|
569
|
+
if (config.classify && !isTransientFailure(failure)) {
|
|
570
|
+
const summary = `${failure.code}${failure.status !== void 0 ? ` (HTTP ${failure.status})` : ""}`;
|
|
571
|
+
this.log(`跳过 ${sessionId}(${reason}): 永久性失败 ${summary} — ${failure.message}`);
|
|
572
|
+
this.bumpStat({ skipped: 1, code: failure.code });
|
|
573
|
+
if (config.notify) {
|
|
574
|
+
this.notify(
|
|
575
|
+
"dsh-auto-continue: 未自动继续",
|
|
576
|
+
`${sessionId}: 永久性错误 ${summary},需要人工处理`,
|
|
577
|
+
this.notifyOptions(sessionId)
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
this.schedule(sessionId, reason);
|
|
583
|
+
}
|
|
584
|
+
/** 通知操作按钮与回调(「立即续跑」/「暂停该会话 1 小时」)。 */
|
|
585
|
+
notifyOptions(sessionId) {
|
|
586
|
+
return {
|
|
587
|
+
actions: [
|
|
588
|
+
{ action: "resume", title: "立即续跑" },
|
|
589
|
+
{ action: "pause1h", title: "暂停该会话 1 小时" }
|
|
590
|
+
],
|
|
591
|
+
onAction: (action) => this.onNotifyAction(sessionId, action)
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
onNotifyAction(sessionId, action) {
|
|
595
|
+
if (action === "resume") {
|
|
596
|
+
this.log(`通知按钮: 立即续跑 ${sessionId}`);
|
|
597
|
+
void this.resumeNow(sessionId);
|
|
598
|
+
} else if (action === "pause1h") {
|
|
599
|
+
this.log(`通知按钮: 暂停 ${sessionId} 1 小时`);
|
|
600
|
+
this.pauseUntil.set(sessionId, Date.now() + 60 * 60 * 1e3);
|
|
601
|
+
this.cancelPending(sessionId, "通知按钮暂停该会话");
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
/** 内存统计(host 单实例): 按今日桶累计。 */
|
|
605
|
+
bumpStat(delta) {
|
|
606
|
+
const today = todayKey();
|
|
607
|
+
if (this.dayStats.date !== today) this.dayStats = emptyDayStats();
|
|
608
|
+
if (delta.sent !== void 0) this.dayStats.sent += delta.sent;
|
|
609
|
+
if (delta.skipped !== void 0) this.dayStats.skipped += delta.skipped;
|
|
610
|
+
if (delta.recovered !== void 0) this.dayStats.recovered += delta.recovered;
|
|
611
|
+
if (delta.failed !== void 0) this.dayStats.failed += delta.failed;
|
|
612
|
+
if (delta.gaveUp !== void 0) this.dayStats.gaveUp += delta.gaveUp;
|
|
613
|
+
if (delta.looped !== void 0) this.dayStats.looped += delta.looped;
|
|
614
|
+
if (delta.code !== void 0) {
|
|
615
|
+
this.dayStats.byCode[delta.code] = (this.dayStats.byCode[delta.code] ?? 0) + 1;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
/** 通知桥: 产生一条通知事件, SSE 端点推给 browser 侧展示。 */
|
|
619
|
+
notify(title, body, options) {
|
|
620
|
+
const notice = {
|
|
621
|
+
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
622
|
+
title,
|
|
623
|
+
body,
|
|
624
|
+
...options?.actions !== void 0 && options.actions.length > 0 ? { actions: options.actions } : { actions: [] },
|
|
625
|
+
at: Date.now()
|
|
626
|
+
};
|
|
627
|
+
this.notices.push(notice);
|
|
628
|
+
for (const listener of this.noticeListeners) listener();
|
|
629
|
+
this.emitState();
|
|
630
|
+
}
|
|
631
|
+
/** 恢复结果记账: 自动发送后窗口内的回合结束, 判定恢复成功或失败。 */
|
|
632
|
+
noteRecovery(sessionId, outcome) {
|
|
633
|
+
const state = this.state(sessionId);
|
|
634
|
+
if (state.pendingRecoveryAt === 0) return;
|
|
635
|
+
if (Date.now() - state.pendingRecoveryAt > RECOVERY_WINDOW_MS) {
|
|
636
|
+
state.pendingRecoveryAt = 0;
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
state.pendingRecoveryAt = 0;
|
|
640
|
+
this.bumpStat(outcome === "completed" ? { recovered: 1 } : { failed: 1 });
|
|
641
|
+
this.log(`恢复结果(${sessionId}): ${outcome === "completed" ? "成功" : "失败"}`);
|
|
642
|
+
}
|
|
643
|
+
/** 立即为该会话发送一次自动继续(无视冷却与连续上限; 由通知按钮触发)。 */
|
|
644
|
+
async resumeNow(sessionId) {
|
|
645
|
+
if (this.disposed) return;
|
|
646
|
+
const state = this.state(sessionId);
|
|
647
|
+
if (state.subagent) return;
|
|
648
|
+
if (state.pendingTimer !== void 0) {
|
|
649
|
+
clearTimeout(state.pendingTimer);
|
|
650
|
+
state.pendingTimer = void 0;
|
|
651
|
+
}
|
|
652
|
+
await this.fire(sessionId, "manual:notification", true);
|
|
653
|
+
}
|
|
654
|
+
/** 本会话当前生效的冷却间隔(自适应退避)。 */
|
|
655
|
+
cooldownFor(state) {
|
|
656
|
+
const config = this.getConfig();
|
|
657
|
+
return effectiveCooldown(
|
|
658
|
+
state.consecutive,
|
|
659
|
+
config.cooldownMs,
|
|
660
|
+
config.backoffFactor,
|
|
661
|
+
config.backoffMaxMs
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
schedule(sessionId, reason) {
|
|
665
|
+
const state = this.state(sessionId);
|
|
666
|
+
const config = this.getConfig();
|
|
667
|
+
if (state.subagent) return;
|
|
668
|
+
if (config.paused) {
|
|
669
|
+
this.log(`跳过 ${sessionId}(${reason}): 全局暂停中`);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
if (Date.now() < (this.pauseUntil.get(sessionId) ?? 0)) {
|
|
673
|
+
this.log(`跳过 ${sessionId}(${reason}): 会话暂停中`);
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
if (state.pendingTimer !== void 0) return;
|
|
677
|
+
if (Date.now() - state.lastAttemptAt < this.cooldownFor(state)) return;
|
|
678
|
+
if (state.consecutive >= config.maxConsecutive) {
|
|
679
|
+
this.log(
|
|
680
|
+
`跳过 ${sessionId}(${reason}): 已连续自动继续 ${state.consecutive} 次, 等待用户介入或成功回合`
|
|
681
|
+
);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
const timer = setTimeout(() => {
|
|
685
|
+
if (state.pendingTimer !== timer) return;
|
|
686
|
+
state.pendingTimer = void 0;
|
|
687
|
+
void this.fire(sessionId, reason);
|
|
688
|
+
}, config.graceMs);
|
|
689
|
+
state.pendingTimer = timer;
|
|
690
|
+
const template = reason.startsWith("loop:") ? config.loopText : reason.includes("max-tokens") ? config.continueTextMaxTokens : config.continueText;
|
|
691
|
+
this.log(
|
|
692
|
+
`检测到非人为中断 ${sessionId}(${reason}), ${config.graceMs}ms 后自动发送「${template}」`
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
cancelPending(sessionId, why) {
|
|
696
|
+
const state = this.state(sessionId);
|
|
697
|
+
if (state.pendingTimer === void 0) return;
|
|
698
|
+
clearTimeout(state.pendingTimer);
|
|
699
|
+
state.pendingTimer = void 0;
|
|
700
|
+
this.log(`取消 ${sessionId} 的自动继续(${why})`);
|
|
701
|
+
}
|
|
702
|
+
fire(sessionId, reason, force = false) {
|
|
703
|
+
if (this.disposed) return;
|
|
704
|
+
const state = this.state(sessionId);
|
|
705
|
+
const config = this.getConfig();
|
|
706
|
+
if (state.subagent) return;
|
|
707
|
+
if (config.paused) {
|
|
708
|
+
this.log(`跳过 ${sessionId}(${reason}): 全局暂停中`);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
if (Date.now() < (this.pauseUntil.get(sessionId) ?? 0)) {
|
|
712
|
+
this.log(`跳过 ${sessionId}(${reason}): 会话暂停中`);
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
if (!force && Date.now() - state.lastAttemptAt < this.cooldownFor(state)) {
|
|
716
|
+
this.log(`跳过 ${sessionId}(${reason}): 处于冷却期`);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
if (!force && state.consecutive >= config.maxConsecutive) {
|
|
720
|
+
this.log(`跳过 ${sessionId}(${reason}): 已连续自动继续 ${state.consecutive} 次, 等待用户介入或成功回合`);
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
const template = reason.startsWith("loop:") ? config.loopText : reason.includes("max-tokens") ? config.continueTextMaxTokens : config.continueText;
|
|
724
|
+
const text = this.buildContinueText(config, state, template);
|
|
725
|
+
const agent = this.ctx.agents.get(sessionId);
|
|
726
|
+
if (agent === void 0) {
|
|
727
|
+
this.log(`跳过 ${sessionId}(${reason}): 无 live agent`);
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
state.lastAttemptAt = Date.now();
|
|
731
|
+
try {
|
|
732
|
+
agent.followup(
|
|
733
|
+
createUserMessage({
|
|
734
|
+
content: [{ type: "text", text }],
|
|
735
|
+
source: { kind: "user" }
|
|
736
|
+
})
|
|
737
|
+
);
|
|
738
|
+
const now = Date.now();
|
|
739
|
+
state.consecutive += 1;
|
|
740
|
+
state.lastAutoAt = now;
|
|
741
|
+
state.lastSentText = text;
|
|
742
|
+
state.pendingRecoveryAt = now;
|
|
743
|
+
this.bumpStat({ sent: 1, ...state.lastFailure !== void 0 ? { code: state.lastFailure.code } : {} });
|
|
744
|
+
this.log(`已自动发送「${text}」到 ${sessionId}(${reason}), 第 ${state.consecutive} 次连续`);
|
|
745
|
+
if (config.notify) {
|
|
746
|
+
this.notify(
|
|
747
|
+
"dsh-auto-continue: 已自动继续",
|
|
748
|
+
`${sessionId}: 已发送「${text}」(第 ${state.consecutive} 次连续)`,
|
|
749
|
+
this.notifyOptions(sessionId)
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
if (state.consecutive >= config.maxConsecutive) {
|
|
753
|
+
this.bumpStat({ gaveUp: 1 });
|
|
754
|
+
this.log(`达到连续上限 ${config.maxConsecutive} 次, 停止自动继续 ${sessionId}`);
|
|
755
|
+
if (config.notify) {
|
|
756
|
+
this.notify(
|
|
757
|
+
"dsh-auto-continue: 已停止自动继续",
|
|
758
|
+
`${sessionId}: 连续失败 ${state.consecutive} 次, 需要人工介入`,
|
|
759
|
+
this.notifyOptions(sessionId)
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
} catch (error) {
|
|
764
|
+
this.log(`发送异常 ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* 组装本次续跑消息: 模板填充 + 幂等护栏。
|
|
769
|
+
* 护栏依据上一步工具调用的执行状态附加指引, 防止重跑副作用操作:
|
|
770
|
+
* - 结果未确认(可能已部分执行)→ 提示先确认状态、不要重复执行
|
|
771
|
+
* - 已确认成功 → 提示已完成、不要重复执行
|
|
772
|
+
* - 已失败 → 不加护栏(重试工具本来就是目的)
|
|
773
|
+
*/
|
|
774
|
+
buildContinueText(config, state, template) {
|
|
775
|
+
let text = fillTemplate(template, {
|
|
776
|
+
facts: state.lastFailure,
|
|
777
|
+
tool: state.lastTool,
|
|
778
|
+
turn: state.lastTurn,
|
|
779
|
+
errorCount: state.consecutive + 1,
|
|
780
|
+
elapsedMs: state.lastFailureAt > 0 ? Date.now() - state.lastFailureAt : void 0
|
|
781
|
+
});
|
|
782
|
+
if (!config.guardTools) return text;
|
|
783
|
+
const guard = this.currentGuard(state);
|
|
784
|
+
if (guard.kind === "pending") {
|
|
785
|
+
text += ` ${fillTemplate(config.guardPendingText, { tool: guard.tool, result: guard.result })}`;
|
|
786
|
+
} else if (guard.kind === "done") {
|
|
787
|
+
text += ` ${fillTemplate(config.guardDoneText, { tool: guard.tool, result: guard.result })}`;
|
|
788
|
+
}
|
|
789
|
+
return text;
|
|
790
|
+
}
|
|
791
|
+
/** 上一步工具调用的护栏状态(实时路径, 由 mux 帧维护)。 */
|
|
792
|
+
currentGuard(state) {
|
|
793
|
+
if (state.lastTool === void 0 || state.lastToolResult === void 0) return { kind: "none" };
|
|
794
|
+
if (state.lastToolResult === "pending") return { kind: "pending", tool: state.lastTool };
|
|
795
|
+
if (state.lastToolResult.ok) {
|
|
796
|
+
return { kind: "done", tool: state.lastTool, result: state.lastToolResult.excerpt };
|
|
797
|
+
}
|
|
798
|
+
return { kind: "failed", tool: state.lastTool };
|
|
799
|
+
}
|
|
800
|
+
async bootScanLoop() {
|
|
801
|
+
await this.scanLoop(Infinity, 3e3);
|
|
802
|
+
}
|
|
803
|
+
/** 反复尝试扫描, 直到成功(宿主就绪)或达到次数上限。 */
|
|
804
|
+
async scanLoop(attempts, delayMs) {
|
|
805
|
+
for (let attempt = 0; attempt < attempts && !this.disposed; attempt += 1) {
|
|
806
|
+
try {
|
|
807
|
+
if (await this.scanInterrupted()) return;
|
|
808
|
+
} catch (error) {
|
|
809
|
+
if (this.disposed) return;
|
|
810
|
+
if (attempt % 10 === 0) {
|
|
811
|
+
this.log(
|
|
812
|
+
`扫描失败(${attempt + 1}/${attempts === Infinity ? "∞" : attempts}): ${error instanceof Error ? error.message : String(error)}`
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
if (attempt + 1 < attempts) await sleep(delayMs);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* 扫描最近中断过的会话: 最后回合以非人为原因结束, 且其后没有新回合或用户消息。
|
|
821
|
+
* @returns 是否成功完成一次扫描(宿主就绪)。
|
|
822
|
+
*/
|
|
823
|
+
async scanInterrupted() {
|
|
824
|
+
const config = this.getConfig();
|
|
825
|
+
if (config.paused) return true;
|
|
826
|
+
const now = Date.now();
|
|
827
|
+
const candidates = [];
|
|
828
|
+
for (const agent of this.ctx.agents.list()) {
|
|
829
|
+
const session = agent.session;
|
|
830
|
+
if (session.header.origin === "subagent") continue;
|
|
831
|
+
candidates.push({ sessionId: session.id, events: session.events });
|
|
832
|
+
}
|
|
833
|
+
for (const candidate of candidates.slice(0, config.scanLimit)) {
|
|
834
|
+
if (this.disposed) return true;
|
|
835
|
+
const state = this.state(candidate.sessionId);
|
|
836
|
+
if (state.pendingTimer !== void 0) continue;
|
|
837
|
+
if (state.consecutive >= config.maxConsecutive) continue;
|
|
838
|
+
if (now - state.lastAttemptAt < this.cooldownFor(state)) continue;
|
|
839
|
+
if (now < (this.pauseUntil.get(candidate.sessionId) ?? 0)) continue;
|
|
840
|
+
const events = candidate.events;
|
|
841
|
+
let lastEnd;
|
|
842
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
843
|
+
const event = events[i];
|
|
844
|
+
if (event !== void 0 && event.type === "turn/end") {
|
|
845
|
+
lastEnd = event;
|
|
846
|
+
break;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
if (lastEnd === void 0) continue;
|
|
850
|
+
const reason = lastEnd.data.reason;
|
|
851
|
+
if (!isNonHumanReason(reason.kind)) continue;
|
|
852
|
+
if (lastEnd.time < now - config.freshMs) continue;
|
|
853
|
+
let superseded = false;
|
|
854
|
+
for (const event of events) {
|
|
855
|
+
if (event.seq <= lastEnd.seq) continue;
|
|
856
|
+
if (event.type === "turn/start") superseded = true;
|
|
857
|
+
if (event.type === "user/message" && event.data.source.kind === "user") superseded = true;
|
|
858
|
+
if (superseded) break;
|
|
859
|
+
}
|
|
860
|
+
if (superseded) continue;
|
|
861
|
+
this.applyGuardFromEvents(state, events, lastEnd.seq);
|
|
862
|
+
this.log(`扫描发现中断 ${candidate.sessionId}(turn/end:${reason.kind}), 安排自动继续`);
|
|
863
|
+
this.schedule(candidate.sessionId, `scan:turn/end:${reason.kind}`);
|
|
864
|
+
}
|
|
865
|
+
return true;
|
|
866
|
+
}
|
|
867
|
+
/** 从历史事件恢复上一步工具调用状态(扫描路径的幂等护栏)。 */
|
|
868
|
+
applyGuardFromEvents(state, events, untilSeq) {
|
|
869
|
+
state.lastTool = void 0;
|
|
870
|
+
state.lastToolResult = void 0;
|
|
871
|
+
let call;
|
|
872
|
+
for (const event of events) {
|
|
873
|
+
if (event.seq >= untilSeq) continue;
|
|
874
|
+
if (event.type === "tool/call") call = event;
|
|
875
|
+
}
|
|
876
|
+
if (call === void 0) return;
|
|
877
|
+
state.lastTool = call.data.name;
|
|
878
|
+
state.lastToolResult = "pending";
|
|
879
|
+
for (const event of events) {
|
|
880
|
+
if (event.seq <= call.seq || event.seq >= untilSeq) continue;
|
|
881
|
+
if (event.type === "tool/result") {
|
|
882
|
+
state.lastToolResult = toolResultFacts(event.data);
|
|
883
|
+
break;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
};
|
|
888
|
+
|
|
889
|
+
// src/index.ts
|
|
4
890
|
var AUTO_CONTINUE_NS = "auto-continue";
|
|
5
|
-
var AutoContinueSchema =
|
|
891
|
+
var AutoContinueSchema = z2.object({
|
|
6
892
|
/** Text automatically sent after an interruption. */
|
|
7
|
-
continueText:
|
|
893
|
+
continueText: z2.string().default("继续"),
|
|
8
894
|
/** Text sent when the output token ceiling is reached (same placeholders as `continueText`). */
|
|
9
|
-
continueTextMaxTokens:
|
|
895
|
+
continueTextMaxTokens: z2.string().default("继续"),
|
|
10
896
|
/** Idempotency guard: inspect the last tool call before resuming and steer the model. */
|
|
11
|
-
guardTools:
|
|
897
|
+
guardTools: z2.boolean().default(true),
|
|
12
898
|
/** Guard text appended when the last tool call has no confirmed result (it may have partially executed). */
|
|
13
|
-
guardPendingText:
|
|
899
|
+
guardPendingText: z2.string().default("(上一步工具「{tool}」可能未完成, 先确认状态再继续, 不要重复执行)"),
|
|
14
900
|
/** Guard text appended when the last tool call completed successfully (don't rerun it). */
|
|
15
|
-
guardDoneText:
|
|
901
|
+
guardDoneText: z2.string().default("(上一步工具「{tool}」已完成, 结果: {result}; 不要重复执行, 直接继续)"),
|
|
16
902
|
/** Grace period after an interruption before auto-sending (ms). */
|
|
17
|
-
graceMs:
|
|
903
|
+
graceMs: z2.natural().default(3e3),
|
|
18
904
|
/** Minimum interval between two auto-continues per session (ms). */
|
|
19
|
-
cooldownMs:
|
|
905
|
+
cooldownMs: z2.natural().default(2e4),
|
|
20
906
|
/** Max consecutive auto-continues per session before stopping. */
|
|
21
|
-
maxConsecutive:
|
|
907
|
+
maxConsecutive: z2.natural().min(1).default(3),
|
|
22
908
|
/** Scan recently interrupted sessions on page load / reconnect. */
|
|
23
|
-
scanOnBoot:
|
|
909
|
+
scanOnBoot: z2.boolean().default(true),
|
|
24
910
|
/** Max sessions the scan checks (most recently updated). */
|
|
25
|
-
scanLimit:
|
|
911
|
+
scanLimit: z2.natural().min(1).default(8),
|
|
26
912
|
/** Scan only considers interruptions inside this window (ms). */
|
|
27
|
-
freshMs:
|
|
913
|
+
freshMs: z2.natural().default(15 * 60 * 1e3),
|
|
28
914
|
/** Delay before scanning after a reconnect (ms). */
|
|
29
|
-
reconnectScanDelayMs:
|
|
915
|
+
reconnectScanDelayMs: z2.natural().default(5e3),
|
|
30
916
|
/** SSE reconnect backoff (ms). */
|
|
31
|
-
reconnectBackoffMs:
|
|
917
|
+
reconnectBackoffMs: z2.natural().default(3e3),
|
|
32
918
|
/** Log `[auto-continue]` lines to the browser console. */
|
|
33
|
-
verbose:
|
|
919
|
+
verbose: z2.boolean().default(true),
|
|
34
920
|
/** Classify failures: auto-continue transient errors only; permanent ones are skipped and notified. */
|
|
35
|
-
classify:
|
|
921
|
+
classify: z2.boolean().default(true),
|
|
36
922
|
/** Cooldown multiplier per consecutive failure (adaptive backoff). */
|
|
37
|
-
backoffFactor:
|
|
923
|
+
backoffFactor: z2.natural().min(1).default(2),
|
|
38
924
|
/** Cap on the effective backoff interval (ms). */
|
|
39
|
-
backoffMaxMs:
|
|
925
|
+
backoffMaxMs: z2.natural().default(3e5),
|
|
40
926
|
/** Show browser notifications for auto-continue events. */
|
|
41
|
-
notify:
|
|
927
|
+
notify: z2.boolean().default(false),
|
|
42
928
|
/** Globally pause auto-continue: no live or scan send. */
|
|
43
|
-
paused:
|
|
929
|
+
paused: z2.boolean().default(false),
|
|
44
930
|
/** Loop guard: detect a running turn spinning in place and restart it. */
|
|
45
|
-
loopGuard:
|
|
931
|
+
loopGuard: z2.boolean().default(true),
|
|
46
932
|
/** A model message shorter than this many chars counts as a short sentence (loop signal). */
|
|
47
|
-
loopShortChars:
|
|
933
|
+
loopShortChars: z2.natural().min(1).default(40),
|
|
48
934
|
/** Consecutive short sentences within this window (ms) with no tool call in between trip the loop guard. */
|
|
49
|
-
loopWindowMs:
|
|
935
|
+
loopWindowMs: z2.natural().min(1e3).default(3e4),
|
|
50
936
|
/** Consecutive short sentences trip the loop guard. */
|
|
51
|
-
loopShortCount:
|
|
937
|
+
loopShortCount: z2.natural().min(2).default(12),
|
|
52
938
|
/** Consecutive identical short sentences trip the loop guard (strongest spinning signal). */
|
|
53
|
-
loopRepeatText:
|
|
939
|
+
loopRepeatText: z2.natural().min(2).default(4),
|
|
54
940
|
/** Consecutive identical tool calls with identical arguments AND results trip the loop guard. */
|
|
55
|
-
loopToolRepeat:
|
|
941
|
+
loopToolRepeat: z2.natural().min(2).default(5),
|
|
56
942
|
/** Text sent after the loop guard cancels and restarts a turn (supports {tool}). */
|
|
57
|
-
loopText:
|
|
943
|
+
loopText: z2.string().default("(检测到你可能陷入循环, 请停止重复刚才的动作, 换一种方式继续)")
|
|
58
944
|
});
|
|
59
945
|
function apply(ctx) {
|
|
60
946
|
ctx.inject(["settings"], (settingsCtx) => {
|
|
@@ -62,6 +948,85 @@ function apply(ctx) {
|
|
|
62
948
|
applies: "live"
|
|
63
949
|
});
|
|
64
950
|
});
|
|
951
|
+
ctx.inject(["settings", "agents", "webServer"], (engineCtx) => {
|
|
952
|
+
const runner = new AutoContinueRunner(
|
|
953
|
+
engineCtx,
|
|
954
|
+
() => resolveConfig(engineCtx.settings.get(settingsNamespace(AUTO_CONTINUE_NS)))
|
|
955
|
+
);
|
|
956
|
+
const sseClients = /* @__PURE__ */ new Set();
|
|
957
|
+
const pushToAll = (data) => {
|
|
958
|
+
for (const send of sseClients) {
|
|
959
|
+
try {
|
|
960
|
+
send(data);
|
|
961
|
+
} catch {
|
|
962
|
+
sseClients.delete(send);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
};
|
|
966
|
+
const statePayload = () => JSON.stringify({
|
|
967
|
+
type: "state",
|
|
968
|
+
stats: runner.todayStats(),
|
|
969
|
+
paused: runner.activePauses()
|
|
970
|
+
});
|
|
971
|
+
runner.subscribeNotices(() => {
|
|
972
|
+
for (const notice of runner.drainNotices()) {
|
|
973
|
+
pushToAll(`data: ${JSON.stringify({ type: "notice", notice })}
|
|
974
|
+
|
|
975
|
+
`);
|
|
976
|
+
}
|
|
977
|
+
});
|
|
978
|
+
runner.subscribeState(() => {
|
|
979
|
+
pushToAll(`data: ${statePayload()}
|
|
980
|
+
|
|
981
|
+
`);
|
|
982
|
+
});
|
|
983
|
+
engineCtx.webServer.register({
|
|
984
|
+
kind: "exact",
|
|
985
|
+
path: "/api/auto-continue-bridge",
|
|
986
|
+
handler: (req, res) => {
|
|
987
|
+
res.writeHead(200, {
|
|
988
|
+
"content-type": "text/event-stream",
|
|
989
|
+
"cache-control": "no-cache",
|
|
990
|
+
connection: "keep-alive"
|
|
991
|
+
});
|
|
992
|
+
res.write(`data: ${statePayload()}
|
|
993
|
+
|
|
994
|
+
`);
|
|
995
|
+
const send = (data) => {
|
|
996
|
+
res.write(data);
|
|
997
|
+
};
|
|
998
|
+
sseClients.add(send);
|
|
999
|
+
req.on("close", () => sseClients.delete(send));
|
|
1000
|
+
}
|
|
1001
|
+
});
|
|
1002
|
+
engineCtx.webServer.register({
|
|
1003
|
+
kind: "exact",
|
|
1004
|
+
path: "/api/auto-continue-action",
|
|
1005
|
+
handler: (req, res) => {
|
|
1006
|
+
let body = "";
|
|
1007
|
+
req.on("data", (chunk) => {
|
|
1008
|
+
body += chunk.toString("utf8");
|
|
1009
|
+
if (body.length > 4096) req.destroy();
|
|
1010
|
+
});
|
|
1011
|
+
req.on("end", () => {
|
|
1012
|
+
try {
|
|
1013
|
+
const parsed = JSON.parse(body);
|
|
1014
|
+
if (typeof parsed.action === "string") {
|
|
1015
|
+
runner.handleNoticeAction(parsed.sessionId ?? void 0, parsed.action);
|
|
1016
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1017
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
1021
|
+
res.end(JSON.stringify({ ok: false }));
|
|
1022
|
+
} catch {
|
|
1023
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
1024
|
+
res.end(JSON.stringify({ ok: false }));
|
|
1025
|
+
}
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
});
|
|
1029
|
+
});
|
|
65
1030
|
}
|
|
66
1031
|
export {
|
|
67
1032
|
AUTO_CONTINUE_NS,
|