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/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 {
@@ -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.6.3",
4
+ "version": "0.7.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -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
- state.consecutive = 0;
776
- state.pendingRecoveryAt = 0;
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.includes('max-tokens') ? config.continueTextMaxTokens : config.continueText;
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.includes('max-tokens') ? config.continueTextMaxTokens : config.continueText;
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);
@@ -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
  /**