dsh-client-auto-continue 0.7.2 → 0.7.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/README.md +5 -3
- package/README.zh.md +5 -3
- package/lib/client.js +188 -67
- package/lib/client.js.map +3 -3
- package/lib/index.js +2 -0
- package/lib/types/client/engine.d.ts +2 -0
- package/lib/types/client/locales.d.ts +2 -0
- package/lib/types/client/settings-card.d.ts +1 -0
- package/lib/types/index.d.ts +4 -0
- package/package.json +1 -1
- package/src/client/engine.ts +223 -72
- package/src/client/locales.ts +4 -0
- package/src/client/settings-card.tsx +13 -0
- package/src/index.ts +2 -0
package/lib/index.js
CHANGED
|
@@ -49,6 +49,8 @@ var AutoContinueSchema = z.object({
|
|
|
49
49
|
loopWindowMs: z.natural().min(1e3).default(3e4),
|
|
50
50
|
/** Consecutive short sentences trip the loop guard. */
|
|
51
51
|
loopShortCount: z.natural().min(2).default(12),
|
|
52
|
+
/** Consecutive identical short sentences trip the loop guard (strongest spinning signal). */
|
|
53
|
+
loopRepeatText: z.natural().min(2).default(4),
|
|
52
54
|
/** Consecutive identical tool calls with identical arguments AND results trip the loop guard. */
|
|
53
55
|
loopToolRepeat: z.natural().min(2).default(5),
|
|
54
56
|
/** Text sent after the loop guard cancels and restarts a turn (supports {tool}). */
|
|
@@ -61,6 +61,8 @@ export interface AutoContinueSettings {
|
|
|
61
61
|
loopShortCount?: number;
|
|
62
62
|
/** Consecutive identical tool calls with identical arguments AND identical results trip the loop guard. */
|
|
63
63
|
loopToolRepeat?: number;
|
|
64
|
+
/** Consecutive identical short sentences trip the loop guard (strongest spinning signal). */
|
|
65
|
+
loopRepeatText?: number;
|
|
64
66
|
/** Text sent after the loop guard cancels and restarts a turn (supports {tool}). */
|
|
65
67
|
loopText?: string;
|
|
66
68
|
}
|
|
@@ -60,6 +60,8 @@ export declare const zh: {
|
|
|
60
60
|
'field.loopWindowMsHint': string;
|
|
61
61
|
'field.loopShortCount': string;
|
|
62
62
|
'field.loopShortCountHint': string;
|
|
63
|
+
'field.loopRepeatText': string;
|
|
64
|
+
'field.loopRepeatTextHint': string;
|
|
63
65
|
'field.loopToolRepeat': string;
|
|
64
66
|
'field.loopToolRepeatHint': string;
|
|
65
67
|
'field.loopText': string;
|
|
@@ -27,6 +27,7 @@ export interface AutoContinueSettingsCardState extends CardShell {
|
|
|
27
27
|
loopShortChars: CardFieldState;
|
|
28
28
|
loopWindowMs: CardFieldState;
|
|
29
29
|
loopShortCount: CardFieldState;
|
|
30
|
+
loopRepeatText: CardFieldState;
|
|
30
31
|
loopToolRepeat: CardFieldState;
|
|
31
32
|
loopText: CardFieldState;
|
|
32
33
|
}
|
package/lib/types/index.d.ts
CHANGED
|
@@ -55,6 +55,8 @@ export declare const AutoContinueSchema: z<Schemastery.ObjectS<{
|
|
|
55
55
|
loopWindowMs: z<number, number>;
|
|
56
56
|
/** Consecutive short sentences trip the loop guard. */
|
|
57
57
|
loopShortCount: z<number, number>;
|
|
58
|
+
/** Consecutive identical short sentences trip the loop guard (strongest spinning signal). */
|
|
59
|
+
loopRepeatText: z<number, number>;
|
|
58
60
|
/** Consecutive identical tool calls with identical arguments AND results trip the loop guard. */
|
|
59
61
|
loopToolRepeat: z<number, number>;
|
|
60
62
|
/** Text sent after the loop guard cancels and restarts a turn (supports {tool}). */
|
|
@@ -106,6 +108,8 @@ export declare const AutoContinueSchema: z<Schemastery.ObjectS<{
|
|
|
106
108
|
loopWindowMs: z<number, number>;
|
|
107
109
|
/** Consecutive short sentences trip the loop guard. */
|
|
108
110
|
loopShortCount: z<number, number>;
|
|
111
|
+
/** Consecutive identical short sentences trip the loop guard (strongest spinning signal). */
|
|
112
|
+
loopRepeatText: z<number, number>;
|
|
109
113
|
/** Consecutive identical tool calls with identical arguments AND results trip the loop guard. */
|
|
110
114
|
loopToolRepeat: z<number, number>;
|
|
111
115
|
/** Text sent after the loop guard cancels and restarts a turn (supports {tool}). */
|
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 \"继续\" (continue) when a request is interrupted by network errors or other non-human causes",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.4",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
package/src/client/engine.ts
CHANGED
|
@@ -70,6 +70,8 @@ export interface AutoContinueSettings {
|
|
|
70
70
|
loopShortCount?: number;
|
|
71
71
|
/** Consecutive identical tool calls with identical arguments AND identical results trip the loop guard. */
|
|
72
72
|
loopToolRepeat?: number;
|
|
73
|
+
/** Consecutive identical short sentences trip the loop guard (strongest spinning signal). */
|
|
74
|
+
loopRepeatText?: number;
|
|
73
75
|
/** Text sent after the loop guard cancels and restarts a turn (supports {tool}). */
|
|
74
76
|
loopText?: string;
|
|
75
77
|
}
|
|
@@ -102,6 +104,7 @@ export const DEFAULT_CONFIG: AutoContinueConfig = {
|
|
|
102
104
|
loopShortChars: 40,
|
|
103
105
|
loopWindowMs: 30000,
|
|
104
106
|
loopShortCount: 12,
|
|
107
|
+
loopRepeatText: 4,
|
|
105
108
|
loopToolRepeat: 5,
|
|
106
109
|
loopText: '(检测到你可能陷入循环, 请停止重复刚才的动作, 换一种方式继续)',
|
|
107
110
|
};
|
|
@@ -157,6 +160,7 @@ export function resolveConfig(section: AutoContinueSettings | undefined): AutoCo
|
|
|
157
160
|
loopShortChars: Math.max(1, numberOr(value.loopShortChars, DEFAULT_CONFIG.loopShortChars)),
|
|
158
161
|
loopWindowMs: Math.max(1000, numberOr(value.loopWindowMs, DEFAULT_CONFIG.loopWindowMs)),
|
|
159
162
|
loopShortCount: Math.max(2, numberOr(value.loopShortCount, DEFAULT_CONFIG.loopShortCount)),
|
|
163
|
+
loopRepeatText: Math.max(2, numberOr(value.loopRepeatText, DEFAULT_CONFIG.loopRepeatText)),
|
|
160
164
|
loopToolRepeat: Math.max(2, numberOr(value.loopToolRepeat, DEFAULT_CONFIG.loopToolRepeat)),
|
|
161
165
|
loopText:
|
|
162
166
|
typeof value.loopText === 'string' && value.loopText.trim() !== ''
|
|
@@ -386,38 +390,118 @@ function clientTimeZone(): string | undefined {
|
|
|
386
390
|
const lockPrefix = 'dsh-auto-continue:';
|
|
387
391
|
const lockKey = (sessionId: SessionId) => `${lockPrefix}lock:${sessionId}`;
|
|
388
392
|
const stampKey = (sessionId: SessionId) => `${lockPrefix}last:${sessionId}`;
|
|
393
|
+
const countKey = (sessionId: SessionId) => `${lockPrefix}count:${sessionId}`;
|
|
389
394
|
|
|
390
|
-
/**
|
|
391
|
-
|
|
395
|
+
/**
|
|
396
|
+
* 上次自动发送的完整记录(跨标签页): 时间戳 + 文本。
|
|
397
|
+
* 回显识别用它而不是单 runner 内存, 所以任何标签页发出的消息都能被所有标签页认出。
|
|
398
|
+
*/
|
|
399
|
+
function readLastSent(sessionId: SessionId): { at: number; text: string } {
|
|
392
400
|
try {
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
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: '' }; // 兼容旧格式(纯时间戳)
|
|
396
408
|
} catch {
|
|
397
|
-
return
|
|
409
|
+
return { at: 0, text: '' };
|
|
398
410
|
}
|
|
399
411
|
}
|
|
400
412
|
|
|
401
|
-
|
|
413
|
+
/** 读「上次自动发送」时间戳(跨标签页冷却)。 */
|
|
414
|
+
function readLastSend(sessionId: SessionId): number {
|
|
415
|
+
return readLastSent(sessionId).at;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function writeLastSend(sessionId: SessionId, at: number, text: string): void {
|
|
402
419
|
try {
|
|
403
|
-
localStorage.
|
|
420
|
+
localStorage.setItem(stampKey(sessionId), JSON.stringify({ at, text }));
|
|
404
421
|
} catch {
|
|
405
422
|
/* ignore */
|
|
406
423
|
}
|
|
407
424
|
}
|
|
408
425
|
|
|
409
|
-
|
|
410
|
-
|
|
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 {
|
|
411
437
|
try {
|
|
412
|
-
|
|
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
|
+
}
|
|
413
446
|
} catch {
|
|
414
|
-
|
|
447
|
+
/* ignore */
|
|
415
448
|
}
|
|
449
|
+
return { at: 0, count: 0 };
|
|
416
450
|
}
|
|
417
451
|
|
|
418
|
-
function
|
|
452
|
+
function bumpSendCount(sessionId: SessionId): void {
|
|
419
453
|
try {
|
|
420
|
-
|
|
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));
|
|
421
505
|
} catch {
|
|
422
506
|
/* ignore */
|
|
423
507
|
}
|
|
@@ -602,6 +686,10 @@ interface SessionState {
|
|
|
602
686
|
shortRun: number;
|
|
603
687
|
/** 最后一条短句的时间(时间窗判定用)。 */
|
|
604
688
|
lastShortAt: number;
|
|
689
|
+
/** 最后一条模型消息的文本(相同文本重复判定用)。 */
|
|
690
|
+
lastAssistantText: string;
|
|
691
|
+
/** 连续相同文本消息数(最强空转信号, 不限长度)。 */
|
|
692
|
+
sameTextRun: number;
|
|
605
693
|
/**
|
|
606
694
|
* 工具重复信号(loop guard 信号 2: 死循环)。
|
|
607
695
|
* 只有「同工具 + 同参数 + 同结果」的连续调用才累计; 参数或结果有变化视为有进展, 计数重置。
|
|
@@ -620,6 +708,8 @@ interface SessionState {
|
|
|
620
708
|
| undefined;
|
|
621
709
|
/** 本回合已触发过 loop guard(防重复打断)。 */
|
|
622
710
|
loopFired: boolean;
|
|
711
|
+
/** loop 重启的延迟定时器(冷却结束后再 schedule)。 */
|
|
712
|
+
loopRetryTimer: number | undefined;
|
|
623
713
|
/** 我们主动 cancel 过本回合(区分用户停止)。 */
|
|
624
714
|
loopCancelled: boolean;
|
|
625
715
|
}
|
|
@@ -641,26 +731,37 @@ const freshState = (): SessionState => ({
|
|
|
641
731
|
pendingRecoveryAt: 0,
|
|
642
732
|
shortRun: 0,
|
|
643
733
|
lastShortAt: 0,
|
|
734
|
+
lastAssistantText: '',
|
|
735
|
+
sameTextRun: 0,
|
|
644
736
|
toolRun: undefined,
|
|
645
737
|
loopFired: false,
|
|
646
738
|
loopCancelled: false,
|
|
739
|
+
loopRetryTimer: undefined,
|
|
647
740
|
});
|
|
648
741
|
|
|
649
742
|
/** 自动发送后, 在该窗口内出现的回合结束才计入恢复统计。 */
|
|
650
743
|
const RECOVERY_WINDOW_MS = 10 * 60 * 1000;
|
|
651
744
|
|
|
652
|
-
/**
|
|
653
|
-
|
|
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 {
|
|
654
754
|
if (event.type !== 'user/message') return false;
|
|
655
755
|
const message = event.data;
|
|
656
756
|
if (message.source.kind !== 'user') return false;
|
|
657
|
-
|
|
658
|
-
if (
|
|
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;
|
|
659
760
|
const text = message.content
|
|
660
761
|
.filter((part): part is { type: 'text'; text: string } => part.type === 'text')
|
|
661
762
|
.map((part) => part.text)
|
|
662
763
|
.join('');
|
|
663
|
-
return text ===
|
|
764
|
+
return text === last.text;
|
|
664
765
|
}
|
|
665
766
|
|
|
666
767
|
/** SSE 帧外壳: `{ rpcId, payload }`。 */
|
|
@@ -744,6 +845,7 @@ export class AutoContinueRunner {
|
|
|
744
845
|
this.hostAbort.abort();
|
|
745
846
|
for (const state of this.states.values()) {
|
|
746
847
|
if (state.pendingTimer !== undefined) clearTimeout(state.pendingTimer);
|
|
848
|
+
if (state.loopRetryTimer !== undefined) clearTimeout(state.loopRetryTimer);
|
|
747
849
|
}
|
|
748
850
|
this.states.clear();
|
|
749
851
|
}
|
|
@@ -860,7 +962,17 @@ export class AutoContinueRunner {
|
|
|
860
962
|
): void {
|
|
861
963
|
if (!this.getConfig().loopGuard) return;
|
|
862
964
|
const text = this.assistantText(event);
|
|
863
|
-
|
|
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) {
|
|
864
976
|
const now = Date.now();
|
|
865
977
|
if (now - state.lastShortAt > this.getConfig().loopWindowMs) {
|
|
866
978
|
state.shortRun = 0; // 超过时间窗: 上一次短句太久远, 不算连续
|
|
@@ -880,7 +992,10 @@ export class AutoContinueRunner {
|
|
|
880
992
|
if (state.loopFired) return;
|
|
881
993
|
if (!state.running) return; // 只干预运行中的回合
|
|
882
994
|
const config = this.getConfig();
|
|
883
|
-
if (state.
|
|
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) {
|
|
884
999
|
this.log(`检测到空转循环 ${sessionId}: 连续 ${state.shortRun} 条短句且无工具调用`);
|
|
885
1000
|
void this.interruptLoop(sessionId, state);
|
|
886
1001
|
} else if (state.toolRun !== undefined && state.toolRun.count >= config.loopToolRepeat) {
|
|
@@ -928,9 +1043,15 @@ export class AutoContinueRunner {
|
|
|
928
1043
|
// loop guard 状态按回合重置
|
|
929
1044
|
state.shortRun = 0;
|
|
930
1045
|
state.lastShortAt = 0;
|
|
1046
|
+
state.lastAssistantText = '';
|
|
1047
|
+
state.sameTextRun = 0;
|
|
931
1048
|
state.toolRun = undefined;
|
|
932
1049
|
state.loopFired = false;
|
|
933
1050
|
state.loopCancelled = false;
|
|
1051
|
+
if (state.loopRetryTimer !== undefined) {
|
|
1052
|
+
clearTimeout(state.loopRetryTimer);
|
|
1053
|
+
state.loopRetryTimer = undefined;
|
|
1054
|
+
}
|
|
934
1055
|
this.cancelPending(sessionId, '宿主自行开启新回合');
|
|
935
1056
|
break;
|
|
936
1057
|
case 'turn/end': {
|
|
@@ -941,24 +1062,39 @@ export class AutoContinueRunner {
|
|
|
941
1062
|
// 成功回合: 恢复健康状态, 并确认上一次自动发送的效果
|
|
942
1063
|
state.consecutive = 0;
|
|
943
1064
|
state.lastFailure = undefined;
|
|
1065
|
+
clearSendCount(sessionId);
|
|
944
1066
|
this.noteRecovery(sessionId, 'completed');
|
|
945
1067
|
} else if (reason.kind === 'aborted') {
|
|
946
1068
|
if (state.loopCancelled) {
|
|
947
1069
|
// 我们自己的 loop guard 打断: 视为可恢复中断, 用循环提示文本重启回合。
|
|
948
|
-
//
|
|
1070
|
+
// 不清 consecutive / lastAttemptAt: 冷却与连续上限在 loop 路径同样生效,
|
|
1071
|
+
// 防止无限打断重发(issue #13); 打断本身受冷却约束, 重启也要等冷却。
|
|
949
1072
|
state.loopCancelled = false;
|
|
950
1073
|
state.loopFired = false;
|
|
951
|
-
state.consecutive = 0;
|
|
952
1074
|
state.pendingRecoveryAt = 0;
|
|
953
1075
|
state.shortRun = 0;
|
|
954
1076
|
state.lastShortAt = 0;
|
|
1077
|
+
state.lastAssistantText = '';
|
|
1078
|
+
state.sameTextRun = 0;
|
|
955
1079
|
state.toolRun = undefined;
|
|
956
|
-
|
|
957
|
-
this.
|
|
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
|
+
}
|
|
958
1093
|
} else {
|
|
959
1094
|
// 用户主动停止: 不自动继续, 视为用户介入
|
|
960
1095
|
state.consecutive = 0;
|
|
961
1096
|
state.pendingRecoveryAt = 0;
|
|
1097
|
+
clearSendCount(sessionId);
|
|
962
1098
|
}
|
|
963
1099
|
} else if (reason.kind === 'blocked') {
|
|
964
1100
|
// 策略拒绝: 不自动继续
|
|
@@ -988,10 +1124,11 @@ export class AutoContinueRunner {
|
|
|
988
1124
|
break;
|
|
989
1125
|
}
|
|
990
1126
|
case 'user/message':
|
|
991
|
-
if (isOurEcho(state, event)) break; // 我们自己的回显
|
|
1127
|
+
if (isOurEcho(state, sessionId, event)) break; // 我们自己的回显(跨标签页识别)
|
|
992
1128
|
if (event.data.source.kind === 'user') {
|
|
993
|
-
//
|
|
1129
|
+
// 用户手动介入: 清零上限与跨标签页发送计数
|
|
994
1130
|
state.consecutive = 0;
|
|
1131
|
+
clearSendCount(sessionId);
|
|
995
1132
|
this.cancelPending(sessionId, '用户手动发送消息');
|
|
996
1133
|
}
|
|
997
1134
|
break;
|
|
@@ -1185,13 +1322,10 @@ export class AutoContinueRunner {
|
|
|
1185
1322
|
this.log(`跳过 ${sessionId}: 已有排队消息`);
|
|
1186
1323
|
return;
|
|
1187
1324
|
}
|
|
1188
|
-
//
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
}
|
|
1193
|
-
if (!claimSend(sessionId)) {
|
|
1194
|
-
this.log(`跳过 ${sessionId}: 其他标签页正在发送`);
|
|
1325
|
+
// 跨标签页持久化发送计数: 窗口内达到 maxConsecutive 即硬抑制,
|
|
1326
|
+
// 不依赖回显识别与单 runner 内存(issue #13 的多标签页刷屏防线)。
|
|
1327
|
+
if (!force && readSendCount(sessionId).count >= config.maxConsecutive) {
|
|
1328
|
+
this.log(`跳过 ${sessionId}: 发送计数已达上限 ${config.maxConsecutive}, 等待用户介入或成功回合`);
|
|
1195
1329
|
return;
|
|
1196
1330
|
}
|
|
1197
1331
|
// 模板填充: continueText 可含 {code}/{message}/{status}/{tool}/{turn}/{errorCount}/{sessionTitle}/{elapsed} 占位符
|
|
@@ -1210,51 +1344,68 @@ export class AutoContinueRunner {
|
|
|
1210
1344
|
}
|
|
1211
1345
|
const text = this.buildContinueText(config, state, template, sessionTitle);
|
|
1212
1346
|
const zone = clientTimeZone();
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
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
|
+
state.lastAttemptAt = Date.now(); // 先记账: 无论成败, 本次尝试都进入冷却
|
|
1365
|
+
try {
|
|
1366
|
+
const response = await this.api.sessions.prompt({
|
|
1367
|
+
sessionId,
|
|
1368
|
+
mode: 'queue',
|
|
1369
|
+
content: [{ type: 'text', text }],
|
|
1370
|
+
...(zone === undefined ? {} : { clientTimeZone: zone }),
|
|
1371
|
+
});
|
|
1372
|
+
if (response.result.ok) {
|
|
1373
|
+
const now = Date.now();
|
|
1374
|
+
state.consecutive += 1;
|
|
1375
|
+
state.lastAutoAt = now;
|
|
1376
|
+
state.lastSentText = text;
|
|
1377
|
+
state.pendingRecoveryAt = now; // 等待窗口内的下一个回合结束来判定恢复结果
|
|
1378
|
+
writeLastSend(sessionId, now, text); // 记录文本: 跨标签页回显识别
|
|
1379
|
+
bumpSendCount(sessionId); // 跨标签页持久化计数(硬上限)
|
|
1380
|
+
bumpStat({ sent: 1, ...(state.lastFailure !== undefined ? { code: state.lastFailure.code } : {}) });
|
|
1381
|
+
this.log(`已自动发送「${text}」到 ${sessionId}(${reason}), 第 ${state.consecutive} 次连续`);
|
|
1240
1382
|
if (config.notify) {
|
|
1241
1383
|
notify(
|
|
1242
|
-
'dsh-auto-continue:
|
|
1243
|
-
`${sessionId}:
|
|
1384
|
+
'dsh-auto-continue: 已自动继续',
|
|
1385
|
+
`${sessionId}: 已发送「${text}」(第 ${state.consecutive} 次连续)`,
|
|
1244
1386
|
this.notifyOptions(sessionId),
|
|
1245
1387
|
);
|
|
1246
1388
|
}
|
|
1389
|
+
if (state.consecutive >= config.maxConsecutive) {
|
|
1390
|
+
bumpStat({ gaveUp: 1 });
|
|
1391
|
+
this.log(`达到连续上限 ${config.maxConsecutive} 次, 停止自动继续 ${sessionId}`);
|
|
1392
|
+
if (config.notify) {
|
|
1393
|
+
notify(
|
|
1394
|
+
'dsh-auto-continue: 已停止自动继续',
|
|
1395
|
+
`${sessionId}: 连续失败 ${state.consecutive} 次, 需要人工介入`,
|
|
1396
|
+
this.notifyOptions(sessionId),
|
|
1397
|
+
);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
} else {
|
|
1401
|
+
this.log(
|
|
1402
|
+
`发送失败 ${sessionId}: ${response.result.error.code} ${response.result.error.message}`,
|
|
1403
|
+
);
|
|
1247
1404
|
}
|
|
1248
|
-
}
|
|
1249
|
-
this.log(
|
|
1250
|
-
`发送失败 ${sessionId}: ${response.result.error.code} ${response.result.error.message}`,
|
|
1251
|
-
);
|
|
1405
|
+
} catch (error) {
|
|
1406
|
+
this.log(`发送异常 ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1252
1407
|
}
|
|
1253
|
-
}
|
|
1254
|
-
this.log(`发送异常 ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1255
|
-
} finally {
|
|
1256
|
-
releaseSend(sessionId);
|
|
1257
|
-
}
|
|
1408
|
+
});
|
|
1258
1409
|
}
|
|
1259
1410
|
|
|
1260
1411
|
/**
|
package/src/client/locales.ts
CHANGED
|
@@ -61,6 +61,8 @@ export const zh = {
|
|
|
61
61
|
'field.loopWindowMsHint': '连续短句必须落在这个时间窗内; 正常思考的短文本散布在长时间里不会被误判。',
|
|
62
62
|
'field.loopShortCount': '连续短句阈值',
|
|
63
63
|
'field.loopShortCountHint': '时间窗内连续多少条短句且期间无工具调用时判定空转循环。',
|
|
64
|
+
'field.loopRepeatText': '相同消息重复次数',
|
|
65
|
+
'field.loopRepeatTextHint': '连续输出多少条完全相同的消息时判定空转(最强信号, 不限长度, 如模型反复说同一句话)。',
|
|
64
66
|
'field.loopToolRepeat': '同工具重复次数',
|
|
65
67
|
'field.loopToolRepeatHint': '同工具+同参数+同结果的连续调用多少次时判定死循环; 参数或结果有变化视为有进展。',
|
|
66
68
|
'field.loopText': '循环提示文本',
|
|
@@ -149,6 +151,8 @@ export const en: Record<SettingsCardKey, string> = {
|
|
|
149
151
|
'field.loopWindowMsHint': 'Consecutive short sentences must land inside this window; normal thinking spread over time is not misjudged.',
|
|
150
152
|
'field.loopShortCount': 'Short-sentence threshold',
|
|
151
153
|
'field.loopShortCountHint': 'How many consecutive short sentences inside the window, with no tool call, trip the loop guard.',
|
|
154
|
+
'field.loopRepeatText': 'Identical message count',
|
|
155
|
+
'field.loopRepeatTextHint': 'How many consecutive identical messages trip the guard (strongest signal, any length, e.g. the model repeating the same line).',
|
|
152
156
|
'field.loopToolRepeat': 'Same-tool repeat count',
|
|
153
157
|
'field.loopToolRepeatHint': 'How many consecutive calls of the same tool with identical arguments and results trip the loop guard; a changed argument or result counts as progress.',
|
|
154
158
|
'field.loopText': 'Loop text',
|
|
@@ -59,6 +59,7 @@ export interface AutoContinueSettingsCardState extends CardShell {
|
|
|
59
59
|
loopShortChars: CardFieldState;
|
|
60
60
|
loopWindowMs: CardFieldState;
|
|
61
61
|
loopShortCount: CardFieldState;
|
|
62
|
+
loopRepeatText: CardFieldState;
|
|
62
63
|
loopToolRepeat: CardFieldState;
|
|
63
64
|
loopText: CardFieldState;
|
|
64
65
|
}
|
|
@@ -104,6 +105,7 @@ export class AutoContinueSettingsCardController {
|
|
|
104
105
|
numberField('loopShortChars', 1),
|
|
105
106
|
numberField('loopWindowMs', 1000),
|
|
106
107
|
numberField('loopShortCount', 2),
|
|
108
|
+
numberField('loopRepeatText', 2),
|
|
107
109
|
numberField('loopToolRepeat', 2),
|
|
108
110
|
textField('loopText'),
|
|
109
111
|
]);
|
|
@@ -136,6 +138,7 @@ export class AutoContinueSettingsCardController {
|
|
|
136
138
|
loopShortChars: this.form.field('loopShortChars'),
|
|
137
139
|
loopWindowMs: this.form.field('loopWindowMs'),
|
|
138
140
|
loopShortCount: this.form.field('loopShortCount'),
|
|
141
|
+
loopRepeatText: this.form.field('loopRepeatText'),
|
|
139
142
|
loopToolRepeat: this.form.field('loopToolRepeat'),
|
|
140
143
|
loopText: this.form.field('loopText'),
|
|
141
144
|
};
|
|
@@ -650,6 +653,16 @@ export function AutoContinueSettingsCard(props: AutoContinueSettingsCardProps) {
|
|
|
650
653
|
onEdit={(text) => props.edit('loopToolRepeat', text)}
|
|
651
654
|
onReset={() => props.resetField('loopToolRepeat')}
|
|
652
655
|
/>
|
|
656
|
+
<ValueField
|
|
657
|
+
id="auto-continue-loop-repeat-text"
|
|
658
|
+
label={t('field.loopRepeatText')}
|
|
659
|
+
hint={t('field.loopRepeatTextHint')}
|
|
660
|
+
numeric
|
|
661
|
+
{...shared}
|
|
662
|
+
{...state.loopRepeatText}
|
|
663
|
+
onEdit={(text) => props.edit('loopRepeatText', text)}
|
|
664
|
+
onReset={() => props.resetField('loopRepeatText')}
|
|
665
|
+
/>
|
|
653
666
|
<ValueField
|
|
654
667
|
id="auto-continue-loop-text"
|
|
655
668
|
label={t('field.loopText')}
|
package/src/index.ts
CHANGED
|
@@ -64,6 +64,8 @@ export const AutoContinueSchema = z.object({
|
|
|
64
64
|
loopWindowMs: z.natural().min(1000).default(30000),
|
|
65
65
|
/** Consecutive short sentences trip the loop guard. */
|
|
66
66
|
loopShortCount: z.natural().min(2).default(12),
|
|
67
|
+
/** Consecutive identical short sentences trip the loop guard (strongest spinning signal). */
|
|
68
|
+
loopRepeatText: z.natural().min(2).default(4),
|
|
67
69
|
/** Consecutive identical tool calls with identical arguments AND results trip the loop guard. */
|
|
68
70
|
loopToolRepeat: z.natural().min(2).default(5),
|
|
69
71
|
/** Text sent after the loop guard cancels and restarts a turn (supports {tool}). */
|