@vergilye/dsh-auto-continue 0.0.0-stage → 2.0.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/LICENSE +21 -0
- package/README.md +210 -2
- package/cordis.patch.yml +32 -0
- package/icon.svg +9 -0
- package/index.js +1503 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +54 -4
package/index.js
ADDED
|
@@ -0,0 +1,1503 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-auto-continue v2 — stop a turn from closing on an unfinished reply.
|
|
3
|
+
*
|
|
4
|
+
* Rewritten from scratch after v1. What v1 taught us, kept here as design rules:
|
|
5
|
+
*
|
|
6
|
+
* 1. A bracketed end-of-reply marker (`[DONE: …]`) puts constant "close the bracket"
|
|
7
|
+
* pressure on every reply and degenerates into runs of stray `]`. v2 uses a fenced
|
|
8
|
+
* block whose body is one checklist line — nothing to close wrong.
|
|
9
|
+
* 2. `systemPrompt.section()` returns a disposer. Ignoring it makes every plugin
|
|
10
|
+
* reload append another system-prompt node to live sessions. v2 registers the
|
|
11
|
+
* disposer through `ctx.effect`, so a reload always leaves exactly one section.
|
|
12
|
+
* 3. `ctx.get('system-prompt')` is wrong: the service key is `systemPrompt`.
|
|
13
|
+
* 4. The `didWork` gate must not block a turn whose prose announces more work — but
|
|
14
|
+
* counting the turn-opening prompt as a "human takeover" disables the cap. v2
|
|
15
|
+
* counts a takeover only for messages after the first assistant output.
|
|
16
|
+
* 5. `maxNudges` must be derived from the durable log (how many pushes this turn
|
|
17
|
+
* already contains), so duplicate plugin instances and reloads cannot overshoot.
|
|
18
|
+
* 6. Cleanup of a damaged stream must never be awaited: a provider that hangs would
|
|
19
|
+
* hold the retry hostage for exactly as long as it hangs.
|
|
20
|
+
*
|
|
21
|
+
* Three layers, cheapest first:
|
|
22
|
+
* - `llm/stream` guard: a stream that stops mid-thought or goes silent becomes a
|
|
23
|
+
* terminal `error`, so DSH's own retry re-sends inside the same step.
|
|
24
|
+
* - completion protocol: an injected system-prompt section asks for an explicit
|
|
25
|
+
* end-of-reply declaration. Declared ⇒ close; missing after real work ⇒ push.
|
|
26
|
+
* - heuristic scopes: reconnect / unsettled text / plan phrasing, for when the
|
|
27
|
+
* protocol is off.
|
|
28
|
+
*
|
|
29
|
+
* Zero runtime dependencies: only `node:` builtins.
|
|
30
|
+
*
|
|
31
|
+
* @module dsh-auto-continue
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
35
|
+
import fs from 'node:fs'
|
|
36
|
+
import os from 'node:os'
|
|
37
|
+
import path from 'node:path'
|
|
38
|
+
|
|
39
|
+
export const name = 'dsh-auto-continue'
|
|
40
|
+
|
|
41
|
+
/** Hard dependency: `agents` owns live agents and dispatches `agent/turn-stopping`. */
|
|
42
|
+
export const inject = ['agents']
|
|
43
|
+
|
|
44
|
+
/** Source kind of the messages this plugin pushes. Never `'user'`. */
|
|
45
|
+
export const SOURCE_KIND = 'auto-continue'
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// defaults
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
export const DEFAULT_NUDGE_TEXT =
|
|
52
|
+
'继续。不要只描述计划:直接执行下一步;若任务已完成,给出结果汇报。'
|
|
53
|
+
|
|
54
|
+
export const DEFAULT_PLAN_PATTERNS = Object.freeze([
|
|
55
|
+
'接下来',
|
|
56
|
+
'下一步',
|
|
57
|
+
'现在(就)?(先)?(改|查|检查|确认|修|跑|执行|补|加|写|做|看|读|试|提交|清理|重跑)',
|
|
58
|
+
'我(继续|这就|马上|立刻)',
|
|
59
|
+
'继续(把|改|查|做|跑|补)',
|
|
60
|
+
'(执行|开始(改|做|跑|执行|检查))改动',
|
|
61
|
+
'(开始|准备)(改|做|执行|检查|动手)',
|
|
62
|
+
'先(查|读|看|确认|执行|改|跑|修|做)',
|
|
63
|
+
'让我(先)?',
|
|
64
|
+
'我要(先)?',
|
|
65
|
+
'待会',
|
|
66
|
+
'然后(再|就)',
|
|
67
|
+
'Now (let|I)',
|
|
68
|
+
'Next,? I',
|
|
69
|
+
"I'll",
|
|
70
|
+
'Let me',
|
|
71
|
+
'Now:',
|
|
72
|
+
])
|
|
73
|
+
|
|
74
|
+
export const DEFAULT_ASKING_PATTERNS = Object.freeze([
|
|
75
|
+
'[??]',
|
|
76
|
+
'要不要',
|
|
77
|
+
'需不需要',
|
|
78
|
+
'是否要',
|
|
79
|
+
'是否需要',
|
|
80
|
+
'要我(来|先|把|改|提|加|帮)?',
|
|
81
|
+
'需要我',
|
|
82
|
+
'你确认',
|
|
83
|
+
'请确认',
|
|
84
|
+
'你同意',
|
|
85
|
+
'你定',
|
|
86
|
+
'你决定',
|
|
87
|
+
'你来定',
|
|
88
|
+
'可以吗',
|
|
89
|
+
'行吗',
|
|
90
|
+
'好吗',
|
|
91
|
+
'对吗',
|
|
92
|
+
'等你',
|
|
93
|
+
'待你',
|
|
94
|
+
'发我',
|
|
95
|
+
'截图发我',
|
|
96
|
+
'告诉我',
|
|
97
|
+
'你说了算',
|
|
98
|
+
'需要的话',
|
|
99
|
+
'可以的话',
|
|
100
|
+
'有需要',
|
|
101
|
+
'我可以(先|再|把|帮)?',
|
|
102
|
+
'如果要',
|
|
103
|
+
'愿意的话',
|
|
104
|
+
'what do you want',
|
|
105
|
+
'shall I',
|
|
106
|
+
'should I',
|
|
107
|
+
'do you want',
|
|
108
|
+
'want me to',
|
|
109
|
+
'let me know',
|
|
110
|
+
'your call',
|
|
111
|
+
])
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Routes the plugin acts on. Empty means every route: a fresh install must work for
|
|
115
|
+
* whoever installed it, without first discovering and hand-editing an allowlist. Narrow
|
|
116
|
+
* it in the profile patch layer when a route needs to stay untouched.
|
|
117
|
+
*/
|
|
118
|
+
export const DEFAULT_ALLOW_MODELS = Object.freeze([])
|
|
119
|
+
|
|
120
|
+
/** Provider failure codes that mean "the answer was cut". */
|
|
121
|
+
export const DEFAULT_RETRY_CODES = Object.freeze(['TRANSPORT', 'TIMEOUT', 'SERVER'])
|
|
122
|
+
|
|
123
|
+
const FENCE = '`'.repeat(3)
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* End-of-reply marker presets. `fence-check` is the default: a fenced block holding
|
|
127
|
+
* one checklist line, which renders as a compact code block.
|
|
128
|
+
*/
|
|
129
|
+
export const MARKER_STYLES = Object.freeze({
|
|
130
|
+
'fence-check': Object.freeze({
|
|
131
|
+
doneMarker: `${FENCE}done`,
|
|
132
|
+
askMarker: `${FENCE}ask`,
|
|
133
|
+
donePrefix: '✅ 已完成:',
|
|
134
|
+
askPrefix: '❓ 待确认:',
|
|
135
|
+
}),
|
|
136
|
+
fence: Object.freeze({
|
|
137
|
+
doneMarker: `${FENCE}done`,
|
|
138
|
+
askMarker: `${FENCE}ask`,
|
|
139
|
+
donePrefix: '',
|
|
140
|
+
askPrefix: '',
|
|
141
|
+
}),
|
|
142
|
+
line: Object.freeze({ doneMarker: 'DONE:', askMarker: 'ASK:', donePrefix: '', askPrefix: '' }),
|
|
143
|
+
plain: Object.freeze({
|
|
144
|
+
doneMarker: '[DONE:',
|
|
145
|
+
askMarker: '[ASK:',
|
|
146
|
+
donePrefix: '',
|
|
147
|
+
askPrefix: '',
|
|
148
|
+
terminator: ']',
|
|
149
|
+
}),
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
/** Section order: after the tool sections, before structured output. */
|
|
153
|
+
export const PROTOCOL_SECTION_ORDER = 9500
|
|
154
|
+
|
|
155
|
+
/** How much of the final body a decision log line records. */
|
|
156
|
+
const DECISION_TAIL_CHARS = 200
|
|
157
|
+
/** Length of the tail recorded in plugin logs. */
|
|
158
|
+
const LOG_TAIL_CHARS = 40
|
|
159
|
+
/** How much of the final line the asking test reads. */
|
|
160
|
+
const ASKING_PROBE_CHARS = 220
|
|
161
|
+
/** Upper bound for the message-source account. */
|
|
162
|
+
const SUMMARY_MAX_CHARS = 120
|
|
163
|
+
|
|
164
|
+
export const DEFAULTS = Object.freeze({
|
|
165
|
+
enabled: true,
|
|
166
|
+
maxNudges: 2,
|
|
167
|
+
nudgeMinIntervalMs: 5_000,
|
|
168
|
+
delayMs: 0,
|
|
169
|
+
dryRun: false,
|
|
170
|
+
retryScope: true,
|
|
171
|
+
truncationScope: true,
|
|
172
|
+
extendedScope: true,
|
|
173
|
+
streamGuard: true,
|
|
174
|
+
streamIdleMs: 90_000,
|
|
175
|
+
maxGuardRetries: 1,
|
|
176
|
+
completionProtocol: true,
|
|
177
|
+
markerStyle: 'fence-check',
|
|
178
|
+
protocolNudgeText:
|
|
179
|
+
'继续。若还有没做完的事就直接做完;完成时用 done 代码块收尾,需要我拍板时用 ask 代码块。',
|
|
180
|
+
giveUpNoticeText:
|
|
181
|
+
'(自动续接已停止:本轮多次推送都没有收到收尾声明。可能是上游断连或配额问题,请检查后手动继续。)',
|
|
182
|
+
nudgeText: DEFAULT_NUDGE_TEXT,
|
|
183
|
+
logDecisions: false,
|
|
184
|
+
logFile: '',
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// config
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Compile a pattern list, dropping unusable regular expressions. A typo in a
|
|
193
|
+
* deployment config must never take the agent loop down.
|
|
194
|
+
* @param {unknown} input - configured list.
|
|
195
|
+
* @param {readonly string[]} fallback - defaults.
|
|
196
|
+
* @returns {{ regexes: RegExp[], invalid: string[] }}
|
|
197
|
+
*/
|
|
198
|
+
export function compilePatterns(input, fallback) {
|
|
199
|
+
const source = Array.isArray(input) ? input : [...fallback]
|
|
200
|
+
const regexes = []
|
|
201
|
+
const invalid = []
|
|
202
|
+
for (const entry of source) {
|
|
203
|
+
if (typeof entry !== 'string') {
|
|
204
|
+
invalid.push(String(entry))
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
regexes.push(new RegExp(entry, 'i'))
|
|
209
|
+
} catch {
|
|
210
|
+
invalid.push(entry)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return { regexes, invalid }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* @param {unknown} value - candidate.
|
|
218
|
+
* @returns {boolean} whether it is a usable non-negative integer.
|
|
219
|
+
*/
|
|
220
|
+
function isCount(value) {
|
|
221
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 0
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Resolve the marker preset from the row config.
|
|
226
|
+
* @param {object} input - raw row config.
|
|
227
|
+
* @returns {{ markerStyle: string, doneMarker: string, askMarker: string,
|
|
228
|
+
* donePrefix: string, askPrefix: string, markerTerminator: string }}
|
|
229
|
+
*/
|
|
230
|
+
function markerSettings(input) {
|
|
231
|
+
const key = Object.hasOwn(MARKER_STYLES, input.markerStyle) ? input.markerStyle : 'fence-check'
|
|
232
|
+
const preset = MARKER_STYLES[key]
|
|
233
|
+
return {
|
|
234
|
+
markerStyle: key,
|
|
235
|
+
doneMarker:
|
|
236
|
+
typeof input.doneMarker === 'string' && input.doneMarker.length > 0
|
|
237
|
+
? input.doneMarker
|
|
238
|
+
: preset.doneMarker,
|
|
239
|
+
askMarker:
|
|
240
|
+
typeof input.askMarker === 'string' && input.askMarker.length > 0
|
|
241
|
+
? input.askMarker
|
|
242
|
+
: preset.askMarker,
|
|
243
|
+
donePrefix: preset.donePrefix,
|
|
244
|
+
askPrefix: preset.askPrefix,
|
|
245
|
+
markerTerminator:
|
|
246
|
+
typeof input.markerTerminator === 'string' && input.markerTerminator.length > 0
|
|
247
|
+
? input.markerTerminator
|
|
248
|
+
: (preset.terminator ?? '\n'),
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Merge a Loader row's `config` over {@link DEFAULTS}.
|
|
254
|
+
* @param {unknown} raw - the row's `config`.
|
|
255
|
+
* @returns {object} resolved options.
|
|
256
|
+
*/
|
|
257
|
+
export function resolveConfig(raw) {
|
|
258
|
+
const input = raw && typeof raw === 'object' ? raw : {}
|
|
259
|
+
const plan = compilePatterns(input.planPatterns, DEFAULT_PLAN_PATTERNS)
|
|
260
|
+
const asking = compilePatterns(input.isAskingPatterns, DEFAULT_ASKING_PATTERNS)
|
|
261
|
+
const markers = markerSettings(input)
|
|
262
|
+
const allowModels = Array.isArray(input.allowModels)
|
|
263
|
+
? input.allowModels.filter((entry) => typeof entry === 'string' && entry.length > 0)
|
|
264
|
+
: [...DEFAULT_ALLOW_MODELS]
|
|
265
|
+
|
|
266
|
+
const bool = (value, fallback) => (typeof value === 'boolean' ? value : fallback)
|
|
267
|
+
const text = (value, fallback) =>
|
|
268
|
+
typeof value === 'string' && value.trim().length > 0 ? value : fallback
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
enabled: bool(input.enabled, DEFAULTS.enabled),
|
|
272
|
+
maxNudges: isCount(input.maxNudges) ? input.maxNudges : DEFAULTS.maxNudges,
|
|
273
|
+
nudgeMinIntervalMs: isCount(input.nudgeMinIntervalMs)
|
|
274
|
+
? input.nudgeMinIntervalMs
|
|
275
|
+
: DEFAULTS.nudgeMinIntervalMs,
|
|
276
|
+
delayMs: isCount(input.delayMs) ? input.delayMs : DEFAULTS.delayMs,
|
|
277
|
+
dryRun: bool(input.dryRun, DEFAULTS.dryRun),
|
|
278
|
+
retryScope: bool(input.retryScope, DEFAULTS.retryScope),
|
|
279
|
+
truncationScope: bool(input.truncationScope, DEFAULTS.truncationScope),
|
|
280
|
+
extendedScope: bool(input.extendedScope, DEFAULTS.extendedScope),
|
|
281
|
+
streamGuard: bool(input.streamGuard, DEFAULTS.streamGuard),
|
|
282
|
+
streamIdleMs: isCount(input.streamIdleMs) ? input.streamIdleMs : DEFAULTS.streamIdleMs,
|
|
283
|
+
maxGuardRetries: isCount(input.maxGuardRetries)
|
|
284
|
+
? input.maxGuardRetries
|
|
285
|
+
: DEFAULTS.maxGuardRetries,
|
|
286
|
+
completionProtocol: bool(input.completionProtocol, DEFAULTS.completionProtocol),
|
|
287
|
+
...markers,
|
|
288
|
+
protocolNudgeText: text(input.protocolNudgeText, DEFAULTS.protocolNudgeText),
|
|
289
|
+
giveUpNoticeText: text(input.giveUpNoticeText, DEFAULTS.giveUpNoticeText),
|
|
290
|
+
nudgeText: text(input.nudgeText, DEFAULT_NUDGE_TEXT),
|
|
291
|
+
allowModels,
|
|
292
|
+
retryCodes: Array.isArray(input.retryCodes)
|
|
293
|
+
? input.retryCodes.filter((entry) => typeof entry === 'string' && entry.length > 0)
|
|
294
|
+
: [...DEFAULT_RETRY_CODES],
|
|
295
|
+
logDecisions: bool(input.logDecisions, DEFAULTS.logDecisions),
|
|
296
|
+
logFile: typeof input.logFile === 'string' ? input.logFile : DEFAULTS.logFile,
|
|
297
|
+
planPatterns: plan.regexes,
|
|
298
|
+
isAskingPatterns: asking.regexes,
|
|
299
|
+
invalidPatterns: [...plan.invalid, ...asking.invalid],
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
// message + text helpers
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* @param {unknown} message - any message-shaped value.
|
|
309
|
+
* @returns {string} joined text blocks.
|
|
310
|
+
*/
|
|
311
|
+
export function messageText(message) {
|
|
312
|
+
const content = Array.isArray(message?.content) ? message.content : []
|
|
313
|
+
let text = ''
|
|
314
|
+
for (const block of content) {
|
|
315
|
+
if (block && block.type === 'text' && typeof block.text === 'string') text += block.text
|
|
316
|
+
}
|
|
317
|
+
return text
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* @param {unknown} message - any message-shaped value.
|
|
322
|
+
* @returns {number} number of tool-call blocks.
|
|
323
|
+
*/
|
|
324
|
+
export function toolCallCount(message) {
|
|
325
|
+
const content = Array.isArray(message?.content) ? message.content : []
|
|
326
|
+
let count = 0
|
|
327
|
+
for (const block of content) {
|
|
328
|
+
if (block && block.type === 'tool-call') count += 1
|
|
329
|
+
}
|
|
330
|
+
return count
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Provider stop reason of one committed assistant message. Only the replay envelope
|
|
335
|
+
* carries it; a streaming snapshot has none.
|
|
336
|
+
* @param {unknown} message - a committed assistant message.
|
|
337
|
+
* @returns {string | undefined}
|
|
338
|
+
*/
|
|
339
|
+
export function stopReasonOf(message) {
|
|
340
|
+
const replay = message?.source?.replayState
|
|
341
|
+
if (replay === undefined || replay === null) return undefined
|
|
342
|
+
const response = replay.response
|
|
343
|
+
if (typeof response === 'string') return response
|
|
344
|
+
const stopReason = response?.stopReason
|
|
345
|
+
return typeof stopReason === 'string' ? stopReason : undefined
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* @param {string} summary - free-form account.
|
|
350
|
+
* @returns {string} bounded to the source-summary limit.
|
|
351
|
+
*/
|
|
352
|
+
function boundSummary(summary) {
|
|
353
|
+
return summary.length <= SUMMARY_MAX_CHARS
|
|
354
|
+
? summary
|
|
355
|
+
: `${summary.slice(0, SUMMARY_MAX_CHARS - 1)}…`
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Build the durable user-role message that re-opens the turn. `role: 'user'` is the
|
|
360
|
+
* only role the model answers, but `source.kind` records that no human spoke.
|
|
361
|
+
* @param {{ turn: number, rule: string, text: string }} input - construction input.
|
|
362
|
+
* @returns {object} a UserMessage ready for `agent.steer()`.
|
|
363
|
+
*/
|
|
364
|
+
export function buildNudgeMessage({ turn, rule, text }) {
|
|
365
|
+
return {
|
|
366
|
+
id: randomUUID(),
|
|
367
|
+
role: 'user',
|
|
368
|
+
content: [{ type: 'text', text }],
|
|
369
|
+
source: {
|
|
370
|
+
kind: SOURCE_KIND,
|
|
371
|
+
form: 'notice',
|
|
372
|
+
rule,
|
|
373
|
+
turn,
|
|
374
|
+
summary: boundSummary(`${name}: ${rule} at turn ${turn}`),
|
|
375
|
+
},
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Content fingerprint of one final message.
|
|
381
|
+
* @param {object | null} finalMessage - folded final message.
|
|
382
|
+
* @returns {string | undefined}
|
|
383
|
+
*/
|
|
384
|
+
export function fingerprintOf(finalMessage) {
|
|
385
|
+
if (finalMessage === undefined || finalMessage === null) return undefined
|
|
386
|
+
const hash = createHash('sha256')
|
|
387
|
+
hash.update(String(finalMessage.id ?? ''))
|
|
388
|
+
hash.update('\u0000')
|
|
389
|
+
hash.update(String(finalMessage.stopReason ?? ''))
|
|
390
|
+
hash.update('\u0000')
|
|
391
|
+
hash.update(finalMessage.text ?? '')
|
|
392
|
+
return hash.digest('hex')
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
// session window
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Locate the committed event window of one open turn.
|
|
401
|
+
* @param {object} session - the agent's session.
|
|
402
|
+
* @param {number} turn - the turn being closed.
|
|
403
|
+
* @returns {{ events: readonly object[] } | undefined}
|
|
404
|
+
*/
|
|
405
|
+
export function turnWindow(session, turn) {
|
|
406
|
+
if (typeof session?.eventAt !== 'function' || typeof session?.snapshotEvents !== 'function') {
|
|
407
|
+
return undefined
|
|
408
|
+
}
|
|
409
|
+
const end = typeof session.seq === 'number' ? session.seq : undefined
|
|
410
|
+
if (typeof end !== 'number') return undefined
|
|
411
|
+
for (let seq = end - 1; seq >= 0; seq -= 1) {
|
|
412
|
+
const event = session.eventAt(seq)
|
|
413
|
+
if (event === undefined || event === null) continue
|
|
414
|
+
if (event.type !== 'turn/start') continue
|
|
415
|
+
if (event.data?.turn !== turn) return undefined
|
|
416
|
+
return { events: session.snapshotEvents(event.seq) }
|
|
417
|
+
}
|
|
418
|
+
return undefined
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Fold one turn's committed events into the facts the rules need.
|
|
423
|
+
* @param {readonly object[]} events - the turn's event window.
|
|
424
|
+
* @returns {object} folded facts.
|
|
425
|
+
*/
|
|
426
|
+
export function foldTurn(events) {
|
|
427
|
+
let hadRetry = false
|
|
428
|
+
let toolCalls = 0
|
|
429
|
+
let userInputs = 0
|
|
430
|
+
let final = null
|
|
431
|
+
let firstAssistantSeq
|
|
432
|
+
let lastPushTime
|
|
433
|
+
const retryCodes = new Set()
|
|
434
|
+
// Every push we made this turn, plus whether the model actually did anything after
|
|
435
|
+
// it. A push that produced work was not wasted, so it must not eat the budget.
|
|
436
|
+
const pushes = []
|
|
437
|
+
let workSincePush = false
|
|
438
|
+
|
|
439
|
+
for (const event of events) {
|
|
440
|
+
if (event === undefined || event === null) continue
|
|
441
|
+
switch (event.type) {
|
|
442
|
+
case 'llm/retry': {
|
|
443
|
+
hadRetry = true
|
|
444
|
+
const code = event.data?.failure?.code
|
|
445
|
+
if (typeof code === 'string') retryCodes.add(code)
|
|
446
|
+
break
|
|
447
|
+
}
|
|
448
|
+
case 'tool/call':
|
|
449
|
+
toolCalls += 1
|
|
450
|
+
workSincePush = true
|
|
451
|
+
break
|
|
452
|
+
case 'user/message': {
|
|
453
|
+
const kind = event.data?.source?.kind
|
|
454
|
+
if (kind === SOURCE_KIND) {
|
|
455
|
+
// Durable record of our own pushes: survives reloads and duplicate instances.
|
|
456
|
+
const previous = pushes[pushes.length - 1]
|
|
457
|
+
if (previous !== undefined) previous.productive = workSincePush
|
|
458
|
+
pushes.push({ productive: false, time: event.time })
|
|
459
|
+
workSincePush = false
|
|
460
|
+
lastPushTime = event.time
|
|
461
|
+
break
|
|
462
|
+
}
|
|
463
|
+
// Only a message *after* the first assistant output is a takeover; the prompt
|
|
464
|
+
// that opened the turn arrives before it and must not reset the bookkeeping.
|
|
465
|
+
if (kind === 'user' && firstAssistantSeq !== undefined && event.seq > firstAssistantSeq) {
|
|
466
|
+
userInputs += 1
|
|
467
|
+
}
|
|
468
|
+
break
|
|
469
|
+
}
|
|
470
|
+
case 'assistant/message': {
|
|
471
|
+
if (firstAssistantSeq === undefined) firstAssistantSeq = event.seq
|
|
472
|
+
const message = event.data?.message
|
|
473
|
+
if (event.data?.interrupted === true) break
|
|
474
|
+
if (message?.source?.replayState === undefined) break
|
|
475
|
+
final = {
|
|
476
|
+
id: message.id,
|
|
477
|
+
seq: event.seq,
|
|
478
|
+
text: messageText(message),
|
|
479
|
+
toolCalls: toolCallCount(message),
|
|
480
|
+
stopReason: stopReasonOf(message),
|
|
481
|
+
provider: message.source?.provider,
|
|
482
|
+
model: message.source?.model,
|
|
483
|
+
}
|
|
484
|
+
break
|
|
485
|
+
}
|
|
486
|
+
default:
|
|
487
|
+
break
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const last = pushes[pushes.length - 1]
|
|
492
|
+
if (last !== undefined) last.productive = workSincePush
|
|
493
|
+
// Trailing run of pushes that led nowhere: that is the only budget worth counting.
|
|
494
|
+
let unproductiveStreak = 0
|
|
495
|
+
for (let i = pushes.length - 1; i >= 0; i -= 1) {
|
|
496
|
+
if (pushes[i].productive) break
|
|
497
|
+
unproductiveStreak += 1
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return {
|
|
501
|
+
hadRetry,
|
|
502
|
+
toolCalls,
|
|
503
|
+
userInputs,
|
|
504
|
+
pushes: pushes.length,
|
|
505
|
+
productivePushes: pushes.filter((push) => push.productive).length,
|
|
506
|
+
unproductiveStreak,
|
|
507
|
+
lastPushTime,
|
|
508
|
+
final,
|
|
509
|
+
retryCodes: [...retryCodes],
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Fold the newest `goal/change` event to learn whether a goal is live.
|
|
515
|
+
* @param {object} session - the agent's session.
|
|
516
|
+
* @returns {'active' | string | 'none' | 'unknown'}
|
|
517
|
+
*/
|
|
518
|
+
export function latestGoalPhase(session) {
|
|
519
|
+
if (typeof session?.eventAt !== 'function' || typeof session?.seq !== 'number') return 'unknown'
|
|
520
|
+
for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
|
|
521
|
+
const event = session.eventAt(seq)
|
|
522
|
+
if (event === undefined || event === null) continue
|
|
523
|
+
if (event.type !== 'goal/change') continue
|
|
524
|
+
if (event.data?.operation === 'clear') return 'none'
|
|
525
|
+
const phase = event.data?.goal?.phase
|
|
526
|
+
return typeof phase === 'string' ? phase : 'unknown'
|
|
527
|
+
}
|
|
528
|
+
return 'none'
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* @param {object} folded - folded turn facts.
|
|
533
|
+
* @param {object} session - the agent's session.
|
|
534
|
+
* @returns {string | undefined} `provider/model` of this turn.
|
|
535
|
+
*/
|
|
536
|
+
export function routeOf(folded, session) {
|
|
537
|
+
const final = folded?.final
|
|
538
|
+
if (typeof final?.provider === 'string' && typeof final?.model === 'string') {
|
|
539
|
+
return `${final.provider}/${final.model}`
|
|
540
|
+
}
|
|
541
|
+
const header = typeof session?.requestHeader === 'function' ? session.requestHeader() : undefined
|
|
542
|
+
const config = header?.config
|
|
543
|
+
if (typeof config?.provider === 'string' && typeof config?.model === 'string') {
|
|
544
|
+
return `${config.provider}/${config.model}`
|
|
545
|
+
}
|
|
546
|
+
return undefined
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ---------------------------------------------------------------------------
|
|
550
|
+
// prose rules
|
|
551
|
+
// ---------------------------------------------------------------------------
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* The tail of the last non-empty line — what actually hands the turn back.
|
|
555
|
+
* Deliberately not sentence-split: `…吗?(只提交 X)` must keep its question mark.
|
|
556
|
+
* @param {string} text - final body.
|
|
557
|
+
* @returns {string}
|
|
558
|
+
*/
|
|
559
|
+
export function lastSentence(text) {
|
|
560
|
+
const body = String(text ?? '').trim()
|
|
561
|
+
if (body.length === 0) return ''
|
|
562
|
+
const lines = body.split('\n').filter((line) => line.trim().length > 0)
|
|
563
|
+
const lastLine = (lines.length > 0 ? lines[lines.length - 1] : body).trim()
|
|
564
|
+
return lastLine.length <= ASKING_PROBE_CHARS ? lastLine : lastLine.slice(-ASKING_PROBE_CHARS)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* The last two non-empty lines: the smallest window that still covers a plan written
|
|
569
|
+
* on the line before a one-line closer.
|
|
570
|
+
* @param {string} text - final body.
|
|
571
|
+
* @returns {string}
|
|
572
|
+
*/
|
|
573
|
+
export function closingRegion(text) {
|
|
574
|
+
const lines = String(text ?? '')
|
|
575
|
+
.split('\n')
|
|
576
|
+
.map((line) => line.trim())
|
|
577
|
+
.filter((line) => line.length > 0)
|
|
578
|
+
return lines.slice(-2).join('\n')
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const TRAILING_CLOSERS = /[)」』”"'\])】》〉\s]+$/u
|
|
582
|
+
const SETTLED_END = /[。!?!?…;;.]$/u
|
|
583
|
+
const OPEN_END = /[::,,、\-–—((\[【「『“"']$/u
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Whether the body ends like a finished thought (as opposed to being cut mid-sentence,
|
|
587
|
+
* which is the silent-truncation shape).
|
|
588
|
+
* @param {string} text - final body.
|
|
589
|
+
* @returns {boolean}
|
|
590
|
+
*/
|
|
591
|
+
export function isSettled(text) {
|
|
592
|
+
const body = String(text ?? '').trim()
|
|
593
|
+
if (body.length === 0) return true
|
|
594
|
+
if (/```\s*$/.test(body)) return true
|
|
595
|
+
const lines = body.split('\n').filter((line) => line.trim().length > 0)
|
|
596
|
+
const lastLine = (lines.length > 0 ? lines[lines.length - 1] : body).trim()
|
|
597
|
+
if (lastLine.length === 0) return true
|
|
598
|
+
if (/^\|/.test(lastLine) || /\|\s*$/.test(lastLine)) return true
|
|
599
|
+
if (OPEN_END.test(lastLine)) return false
|
|
600
|
+
const stripped = lastLine.replace(TRAILING_CLOSERS, '').replace(/[*_`]+$/u, '')
|
|
601
|
+
if (stripped.length === 0) return true
|
|
602
|
+
if (SETTLED_END.test(stripped)) return true
|
|
603
|
+
const lastToken = stripped.split(/\s+/u).at(-1) ?? ''
|
|
604
|
+
// A tail with no letters is a number/unit/code fragment: complete by nature.
|
|
605
|
+
if (lastToken.length > 0 && !/\p{L}/u.test(lastToken)) return true
|
|
606
|
+
return false
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* @param {object} folded - folded turn facts.
|
|
611
|
+
* @param {object} options - resolved config.
|
|
612
|
+
* @returns {boolean} whether the closing region announces a plan.
|
|
613
|
+
*/
|
|
614
|
+
export function matchesPlanTail(folded, options) {
|
|
615
|
+
const text = folded.final?.text ?? ''
|
|
616
|
+
if (text.trim().length === 0) return false
|
|
617
|
+
const region = closingRegion(text)
|
|
618
|
+
if (region.length === 0) return false
|
|
619
|
+
return options.planPatterns.some((pattern) => pattern.test(region))
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* @param {object} folded - folded turn facts.
|
|
624
|
+
* @param {object} options - resolved config.
|
|
625
|
+
* @returns {boolean} whether the reply hands the turn back to the human.
|
|
626
|
+
*/
|
|
627
|
+
export function matchesAsking(folded, options) {
|
|
628
|
+
const sentence = lastSentence(folded.final?.text ?? '')
|
|
629
|
+
if (sentence.length === 0) return false
|
|
630
|
+
return options.isAskingPatterns.some((pattern) => pattern.test(sentence))
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* @param {object} folded - folded turn facts.
|
|
635
|
+
* @param {object} options - resolved config.
|
|
636
|
+
* @returns {string[]} matched heuristic scopes.
|
|
637
|
+
*/
|
|
638
|
+
export function matchedScopes(folded, options) {
|
|
639
|
+
const scopes = []
|
|
640
|
+
const text = folded.final?.text ?? ''
|
|
641
|
+
if (options.retryScope && folded.hadRetry) {
|
|
642
|
+
const codes = Array.isArray(folded.retryCodes) ? folded.retryCodes : []
|
|
643
|
+
const allowed =
|
|
644
|
+
!Array.isArray(options.retryCodes) || options.retryCodes.length === 0
|
|
645
|
+
? true
|
|
646
|
+
: codes.some((code) => options.retryCodes.includes(code))
|
|
647
|
+
if (allowed || codes.length === 0) scopes.push('retry')
|
|
648
|
+
}
|
|
649
|
+
if (options.truncationScope && !isSettled(text)) scopes.push('truncated')
|
|
650
|
+
if (options.extendedScope && matchesPlanTail(folded, options)) scopes.push('plan')
|
|
651
|
+
return scopes
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// ---------------------------------------------------------------------------
|
|
655
|
+
// completion protocol
|
|
656
|
+
// ---------------------------------------------------------------------------
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* The instruction injected into the system prompt.
|
|
660
|
+
* @param {object} options - resolved config.
|
|
661
|
+
* @returns {string}
|
|
662
|
+
*/
|
|
663
|
+
export function buildProtocolInstruction(options) {
|
|
664
|
+
const { doneMarker, askMarker, donePrefix, askPrefix } = options
|
|
665
|
+
const fenced = doneMarker.startsWith('`')
|
|
666
|
+
const done = `${donePrefix}<一句话结果摘要>`
|
|
667
|
+
const ask = `${askPrefix}<需要用户拍板的具体问题>`
|
|
668
|
+
|
|
669
|
+
if (fenced) {
|
|
670
|
+
return [
|
|
671
|
+
'## Turn-ending protocol (mandatory)',
|
|
672
|
+
'',
|
|
673
|
+
'End EVERY reply with exactly one of these two fenced blocks, as the last thing',
|
|
674
|
+
'in the message:',
|
|
675
|
+
'',
|
|
676
|
+
'````',
|
|
677
|
+
doneMarker,
|
|
678
|
+
done,
|
|
679
|
+
'```',
|
|
680
|
+
'````',
|
|
681
|
+
'',
|
|
682
|
+
'when the requested work is finished and verified (the summary must state what',
|
|
683
|
+
'was actually delivered), or',
|
|
684
|
+
'',
|
|
685
|
+
'````',
|
|
686
|
+
askMarker,
|
|
687
|
+
ask,
|
|
688
|
+
'```',
|
|
689
|
+
'````',
|
|
690
|
+
'',
|
|
691
|
+
'when you cannot continue without the user (a decision, a credential, a manual',
|
|
692
|
+
'check, missing information).',
|
|
693
|
+
'',
|
|
694
|
+
'Never end a reply without one of them. Never write a plan ("next I will …") as',
|
|
695
|
+
'the last thing you say: if work remains, keep working instead of ending the',
|
|
696
|
+
'turn. The two blocks are the only endings that close a turn.',
|
|
697
|
+
].join('\n')
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
return [
|
|
701
|
+
'## Turn-ending protocol (mandatory)',
|
|
702
|
+
'',
|
|
703
|
+
'End EVERY reply with exactly one of these two markers on its own final line:',
|
|
704
|
+
'',
|
|
705
|
+
`- ${doneMarker} ${done}${options.markerTerminator}`,
|
|
706
|
+
`- ${askMarker} ${ask}${options.markerTerminator}`,
|
|
707
|
+
'',
|
|
708
|
+
'Never end a reply without one of them. Never write a plan as the last thing you',
|
|
709
|
+
'say: if work remains, keep working instead of ending the turn.',
|
|
710
|
+
].join('\n')
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Read the protocol verdict from a final body. Only the closing lines are inspected:
|
|
715
|
+
* a reply that *mentions* the markers mid-text is not making a declaration.
|
|
716
|
+
* @param {string} text - final body.
|
|
717
|
+
* @param {object} options - resolved config.
|
|
718
|
+
* @returns {{ verdict: 'done' | 'ask' | 'missing' | 'empty', summary: string }}
|
|
719
|
+
*/
|
|
720
|
+
export function parseProtocol(text, options) {
|
|
721
|
+
const body = String(text ?? '')
|
|
722
|
+
const fenced = options.doneMarker.startsWith('`')
|
|
723
|
+
if (fenced) return parseFencedProtocol(body, options)
|
|
724
|
+
|
|
725
|
+
const tail = body.split('\n').filter((line) => line.trim().length > 0).slice(-3).join('\n')
|
|
726
|
+
const doneIndex = tail.lastIndexOf(options.doneMarker)
|
|
727
|
+
const askIndex = tail.lastIndexOf(options.askMarker)
|
|
728
|
+
const terminator = options.markerTerminator
|
|
729
|
+
const read = (index, marker) => {
|
|
730
|
+
if (index < 0) return undefined
|
|
731
|
+
const rest = tail.slice(index + marker.length)
|
|
732
|
+
const close = rest.indexOf(terminator)
|
|
733
|
+
return (close >= 0 ? rest.slice(0, close) : rest).replace(/^\s*:?\s*/, '').trim()
|
|
734
|
+
}
|
|
735
|
+
if (askIndex > doneIndex) {
|
|
736
|
+
const question = read(askIndex, options.askMarker)
|
|
737
|
+
if (question === undefined) return { verdict: 'missing', summary: '' }
|
|
738
|
+
return { verdict: question.length > 0 ? 'ask' : 'empty', summary: question }
|
|
739
|
+
}
|
|
740
|
+
if (doneIndex >= 0) {
|
|
741
|
+
const summary = read(doneIndex, options.doneMarker)
|
|
742
|
+
if (summary === undefined) return { verdict: 'missing', summary: '' }
|
|
743
|
+
return { verdict: summary.length > 0 ? 'done' : 'empty', summary }
|
|
744
|
+
}
|
|
745
|
+
return { verdict: 'missing', summary: '' }
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Whether the declaration block is the *last* thing in the body.
|
|
750
|
+
*
|
|
751
|
+
* The protocol text asks for the block "as the last thing in the message", but a model
|
|
752
|
+
* that writes "```done" and then keeps talking ("wait, I am not finished yet") used to be
|
|
753
|
+
* read as done, which closed the turn mid-task. So the bar is structural: the final
|
|
754
|
+
* non-blank line must close a fenced block, and that block must be the declaration block.
|
|
755
|
+
* Only that shape counts as a finished reply — trailing prose, a second block, or a
|
|
756
|
+
* declaration that sits mid-text all mean the turn is not settled.
|
|
757
|
+
* @param {string} body - final body.
|
|
758
|
+
* @param {object} options - resolved config.
|
|
759
|
+
* @returns {boolean}
|
|
760
|
+
*/
|
|
761
|
+
function declarationIsLastBlock(body, options) {
|
|
762
|
+
const lines = body.split('\n')
|
|
763
|
+
let end = -1
|
|
764
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
765
|
+
if (lines[i].trim().length > 0) {
|
|
766
|
+
end = i
|
|
767
|
+
break
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
if (end < 0) return false
|
|
771
|
+
// The closing fence must be the last thing: only backticks, nothing after it.
|
|
772
|
+
if (!/^`{3,}$/.test(lines[end].trim())) return false
|
|
773
|
+
// Walk up to the fence that opened this block. Whichever fence line we hit first is the
|
|
774
|
+
// opener, because fenced blocks do not interleave with prose.
|
|
775
|
+
for (let i = end - 1; i >= 0; i -= 1) {
|
|
776
|
+
const trimmed = lines[i].trim()
|
|
777
|
+
if (!trimmed.startsWith(FENCE)) continue
|
|
778
|
+
return trimmed.startsWith(options.doneMarker) || trimmed.startsWith(options.askMarker)
|
|
779
|
+
}
|
|
780
|
+
return false
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Parse the fenced form: the marker opens a block whose first non-blank line is the
|
|
785
|
+
* declaration body.
|
|
786
|
+
* @param {string} body - final body.
|
|
787
|
+
* @param {object} options - resolved config.
|
|
788
|
+
* @returns {{ verdict: 'done' | 'ask' | 'missing' | 'empty', summary: string }}
|
|
789
|
+
*/
|
|
790
|
+
function parseFencedProtocol(body, options) {
|
|
791
|
+
const lines = body.split('\n')
|
|
792
|
+
let found = -1
|
|
793
|
+
let kind = 'missing'
|
|
794
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
795
|
+
const trimmed = lines[i].trim()
|
|
796
|
+
if (trimmed.startsWith(options.doneMarker)) {
|
|
797
|
+
found = i
|
|
798
|
+
kind = 'done'
|
|
799
|
+
break
|
|
800
|
+
}
|
|
801
|
+
if (trimmed.startsWith(options.askMarker)) {
|
|
802
|
+
found = i
|
|
803
|
+
kind = 'ask'
|
|
804
|
+
break
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
if (found < 0) return { verdict: 'missing', summary: '' }
|
|
808
|
+
|
|
809
|
+
const collected = []
|
|
810
|
+
for (let j = found + 1; j < lines.length; j += 1) {
|
|
811
|
+
if (lines[j].trim().startsWith(FENCE)) break
|
|
812
|
+
collected.push(lines[j])
|
|
813
|
+
}
|
|
814
|
+
const summary = collected.join('\n').trim()
|
|
815
|
+
// A declaration that is not the final block does not close the turn: the model went on
|
|
816
|
+
// talking after it, so treat it as undeclared and let the listener push once.
|
|
817
|
+
if (!declarationIsLastBlock(body, options)) {
|
|
818
|
+
return { verdict: summary.length > 0 ? 'missing' : 'empty', summary }
|
|
819
|
+
}
|
|
820
|
+
return { verdict: summary.length > 0 ? kind : 'empty', summary }
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Replace the whole trailing marker block with nothing, so the prose rules judge the
|
|
825
|
+
* prose and the stream guard does not read a closing fence as a cut-off sentence.
|
|
826
|
+
* @param {string} text - final body.
|
|
827
|
+
* @param {object} options - resolved config.
|
|
828
|
+
* @returns {string}
|
|
829
|
+
*/
|
|
830
|
+
export function stripProtocolMarkers(text, options) {
|
|
831
|
+
const body = String(text ?? '')
|
|
832
|
+
if (!options.completionProtocol) return body
|
|
833
|
+
|
|
834
|
+
if (options.doneMarker.startsWith('`')) {
|
|
835
|
+
const lines = body.split('\n')
|
|
836
|
+
let start = -1
|
|
837
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
838
|
+
const trimmed = lines[i].trim()
|
|
839
|
+
if (trimmed.startsWith(options.doneMarker) || trimmed.startsWith(options.askMarker)) {
|
|
840
|
+
start = i
|
|
841
|
+
break
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
if (start < 0) return body.trimEnd()
|
|
845
|
+
let end = lines.length
|
|
846
|
+
for (let j = start + 1; j < lines.length; j += 1) {
|
|
847
|
+
if (lines[j].trim().startsWith(FENCE)) {
|
|
848
|
+
end = j + 1
|
|
849
|
+
break
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return [...lines.slice(0, start), ...lines.slice(end)].join('\n').trimEnd()
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
return body
|
|
856
|
+
.split('\n')
|
|
857
|
+
.filter((line) => {
|
|
858
|
+
const trimmed = line.trim()
|
|
859
|
+
return !trimmed.startsWith(options.doneMarker) && !trimmed.startsWith(options.askMarker)
|
|
860
|
+
})
|
|
861
|
+
.join('\n')
|
|
862
|
+
.trimEnd()
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// ---------------------------------------------------------------------------
|
|
866
|
+
// decision
|
|
867
|
+
// ---------------------------------------------------------------------------
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Decide whether this turn should be re-opened.
|
|
871
|
+
* @param {object} folded - folded turn facts.
|
|
872
|
+
* @param {string | undefined} route - `provider/model`.
|
|
873
|
+
* @param {object} options - resolved config.
|
|
874
|
+
* @returns {{ nudge: boolean, rule: string, reason: string, tail: string, scopes: string[] }}
|
|
875
|
+
*/
|
|
876
|
+
export function decide(folded, route, options) {
|
|
877
|
+
const tail = (folded.final?.text ?? '').trim().slice(-DECISION_TAIL_CHARS)
|
|
878
|
+
const early = (reason) => ({ nudge: false, rule: 'none', reason, tail, scopes: [] })
|
|
879
|
+
|
|
880
|
+
if (!options.enabled) return early('disabled')
|
|
881
|
+
if (options.maxNudges <= 0) return early('max-nudges-0')
|
|
882
|
+
if (options.allowModels.length > 0) {
|
|
883
|
+
if (route === undefined) return early('unknown-route')
|
|
884
|
+
if (!options.allowModels.includes(route)) return early('model-not-allowed')
|
|
885
|
+
}
|
|
886
|
+
if (folded.final === null) return early('no-final-message')
|
|
887
|
+
if (folded.final.stopReason !== 'stop') {
|
|
888
|
+
return early(`stop-reason:${String(folded.final.stopReason)}`)
|
|
889
|
+
}
|
|
890
|
+
if (folded.final.toolCalls > 0) return early('final-has-tool-call')
|
|
891
|
+
|
|
892
|
+
const prose = stripProtocolMarkers(folded.final.text ?? '', options)
|
|
893
|
+
const judged = { ...folded, final: { ...folded.final, text: prose } }
|
|
894
|
+
const scopes = matchedScopes(judged, options)
|
|
895
|
+
|
|
896
|
+
if (options.completionProtocol) {
|
|
897
|
+
// The protocol asks for a declaration at the end of every reply, so a missing one
|
|
898
|
+
// must never be allowed to close the turn — whatever the shape of the reply. An
|
|
899
|
+
// empty body, a plan announcement and ordinary chatter all read as "not declared".
|
|
900
|
+
const protocol = parseProtocol(folded.final.text ?? '', options)
|
|
901
|
+
if (protocol.verdict === 'done' || protocol.verdict === 'ask') {
|
|
902
|
+
return { nudge: false, rule: 'none', reason: `protocol-${protocol.verdict}`, tail, scopes }
|
|
903
|
+
}
|
|
904
|
+
const emptyProse = prose.trim().length === 0
|
|
905
|
+
return {
|
|
906
|
+
nudge: true,
|
|
907
|
+
rule: emptyProse
|
|
908
|
+
? 'protocol-empty-text'
|
|
909
|
+
: protocol.verdict === 'empty'
|
|
910
|
+
? 'protocol-empty'
|
|
911
|
+
: 'protocol-missing',
|
|
912
|
+
reason: 'protocol-missing',
|
|
913
|
+
tail,
|
|
914
|
+
scopes,
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
if (prose.trim().length === 0) return early('empty-final-text')
|
|
919
|
+
|
|
920
|
+
const planOnly = scopes.length > 0 && scopes.every((scope) => scope === 'plan')
|
|
921
|
+
if (folded.toolCalls === 0 && !planOnly) return early('no-work-this-turn')
|
|
922
|
+
if (scopes.length === 0) {
|
|
923
|
+
const allOff = !options.retryScope && !options.truncationScope && !options.extendedScope
|
|
924
|
+
return {
|
|
925
|
+
nudge: false,
|
|
926
|
+
rule: 'none',
|
|
927
|
+
reason: allOff ? 'all-scopes-off' : `no-scope:retry=${folded.hadRetry}`,
|
|
928
|
+
tail,
|
|
929
|
+
scopes,
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
if (matchesAsking(judged, options)) {
|
|
933
|
+
return { nudge: false, rule: 'none', reason: 'asking-user', tail, scopes }
|
|
934
|
+
}
|
|
935
|
+
return { nudge: true, rule: scopes.join('+'), reason: 'matched', tail, scopes }
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// ---------------------------------------------------------------------------
|
|
939
|
+
// provider-side guard
|
|
940
|
+
// ---------------------------------------------------------------------------
|
|
941
|
+
|
|
942
|
+
export const STREAM_STALL_CODE = 'STREAM_STALL'
|
|
943
|
+
export const STREAM_TRUNCATED_CODE = 'STREAM_TRUNCATED'
|
|
944
|
+
export const GUARD_CODES = Object.freeze([STREAM_STALL_CODE, STREAM_TRUNCATED_CODE])
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* A terminal `error` finish chunk for a damaged stream.
|
|
948
|
+
* @param {string} code - guard code.
|
|
949
|
+
* @param {string} message - account for the failure.
|
|
950
|
+
* @returns {object} a `StreamChunk` of type `finish`.
|
|
951
|
+
*/
|
|
952
|
+
export function guardFinishChunk(code, message) {
|
|
953
|
+
return { type: 'finish', reason: { kind: 'error', failure: { code, message, status: 0 } } }
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Whether an adapter's terminal `finish` should be replaced by an error.
|
|
958
|
+
* @param {object} finishChunk - the adapter finish chunk.
|
|
959
|
+
* @param {{ text: string, toolCalls: number }} pending - accumulated stream facts.
|
|
960
|
+
* @param {object} options - resolved config.
|
|
961
|
+
* @returns {{ damage: boolean, reason: string }}
|
|
962
|
+
*/
|
|
963
|
+
export function inspectFinish(finishChunk, pending, options) {
|
|
964
|
+
const reason = finishChunk?.reason
|
|
965
|
+
if (reason === undefined || reason === null) return { damage: false, reason: 'no-reason' }
|
|
966
|
+
if (reason.kind !== 'stop') return { damage: false, reason: `kind:${String(reason.kind)}` }
|
|
967
|
+
if (!options.streamGuard) return { damage: false, reason: 'guard-off' }
|
|
968
|
+
if (pending.toolCalls > 0) return { damage: false, reason: 'tool-calls' }
|
|
969
|
+
const text = stripProtocolMarkers(pending.text, options)
|
|
970
|
+
if (text.trim().length === 0) return { damage: false, reason: 'no-text' }
|
|
971
|
+
if (matchesAsking({ final: { text, toolCalls: 0 } }, options)) {
|
|
972
|
+
return { damage: false, reason: 'asking-user' }
|
|
973
|
+
}
|
|
974
|
+
if (isSettled(text)) return { damage: false, reason: 'settled' }
|
|
975
|
+
return { damage: true, reason: 'unsettled-text' }
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
class IdleTimeout extends Error {}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* Await the next chunk, failing when the adapter goes silent for `idleMs`.
|
|
982
|
+
* @param {AsyncIterator<object>} iterator - upstream iterator.
|
|
983
|
+
* @param {number} idleMs - silence budget; non-positive disables the watchdog.
|
|
984
|
+
* @returns {Promise<object | undefined>}
|
|
985
|
+
*/
|
|
986
|
+
function nextWithWatchdog(iterator, idleMs) {
|
|
987
|
+
if (!(idleMs > 0)) return iterator.next().then((step) => step.value)
|
|
988
|
+
let timer
|
|
989
|
+
const timeout = new Promise((_, reject) => {
|
|
990
|
+
timer = setTimeout(() => reject(new IdleTimeout('idle')), idleMs)
|
|
991
|
+
if (typeof timer?.unref === 'function') timer.unref()
|
|
992
|
+
})
|
|
993
|
+
const read = iterator.next().then((step) => step.value)
|
|
994
|
+
return Promise.race([read, timeout]).finally(() => clearTimeout(timer))
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Wrap one adapter stream with the stall watchdog and the truncation guard.
|
|
999
|
+
* @param {AsyncIterable<object>} upstream - the adapter stream.
|
|
1000
|
+
* @param {object} context - guard context.
|
|
1001
|
+
* @param {object} context.options - resolved config.
|
|
1002
|
+
* @param {() => boolean} context.consumeTruncationBudget - budget check.
|
|
1003
|
+
* @param {(record: object) => void} context.log - decision sink.
|
|
1004
|
+
* @returns {AsyncIterable<object>}
|
|
1005
|
+
*/
|
|
1006
|
+
export async function* guardStream(upstream, context) {
|
|
1007
|
+
const { options, consumeTruncationBudget, log } = context
|
|
1008
|
+
const pending = { text: '', toolCalls: 0 }
|
|
1009
|
+
const iterator = upstream[Symbol.asyncIterator]()
|
|
1010
|
+
let finished = false
|
|
1011
|
+
|
|
1012
|
+
try {
|
|
1013
|
+
for (;;) {
|
|
1014
|
+
let chunk
|
|
1015
|
+
try {
|
|
1016
|
+
chunk = await nextWithWatchdog(iterator, options.streamIdleMs)
|
|
1017
|
+
} catch (error) {
|
|
1018
|
+
if (error instanceof IdleTimeout) {
|
|
1019
|
+
log({ event: 'guard', damage: 'stall', action: 'error-finish' })
|
|
1020
|
+
yield guardFinishChunk(
|
|
1021
|
+
STREAM_STALL_CODE,
|
|
1022
|
+
`no stream chunk for ${options.streamIdleMs}ms; treating the request as failed`,
|
|
1023
|
+
)
|
|
1024
|
+
return
|
|
1025
|
+
}
|
|
1026
|
+
throw error
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
if (chunk === undefined) {
|
|
1030
|
+
log({ event: 'guard', damage: 'no-finish', action: 'error-finish' })
|
|
1031
|
+
yield guardFinishChunk(STREAM_TRUNCATED_CODE, 'stream ended without a finish chunk')
|
|
1032
|
+
return
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
if (chunk.type === 'text-delta' && typeof chunk.text === 'string') pending.text += chunk.text
|
|
1036
|
+
else if (chunk.type === 'block-end' && chunk.block?.type === 'tool-call') pending.toolCalls += 1
|
|
1037
|
+
else if (chunk.type === 'block-start' && chunk.blockType === 'tool-call') {
|
|
1038
|
+
pending.toolCalls += 1
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
if (chunk.type === 'finish') {
|
|
1042
|
+
finished = true
|
|
1043
|
+
const verdict = inspectFinish(chunk, pending, options)
|
|
1044
|
+
if (verdict.damage && consumeTruncationBudget()) {
|
|
1045
|
+
log({
|
|
1046
|
+
event: 'guard',
|
|
1047
|
+
damage: 'truncated',
|
|
1048
|
+
verdict: verdict.reason,
|
|
1049
|
+
action: 'error-finish',
|
|
1050
|
+
tail: pending.text.trim().slice(-LOG_TAIL_CHARS),
|
|
1051
|
+
})
|
|
1052
|
+
yield guardFinishChunk(
|
|
1053
|
+
STREAM_TRUNCATED_CODE,
|
|
1054
|
+
'stream stopped mid-thought; treating the request as failed',
|
|
1055
|
+
)
|
|
1056
|
+
return
|
|
1057
|
+
}
|
|
1058
|
+
yield chunk
|
|
1059
|
+
return
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
yield chunk
|
|
1063
|
+
}
|
|
1064
|
+
} finally {
|
|
1065
|
+
if (!finished) {
|
|
1066
|
+
// Never awaited: a hanging provider would hold the retry for as long as it hangs.
|
|
1067
|
+
try {
|
|
1068
|
+
const pendingReturn = iterator.return?.()
|
|
1069
|
+
if (pendingReturn !== undefined && typeof pendingReturn.then === 'function') {
|
|
1070
|
+
pendingReturn.then(undefined, () => {})
|
|
1071
|
+
}
|
|
1072
|
+
} catch {
|
|
1073
|
+
/* best-effort cancellation */
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// ---------------------------------------------------------------------------
|
|
1080
|
+
// logging
|
|
1081
|
+
// ---------------------------------------------------------------------------
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* @returns {string} the default plugin log path.
|
|
1085
|
+
*/
|
|
1086
|
+
export function defaultLogFile() {
|
|
1087
|
+
const home = process.env.DSH_HOME ?? path.join(os.homedir(), '.dsh')
|
|
1088
|
+
return path.join(home, name, 'events.jsonl')
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Append one plugin-log line. Every failure path is swallowed: logging must never be
|
|
1093
|
+
* able to break a turn.
|
|
1094
|
+
* @param {object} options - resolved config.
|
|
1095
|
+
* @param {object} ctx - plugin context, for `ctx.logger`.
|
|
1096
|
+
* @param {object} record - the line to write.
|
|
1097
|
+
* @returns {void}
|
|
1098
|
+
*/
|
|
1099
|
+
export function writePluginLog(options, ctx, record) {
|
|
1100
|
+
const interesting =
|
|
1101
|
+
record.event === 'nudge' || record.event === 'error' || record.event === 'inert'
|
|
1102
|
+
if (!options.logDecisions && !interesting) return
|
|
1103
|
+
const line = JSON.stringify({ time: new Date().toISOString(), plugin: name, ...record })
|
|
1104
|
+
try {
|
|
1105
|
+
const file = options.logFile.length > 0 ? options.logFile : defaultLogFile()
|
|
1106
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
1107
|
+
fs.appendFileSync(file, `${line}\n`)
|
|
1108
|
+
} catch {
|
|
1109
|
+
/* a log write must never affect the loop */
|
|
1110
|
+
}
|
|
1111
|
+
try {
|
|
1112
|
+
if (record.event === 'error') ctx?.logger?.warn?.(`${name}: ${record.message ?? 'error'}`)
|
|
1113
|
+
else ctx?.logger?.info?.(`${name}: ${record.event}`)
|
|
1114
|
+
} catch {
|
|
1115
|
+
/* a logger must never affect the loop */
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// ---------------------------------------------------------------------------
|
|
1120
|
+
// listener
|
|
1121
|
+
// ---------------------------------------------------------------------------
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* Read (or start) the per-agent bookkeeping for the current turn.
|
|
1125
|
+
* @param {Map<string, object>} states - per-agent state.
|
|
1126
|
+
* @param {string} agentId - agent identity.
|
|
1127
|
+
* @param {number} turn - turn being closed.
|
|
1128
|
+
* @param {object} folded - folded turn facts.
|
|
1129
|
+
* @returns {{ turn: number, count: number, fingerprints: Set<string> }}
|
|
1130
|
+
*/
|
|
1131
|
+
export function stateFor(states, agentId, turn, folded) {
|
|
1132
|
+
let state = states.get(agentId)
|
|
1133
|
+
if (state === undefined || state.turn !== turn) {
|
|
1134
|
+
state = { turn, count: 0, attempts: 0, gaveUp: false, lastPushAt: undefined, fingerprints: new Set() }
|
|
1135
|
+
states.set(agentId, state)
|
|
1136
|
+
} else if (folded.userInputs > 0) {
|
|
1137
|
+
state.count = 0
|
|
1138
|
+
state.attempts = 0
|
|
1139
|
+
state.gaveUp = false
|
|
1140
|
+
state.fingerprints.clear()
|
|
1141
|
+
}
|
|
1142
|
+
return state
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* @param {number} ms - delay.
|
|
1147
|
+
* @returns {Promise<void>}
|
|
1148
|
+
*/
|
|
1149
|
+
function sleep(ms) {
|
|
1150
|
+
return new Promise((resolve) => {
|
|
1151
|
+
const timer = setTimeout(resolve, ms)
|
|
1152
|
+
if (typeof timer?.unref === 'function') timer.unref()
|
|
1153
|
+
})
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* One `agent/turn-stopping` evaluation.
|
|
1158
|
+
* @param {object} ctx - plugin context.
|
|
1159
|
+
* @param {object} options - resolved config.
|
|
1160
|
+
* @param {Map<string, object>} states - per-agent state.
|
|
1161
|
+
* @param {object} payload - event payload.
|
|
1162
|
+
* @returns {Promise<void>}
|
|
1163
|
+
*/
|
|
1164
|
+
async function onTurnStopping(ctx, options, states, payload) {
|
|
1165
|
+
const agent = payload?.agent
|
|
1166
|
+
const turn = payload?.turn
|
|
1167
|
+
const signal = payload?.signal
|
|
1168
|
+
if (agent === undefined || agent === null || typeof agent.steer !== 'function') return
|
|
1169
|
+
if (!Number.isInteger(turn)) return
|
|
1170
|
+
const session = agent.session
|
|
1171
|
+
if (session === undefined || session === null) return
|
|
1172
|
+
|
|
1173
|
+
if (signal?.aborted === true) {
|
|
1174
|
+
writePluginLog(options, ctx, { event: 'skip', turn, reason: 'turn-aborted' })
|
|
1175
|
+
return
|
|
1176
|
+
}
|
|
1177
|
+
if (agent.status !== undefined && agent.status !== 'running') {
|
|
1178
|
+
writePluginLog(options, ctx, { event: 'skip', turn, reason: `status:${String(agent.status)}` })
|
|
1179
|
+
return
|
|
1180
|
+
}
|
|
1181
|
+
if (latestGoalPhase(session) === 'active') {
|
|
1182
|
+
writePluginLog(options, ctx, { event: 'skip', turn, reason: 'goal-active' })
|
|
1183
|
+
return
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
const window = turnWindow(session, turn)
|
|
1187
|
+
if (window === undefined) {
|
|
1188
|
+
writePluginLog(options, ctx, { event: 'skip', turn, reason: 'no-turn-window' })
|
|
1189
|
+
return
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
const folded = foldTurn(window.events)
|
|
1193
|
+
const route = routeOf(folded, session)
|
|
1194
|
+
const decision = decide(folded, route, options)
|
|
1195
|
+
const agentId = String(agent.id ?? 'unknown')
|
|
1196
|
+
const state = stateFor(states, agentId, turn, folded)
|
|
1197
|
+
|
|
1198
|
+
const base = {
|
|
1199
|
+
event: 'decision',
|
|
1200
|
+
agentId,
|
|
1201
|
+
sessionId: typeof session.id === 'string' ? session.id : null,
|
|
1202
|
+
turn,
|
|
1203
|
+
route: route ?? null,
|
|
1204
|
+
hadRetry: folded.hadRetry,
|
|
1205
|
+
toolCalls: folded.toolCalls,
|
|
1206
|
+
stopReason: folded.final?.stopReason ?? null,
|
|
1207
|
+
isAsking: matchesAsking(folded, options),
|
|
1208
|
+
planTail: matchesPlanTail(folded, options),
|
|
1209
|
+
scopes: matchedScopes(folded, options),
|
|
1210
|
+
userTakeovers: folded.userInputs,
|
|
1211
|
+
priorNudges: state.count,
|
|
1212
|
+
rule: decision.rule,
|
|
1213
|
+
reason: decision.reason,
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
if (!decision.nudge) {
|
|
1217
|
+
writePluginLog(options, ctx, { ...base, nudge: false })
|
|
1218
|
+
return
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// Budget: only pushes that led nowhere count, and a push that made the model do real
|
|
1222
|
+
// work resets the streak. There is deliberately NO total-attempts ceiling: a turn that
|
|
1223
|
+
// keeps making progress must be re-opened as often as it stops. A model that stops
|
|
1224
|
+
// without producing anything simply adds to the streak and gets cut off at maxNudges.
|
|
1225
|
+
// The durable log is the authority, so a reload or a second instance cannot talk itself
|
|
1226
|
+
// past the cap.
|
|
1227
|
+
const durableStreak = folded.unproductiveStreak ?? 0
|
|
1228
|
+
state.count = durableStreak
|
|
1229
|
+
// `state.attempts` is kept for the log only (how many pushes this turn saw in total).
|
|
1230
|
+
// It must never gate a push.
|
|
1231
|
+
if (state.count >= options.maxNudges) {
|
|
1232
|
+
// R5: when we really give up, say so in the conversation instead of closing silently.
|
|
1233
|
+
if (!state.gaveUp) {
|
|
1234
|
+
state.gaveUp = true
|
|
1235
|
+
try {
|
|
1236
|
+
const notice = buildNudgeMessage({ turn, rule: 'give-up', text: options.giveUpNoticeText })
|
|
1237
|
+
agent.steer(notice)
|
|
1238
|
+
writePluginLog(options, ctx, {
|
|
1239
|
+
event: 'giveup',
|
|
1240
|
+
turn,
|
|
1241
|
+
reason: 'max-nudges-reached',
|
|
1242
|
+
attempts: state.attempts,
|
|
1243
|
+
messageId: notice.id,
|
|
1244
|
+
})
|
|
1245
|
+
} catch {
|
|
1246
|
+
/* a failed notice must never take the turn down */
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
writePluginLog(options, ctx, {
|
|
1250
|
+
...base,
|
|
1251
|
+
priorNudges: state.count,
|
|
1252
|
+
attempts: state.attempts,
|
|
1253
|
+
nudge: false,
|
|
1254
|
+
reason: 'max-nudges-reached',
|
|
1255
|
+
})
|
|
1256
|
+
return
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
const fingerprint = fingerprintOf(folded.final)
|
|
1260
|
+
if (fingerprint !== undefined && state.fingerprints.has(fingerprint)) {
|
|
1261
|
+
writePluginLog(options, ctx, { ...base, nudge: false, reason: 'duplicate-fingerprint' })
|
|
1262
|
+
return
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
if (options.dryRun) {
|
|
1266
|
+
writePluginLog(options, ctx, { ...base, nudge: false, reason: 'dry-run', wouldNudge: true })
|
|
1267
|
+
return
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// R4: never fire two pushes back to back. A one-second upstream hiccup must not eat the
|
|
1271
|
+
// whole budget, so the gap is measured from the last push we actually made.
|
|
1272
|
+
const lastPushAt = state.lastPushAt ?? folded.lastPushTime
|
|
1273
|
+
const sinceLast = typeof lastPushAt === 'number' ? Date.now() - lastPushAt : Number.POSITIVE_INFINITY
|
|
1274
|
+
const waitMs = Math.max(options.delayMs, options.nudgeMinIntervalMs - sinceLast, 0)
|
|
1275
|
+
if (waitMs > 0) {
|
|
1276
|
+
await sleep(waitMs)
|
|
1277
|
+
if (signal?.aborted === true) {
|
|
1278
|
+
writePluginLog(options, ctx, { ...base, nudge: false, reason: 'turn-aborted-after-delay' })
|
|
1279
|
+
return
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
const message = buildNudgeMessage({
|
|
1284
|
+
turn,
|
|
1285
|
+
rule: decision.rule,
|
|
1286
|
+
text:
|
|
1287
|
+
options.completionProtocol && decision.reason === 'protocol-missing'
|
|
1288
|
+
? options.protocolNudgeText
|
|
1289
|
+
: options.nudgeText,
|
|
1290
|
+
})
|
|
1291
|
+
agent.steer(message)
|
|
1292
|
+
|
|
1293
|
+
state.count += 1
|
|
1294
|
+
state.attempts += 1
|
|
1295
|
+
state.lastPushAt = Date.now()
|
|
1296
|
+
if (fingerprint !== undefined) state.fingerprints.add(fingerprint)
|
|
1297
|
+
writePluginLog(options, ctx, {
|
|
1298
|
+
...base,
|
|
1299
|
+
event: 'nudge',
|
|
1300
|
+
nudge: true,
|
|
1301
|
+
priorNudges: state.count - 1,
|
|
1302
|
+
nudgeIndex: state.attempts,
|
|
1303
|
+
attempts: state.attempts,
|
|
1304
|
+
maxNudges: options.maxNudges,
|
|
1305
|
+
messageId: message.id,
|
|
1306
|
+
})
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/**
|
|
1310
|
+
* Install the plugin.
|
|
1311
|
+
*
|
|
1312
|
+
* With `enabled: false` or `maxNudges: 0` nothing is installed and the plugin is
|
|
1313
|
+
* behaviourally identical to not being installed.
|
|
1314
|
+
* @param {object} ctx - the plugin context.
|
|
1315
|
+
* @param {unknown} config - the Loader row's config.
|
|
1316
|
+
* @returns {void}
|
|
1317
|
+
*/
|
|
1318
|
+
export function apply(ctx, config) {
|
|
1319
|
+
const options = resolveConfig(config)
|
|
1320
|
+
if (options.invalidPatterns.length > 0) {
|
|
1321
|
+
writePluginLog(options, ctx, {
|
|
1322
|
+
event: 'inert',
|
|
1323
|
+
message: `ignored ${options.invalidPatterns.length} invalid pattern(s)`,
|
|
1324
|
+
})
|
|
1325
|
+
}
|
|
1326
|
+
if (!options.enabled || options.maxNudges <= 0) {
|
|
1327
|
+
writePluginLog(options, ctx, {
|
|
1328
|
+
event: 'inert',
|
|
1329
|
+
reason: options.enabled ? 'maxNudges=0' : 'enabled=false',
|
|
1330
|
+
})
|
|
1331
|
+
return
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/** @type {Map<string, { turn: number, count: number, fingerprints: Set<string> }>} */
|
|
1335
|
+
const states = new Map()
|
|
1336
|
+
/** @type {Map<string, { used: number }>} */
|
|
1337
|
+
const guardBudgets = new Map()
|
|
1338
|
+
/** @type {Map<string, number>} */
|
|
1339
|
+
const guardRetries = new Map()
|
|
1340
|
+
|
|
1341
|
+
// ---- completion protocol: our own system-prompt section -------------------
|
|
1342
|
+
// The service key is `systemPrompt` (camelCase); `system-prompt` is the event name.
|
|
1343
|
+
// The returned disposer MUST be registered: without it every reload appends another
|
|
1344
|
+
// section to live sessions.
|
|
1345
|
+
if (options.completionProtocol) {
|
|
1346
|
+
try {
|
|
1347
|
+
const systemPrompt = ctx.get('systemPrompt')
|
|
1348
|
+
if (systemPrompt === undefined) {
|
|
1349
|
+
writePluginLog(options, ctx, {
|
|
1350
|
+
event: 'error',
|
|
1351
|
+
message: 'completionProtocol requested but no systemPrompt service is mounted',
|
|
1352
|
+
})
|
|
1353
|
+
} else {
|
|
1354
|
+
ctx.effect(() =>
|
|
1355
|
+
systemPrompt.section({
|
|
1356
|
+
name: 'auto-continue-protocol',
|
|
1357
|
+
order: PROTOCOL_SECTION_ORDER,
|
|
1358
|
+
text: buildProtocolInstruction(options),
|
|
1359
|
+
}),
|
|
1360
|
+
)
|
|
1361
|
+
writePluginLog(options, ctx, {
|
|
1362
|
+
event: 'inert',
|
|
1363
|
+
message: `completion protocol section installed (${options.markerStyle})`,
|
|
1364
|
+
})
|
|
1365
|
+
}
|
|
1366
|
+
} catch (error) {
|
|
1367
|
+
try {
|
|
1368
|
+
writePluginLog(options, ctx, {
|
|
1369
|
+
event: 'error',
|
|
1370
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1371
|
+
stage: 'protocol-section',
|
|
1372
|
+
})
|
|
1373
|
+
} catch {
|
|
1374
|
+
/* activation must not fail over a prompt section */
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
ctx.on('agent/disposed', (payload) => {
|
|
1380
|
+
try {
|
|
1381
|
+
const agentId = payload?.agent?.id
|
|
1382
|
+
if (agentId === undefined) {
|
|
1383
|
+
states.clear()
|
|
1384
|
+
guardBudgets.clear()
|
|
1385
|
+
guardRetries.clear()
|
|
1386
|
+
return
|
|
1387
|
+
}
|
|
1388
|
+
const key = String(agentId)
|
|
1389
|
+
states.delete(key)
|
|
1390
|
+
guardBudgets.delete(key)
|
|
1391
|
+
const prefix = `${key}|`
|
|
1392
|
+
for (const retryKey of guardRetries.keys()) {
|
|
1393
|
+
if (retryKey.startsWith(prefix)) guardRetries.delete(retryKey)
|
|
1394
|
+
}
|
|
1395
|
+
} catch {
|
|
1396
|
+
/* bookkeeping only */
|
|
1397
|
+
}
|
|
1398
|
+
})
|
|
1399
|
+
|
|
1400
|
+
ctx.on('agent/turn-stopping', async (payload) => {
|
|
1401
|
+
try {
|
|
1402
|
+
await onTurnStopping(ctx, options, states, payload)
|
|
1403
|
+
} catch (error) {
|
|
1404
|
+
try {
|
|
1405
|
+
writePluginLog(options, ctx, {
|
|
1406
|
+
event: 'error',
|
|
1407
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1408
|
+
turn: payload?.turn ?? null,
|
|
1409
|
+
})
|
|
1410
|
+
} catch {
|
|
1411
|
+
/* the innermost failure path still must not throw */
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
})
|
|
1415
|
+
|
|
1416
|
+
// ---- provider-side guard --------------------------------------------------
|
|
1417
|
+
if (options.streamGuard) {
|
|
1418
|
+
const logGuard = (record) => {
|
|
1419
|
+
try {
|
|
1420
|
+
writePluginLog(options, ctx, record)
|
|
1421
|
+
} catch {
|
|
1422
|
+
/* diagnostics must never affect the stream */
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
ctx.on('llm/stream', (requestOptions, next) => {
|
|
1427
|
+
const route = `${String(requestOptions?.provider)}/${String(requestOptions?.model)}`
|
|
1428
|
+
if (requestOptions?.purpose !== undefined) return next()
|
|
1429
|
+
if (options.allowModels.length > 0 && !options.allowModels.includes(route)) return next()
|
|
1430
|
+
|
|
1431
|
+
const sessionId = requestOptions?.sessionId
|
|
1432
|
+
const key = typeof sessionId === 'string' ? sessionId : 'anonymous'
|
|
1433
|
+
let budget = guardBudgets.get(key)
|
|
1434
|
+
if (budget === undefined) {
|
|
1435
|
+
budget = { used: 0 }
|
|
1436
|
+
guardBudgets.set(key, budget)
|
|
1437
|
+
}
|
|
1438
|
+
const theBudget = budget
|
|
1439
|
+
const consumeTruncationBudget = () => {
|
|
1440
|
+
if (theBudget.used >= options.maxGuardRetries) return false
|
|
1441
|
+
theBudget.used += 1
|
|
1442
|
+
return true
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
const wrapped = guardStream(next(), { options, consumeTruncationBudget, log: logGuard })
|
|
1446
|
+
|
|
1447
|
+
// Only a clean, settled finish gives the budget back; an unsettled stop that the
|
|
1448
|
+
// guard merely delegated must not reset it, or a route that truncates every time
|
|
1449
|
+
// would be intercepted again and again instead of staying capped.
|
|
1450
|
+
return (async function* () {
|
|
1451
|
+
let text = ''
|
|
1452
|
+
for await (const chunk of wrapped) {
|
|
1453
|
+
if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text
|
|
1454
|
+
if (chunk?.type === 'finish' && chunk.reason?.kind === 'stop') {
|
|
1455
|
+
if (isSettled(stripProtocolMarkers(text, options))) theBudget.used = 0
|
|
1456
|
+
}
|
|
1457
|
+
yield chunk
|
|
1458
|
+
}
|
|
1459
|
+
})()
|
|
1460
|
+
})
|
|
1461
|
+
|
|
1462
|
+
ctx.on('agent/request-error', async (payload, next) => {
|
|
1463
|
+
try {
|
|
1464
|
+
const code = payload?.failure?.code
|
|
1465
|
+
if (!GUARD_CODES.includes(code)) return next()
|
|
1466
|
+
const agentId = String(payload?.agent?.id ?? 'unknown')
|
|
1467
|
+
const retryKey = `${agentId}|${payload?.turn}|${payload?.step}|${code}`
|
|
1468
|
+
const taken = guardRetries.get(retryKey) ?? 0
|
|
1469
|
+
if (taken >= options.maxGuardRetries) {
|
|
1470
|
+
logGuard({ event: 'guard', damage: code, action: 'delegate', retriesTaken: taken })
|
|
1471
|
+
return next()
|
|
1472
|
+
}
|
|
1473
|
+
guardRetries.set(retryKey, taken + 1)
|
|
1474
|
+
logGuard({
|
|
1475
|
+
event: 'guard',
|
|
1476
|
+
damage: code,
|
|
1477
|
+
action: 'retry',
|
|
1478
|
+
retryIndex: taken + 1,
|
|
1479
|
+
turn: payload?.turn ?? null,
|
|
1480
|
+
step: payload?.step ?? null,
|
|
1481
|
+
})
|
|
1482
|
+
return { kind: 'retry' }
|
|
1483
|
+
} catch (error) {
|
|
1484
|
+
try {
|
|
1485
|
+
logGuard({
|
|
1486
|
+
event: 'error',
|
|
1487
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1488
|
+
stage: 'guard-retry',
|
|
1489
|
+
})
|
|
1490
|
+
} catch {
|
|
1491
|
+
/* never let guard logging break recovery */
|
|
1492
|
+
}
|
|
1493
|
+
return next()
|
|
1494
|
+
}
|
|
1495
|
+
})
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
ctx.effect(() => () => {
|
|
1499
|
+
states.clear()
|
|
1500
|
+
guardBudgets.clear()
|
|
1501
|
+
guardRetries.clear()
|
|
1502
|
+
})
|
|
1503
|
+
}
|