dsh-client-auto-continue 0.6.3 → 0.7.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 +13 -2
- package/README.zh.md +13 -2
- package/lib/client.js +210 -12
- package/lib/client.js.map +2 -2
- package/lib/index.js +11 -1
- package/lib/types/client/engine.d.ts +27 -0
- package/lib/types/client/locales.d.ts +11 -0
- package/lib/types/client/settings-card.d.ts +5 -0
- package/lib/types/index.d.ts +20 -0
- package/package.json +1 -1
- package/src/client/engine.ts +154 -7
- package/src/client/locales.ts +22 -0
- package/src/client/settings-card.tsx +65 -1
- package/src/index.ts +12 -0
package/lib/index.js
CHANGED
|
@@ -40,7 +40,17 @@ var AutoContinueSchema = z.object({
|
|
|
40
40
|
/** Show browser notifications for auto-continue events. */
|
|
41
41
|
notify: z.boolean().default(false),
|
|
42
42
|
/** Globally pause auto-continue: no live or scan send. */
|
|
43
|
-
paused: z.boolean().default(false)
|
|
43
|
+
paused: z.boolean().default(false),
|
|
44
|
+
/** Loop guard: detect a running turn spinning in place and restart it. */
|
|
45
|
+
loopGuard: z.boolean().default(true),
|
|
46
|
+
/** A model message shorter than this many chars counts as a short sentence (loop signal). */
|
|
47
|
+
loopShortChars: z.natural().min(1).default(40),
|
|
48
|
+
/** Consecutive short sentences with no tool call in between trip the loop guard. */
|
|
49
|
+
loopShortCount: z.natural().min(2).default(8),
|
|
50
|
+
/** Consecutive identical tool calls trip the loop guard. */
|
|
51
|
+
loopToolRepeat: z.natural().min(2).default(4),
|
|
52
|
+
/** Text sent after the loop guard cancels and restarts a turn (supports {tool}). */
|
|
53
|
+
loopText: z.string().default("(检测到你可能陷入循环, 请停止重复刚才的动作, 换一种方式继续)")
|
|
44
54
|
});
|
|
45
55
|
function apply(ctx) {
|
|
46
56
|
ctx.inject(["settings"], (settingsCtx) => {
|
|
@@ -51,6 +51,16 @@ export interface AutoContinueSettings {
|
|
|
51
51
|
notify?: boolean;
|
|
52
52
|
/** Globally pause auto-continue: no live or scan send, queued pending sends cancelled. */
|
|
53
53
|
paused?: boolean;
|
|
54
|
+
/** Loop guard: detect a running turn spinning in place (short talk without tools, or the same tool repeating) and restart it. */
|
|
55
|
+
loopGuard?: boolean;
|
|
56
|
+
/** A model message shorter than this many chars counts as a "short sentence" (loop signal). */
|
|
57
|
+
loopShortChars?: number;
|
|
58
|
+
/** Consecutive short sentences with no tool call in between trip the loop guard. */
|
|
59
|
+
loopShortCount?: number;
|
|
60
|
+
/** Consecutive identical tool calls trip the loop guard. */
|
|
61
|
+
loopToolRepeat?: number;
|
|
62
|
+
/** Text sent after the loop guard cancels and restarts a turn (supports {tool}). */
|
|
63
|
+
loopText?: string;
|
|
54
64
|
}
|
|
55
65
|
/** Fully resolved configuration (built-in defaults + user overrides). */
|
|
56
66
|
export type AutoContinueConfig = Required<AutoContinueSettings>;
|
|
@@ -144,6 +154,8 @@ export interface DayStats {
|
|
|
144
154
|
failed: number;
|
|
145
155
|
/** 达到连续上限而停止的次数(按停止事件计)。 */
|
|
146
156
|
gaveUp: number;
|
|
157
|
+
/** loop guard 打断并重启回合的次数。 */
|
|
158
|
+
looped: number;
|
|
147
159
|
/** 按错误码计数的失败分布。 */
|
|
148
160
|
byCode: Record<string, number>;
|
|
149
161
|
}
|
|
@@ -171,6 +183,21 @@ export declare class AutoContinueRunner {
|
|
|
171
183
|
private runMux;
|
|
172
184
|
private runHost;
|
|
173
185
|
private onMuxFrame;
|
|
186
|
+
/** 从 assistant/message 事件提取纯文本。 */
|
|
187
|
+
private assistantText;
|
|
188
|
+
/**
|
|
189
|
+
* loop guard 信号 1(空转): 连续短句且期间无工具调用。
|
|
190
|
+
* 短句 = 模型消息文本短于 loopShortChars; 长句或工具调用都会重置计数。
|
|
191
|
+
*/
|
|
192
|
+
private onAssistantMessage;
|
|
193
|
+
/** 两个循环信号的公共检查; 命中且本回合未打断过则打断。 */
|
|
194
|
+
private checkLoop;
|
|
195
|
+
/**
|
|
196
|
+
* 打断运行中的回合: cancel(带来源标记)+ 进冷却。
|
|
197
|
+
* 随后的 turn/end aborted 会因 loopCancelled 走「可恢复中断」路径,
|
|
198
|
+
* 用 loopText 重启回合——不会与用户手动停止混淆。
|
|
199
|
+
*/
|
|
200
|
+
private interruptLoop;
|
|
174
201
|
private onSessionEvent;
|
|
175
202
|
private onHostFrame;
|
|
176
203
|
/** 回合失败入口: 先做错误分类, 永久性失败跳过并通知, 临时性失败走正常调度。 */
|
|
@@ -51,6 +51,17 @@ export declare const zh: {
|
|
|
51
51
|
'stats.recovered': string;
|
|
52
52
|
'stats.failed': string;
|
|
53
53
|
'stats.gaveUp': string;
|
|
54
|
+
'stats.looped': string;
|
|
55
|
+
'field.loopGuard': string;
|
|
56
|
+
'field.loopGuardHint': string;
|
|
57
|
+
'field.loopShortChars': string;
|
|
58
|
+
'field.loopShortCharsHint': string;
|
|
59
|
+
'field.loopShortCount': string;
|
|
60
|
+
'field.loopShortCountHint': string;
|
|
61
|
+
'field.loopToolRepeat': string;
|
|
62
|
+
'field.loopToolRepeatHint': string;
|
|
63
|
+
'field.loopText': string;
|
|
64
|
+
'field.loopTextHint': string;
|
|
54
65
|
'stats.byCode': string;
|
|
55
66
|
'stats.empty': string;
|
|
56
67
|
'stats.reset': string;
|
|
@@ -23,6 +23,11 @@ export interface AutoContinueSettingsCardState extends CardShell {
|
|
|
23
23
|
backoffFactor: CardFieldState;
|
|
24
24
|
backoffMaxMs: CardFieldState;
|
|
25
25
|
notify: CardFieldState;
|
|
26
|
+
loopGuard: CardFieldState;
|
|
27
|
+
loopShortChars: CardFieldState;
|
|
28
|
+
loopShortCount: CardFieldState;
|
|
29
|
+
loopToolRepeat: CardFieldState;
|
|
30
|
+
loopText: CardFieldState;
|
|
26
31
|
}
|
|
27
32
|
/** The registration-side face the card's slot entry injects. */
|
|
28
33
|
export interface AutoContinueSettingsCardFace extends CardActions {
|
package/lib/types/index.d.ts
CHANGED
|
@@ -47,6 +47,16 @@ export declare const AutoContinueSchema: z<Schemastery.ObjectS<{
|
|
|
47
47
|
notify: z<boolean, boolean>;
|
|
48
48
|
/** Globally pause auto-continue: no live or scan send. */
|
|
49
49
|
paused: z<boolean, boolean>;
|
|
50
|
+
/** Loop guard: detect a running turn spinning in place and restart it. */
|
|
51
|
+
loopGuard: z<boolean, boolean>;
|
|
52
|
+
/** A model message shorter than this many chars counts as a short sentence (loop signal). */
|
|
53
|
+
loopShortChars: z<number, number>;
|
|
54
|
+
/** Consecutive short sentences with no tool call in between trip the loop guard. */
|
|
55
|
+
loopShortCount: z<number, number>;
|
|
56
|
+
/** Consecutive identical tool calls trip the loop guard. */
|
|
57
|
+
loopToolRepeat: z<number, number>;
|
|
58
|
+
/** Text sent after the loop guard cancels and restarts a turn (supports {tool}). */
|
|
59
|
+
loopText: z<string, string>;
|
|
50
60
|
}>, Schemastery.ObjectT<{
|
|
51
61
|
/** Text automatically sent after an interruption. */
|
|
52
62
|
continueText: z<string, string>;
|
|
@@ -86,6 +96,16 @@ export declare const AutoContinueSchema: z<Schemastery.ObjectS<{
|
|
|
86
96
|
notify: z<boolean, boolean>;
|
|
87
97
|
/** Globally pause auto-continue: no live or scan send. */
|
|
88
98
|
paused: z<boolean, boolean>;
|
|
99
|
+
/** Loop guard: detect a running turn spinning in place and restart it. */
|
|
100
|
+
loopGuard: z<boolean, boolean>;
|
|
101
|
+
/** A model message shorter than this many chars counts as a short sentence (loop signal). */
|
|
102
|
+
loopShortChars: z<number, number>;
|
|
103
|
+
/** Consecutive short sentences with no tool call in between trip the loop guard. */
|
|
104
|
+
loopShortCount: z<number, number>;
|
|
105
|
+
/** Consecutive identical tool calls trip the loop guard. */
|
|
106
|
+
loopToolRepeat: z<number, number>;
|
|
107
|
+
/** Text sent after the loop guard cancels and restarts a turn (supports {tool}). */
|
|
108
|
+
loopText: z<string, string>;
|
|
89
109
|
}>>;
|
|
90
110
|
/**
|
|
91
111
|
* Plugin body: register the settings namespace when a settings provider is
|
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.
|
|
4
|
+
"version": "0.7.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
package/src/client/engine.ts
CHANGED
|
@@ -60,6 +60,16 @@ export interface AutoContinueSettings {
|
|
|
60
60
|
notify?: boolean;
|
|
61
61
|
/** Globally pause auto-continue: no live or scan send, queued pending sends cancelled. */
|
|
62
62
|
paused?: boolean;
|
|
63
|
+
/** Loop guard: detect a running turn spinning in place (short talk without tools, or the same tool repeating) and restart it. */
|
|
64
|
+
loopGuard?: boolean;
|
|
65
|
+
/** A model message shorter than this many chars counts as a "short sentence" (loop signal). */
|
|
66
|
+
loopShortChars?: number;
|
|
67
|
+
/** Consecutive short sentences with no tool call in between trip the loop guard. */
|
|
68
|
+
loopShortCount?: number;
|
|
69
|
+
/** Consecutive identical tool calls trip the loop guard. */
|
|
70
|
+
loopToolRepeat?: number;
|
|
71
|
+
/** Text sent after the loop guard cancels and restarts a turn (supports {tool}). */
|
|
72
|
+
loopText?: string;
|
|
63
73
|
}
|
|
64
74
|
|
|
65
75
|
/** Fully resolved configuration (built-in defaults + user overrides). */
|
|
@@ -86,6 +96,11 @@ export const DEFAULT_CONFIG: AutoContinueConfig = {
|
|
|
86
96
|
backoffMaxMs: 300000,
|
|
87
97
|
notify: false,
|
|
88
98
|
paused: false,
|
|
99
|
+
loopGuard: true,
|
|
100
|
+
loopShortChars: 40,
|
|
101
|
+
loopShortCount: 8,
|
|
102
|
+
loopToolRepeat: 4,
|
|
103
|
+
loopText: '(检测到你可能陷入循环, 请停止重复刚才的动作, 换一种方式继续)',
|
|
89
104
|
};
|
|
90
105
|
|
|
91
106
|
function numberOr(value: unknown, fallback: number): number {
|
|
@@ -135,6 +150,14 @@ export function resolveConfig(section: AutoContinueSettings | undefined): AutoCo
|
|
|
135
150
|
backoffMaxMs: numberOr(value.backoffMaxMs, DEFAULT_CONFIG.backoffMaxMs),
|
|
136
151
|
notify: booleanOr(value.notify, DEFAULT_CONFIG.notify),
|
|
137
152
|
paused: booleanOr(value.paused, DEFAULT_CONFIG.paused),
|
|
153
|
+
loopGuard: booleanOr(value.loopGuard, DEFAULT_CONFIG.loopGuard),
|
|
154
|
+
loopShortChars: Math.max(1, numberOr(value.loopShortChars, DEFAULT_CONFIG.loopShortChars)),
|
|
155
|
+
loopShortCount: Math.max(2, numberOr(value.loopShortCount, DEFAULT_CONFIG.loopShortCount)),
|
|
156
|
+
loopToolRepeat: Math.max(2, numberOr(value.loopToolRepeat, DEFAULT_CONFIG.loopToolRepeat)),
|
|
157
|
+
loopText:
|
|
158
|
+
typeof value.loopText === 'string' && value.loopText.trim() !== ''
|
|
159
|
+
? value.loopText
|
|
160
|
+
: DEFAULT_CONFIG.loopText,
|
|
138
161
|
};
|
|
139
162
|
}
|
|
140
163
|
|
|
@@ -462,6 +485,8 @@ export interface DayStats {
|
|
|
462
485
|
failed: number;
|
|
463
486
|
/** 达到连续上限而停止的次数(按停止事件计)。 */
|
|
464
487
|
gaveUp: number;
|
|
488
|
+
/** loop guard 打断并重启回合的次数。 */
|
|
489
|
+
looped: number;
|
|
465
490
|
/** 按错误码计数的失败分布。 */
|
|
466
491
|
byCode: Record<string, number>;
|
|
467
492
|
}
|
|
@@ -506,12 +531,13 @@ function bumpStat(delta: {
|
|
|
506
531
|
recovered?: number;
|
|
507
532
|
failed?: number;
|
|
508
533
|
gaveUp?: number;
|
|
534
|
+
looped?: number;
|
|
509
535
|
code?: string;
|
|
510
536
|
}): void {
|
|
511
537
|
const list = readStats();
|
|
512
538
|
let day = list.find((item) => item.date === todayKey());
|
|
513
539
|
if (day === undefined) {
|
|
514
|
-
day = { date: todayKey(), sent: 0, skipped: 0, recovered: 0, failed: 0, gaveUp: 0, byCode: {} };
|
|
540
|
+
day = { date: todayKey(), sent: 0, skipped: 0, recovered: 0, failed: 0, gaveUp: 0, looped: 0, byCode: {} };
|
|
515
541
|
list.unshift(day);
|
|
516
542
|
}
|
|
517
543
|
if (delta.sent !== undefined) day.sent += delta.sent;
|
|
@@ -519,6 +545,7 @@ function bumpStat(delta: {
|
|
|
519
545
|
if (delta.recovered !== undefined) day.recovered += delta.recovered;
|
|
520
546
|
if (delta.failed !== undefined) day.failed += delta.failed;
|
|
521
547
|
if (delta.gaveUp !== undefined) day.gaveUp += delta.gaveUp;
|
|
548
|
+
if (delta.looped !== undefined) day.looped += delta.looped;
|
|
522
549
|
if (delta.code !== undefined) day.byCode[delta.code] = (day.byCode[delta.code] ?? 0) + 1;
|
|
523
550
|
writeStats(list.slice(0, STATS_MAX_DAYS));
|
|
524
551
|
}
|
|
@@ -528,7 +555,7 @@ export function readTodayStats(): DayStats {
|
|
|
528
555
|
const today = todayKey();
|
|
529
556
|
const found = readStats().find((item) => item.date === today);
|
|
530
557
|
return (
|
|
531
|
-
found ?? { date: today, sent: 0, skipped: 0, recovered: 0, failed: 0, gaveUp: 0, byCode: {} }
|
|
558
|
+
found ?? { date: today, sent: 0, skipped: 0, recovered: 0, failed: 0, gaveUp: 0, looped: 0, byCode: {} }
|
|
532
559
|
);
|
|
533
560
|
}
|
|
534
561
|
|
|
@@ -567,6 +594,14 @@ interface SessionState {
|
|
|
567
594
|
lastTurn: number | undefined;
|
|
568
595
|
/** 我们最近一次自动发送的时间戳; 0 = 没有待确认的恢复。 */
|
|
569
596
|
pendingRecoveryAt: number;
|
|
597
|
+
/** 当前连续短句数(loop guard 信号 1: 空转)。 */
|
|
598
|
+
shortRun: number;
|
|
599
|
+
/** 当前连续相同工具调用(loop guard 信号 2: 死循环)。 */
|
|
600
|
+
toolRun: { name: string; count: number } | undefined;
|
|
601
|
+
/** 本回合已触发过 loop guard(防重复打断)。 */
|
|
602
|
+
loopFired: boolean;
|
|
603
|
+
/** 我们主动 cancel 过本回合(区分用户停止)。 */
|
|
604
|
+
loopCancelled: boolean;
|
|
570
605
|
}
|
|
571
606
|
|
|
572
607
|
const freshState = (): SessionState => ({
|
|
@@ -584,6 +619,10 @@ const freshState = (): SessionState => ({
|
|
|
584
619
|
lastToolResult: undefined,
|
|
585
620
|
lastTurn: undefined,
|
|
586
621
|
pendingRecoveryAt: 0,
|
|
622
|
+
shortRun: 0,
|
|
623
|
+
toolRun: undefined,
|
|
624
|
+
loopFired: false,
|
|
625
|
+
loopCancelled: false,
|
|
587
626
|
});
|
|
588
627
|
|
|
589
628
|
/** 自动发送后, 在该窗口内出现的回合结束才计入恢复统计。 */
|
|
@@ -730,12 +769,23 @@ export class AutoContinueRunner {
|
|
|
730
769
|
const state = this.state(frame.sessionId);
|
|
731
770
|
state.lastTool = name;
|
|
732
771
|
state.lastToolResult = 'pending'; // 已发起, 尚未见结果
|
|
772
|
+
// loop guard 信号 2: 连续相同工具调用(工具调用 = 有进展, 重置短句信号)
|
|
773
|
+
state.shortRun = 0;
|
|
774
|
+
if (state.toolRun?.name === name) {
|
|
775
|
+
state.toolRun.count += 1;
|
|
776
|
+
} else {
|
|
777
|
+
state.toolRun = { name, count: 1 };
|
|
778
|
+
}
|
|
779
|
+
this.checkLoop(frame.sessionId, state);
|
|
733
780
|
}
|
|
734
781
|
} else if (frame.event.type === 'tool/result') {
|
|
735
782
|
const state = this.state(frame.sessionId);
|
|
736
783
|
if (state.lastToolResult === 'pending') {
|
|
737
784
|
state.lastToolResult = toolResultFacts(frame.event.data);
|
|
738
785
|
}
|
|
786
|
+
} else if (frame.event.type === 'assistant/message') {
|
|
787
|
+
const state = this.state(frame.sessionId);
|
|
788
|
+
this.onAssistantMessage(frame.sessionId, state, frame.event);
|
|
739
789
|
}
|
|
740
790
|
this.onSessionEvent(frame.sessionId, frame.event);
|
|
741
791
|
break;
|
|
@@ -751,6 +801,77 @@ export class AutoContinueRunner {
|
|
|
751
801
|
}
|
|
752
802
|
}
|
|
753
803
|
|
|
804
|
+
/** 从 assistant/message 事件提取纯文本。 */
|
|
805
|
+
private assistantText(event: SessionEvent<'assistant/message'>): string {
|
|
806
|
+
const content = event.data.message.content;
|
|
807
|
+
if (!Array.isArray(content)) return '';
|
|
808
|
+
return content
|
|
809
|
+
.filter((part): part is { type: 'text'; text: string } => part.type === 'text')
|
|
810
|
+
.map((part) => part.text)
|
|
811
|
+
.join('');
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* loop guard 信号 1(空转): 连续短句且期间无工具调用。
|
|
816
|
+
* 短句 = 模型消息文本短于 loopShortChars; 长句或工具调用都会重置计数。
|
|
817
|
+
*/
|
|
818
|
+
private onAssistantMessage(
|
|
819
|
+
sessionId: SessionId,
|
|
820
|
+
state: SessionState,
|
|
821
|
+
event: SessionEvent<'assistant/message'>,
|
|
822
|
+
): void {
|
|
823
|
+
if (!this.getConfig().loopGuard) return;
|
|
824
|
+
const text = this.assistantText(event);
|
|
825
|
+
if (text.trim().length < this.getConfig().loopShortChars) {
|
|
826
|
+
state.shortRun += 1;
|
|
827
|
+
} else {
|
|
828
|
+
state.shortRun = 0; // 长句 = 有实际输出, 重置
|
|
829
|
+
}
|
|
830
|
+
this.checkLoop(sessionId, state);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** 两个循环信号的公共检查; 命中且本回合未打断过则打断。 */
|
|
834
|
+
private checkLoop(sessionId: SessionId, state: SessionState): void {
|
|
835
|
+
if (!this.getConfig().loopGuard) return;
|
|
836
|
+
if (state.loopFired) return;
|
|
837
|
+
if (!state.running) return; // 只干预运行中的回合
|
|
838
|
+
const config = this.getConfig();
|
|
839
|
+
if (state.shortRun >= config.loopShortCount) {
|
|
840
|
+
this.log(`检测到空转循环 ${sessionId}: 连续 ${state.shortRun} 条短句且无工具调用`);
|
|
841
|
+
void this.interruptLoop(sessionId, state);
|
|
842
|
+
} else if (state.toolRun !== undefined && state.toolRun.count >= config.loopToolRepeat) {
|
|
843
|
+
this.log(`检测到工具死循环 ${sessionId}: 「${state.toolRun.name}」连续 ${state.toolRun.count} 次`);
|
|
844
|
+
void this.interruptLoop(sessionId, state);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* 打断运行中的回合: cancel(带来源标记)+ 进冷却。
|
|
850
|
+
* 随后的 turn/end aborted 会因 loopCancelled 走「可恢复中断」路径,
|
|
851
|
+
* 用 loopText 重启回合——不会与用户手动停止混淆。
|
|
852
|
+
*/
|
|
853
|
+
private async interruptLoop(sessionId: SessionId, state: SessionState): Promise<void> {
|
|
854
|
+
if (state.loopFired) return;
|
|
855
|
+
// 打断本身受冷却约束: 距上次打断/发送太近时不再打断, 防止反复打断刷屏
|
|
856
|
+
if (Date.now() - state.lastAttemptAt < this.cooldownFor(state)) {
|
|
857
|
+
this.log(`跳过循环打断 ${sessionId}: 处于冷却期`);
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
state.loopFired = true;
|
|
861
|
+
state.loopCancelled = true;
|
|
862
|
+
state.lastAttemptAt = Date.now(); // 打断计入冷却, 防反复打断
|
|
863
|
+
bumpStat({ looped: 1 });
|
|
864
|
+
try {
|
|
865
|
+
const response = await this.api.sessions.cancel({ sessionId });
|
|
866
|
+
this.log(
|
|
867
|
+
`已打断循环 ${sessionId}: ${response.result.ok ? 'cancel 已受理' : 'cancel 被拒绝'}`,
|
|
868
|
+
);
|
|
869
|
+
} catch (error) {
|
|
870
|
+
this.log(`打断循环失败 ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
871
|
+
state.loopCancelled = false;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
754
875
|
private onSessionEvent(sessionId: SessionId, event: SessionEvent): void {
|
|
755
876
|
const state = this.state(sessionId);
|
|
756
877
|
switch (event.type) {
|
|
@@ -759,6 +880,11 @@ export class AutoContinueRunner {
|
|
|
759
880
|
// 新回合开始: 清空上一步工具调用状态, 避免跨回合误用护栏
|
|
760
881
|
state.lastTool = undefined;
|
|
761
882
|
state.lastToolResult = undefined;
|
|
883
|
+
// loop guard 状态按回合重置
|
|
884
|
+
state.shortRun = 0;
|
|
885
|
+
state.toolRun = undefined;
|
|
886
|
+
state.loopFired = false;
|
|
887
|
+
state.loopCancelled = false;
|
|
762
888
|
this.cancelPending(sessionId, '宿主自行开启新回合');
|
|
763
889
|
break;
|
|
764
890
|
case 'turn/end': {
|
|
@@ -771,9 +897,22 @@ export class AutoContinueRunner {
|
|
|
771
897
|
state.lastFailure = undefined;
|
|
772
898
|
this.noteRecovery(sessionId, 'completed');
|
|
773
899
|
} else if (reason.kind === 'aborted') {
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
900
|
+
if (state.loopCancelled) {
|
|
901
|
+
// 我们自己的 loop guard 打断: 视为可恢复中断, 用循环提示文本重启回合。
|
|
902
|
+
// 重启不受冷却限制(冷却约束的是"再次打断")。
|
|
903
|
+
state.loopCancelled = false;
|
|
904
|
+
state.loopFired = false;
|
|
905
|
+
state.consecutive = 0;
|
|
906
|
+
state.pendingRecoveryAt = 0;
|
|
907
|
+
state.shortRun = 0;
|
|
908
|
+
state.toolRun = undefined;
|
|
909
|
+
state.lastAttemptAt = 0;
|
|
910
|
+
this.schedule(sessionId, 'loop:aborted');
|
|
911
|
+
} else {
|
|
912
|
+
// 用户主动停止: 不自动继续, 视为用户介入
|
|
913
|
+
state.consecutive = 0;
|
|
914
|
+
state.pendingRecoveryAt = 0;
|
|
915
|
+
}
|
|
777
916
|
} else if (reason.kind === 'blocked') {
|
|
778
917
|
// 策略拒绝: 不自动继续
|
|
779
918
|
} else if (reason.kind === 'interrupted') {
|
|
@@ -962,7 +1101,11 @@ export class AutoContinueRunner {
|
|
|
962
1101
|
void this.fire(sessionId, reason);
|
|
963
1102
|
}, config.graceMs);
|
|
964
1103
|
state.pendingTimer = timer;
|
|
965
|
-
const template = reason.
|
|
1104
|
+
const template = reason.startsWith('loop:')
|
|
1105
|
+
? config.loopText
|
|
1106
|
+
: reason.includes('max-tokens')
|
|
1107
|
+
? config.continueTextMaxTokens
|
|
1108
|
+
: config.continueText;
|
|
966
1109
|
this.log(
|
|
967
1110
|
`检测到非人为中断 ${sessionId}(${reason}), ${config.graceMs}ms 后自动发送「${template}」`,
|
|
968
1111
|
);
|
|
@@ -1005,7 +1148,11 @@ export class AutoContinueRunner {
|
|
|
1005
1148
|
return;
|
|
1006
1149
|
}
|
|
1007
1150
|
// 模板填充: continueText 可含 {code}/{message}/{status}/{tool}/{turn}/{errorCount}/{sessionTitle}/{elapsed} 占位符
|
|
1008
|
-
const template = reason.
|
|
1151
|
+
const template = reason.startsWith('loop:')
|
|
1152
|
+
? config.loopText
|
|
1153
|
+
: reason.includes('max-tokens')
|
|
1154
|
+
? config.continueTextMaxTokens
|
|
1155
|
+
: config.continueText;
|
|
1009
1156
|
let sessionTitle: string | undefined;
|
|
1010
1157
|
if (template.includes('{sessionTitle}')) {
|
|
1011
1158
|
sessionTitle = this.titles.get(sessionId);
|
package/src/client/locales.ts
CHANGED
|
@@ -52,6 +52,17 @@ export const zh = {
|
|
|
52
52
|
'stats.recovered': '恢复成功',
|
|
53
53
|
'stats.failed': '继续后仍失败',
|
|
54
54
|
'stats.gaveUp': '停止(达上限)',
|
|
55
|
+
'stats.looped': '循环打断',
|
|
56
|
+
'field.loopGuard': '循环守卫',
|
|
57
|
+
'field.loopGuardHint': '检测运行中的回合空转: 连续短句且无工具调用, 或连续调用相同工具时, 自动取消并用循环提示文本重启回合。',
|
|
58
|
+
'field.loopShortChars': '短句长度上限 (字符)',
|
|
59
|
+
'field.loopShortCharsHint': '模型消息文本短于该值计为一条短句(空转信号)。',
|
|
60
|
+
'field.loopShortCount': '连续短句阈值',
|
|
61
|
+
'field.loopShortCountHint': '连续多少条短句且期间无工具调用时判定空转循环。',
|
|
62
|
+
'field.loopToolRepeat': '相同工具连续次数',
|
|
63
|
+
'field.loopToolRepeatHint': '连续调用同一工具多少次时判定死循环。',
|
|
64
|
+
'field.loopText': '循环提示文本',
|
|
65
|
+
'field.loopTextHint': '打断后重启回合时发送的文本, 支持 {tool} 占位符。',
|
|
55
66
|
'stats.byCode': '按错误码统计',
|
|
56
67
|
'stats.empty': '今天还没有自动继续记录。',
|
|
57
68
|
'stats.reset': '清零',
|
|
@@ -127,6 +138,17 @@ export const en: Record<SettingsCardKey, string> = {
|
|
|
127
138
|
'stats.recovered': 'Recovered',
|
|
128
139
|
'stats.failed': 'Failed after',
|
|
129
140
|
'stats.gaveUp': 'Gave up (cap)',
|
|
141
|
+
'stats.looped': 'Loops broken',
|
|
142
|
+
'field.loopGuard': 'Loop guard',
|
|
143
|
+
'field.loopGuardHint': 'Detects a running turn spinning in place — many short sentences with no tool calls, or the same tool repeating — cancels it and restarts with the loop text.',
|
|
144
|
+
'field.loopShortChars': 'Short-sentence max (chars)',
|
|
145
|
+
'field.loopShortCharsHint': 'A model message shorter than this counts as a short sentence (spinning signal).',
|
|
146
|
+
'field.loopShortCount': 'Short-sentence threshold',
|
|
147
|
+
'field.loopShortCountHint': 'How many consecutive short sentences with no tool call trip the loop guard.',
|
|
148
|
+
'field.loopToolRepeat': 'Same-tool repeat count',
|
|
149
|
+
'field.loopToolRepeatHint': 'How many consecutive identical tool calls trip the loop guard.',
|
|
150
|
+
'field.loopText': 'Loop text',
|
|
151
|
+
'field.loopTextHint': 'Text sent after the loop guard restarts a turn; supports the {tool} placeholder.',
|
|
130
152
|
'stats.byCode': 'By error code',
|
|
131
153
|
'stats.empty': 'No auto-continue activity today.',
|
|
132
154
|
'stats.reset': 'Reset',
|
|
@@ -54,6 +54,11 @@ export interface AutoContinueSettingsCardState extends CardShell {
|
|
|
54
54
|
backoffFactor: CardFieldState;
|
|
55
55
|
backoffMaxMs: CardFieldState;
|
|
56
56
|
notify: CardFieldState;
|
|
57
|
+
loopGuard: CardFieldState;
|
|
58
|
+
loopShortChars: CardFieldState;
|
|
59
|
+
loopShortCount: CardFieldState;
|
|
60
|
+
loopToolRepeat: CardFieldState;
|
|
61
|
+
loopText: CardFieldState;
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
/** The registration-side face the card's slot entry injects. */
|
|
@@ -93,6 +98,11 @@ export class AutoContinueSettingsCardController {
|
|
|
93
98
|
numberField('backoffFactor', 1),
|
|
94
99
|
numberField('backoffMaxMs', 0),
|
|
95
100
|
booleanField('notify'),
|
|
101
|
+
booleanField('loopGuard'),
|
|
102
|
+
numberField('loopShortChars', 1),
|
|
103
|
+
numberField('loopShortCount', 2),
|
|
104
|
+
numberField('loopToolRepeat', 2),
|
|
105
|
+
textField('loopText'),
|
|
96
106
|
]);
|
|
97
107
|
this.store = this.form.bind(() => this.projection(), createSnapshotStore);
|
|
98
108
|
}
|
|
@@ -119,6 +129,11 @@ export class AutoContinueSettingsCardController {
|
|
|
119
129
|
backoffFactor: this.form.field('backoffFactor'),
|
|
120
130
|
backoffMaxMs: this.form.field('backoffMaxMs'),
|
|
121
131
|
notify: this.form.field('notify'),
|
|
132
|
+
loopGuard: this.form.field('loopGuard'),
|
|
133
|
+
loopShortChars: this.form.field('loopShortChars'),
|
|
134
|
+
loopShortCount: this.form.field('loopShortCount'),
|
|
135
|
+
loopToolRepeat: this.form.field('loopToolRepeat'),
|
|
136
|
+
loopText: this.form.field('loopText'),
|
|
122
137
|
};
|
|
123
138
|
}
|
|
124
139
|
|
|
@@ -286,7 +301,7 @@ function LivePanels(props: { t: (key: SettingsCardKey) => string }) {
|
|
|
286
301
|
return () => clearInterval(timer);
|
|
287
302
|
}, []);
|
|
288
303
|
const stats = readTodayStats();
|
|
289
|
-
const hasStats = stats.sent + stats.skipped + stats.recovered + stats.failed + stats.gaveUp > 0;
|
|
304
|
+
const hasStats = stats.sent + stats.skipped + stats.recovered + stats.failed + stats.gaveUp + stats.looped > 0;
|
|
290
305
|
const codes = Object.entries(stats.byCode)
|
|
291
306
|
.sort((a, b) => b[1] - a[1])
|
|
292
307
|
.slice(0, 5);
|
|
@@ -319,6 +334,7 @@ function LivePanels(props: { t: (key: SettingsCardKey) => string }) {
|
|
|
319
334
|
<div><dt>{t('stats.failed')}</dt><dd>{stats.failed}</dd></div>
|
|
320
335
|
<div><dt>{t('stats.skipped')}</dt><dd>{stats.skipped}</dd></div>
|
|
321
336
|
<div><dt>{t('stats.gaveUp')}</dt><dd>{stats.gaveUp}</dd></div>
|
|
337
|
+
<div><dt>{t('stats.looped')}</dt><dd>{stats.looped}</dd></div>
|
|
322
338
|
</dl>
|
|
323
339
|
{codes.length > 0 ? (
|
|
324
340
|
<div className="dshAcCodes">
|
|
@@ -577,6 +593,54 @@ export function AutoContinueSettingsCard(props: AutoContinueSettingsCardProps) {
|
|
|
577
593
|
onEdit={(text) => props.edit('notify', text)}
|
|
578
594
|
onReset={() => props.resetField('notify')}
|
|
579
595
|
/>
|
|
596
|
+
<BooleanField
|
|
597
|
+
id="auto-continue-loop-guard"
|
|
598
|
+
label={t('field.loopGuard')}
|
|
599
|
+
hint={t('field.loopGuardHint')}
|
|
600
|
+
{...shared}
|
|
601
|
+
{...state.loopGuard}
|
|
602
|
+
onEdit={(text) => props.edit('loopGuard', text)}
|
|
603
|
+
onReset={() => props.resetField('loopGuard')}
|
|
604
|
+
/>
|
|
605
|
+
<ValueField
|
|
606
|
+
id="auto-continue-loop-short-chars"
|
|
607
|
+
label={t('field.loopShortChars')}
|
|
608
|
+
hint={t('field.loopShortCharsHint')}
|
|
609
|
+
numeric
|
|
610
|
+
{...shared}
|
|
611
|
+
{...state.loopShortChars}
|
|
612
|
+
onEdit={(text) => props.edit('loopShortChars', text)}
|
|
613
|
+
onReset={() => props.resetField('loopShortChars')}
|
|
614
|
+
/>
|
|
615
|
+
<ValueField
|
|
616
|
+
id="auto-continue-loop-short-count"
|
|
617
|
+
label={t('field.loopShortCount')}
|
|
618
|
+
hint={t('field.loopShortCountHint')}
|
|
619
|
+
numeric
|
|
620
|
+
{...shared}
|
|
621
|
+
{...state.loopShortCount}
|
|
622
|
+
onEdit={(text) => props.edit('loopShortCount', text)}
|
|
623
|
+
onReset={() => props.resetField('loopShortCount')}
|
|
624
|
+
/>
|
|
625
|
+
<ValueField
|
|
626
|
+
id="auto-continue-loop-tool-repeat"
|
|
627
|
+
label={t('field.loopToolRepeat')}
|
|
628
|
+
hint={t('field.loopToolRepeatHint')}
|
|
629
|
+
numeric
|
|
630
|
+
{...shared}
|
|
631
|
+
{...state.loopToolRepeat}
|
|
632
|
+
onEdit={(text) => props.edit('loopToolRepeat', text)}
|
|
633
|
+
onReset={() => props.resetField('loopToolRepeat')}
|
|
634
|
+
/>
|
|
635
|
+
<ValueField
|
|
636
|
+
id="auto-continue-loop-text"
|
|
637
|
+
label={t('field.loopText')}
|
|
638
|
+
hint={t('field.loopTextHint')}
|
|
639
|
+
{...shared}
|
|
640
|
+
{...state.loopText}
|
|
641
|
+
onEdit={(text) => props.edit('loopText', text)}
|
|
642
|
+
onReset={() => props.resetField('loopText')}
|
|
643
|
+
/>
|
|
580
644
|
<LivePanels t={t} />
|
|
581
645
|
</SettingsCard>
|
|
582
646
|
);
|
package/src/index.ts
CHANGED
|
@@ -56,6 +56,18 @@ export const AutoContinueSchema = z.object({
|
|
|
56
56
|
notify: z.boolean().default(false),
|
|
57
57
|
/** Globally pause auto-continue: no live or scan send. */
|
|
58
58
|
paused: z.boolean().default(false),
|
|
59
|
+
/** Loop guard: detect a running turn spinning in place and restart it. */
|
|
60
|
+
loopGuard: z.boolean().default(true),
|
|
61
|
+
/** A model message shorter than this many chars counts as a short sentence (loop signal). */
|
|
62
|
+
loopShortChars: z.natural().min(1).default(40),
|
|
63
|
+
/** Consecutive short sentences with no tool call in between trip the loop guard. */
|
|
64
|
+
loopShortCount: z.natural().min(2).default(8),
|
|
65
|
+
/** Consecutive identical tool calls trip the loop guard. */
|
|
66
|
+
loopToolRepeat: z.natural().min(2).default(4),
|
|
67
|
+
/** Text sent after the loop guard cancels and restarts a turn (supports {tool}). */
|
|
68
|
+
loopText: z
|
|
69
|
+
.string()
|
|
70
|
+
.default('(检测到你可能陷入循环, 请停止重复刚才的动作, 换一种方式继续)'),
|
|
59
71
|
});
|
|
60
72
|
|
|
61
73
|
/**
|