dsh-client-auto-continue 0.5.7 → 0.6.1
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 +16 -9
- package/README.zh.md +15 -9
- package/lib/client.js +159 -12
- package/lib/client.js.map +2 -2
- package/lib/index.js +6 -0
- package/lib/types/client/engine.d.ts +28 -1
- package/lib/types/client/locales.d.ts +6 -0
- package/lib/types/client/settings-card.d.ts +3 -0
- package/lib/types/index.d.ts +12 -0
- package/package.json +10 -10
- package/scripts/patch-expose.mjs +0 -0
- package/src/client/engine.ts +160 -12
- package/src/client/index.ts +4 -5
- package/src/client/locales.ts +12 -0
- package/src/client/settings-card.tsx +36 -0
- package/src/index.ts +10 -0
package/lib/index.js
CHANGED
|
@@ -7,6 +7,12 @@ var AutoContinueSchema = z.object({
|
|
|
7
7
|
continueText: z.string().default("继续"),
|
|
8
8
|
/** Text sent when the output token ceiling is reached (same placeholders as `continueText`). */
|
|
9
9
|
continueTextMaxTokens: z.string().default("继续"),
|
|
10
|
+
/** Idempotency guard: inspect the last tool call before resuming and steer the model. */
|
|
11
|
+
guardTools: z.boolean().default(true),
|
|
12
|
+
/** Guard text appended when the last tool call has no confirmed result (it may have partially executed). */
|
|
13
|
+
guardPendingText: z.string().default("(上一步工具「{tool}」可能未完成, 先确认状态再继续, 不要重复执行)"),
|
|
14
|
+
/** Guard text appended when the last tool call completed successfully (don't rerun it). */
|
|
15
|
+
guardDoneText: z.string().default("(上一步工具「{tool}」已完成, 结果: {result}; 不要重复执行, 直接继续)"),
|
|
10
16
|
/** Grace period after an interruption before auto-sending (ms). */
|
|
11
17
|
graceMs: z.natural().default(3e3),
|
|
12
18
|
/** Minimum interval between two auto-continues per session (ms). */
|
|
@@ -17,6 +17,12 @@ export interface AutoContinueSettings {
|
|
|
17
17
|
continueText?: string;
|
|
18
18
|
/** Text sent when the output token ceiling is reached (same placeholders as `continueText`). */
|
|
19
19
|
continueTextMaxTokens?: string;
|
|
20
|
+
/** Idempotency guard: inspect the last tool call before resuming and steer the model. */
|
|
21
|
+
guardTools?: boolean;
|
|
22
|
+
/** Guard text appended when the last tool call has no confirmed result (it may have partially executed). */
|
|
23
|
+
guardPendingText?: string;
|
|
24
|
+
/** Guard text appended when the last tool call completed successfully (don't rerun it). */
|
|
25
|
+
guardDoneText?: string;
|
|
20
26
|
/** Grace period after an interruption before auto-sending (ms). */
|
|
21
27
|
graceMs?: number;
|
|
22
28
|
/** Minimum interval between two auto-continues per session (ms). */
|
|
@@ -99,9 +105,18 @@ export interface TemplateContext {
|
|
|
99
105
|
sessionTitle?: string;
|
|
100
106
|
/** 自失败发生以来的毫秒数, 对应 {elapsed}。 */
|
|
101
107
|
elapsedMs?: number;
|
|
108
|
+
/** 上一步工具结果摘要(截断), 对应 {result}(护栏模板用)。 */
|
|
109
|
+
result?: string;
|
|
102
110
|
}
|
|
103
|
-
/** 用失败事实与回合信息填充 continueText 模板占位符({code}/{message}/{status}/{tool}/{turn}/{errorCount}/{sessionTitle}/{elapsed})。 */
|
|
111
|
+
/** 用失败事实与回合信息填充 continueText 模板占位符({code}/{message}/{status}/{tool}/{turn}/{errorCount}/{sessionTitle}/{elapsed}/{result})。 */
|
|
104
112
|
export declare function fillTemplate(template: string, ctx: TemplateContext): string;
|
|
113
|
+
/** 上一步工具调用的判定结果: 是否已确认完成, 以及文本摘要。 */
|
|
114
|
+
export interface ToolResultFacts {
|
|
115
|
+
/** 工具是否成功完成(内部失败或 isError 视为未成功)。 */
|
|
116
|
+
ok: boolean;
|
|
117
|
+
/** 工具输出的文本摘要(截断)。 */
|
|
118
|
+
excerpt: string;
|
|
119
|
+
}
|
|
105
120
|
/** 自适应退避: 同一会话连续失败时的有效冷却间隔。 */
|
|
106
121
|
export declare function effectiveCooldown(consecutive: number, base: number, factor: number, max: number): number;
|
|
107
122
|
/** 暂停某会话: 到 `until` 之前, 引擎不会为该会话自动继续(通知按钮等调用)。 */
|
|
@@ -172,6 +187,16 @@ export declare class AutoContinueRunner {
|
|
|
172
187
|
private schedule;
|
|
173
188
|
private cancelPending;
|
|
174
189
|
private fire;
|
|
190
|
+
/**
|
|
191
|
+
* 组装本次续跑消息: 模板填充 + 幂等护栏。
|
|
192
|
+
* 护栏依据上一步工具调用的执行状态附加指引, 防止重跑副作用操作:
|
|
193
|
+
* - 结果未确认(可能已部分执行)→ 提示先确认状态、不要重复执行
|
|
194
|
+
* - 已确认成功 → 提示已完成、不要重复执行
|
|
195
|
+
* - 已失败 → 不加护栏(重试工具本来就是目的)
|
|
196
|
+
*/
|
|
197
|
+
private buildContinueText;
|
|
198
|
+
/** 上一步工具调用的护栏状态(实时路径, 由 mux 帧维护)。 */
|
|
199
|
+
private currentGuard;
|
|
175
200
|
/** 会话标题缓存(来自 session.list 投影, {sessionTitle} 占位符用)。 */
|
|
176
201
|
private readonly titles;
|
|
177
202
|
/** 查一次 session.list, 顺带缓存该会话的标题。 */
|
|
@@ -186,4 +211,6 @@ export declare class AutoContinueRunner {
|
|
|
186
211
|
* @returns 是否成功完成一次扫描(宿主就绪)。
|
|
187
212
|
*/
|
|
188
213
|
private scanInterrupted;
|
|
214
|
+
/** 从历史事件恢复上一步工具调用状态(扫描路径的幂等护栏)。 */
|
|
215
|
+
private applyGuardFromEvents;
|
|
189
216
|
}
|
|
@@ -13,6 +13,12 @@ export declare const zh: {
|
|
|
13
13
|
'field.continueTextHint': string;
|
|
14
14
|
'field.continueTextMaxTokens': string;
|
|
15
15
|
'field.continueTextMaxTokensHint': string;
|
|
16
|
+
'field.guardTools': string;
|
|
17
|
+
'field.guardToolsHint': string;
|
|
18
|
+
'field.guardPendingText': string;
|
|
19
|
+
'field.guardPendingTextHint': string;
|
|
20
|
+
'field.guardDoneText': string;
|
|
21
|
+
'field.guardDoneTextHint': string;
|
|
16
22
|
'field.graceMs': string;
|
|
17
23
|
'field.graceMsHint': string;
|
|
18
24
|
'field.cooldownMs': string;
|
|
@@ -7,6 +7,9 @@ export interface AutoContinueSettingsCardState extends CardShell {
|
|
|
7
7
|
paused: CardFieldState;
|
|
8
8
|
continueText: CardFieldState;
|
|
9
9
|
continueTextMaxTokens: CardFieldState;
|
|
10
|
+
guardTools: CardFieldState;
|
|
11
|
+
guardPendingText: CardFieldState;
|
|
12
|
+
guardDoneText: CardFieldState;
|
|
10
13
|
graceMs: CardFieldState;
|
|
11
14
|
cooldownMs: CardFieldState;
|
|
12
15
|
maxConsecutive: CardFieldState;
|
package/lib/types/index.d.ts
CHANGED
|
@@ -13,6 +13,12 @@ export declare const AutoContinueSchema: z<Schemastery.ObjectS<{
|
|
|
13
13
|
continueText: z<string, string>;
|
|
14
14
|
/** Text sent when the output token ceiling is reached (same placeholders as `continueText`). */
|
|
15
15
|
continueTextMaxTokens: z<string, string>;
|
|
16
|
+
/** Idempotency guard: inspect the last tool call before resuming and steer the model. */
|
|
17
|
+
guardTools: z<boolean, boolean>;
|
|
18
|
+
/** Guard text appended when the last tool call has no confirmed result (it may have partially executed). */
|
|
19
|
+
guardPendingText: z<string, string>;
|
|
20
|
+
/** Guard text appended when the last tool call completed successfully (don't rerun it). */
|
|
21
|
+
guardDoneText: z<string, string>;
|
|
16
22
|
/** Grace period after an interruption before auto-sending (ms). */
|
|
17
23
|
graceMs: z<number, number>;
|
|
18
24
|
/** Minimum interval between two auto-continues per session (ms). */
|
|
@@ -46,6 +52,12 @@ export declare const AutoContinueSchema: z<Schemastery.ObjectS<{
|
|
|
46
52
|
continueText: z<string, string>;
|
|
47
53
|
/** Text sent when the output token ceiling is reached (same placeholders as `continueText`). */
|
|
48
54
|
continueTextMaxTokens: z<string, string>;
|
|
55
|
+
/** Idempotency guard: inspect the last tool call before resuming and steer the model. */
|
|
56
|
+
guardTools: z<boolean, boolean>;
|
|
57
|
+
/** Guard text appended when the last tool call has no confirmed result (it may have partially executed). */
|
|
58
|
+
guardPendingText: z<string, string>;
|
|
59
|
+
/** Guard text appended when the last tool call completed successfully (don't rerun it). */
|
|
60
|
+
guardDoneText: z<string, string>;
|
|
49
61
|
/** Grace period after an interruption before auto-sending (ms). */
|
|
50
62
|
graceMs: z<number, number>;
|
|
51
63
|
/** Minimum interval between two auto-continues per session (ms). */
|
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.6.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -68,20 +68,20 @@
|
|
|
68
68
|
"typescript": "~5.8.0",
|
|
69
69
|
"@types/react": "~18.3.1",
|
|
70
70
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
71
|
-
"@deepseek-ai/dsh-client-connection": "^0.1.0-rc.
|
|
72
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.
|
|
73
|
-
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.
|
|
74
|
-
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.
|
|
75
|
-
"@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.0-rc.
|
|
76
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.
|
|
77
|
-
"@deepseek-ai/dsh-session": "^0.1.0-rc.
|
|
78
|
-
"@deepseek-ai/dsh-settings": "^0.1.0-rc.
|
|
71
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.0-rc.7",
|
|
72
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.7",
|
|
73
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.7",
|
|
74
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.7",
|
|
75
|
+
"@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.0-rc.7",
|
|
76
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.7",
|
|
77
|
+
"@deepseek-ai/dsh-session": "^0.1.0-rc.7",
|
|
78
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
|
|
79
79
|
"@deepseek-ai/schemastery": "^3.18.1"
|
|
80
80
|
},
|
|
81
81
|
"license": "MIT",
|
|
82
82
|
"peerDependencies": {
|
|
83
83
|
"react": "^18.2.0",
|
|
84
|
-
"@deepseek-ai/dsh-settings": "^0.1.0-rc.
|
|
84
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
|
|
85
85
|
"@deepseek-ai/schemastery": "^3.18.1",
|
|
86
86
|
"@deepseek-ai/cordis": "^4.0.1"
|
|
87
87
|
},
|
package/scripts/patch-expose.mjs
CHANGED
|
File without changes
|
package/src/client/engine.ts
CHANGED
|
@@ -26,6 +26,12 @@ export interface AutoContinueSettings {
|
|
|
26
26
|
continueText?: string;
|
|
27
27
|
/** Text sent when the output token ceiling is reached (same placeholders as `continueText`). */
|
|
28
28
|
continueTextMaxTokens?: string;
|
|
29
|
+
/** Idempotency guard: inspect the last tool call before resuming and steer the model. */
|
|
30
|
+
guardTools?: boolean;
|
|
31
|
+
/** Guard text appended when the last tool call has no confirmed result (it may have partially executed). */
|
|
32
|
+
guardPendingText?: string;
|
|
33
|
+
/** Guard text appended when the last tool call completed successfully (don't rerun it). */
|
|
34
|
+
guardDoneText?: string;
|
|
29
35
|
/** Grace period after an interruption before auto-sending (ms). */
|
|
30
36
|
graceMs?: number;
|
|
31
37
|
/** Minimum interval between two auto-continues per session (ms). */
|
|
@@ -63,6 +69,9 @@ export type AutoContinueConfig = Required<AutoContinueSettings>;
|
|
|
63
69
|
export const DEFAULT_CONFIG: AutoContinueConfig = {
|
|
64
70
|
continueText: '继续',
|
|
65
71
|
continueTextMaxTokens: '继续',
|
|
72
|
+
guardTools: true,
|
|
73
|
+
guardPendingText: '(上一步工具「{tool}」可能未完成, 先确认状态再继续, 不要重复执行)',
|
|
74
|
+
guardDoneText: '(上一步工具「{tool}」已完成, 结果: {result}; 不要重复执行, 直接继续)',
|
|
66
75
|
graceMs: 3000,
|
|
67
76
|
cooldownMs: 20000,
|
|
68
77
|
maxConsecutive: 3,
|
|
@@ -98,9 +107,20 @@ export function resolveConfig(section: AutoContinueSettings | undefined): AutoCo
|
|
|
98
107
|
typeof value.continueTextMaxTokens === 'string' && value.continueTextMaxTokens.trim() !== ''
|
|
99
108
|
? value.continueTextMaxTokens
|
|
100
109
|
: DEFAULT_CONFIG.continueTextMaxTokens;
|
|
110
|
+
const guardPendingText =
|
|
111
|
+
typeof value.guardPendingText === 'string' && value.guardPendingText.trim() !== ''
|
|
112
|
+
? value.guardPendingText
|
|
113
|
+
: DEFAULT_CONFIG.guardPendingText;
|
|
114
|
+
const guardDoneText =
|
|
115
|
+
typeof value.guardDoneText === 'string' && value.guardDoneText.trim() !== ''
|
|
116
|
+
? value.guardDoneText
|
|
117
|
+
: DEFAULT_CONFIG.guardDoneText;
|
|
101
118
|
return {
|
|
102
119
|
continueText: text,
|
|
103
120
|
continueTextMaxTokens: maxTokensText,
|
|
121
|
+
guardTools: booleanOr(value.guardTools, DEFAULT_CONFIG.guardTools),
|
|
122
|
+
guardPendingText,
|
|
123
|
+
guardDoneText,
|
|
104
124
|
graceMs: numberOr(value.graceMs, DEFAULT_CONFIG.graceMs),
|
|
105
125
|
cooldownMs: numberOr(value.cooldownMs, DEFAULT_CONFIG.cooldownMs),
|
|
106
126
|
maxConsecutive: Math.max(1, numberOr(value.maxConsecutive, DEFAULT_CONFIG.maxConsecutive)),
|
|
@@ -249,9 +269,11 @@ export interface TemplateContext {
|
|
|
249
269
|
sessionTitle?: string;
|
|
250
270
|
/** 自失败发生以来的毫秒数, 对应 {elapsed}。 */
|
|
251
271
|
elapsedMs?: number;
|
|
272
|
+
/** 上一步工具结果摘要(截断), 对应 {result}(护栏模板用)。 */
|
|
273
|
+
result?: string;
|
|
252
274
|
}
|
|
253
275
|
|
|
254
|
-
/** 用失败事实与回合信息填充 continueText 模板占位符({code}/{message}/{status}/{tool}/{turn}/{errorCount}/{sessionTitle}/{elapsed})。 */
|
|
276
|
+
/** 用失败事实与回合信息填充 continueText 模板占位符({code}/{message}/{status}/{tool}/{turn}/{errorCount}/{sessionTitle}/{elapsed}/{result})。 */
|
|
255
277
|
export function fillTemplate(template: string, ctx: TemplateContext): string {
|
|
256
278
|
return template
|
|
257
279
|
.replace(/\{code\}/g, ctx.facts?.code ?? '')
|
|
@@ -261,7 +283,51 @@ export function fillTemplate(template: string, ctx: TemplateContext): string {
|
|
|
261
283
|
.replace(/\{turn\}/g, ctx.turn !== undefined ? String(ctx.turn) : '')
|
|
262
284
|
.replace(/\{errorCount\}/g, ctx.errorCount !== undefined ? String(ctx.errorCount) : '')
|
|
263
285
|
.replace(/\{sessionTitle\}/g, ctx.sessionTitle ?? '')
|
|
264
|
-
.replace(/\{elapsed\}/g, formatElapsed(ctx.elapsedMs))
|
|
286
|
+
.replace(/\{elapsed\}/g, formatElapsed(ctx.elapsedMs))
|
|
287
|
+
.replace(/\{result\}/g, ctx.result ?? '');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---------- 幂等护栏: 上一步工具调用的执行状态 ----------
|
|
291
|
+
|
|
292
|
+
/** 工具结果摘要的最大长度(护栏模板 {result} 用)。 */
|
|
293
|
+
const TOOL_RESULT_CAP = 160;
|
|
294
|
+
|
|
295
|
+
/** 从任意内容块里递归收集文本(结果为模型可见的工具输出)。 */
|
|
296
|
+
function extractText(blocks: unknown, cap: number): string {
|
|
297
|
+
let out = '';
|
|
298
|
+
const walk = (value: unknown): void => {
|
|
299
|
+
if (out.length >= cap) return;
|
|
300
|
+
if (Array.isArray(value)) {
|
|
301
|
+
for (const item of value) walk(item);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (typeof value !== 'object' || value === null) return;
|
|
305
|
+
const record = value as Record<string, unknown>;
|
|
306
|
+
if (record['type'] === 'text' && typeof record['text'] === 'string') {
|
|
307
|
+
out += record['text'];
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
for (const child of Object.values(record)) walk(child);
|
|
311
|
+
};
|
|
312
|
+
walk(blocks);
|
|
313
|
+
return out.slice(0, cap);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** 上一步工具调用的判定结果: 是否已确认完成, 以及文本摘要。 */
|
|
317
|
+
export interface ToolResultFacts {
|
|
318
|
+
/** 工具是否成功完成(内部失败或 isError 视为未成功)。 */
|
|
319
|
+
ok: boolean;
|
|
320
|
+
/** 工具输出的文本摘要(截断)。 */
|
|
321
|
+
excerpt: string;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** 从 tool/result 事件载荷提取成功与否与文本摘要。 */
|
|
325
|
+
function toolResultFacts(data: {
|
|
326
|
+
error?: { name?: string; code?: string };
|
|
327
|
+
message?: { content?: Array<{ type?: string; content?: unknown; isError?: boolean }> };
|
|
328
|
+
}): ToolResultFacts {
|
|
329
|
+
const failed = data.error !== undefined || data.message?.content?.[0]?.isError === true;
|
|
330
|
+
return { ok: !failed, excerpt: extractText(data.message?.content?.[0]?.content, TOOL_RESULT_CAP) };
|
|
265
331
|
}
|
|
266
332
|
|
|
267
333
|
/** 自适应退避: 同一会话连续失败时的有效冷却间隔。 */
|
|
@@ -493,8 +559,10 @@ interface SessionState {
|
|
|
493
559
|
lastFailure: FailureFacts | undefined;
|
|
494
560
|
/** 最近一次失败的发生时间(模板 {elapsed} 与恢复统计用)。 */
|
|
495
561
|
lastFailureAt: number;
|
|
496
|
-
/** 失败前最后一次工具调用的名称(模板 {tool})。 */
|
|
562
|
+
/** 失败前最后一次工具调用的名称(模板 {tool} 与幂等护栏用)。 */
|
|
497
563
|
lastTool: string | undefined;
|
|
564
|
+
/** 上一步工具调用的结果状态: 'pending' = 已发起未见结果(可能已部分执行)。 */
|
|
565
|
+
lastToolResult: 'pending' | ToolResultFacts | undefined;
|
|
498
566
|
/** 失败回合的编号(模板 {turn})。 */
|
|
499
567
|
lastTurn: number | undefined;
|
|
500
568
|
/** 我们最近一次自动发送的时间戳; 0 = 没有待确认的恢复。 */
|
|
@@ -513,6 +581,7 @@ const freshState = (): SessionState => ({
|
|
|
513
581
|
lastFailure: undefined,
|
|
514
582
|
lastFailureAt: 0,
|
|
515
583
|
lastTool: undefined,
|
|
584
|
+
lastToolResult: undefined,
|
|
516
585
|
lastTurn: undefined,
|
|
517
586
|
pendingRecoveryAt: 0,
|
|
518
587
|
});
|
|
@@ -657,7 +726,16 @@ export class AutoContinueRunner {
|
|
|
657
726
|
case 'session/event':
|
|
658
727
|
if (frame.event.type === 'tool/call') {
|
|
659
728
|
const name = frame.event.data.name;
|
|
660
|
-
if (typeof name === 'string')
|
|
729
|
+
if (typeof name === 'string') {
|
|
730
|
+
const state = this.state(frame.sessionId);
|
|
731
|
+
state.lastTool = name;
|
|
732
|
+
state.lastToolResult = 'pending'; // 已发起, 尚未见结果
|
|
733
|
+
}
|
|
734
|
+
} else if (frame.event.type === 'tool/result') {
|
|
735
|
+
const state = this.state(frame.sessionId);
|
|
736
|
+
if (state.lastToolResult === 'pending') {
|
|
737
|
+
state.lastToolResult = toolResultFacts(frame.event.data);
|
|
738
|
+
}
|
|
661
739
|
}
|
|
662
740
|
this.onSessionEvent(frame.sessionId, frame.event);
|
|
663
741
|
break;
|
|
@@ -678,6 +756,9 @@ export class AutoContinueRunner {
|
|
|
678
756
|
switch (event.type) {
|
|
679
757
|
case 'turn/start':
|
|
680
758
|
state.running = true;
|
|
759
|
+
// 新回合开始: 清空上一步工具调用状态, 避免跨回合误用护栏
|
|
760
|
+
state.lastTool = undefined;
|
|
761
|
+
state.lastToolResult = undefined;
|
|
681
762
|
this.cancelPending(sessionId, '宿主自行开启新回合');
|
|
682
763
|
break;
|
|
683
764
|
case 'turn/end': {
|
|
@@ -930,14 +1011,7 @@ export class AutoContinueRunner {
|
|
|
930
1011
|
sessionTitle = info?.title;
|
|
931
1012
|
}
|
|
932
1013
|
}
|
|
933
|
-
const text =
|
|
934
|
-
facts: state.lastFailure,
|
|
935
|
-
tool: state.lastTool,
|
|
936
|
-
turn: state.lastTurn,
|
|
937
|
-
errorCount: state.consecutive + 1,
|
|
938
|
-
sessionTitle,
|
|
939
|
-
elapsedMs: state.lastFailureAt > 0 ? Date.now() - state.lastFailureAt : undefined,
|
|
940
|
-
});
|
|
1014
|
+
const text = this.buildContinueText(config, state, template, sessionTitle);
|
|
941
1015
|
const zone = clientTimeZone();
|
|
942
1016
|
state.lastAttemptAt = Date.now(); // 先记账: 无论成败, 本次尝试都进入冷却
|
|
943
1017
|
try {
|
|
@@ -986,6 +1060,51 @@ export class AutoContinueRunner {
|
|
|
986
1060
|
}
|
|
987
1061
|
}
|
|
988
1062
|
|
|
1063
|
+
/**
|
|
1064
|
+
* 组装本次续跑消息: 模板填充 + 幂等护栏。
|
|
1065
|
+
* 护栏依据上一步工具调用的执行状态附加指引, 防止重跑副作用操作:
|
|
1066
|
+
* - 结果未确认(可能已部分执行)→ 提示先确认状态、不要重复执行
|
|
1067
|
+
* - 已确认成功 → 提示已完成、不要重复执行
|
|
1068
|
+
* - 已失败 → 不加护栏(重试工具本来就是目的)
|
|
1069
|
+
*/
|
|
1070
|
+
private buildContinueText(
|
|
1071
|
+
config: AutoContinueConfig,
|
|
1072
|
+
state: SessionState,
|
|
1073
|
+
template: string,
|
|
1074
|
+
sessionTitle: string | undefined,
|
|
1075
|
+
): string {
|
|
1076
|
+
let text = fillTemplate(template, {
|
|
1077
|
+
facts: state.lastFailure,
|
|
1078
|
+
tool: state.lastTool,
|
|
1079
|
+
turn: state.lastTurn,
|
|
1080
|
+
errorCount: state.consecutive + 1,
|
|
1081
|
+
sessionTitle,
|
|
1082
|
+
elapsedMs: state.lastFailureAt > 0 ? Date.now() - state.lastFailureAt : undefined,
|
|
1083
|
+
});
|
|
1084
|
+
if (!config.guardTools) return text;
|
|
1085
|
+
const guard = this.currentGuard(state);
|
|
1086
|
+
if (guard.kind === 'pending') {
|
|
1087
|
+
text += ` ${fillTemplate(config.guardPendingText, { tool: guard.tool, result: guard.result })}`;
|
|
1088
|
+
} else if (guard.kind === 'done') {
|
|
1089
|
+
text += ` ${fillTemplate(config.guardDoneText, { tool: guard.tool, result: guard.result })}`;
|
|
1090
|
+
}
|
|
1091
|
+
return text;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/** 上一步工具调用的护栏状态(实时路径, 由 mux 帧维护)。 */
|
|
1095
|
+
private currentGuard(state: SessionState): {
|
|
1096
|
+
kind: 'none' | 'pending' | 'done' | 'failed';
|
|
1097
|
+
tool?: string;
|
|
1098
|
+
result?: string;
|
|
1099
|
+
} {
|
|
1100
|
+
if (state.lastTool === undefined || state.lastToolResult === undefined) return { kind: 'none' };
|
|
1101
|
+
if (state.lastToolResult === 'pending') return { kind: 'pending', tool: state.lastTool };
|
|
1102
|
+
if (state.lastToolResult.ok) {
|
|
1103
|
+
return { kind: 'done', tool: state.lastTool, result: state.lastToolResult.excerpt };
|
|
1104
|
+
}
|
|
1105
|
+
return { kind: 'failed', tool: state.lastTool };
|
|
1106
|
+
}
|
|
1107
|
+
|
|
989
1108
|
/** 会话标题缓存(来自 session.list 投影, {sessionTitle} 占位符用)。 */
|
|
990
1109
|
private readonly titles = new Map<SessionId, string>();
|
|
991
1110
|
|
|
@@ -1108,9 +1227,38 @@ export class AutoContinueRunner {
|
|
|
1108
1227
|
if (superseded) break;
|
|
1109
1228
|
}
|
|
1110
1229
|
if (superseded) continue;
|
|
1230
|
+
// 幂等护栏: 从历史事件里重建上一步工具调用的执行状态
|
|
1231
|
+
this.applyGuardFromEvents(state, events, lastEnd.seq);
|
|
1111
1232
|
this.log(`扫描发现中断 ${summary.sessionId}(turn/end:${reason.kind}), 安排自动继续`);
|
|
1112
1233
|
this.schedule(summary.sessionId, `scan:turn/end:${reason.kind}`);
|
|
1113
1234
|
}
|
|
1114
1235
|
return true;
|
|
1115
1236
|
}
|
|
1237
|
+
|
|
1238
|
+
/** 从历史事件恢复上一步工具调用状态(扫描路径的幂等护栏)。 */
|
|
1239
|
+
private applyGuardFromEvents(
|
|
1240
|
+
state: SessionState,
|
|
1241
|
+
events: { event: SessionEvent }[],
|
|
1242
|
+
untilSeq: number,
|
|
1243
|
+
): void {
|
|
1244
|
+
state.lastTool = undefined;
|
|
1245
|
+
state.lastToolResult = undefined;
|
|
1246
|
+
let call: SessionEvent<'tool/call'> | undefined;
|
|
1247
|
+
for (const entry of events) {
|
|
1248
|
+
const event = entry.event;
|
|
1249
|
+
if (event.seq >= untilSeq) continue;
|
|
1250
|
+
if (event.type === 'tool/call') call = event;
|
|
1251
|
+
}
|
|
1252
|
+
if (call === undefined) return;
|
|
1253
|
+
state.lastTool = call.data.name;
|
|
1254
|
+
state.lastToolResult = 'pending';
|
|
1255
|
+
for (const entry of events) {
|
|
1256
|
+
const event = entry.event;
|
|
1257
|
+
if (event.seq <= call.seq || event.seq >= untilSeq) continue;
|
|
1258
|
+
if (event.type === 'tool/result') {
|
|
1259
|
+
state.lastToolResult = toolResultFacts(event.data);
|
|
1260
|
+
break;
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1116
1264
|
}
|
package/src/client/index.ts
CHANGED
|
@@ -72,16 +72,15 @@ export function apply(ctx: ClientContext): void {
|
|
|
72
72
|
|
|
73
73
|
// Plugin configuration card: one staged form over the `auto-continue`
|
|
74
74
|
// settings namespace, contributed to the plugin-configuration section
|
|
75
|
-
// (Settings → Plugins).
|
|
76
|
-
// slot
|
|
77
|
-
//
|
|
75
|
+
// (Settings → Plugins). Since DSH 0.1.0-rc.7 `settings.plugin.item` is a
|
|
76
|
+
// keyed slot dispatched by the settings namespace it edits, so the entry
|
|
77
|
+
// registers with `key` (the namespace), like the official cards.
|
|
78
78
|
const controller = new AutoContinueSettingsCardController(scope);
|
|
79
79
|
ctx.slots.inject('settings.plugin.item', () =>
|
|
80
80
|
ctx.slots.register(
|
|
81
81
|
{
|
|
82
82
|
name: 'settings.plugin.item',
|
|
83
|
-
|
|
84
|
-
order: 90,
|
|
83
|
+
key: SETTINGS_NS,
|
|
85
84
|
locale: NS,
|
|
86
85
|
inject: () => controller.inject(),
|
|
87
86
|
},
|
package/src/client/locales.ts
CHANGED
|
@@ -14,6 +14,12 @@ export const zh = {
|
|
|
14
14
|
'field.continueTextHint': '中断后自动发送的消息内容。',
|
|
15
15
|
'field.continueTextMaxTokens': '超限时的继续文本',
|
|
16
16
|
'field.continueTextMaxTokensHint': '达到输出 token 上限时自动发送的文本, 支持与继续文本相同的占位符。',
|
|
17
|
+
'field.guardTools': '幂等护栏',
|
|
18
|
+
'field.guardToolsHint': '续跑前检查上一步工具调用: 结果未确认时提示先确认状态, 已成功时提示不要重复执行, 避免重复 commit/调 API。',
|
|
19
|
+
'field.guardPendingText': '结果未确认时的护栏文本',
|
|
20
|
+
'field.guardPendingTextHint': '上一步工具可能已部分执行时附加到继续文本之后, 支持 {tool} 占位符。',
|
|
21
|
+
'field.guardDoneText': '工具已成功时的护栏文本',
|
|
22
|
+
'field.guardDoneTextHint': '上一步工具已确认成功时附加到继续文本之后, 支持 {tool} 与 {result}(结果摘要)占位符。',
|
|
17
23
|
'field.graceMs': '宽限期 (ms)',
|
|
18
24
|
'field.graceMsHint': '检测到中断后等待的时长; 期间宿主自行恢复则取消。',
|
|
19
25
|
'field.cooldownMs': '冷却时间 (ms)',
|
|
@@ -83,6 +89,12 @@ export const en: Record<SettingsCardKey, string> = {
|
|
|
83
89
|
'field.continueTextHint': 'Message automatically sent after an interruption.',
|
|
84
90
|
'field.continueTextMaxTokens': 'Continue text (max tokens)',
|
|
85
91
|
'field.continueTextMaxTokensHint': 'Text sent when the output token ceiling is reached; same placeholders as the continue text.',
|
|
92
|
+
'field.guardTools': 'Idempotency guard',
|
|
93
|
+
'field.guardToolsHint': 'Before resuming, inspect the last tool call: if its result is unconfirmed, tell the model to check state first; if it succeeded, tell it not to rerun — avoids duplicate commits / API calls.',
|
|
94
|
+
'field.guardPendingText': 'Guard text (unconfirmed result)',
|
|
95
|
+
'field.guardPendingTextHint': 'Appended when the last tool may have partially executed; supports the {tool} placeholder.',
|
|
96
|
+
'field.guardDoneText': 'Guard text (tool succeeded)',
|
|
97
|
+
'field.guardDoneTextHint': 'Appended when the last tool is confirmed done; supports {tool} and {result} (result excerpt).',
|
|
86
98
|
'field.graceMs': 'Grace period (ms)',
|
|
87
99
|
'field.graceMsHint': 'Wait after an interruption; cancelled if the host recovers on its own.',
|
|
88
100
|
'field.cooldownMs': 'Cooldown (ms)',
|
|
@@ -38,6 +38,9 @@ export interface AutoContinueSettingsCardState extends CardShell {
|
|
|
38
38
|
paused: CardFieldState;
|
|
39
39
|
continueText: CardFieldState;
|
|
40
40
|
continueTextMaxTokens: CardFieldState;
|
|
41
|
+
guardTools: CardFieldState;
|
|
42
|
+
guardPendingText: CardFieldState;
|
|
43
|
+
guardDoneText: CardFieldState;
|
|
41
44
|
graceMs: CardFieldState;
|
|
42
45
|
cooldownMs: CardFieldState;
|
|
43
46
|
maxConsecutive: CardFieldState;
|
|
@@ -74,6 +77,9 @@ export class AutoContinueSettingsCardController {
|
|
|
74
77
|
booleanField('paused'),
|
|
75
78
|
textField('continueText'),
|
|
76
79
|
textField('continueTextMaxTokens'),
|
|
80
|
+
booleanField('guardTools'),
|
|
81
|
+
textField('guardPendingText'),
|
|
82
|
+
textField('guardDoneText'),
|
|
77
83
|
numberField('graceMs', 0),
|
|
78
84
|
numberField('cooldownMs', 0),
|
|
79
85
|
numberField('maxConsecutive', 1),
|
|
@@ -97,6 +103,9 @@ export class AutoContinueSettingsCardController {
|
|
|
97
103
|
paused: this.form.field('paused'),
|
|
98
104
|
continueText: this.form.field('continueText'),
|
|
99
105
|
continueTextMaxTokens: this.form.field('continueTextMaxTokens'),
|
|
106
|
+
guardTools: this.form.field('guardTools'),
|
|
107
|
+
guardPendingText: this.form.field('guardPendingText'),
|
|
108
|
+
guardDoneText: this.form.field('guardDoneText'),
|
|
100
109
|
graceMs: this.form.field('graceMs'),
|
|
101
110
|
cooldownMs: this.form.field('cooldownMs'),
|
|
102
111
|
maxConsecutive: this.form.field('maxConsecutive'),
|
|
@@ -415,6 +424,33 @@ export function AutoContinueSettingsCard(props: AutoContinueSettingsCardProps) {
|
|
|
415
424
|
onEdit={(text) => props.edit('continueTextMaxTokens', text)}
|
|
416
425
|
onReset={() => props.resetField('continueTextMaxTokens')}
|
|
417
426
|
/>
|
|
427
|
+
<BooleanField
|
|
428
|
+
id="auto-continue-guard-tools"
|
|
429
|
+
label={t('field.guardTools')}
|
|
430
|
+
hint={t('field.guardToolsHint')}
|
|
431
|
+
{...shared}
|
|
432
|
+
{...state.guardTools}
|
|
433
|
+
onEdit={(text) => props.edit('guardTools', text)}
|
|
434
|
+
onReset={() => props.resetField('guardTools')}
|
|
435
|
+
/>
|
|
436
|
+
<ValueField
|
|
437
|
+
id="auto-continue-guard-pending-text"
|
|
438
|
+
label={t('field.guardPendingText')}
|
|
439
|
+
hint={t('field.guardPendingTextHint')}
|
|
440
|
+
{...shared}
|
|
441
|
+
{...state.guardPendingText}
|
|
442
|
+
onEdit={(text) => props.edit('guardPendingText', text)}
|
|
443
|
+
onReset={() => props.resetField('guardPendingText')}
|
|
444
|
+
/>
|
|
445
|
+
<ValueField
|
|
446
|
+
id="auto-continue-guard-done-text"
|
|
447
|
+
label={t('field.guardDoneText')}
|
|
448
|
+
hint={t('field.guardDoneTextHint')}
|
|
449
|
+
{...shared}
|
|
450
|
+
{...state.guardDoneText}
|
|
451
|
+
onEdit={(text) => props.edit('guardDoneText', text)}
|
|
452
|
+
onReset={() => props.resetField('guardDoneText')}
|
|
453
|
+
/>
|
|
418
454
|
<ValueField
|
|
419
455
|
id="auto-continue-grace-ms"
|
|
420
456
|
label={t('field.graceMs')}
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,16 @@ export const AutoContinueSchema = z.object({
|
|
|
18
18
|
continueText: z.string().default('继续'),
|
|
19
19
|
/** Text sent when the output token ceiling is reached (same placeholders as `continueText`). */
|
|
20
20
|
continueTextMaxTokens: z.string().default('继续'),
|
|
21
|
+
/** Idempotency guard: inspect the last tool call before resuming and steer the model. */
|
|
22
|
+
guardTools: z.boolean().default(true),
|
|
23
|
+
/** Guard text appended when the last tool call has no confirmed result (it may have partially executed). */
|
|
24
|
+
guardPendingText: z
|
|
25
|
+
.string()
|
|
26
|
+
.default('(上一步工具「{tool}」可能未完成, 先确认状态再继续, 不要重复执行)'),
|
|
27
|
+
/** Guard text appended when the last tool call completed successfully (don't rerun it). */
|
|
28
|
+
guardDoneText: z
|
|
29
|
+
.string()
|
|
30
|
+
.default('(上一步工具「{tool}」已完成, 结果: {result}; 不要重复执行, 直接继续)'),
|
|
21
31
|
/** Grace period after an interruption before auto-sending (ms). */
|
|
22
32
|
graceMs: z.natural().default(3000),
|
|
23
33
|
/** Minimum interval between two auto-continues per session (ms). */
|