dsh-loop-continue 0.1.0 → 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1
4
+
5
+ ### Fixed
6
+
7
+ - **Judge route leaked a literal `null` provider.** With `judgeProvider` absent
8
+ the guard tested `!== undefined`, so a `null` in the profile config was passed
9
+ straight to the LLM service as `{ provider: null, model: null }`. The service
10
+ reports an unknown provider through an error *finish chunk* rather than a
11
+ throw, so the call failed silently, the judge read an empty answer, and every
12
+ turn logged `verdict=false`. The guard now treats `null` and unset alike and
13
+ follows the conversation's own provider/model.
14
+ - **A judge failure was invisible.** An error finish chunk is now surfaced as
15
+ `judge failed: ...` instead of being parsed as a `false` verdict.
16
+ - **Steering could burn the whole per-turn budget on the same refusal.** The
17
+ guard now records the session-log position at each steer and checks whether
18
+ any step in between actually called a tool. A steer the model ignored ends the
19
+ turn instead of spending the remaining budget.
20
+
21
+ ### Changed
22
+
23
+ - **Configuration is resolved per evaluation** rather than frozen at mount, so
24
+ a change reaches the next turn-stopping check without a restart.
25
+ - **A Settings section is installed** (namespace `loop-continue`) for live
26
+ reconfiguration from the harness Settings UI.
27
+ - `judgeProvider`/`judgeModel` at `null` or unset both mean "follow the
28
+ conversation's route"; the judge no longer derives a route from the global
29
+ default-model setting.
30
+
31
+ ## 0.1.0
32
+
33
+ Initial release: continue a turn whose trailing text promised an action that no
34
+ tool call performed.
package/README.md CHANGED
@@ -18,9 +18,9 @@ more step; `false` (or an unparseable answer) lets the turn close.
18
18
  |------------------|---------|------------------------------------------------|
19
19
  | `maxContinuations` | 10 | hard cap on steering per turn (no infinite loop) |
20
20
  | `maxSteps` | 10 | newest steps shown to the judge |
21
- | `maxTailChars` | 2000 | cap on the trailing assistant text |
22
- | `judgeProvider` | null | override provider; null = follow session route |
23
- | `judgeModel` | null | override model; null = follow session route |
21
+ | `maxTailChars` | 2000 | trailing-text budget split across the first and last halves |
22
+ | `judgeProvider` | null | override provider; null/unset = derive from the active default model |
23
+ | `judgeModel` | null | override model; null/unset = derive from the active default model |
24
24
  | `judgeMaxTokens` | 64 | judge output cap |
25
25
  | `judgeTemperature` | 0 | judge sampling temperature |
26
26
  | `steerText` | built-in | message that resumes the turn |
@@ -35,3 +35,51 @@ No model call runs unless the turn *both*:
35
35
 
36
36
  This keeps the extra judge call off ordinary finished turns and only spends it
37
37
  where the model plausibly dropped a pending action.
38
+
39
+ ## Stopping early
40
+
41
+ Steering is not free: each continuation costs a judge round-trip plus one more
42
+ agent step. So the guard also watches whether a steer *worked*.
43
+
44
+ When it steers, it records how far the session log had grown. The next time the
45
+ same turn stops, it checks whether any assistant step in between actually called
46
+ a tool. If the model only narrated again, the steer was ignored and the turn is
47
+ closed instead of spending the remaining budget on the same refusal. A model
48
+ that answers the steer with a real tool call keeps its normal budget.
49
+
50
+ ## Configuring the guard
51
+
52
+ The guard resolves its config on every evaluation rather than freezing it at
53
+ mount, so a change reaches the very next turn-stopping check. Two ways to set
54
+ values:
55
+
56
+ - the harness **Settings UI** (the plugin installs a `loop-continue` section), or
57
+ - the profile's own `cordis.patch.yml`.
58
+
59
+ Prefer leaving `judgeProvider`/`judgeModel` unset. The guard then derives a
60
+ registered route from the active default model, which always resolves; a
61
+ hand-written route can name a provider the LLM service does not have registered,
62
+ and the judge call then fails.
63
+
64
+ ## Hot mount
65
+
66
+ `cordis.patch.yml` ships a **plain insert** — only `id` + `name`, no `config`
67
+ and no `!!js` expressions — so a market hot-mount can add or remove this plugin
68
+ as a minimal row. No user-specific endpoint, provider, or key is baked into the
69
+ patch: policy is resolved at runtime as described above.
70
+
71
+ A patch-layer change is replayed in full on every reload
72
+ (`applyEntryPatches` clones the entry list before applying), so a row adds and
73
+ removes cleanly and never accumulates.
74
+
75
+ Whether a patch edit takes effect *without a restart* depends on the host:
76
+
77
+ - Under the CLI (`dsh profile`), `runProfile` installs an HMR service and
78
+ registers the profile and user patch files with it, so patch edits are applied
79
+ live.
80
+ - Under **DSH Desktop 2.0.3** that path is not taken — the desktop shell
81
+ composes the profile itself (`dsh-app-boot` helpers) and never loads
82
+ `cordis-plugin-hmr`, so a `cordis.patch.yml` edit needs a profile restart.
83
+
84
+ Edits to `lib/*.js` always need a restart: the dsh HMR service is created with
85
+ `root: []`, so no source directory is watched for module replacement.
package/cordis.patch.yml CHANGED
@@ -1,6 +1,15 @@
1
1
  # dsh bundle patch: inserts this plugin into a profile's layer stack.
2
- # Plain insert only (id + name), so hot-mount can activate it live.
3
- # Config lives in the profile's own cordis.patch.yml (or Settings UI).
2
+ #
3
+ # This is a PLAIN insert -- only `id` + `name`, no `config`, no `!!js`
4
+ # expressions -- so the market's hot-mount parser can activate it live without
5
+ # a profile restart. `applyEntryPatches` structuredClones the entry list and
6
+ # replays the whole patch stack on every reload, so a minimal row adds and
7
+ # removes cleanly.
8
+ #
9
+ # Policy is NOT frozen here. The guard resolves its config on every evaluation
10
+ # (and accepts a live settings source), so values set in the profile's own
11
+ # cordis.patch.yml or in the harness Settings UI apply to the next turn without
12
+ # a restart.
4
13
  - insert:
5
14
  - id: loop-continue
6
15
  name: 'dsh-loop-continue'
package/lib/index.d.ts CHANGED
@@ -17,11 +17,11 @@ export interface Config {
17
17
  maxContinuations?: number
18
18
  /** Newest steps shown to the judge. */
19
19
  maxSteps?: number
20
- /** Cap on the trailing assistant text handed to the judge. */
20
+ /** Total trailing-text characters handed to the judge, split across the first and last halves. */
21
21
  maxTailChars?: number
22
- /** Override provider; null = follow the session route. */
22
+ /** Override provider; null/unset derives from the active default model. */
23
23
  judgeProvider?: string | null
24
- /** Override model; null = follow the session route. */
24
+ /** Override model; null/unset derives from the active default model. */
25
25
  judgeModel?: string | null
26
26
  /** Judge output cap. */
27
27
  judgeMaxTokens?: number
@@ -73,3 +73,6 @@ export declare function parseVerdict(text: string): boolean
73
73
 
74
74
  /** Register the turn-stopping guard on a Cordis context. */
75
75
  export declare function apply(ctx: unknown, config: Config): void
76
+
77
+ /** Settings namespace carrying this plugin's policy; drives live reconfiguration. */
78
+ export declare const SETTINGS_NAMESPACE: 'loop-continue'
package/lib/index.js CHANGED
@@ -33,7 +33,7 @@ const DEFAULT_MAX_CONTINUATIONS = 10
33
33
  /** How many most-recent steps of the current turn the summary shows. */
34
34
  const DEFAULT_MAX_STEPS = 10
35
35
 
36
- /** Cap on the trailing assistant text handed to the judge. */
36
+ /** Total characters of trailing text handed to the judge, split across the first and last halves. */
37
37
  const DEFAULT_MAX_TAIL_CHARS = 2000
38
38
 
39
39
  export const Config = z.object({
@@ -119,16 +119,40 @@ export function summarizeTurn(events, turn, maxSteps) {
119
119
  }
120
120
  }
121
121
 
122
- // The user instruction that opened this turn is the nearest preceding
123
- // human message; it anchors the judge's notion of "the task".
122
+ // The judge needs the task this turn is answering. Walk the preceding
123
+ // human turns in reverse, skipping bare acknowledgements ("continue",
124
+ // "ok", ...) that carry no task information, and keep the most recent few
125
+ // substantive requests in chronological order.
126
+ const acknowledgements = new Set([
127
+ '继续', '继续吧', '接着', '继续搞', '好的', '好', '嗯', '知道了',
128
+ 'ok', 'okay', 'go on', 'continue', 'go', 'next',
129
+ ])
130
+ const isAcknowledgement = text => acknowledgements.has(text.trim().toLowerCase())
131
+ const requests = []
124
132
  for (let i = events.length - 1; i >= 0; i -= 1) {
125
133
  const event = events[i]
126
134
  if (event.type !== 'user/message' || event.data?.turn > turn) continue
127
- const source = event.data.source
128
- if (source?.kind !== 'user') continue
129
- userRequest = (event.data.content ?? [])
130
- .filter(b => b.type === 'text').map(b => b.text).join('\n')
131
- break
135
+ if (event.data.source?.kind !== 'user') continue
136
+ const text = (event.data.content ?? [])
137
+ .filter(b => b.type === 'text').map(b => b.text).join('\n').trim()
138
+ if (!text) continue
139
+ if (isAcknowledgement(text)) continue
140
+ requests.unshift(text)
141
+ if (requests.length >= 3) break
142
+ }
143
+ // Fall back to the nearest human message when every preceding one is a bare
144
+ // acknowledgement (or there are none); a weak anchor beats a blank one.
145
+ if (requests.length === 0) {
146
+ for (let i = events.length - 1; i >= 0; i -= 1) {
147
+ const event = events[i]
148
+ if (event.type !== 'user/message' || event.data?.turn > turn) continue
149
+ if (event.data.source?.kind !== 'user') continue
150
+ userRequest = (event.data.content ?? [])
151
+ .filter(b => b.type === 'text').map(b => b.text).join('\n').trim()
152
+ if (userRequest) break
153
+ }
154
+ } else {
155
+ userRequest = requests.join('\n')
132
156
  }
133
157
 
134
158
  const window = steps.slice(-maxSteps)
@@ -164,8 +188,13 @@ export function renderSummary(summary, maxTailChars) {
164
188
  const tools = s.tools.length > 0 ? s.tools.join(', ') : 'no tool call'
165
189
  return ` step ${s.step}: ${tools}`
166
190
  })
191
+ // The model states its *next* action near the front ("now I will..."),
192
+ // then often rambles; the very end usually re-commits. Keep both ends so
193
+ // the judge sees the promise without eating the whole budget.
167
194
  const tail = summary.text.length > maxTailChars
168
- ? `${summary.text.slice(-maxTailChars)}`
195
+ ? `${summary.text.slice(0, Math.floor(maxTailChars / 2))}\n`
196
+ + '...\n[truncated middle]\n...\n'
197
+ + `${summary.text.slice(-Math.ceil(maxTailChars / 2))}`
169
198
  : summary.text
170
199
  const priorNote = summary.truncated
171
200
  ? ` (showing only the last ${summary.steps.length} steps)\n`
@@ -210,8 +239,12 @@ async function judge(ctx, route, summary, config, signal) {
210
239
  'A turn ends when the agent writes text and calls no tool.',
211
240
  'Decide whether that trailing text states an action the agent still',
212
241
  'intends to perform, or merely reports completed work.',
213
- 'A promise of future action ("now I will...", "next I will...") that no',
214
- 'tool call in the step list performed means the task is unfinished.',
242
+ 'A promise of future action means the task is unfinished when no tool',
243
+ 'call in the step list performed it. This includes explicit "now I will...",',
244
+ '"next I will..." as well as softer commitments like "let me check/confirm',
245
+ '/verify ... then ...", "I need to look at ...", "let me first ...", or',
246
+ 'phrases that name a pending read, edit, run, or lookup the agent has not',
247
+ 'yet performed.',
215
248
  'A finished report, a question to the human, or a final answer means the',
216
249
  'task is finished.',
217
250
  'Reply with exactly one word: true or false.',
@@ -237,6 +270,14 @@ async function judge(ctx, route, summary, config, signal) {
237
270
 
238
271
  const text = assembler.blocks()
239
272
  .filter(b => b.type === 'text').map(b => b.text).join('')
273
+ // A missing or unknown provider is swallowed into an error finish chunk by
274
+ // the LLM service; surface it so a misconfigured judge route is visible in
275
+ // the log instead of reading as an endless stream of `verdict=false`.
276
+ const finish = assembler.finish
277
+ if (finish?.kind === 'error') {
278
+ const detail = finish.failure?.message ?? JSON.stringify(finish.failure ?? {})
279
+ throw new Error(`judge call failed: ${detail}`)
280
+ }
240
281
  return parseVerdict(text)
241
282
  }
242
283
 
@@ -246,26 +287,15 @@ async function judge(ctx, route, summary, config, signal) {
246
287
  * @param config - plugin policy.
247
288
  */
248
289
  export function apply(ctx, config) {
249
- const maxContinuations = config.maxContinuations ?? DEFAULT_MAX_CONTINUATIONS
250
- const maxSteps = config.maxSteps ?? DEFAULT_MAX_STEPS
251
- const maxTailChars = config.maxTailChars ?? DEFAULT_MAX_TAIL_CHARS
252
- const resolved = {
253
- ...config,
254
- maxContinuations,
255
- maxSteps,
256
- maxTailChars,
257
- judgeMaxTokens: config.judgeMaxTokens ?? 64,
258
- judgeTemperature: config.judgeTemperature ?? 0,
259
- steerText: config.steerText ?? (
260
- 'You described an action but did not call any tool. Continue the task now: '
261
- + 'call the tool for the action you just described. Do not narrate — emit the tool call.'
262
- ),
263
- debug: config.debug ?? false,
264
- }
290
+ // The live config source. A Settings-UI change swaps this closure instead of
291
+ // rebuilding the plugin, so every evaluation below reads the current values.
292
+ let current = () => config
293
+ registerSettings(ctx, config, { setSource: source => { current = source } })
265
294
 
266
- ctx.logger?.info?.(`[${name}] loaded: maxContinuations=${resolved.maxContinuations} `
267
- + `maxSteps=${resolved.maxSteps} judge=${resolved.judgeProvider ?? '<session-route>'}`
268
- + `/${resolved.judgeModel ?? '<session-route>'} debug=${String(resolved.debug)}`)
295
+ const initial = resolveConfig(current())
296
+ ctx.logger?.info?.(`[${name}] loaded: maxContinuations=${initial.maxContinuations} `
297
+ + `maxSteps=${initial.maxSteps} judge=${initial.judgeProvider ?? '<session-route>'}`
298
+ + `/${initial.judgeModel ?? '<session-route>'} debug=${String(initial.debug)}`)
269
299
 
270
300
  /**
271
301
  * Continuations already spent per turn. The turn number is stable while
@@ -274,7 +304,16 @@ export function apply(ctx, config) {
274
304
  */
275
305
  const spent = new Map()
276
306
 
307
+ /**
308
+ * Turns waiting to see whether their last steer changed anything. A steer
309
+ * that the model ignores costs a full judge round-trip and produces another
310
+ * narration, so one ignored steer ends the turn instead of burning the whole
311
+ * budget. Cleared as soon as the probe is read, or when the turn closes.
312
+ */
313
+ const pendingProbe = new Map()
314
+
277
315
  ctx.on('agent/turn-stopping', async ({ agent, turn, signal }) => {
316
+ const resolved = resolveConfig(current())
278
317
  const used = spent.get(turn) ?? 0
279
318
  if (used >= resolved.maxContinuations) {
280
319
  // Keep the exhausted entry: the hook fires again after every extra step
@@ -283,10 +322,31 @@ export function apply(ctx, config) {
283
322
  return
284
323
  }
285
324
 
286
- const summary = summarizeTurn(readEvents(agent.session), turn, resolved.maxSteps)
325
+ const events = readEvents(agent.session)
326
+ const summary = summarizeTurn(events, turn, resolved.maxSteps)
287
327
  if (!looksUnfinished(summary)) return
288
328
 
289
- const route = resolved.judgeProvider !== undefined && resolved.judgeModel !== undefined
329
+ // Did the previous steer actually make the model call a tool? A turn that
330
+ // narrates again after being told to emit the call will keep doing so, so
331
+ // stop here rather than spend the remaining budget on the same refusal.
332
+ const probe = pendingProbe.get(turn)
333
+ if (probe !== undefined) {
334
+ pendingProbe.delete(turn)
335
+ const calledTool = events.slice(probe.eventsAtSteer).some(event => {
336
+ if (event.type !== 'assistant/message' || event.data?.turn !== turn) return false
337
+ return (event.data.message?.content ?? []).some(b => b.type === 'tool-call')
338
+ })
339
+ if (!calledTool) {
340
+ spent.set(turn, resolved.maxContinuations)
341
+ if (resolved.debug) {
342
+ ctx.logger?.info?.(`[${name}] turn ${turn}: previous steer produced no tool call; `
343
+ + 'stopping instead of steering again')
344
+ }
345
+ return
346
+ }
347
+ }
348
+
349
+ const route = resolved.judgeProvider != null && resolved.judgeModel != null
290
350
  ? { provider: resolved.judgeProvider, model: resolved.judgeModel }
291
351
  : routeOf(agent)
292
352
 
@@ -309,6 +369,7 @@ export function apply(ctx, config) {
309
369
  }
310
370
 
311
371
  spent.set(turn, used + 1)
372
+ pendingProbe.set(turn, { eventsAtSteer: events.length })
312
373
  agent.steer(createUserMessage({
313
374
  content: [{ type: 'text', text: resolved.steerText }],
314
375
  source: PLUGIN_SOURCE,
@@ -317,15 +378,90 @@ export function apply(ctx, config) {
317
378
 
318
379
  /** Drop bookkeeping once a turn truly closes. */
319
380
  ctx.on('session/event', (_session, event) => {
320
- if (event.type === 'turn/end') spent.delete(event.data.turn)
381
+ if (event.type === 'turn/end') {
382
+ spent.delete(event.data.turn)
383
+ pendingProbe.delete(event.data.turn)
384
+ }
321
385
  })
322
386
  }
323
387
 
324
- /** Read the turn's effective provider/model from the session request header. */
388
+ /**
389
+ * Resolve one config snapshot.
390
+ *
391
+ * Called per evaluation rather than once at mount so a Settings-UI or patch
392
+ * change takes effect on the next turn-stopping hook without a restart.
393
+ *
394
+ * @param config - raw plugin config from the loader or the Settings section.
395
+ * @returns the config with every default applied.
396
+ */
397
+ function resolveConfig(config) {
398
+ return {
399
+ ...config,
400
+ maxContinuations: config.maxContinuations ?? DEFAULT_MAX_CONTINUATIONS,
401
+ maxSteps: config.maxSteps ?? DEFAULT_MAX_STEPS,
402
+ maxTailChars: config.maxTailChars ?? DEFAULT_MAX_TAIL_CHARS,
403
+ judgeMaxTokens: config.judgeMaxTokens ?? 64,
404
+ judgeTemperature: config.judgeTemperature ?? 0,
405
+ steerText: config.steerText ?? (
406
+ 'You described an action but did not call any tool. Continue the task now: '
407
+ + 'call the tool for the action you just described. Do not narrate — emit the tool call.'
408
+ ),
409
+ debug: config.debug ?? false,
410
+ }
411
+ }
412
+
413
+ /** Settings namespace so the harness Settings UI can drive this plugin live. */
414
+ export const SETTINGS_NAMESPACE = 'loop-continue'
415
+
416
+ /**
417
+ * Mount the plugin's Settings section.
418
+ *
419
+ * Registration is cosmetic: the guard works from the loader config even when
420
+ * no settings service is present. On a new core the section is installed
421
+ * through this plugin's own injector scope, so it lives for exactly this
422
+ * plugin's lifetime and disappears with it; on an older core the top-level
423
+ * helper is imported lazily, because the symbol only exists there.
424
+ *
425
+ * @param ctx - plugin context.
426
+ * @param config - base config used until a settings source replaces it.
427
+ * @param hooks - `setSource` receives the live settings-backed getter.
428
+ */
429
+ function registerSettings(ctx, config, hooks) {
430
+ if (typeof ctx.inject === 'function') {
431
+ ctx.inject(['settings'], (settingsCtx) => {
432
+ const provider = settingsCtx?.settings
433
+ if (provider !== undefined && typeof provider.installSection === 'function') {
434
+ provider.installSection(ctx, SETTINGS_NAMESPACE, Config, config, hooks)
435
+ }
436
+ })
437
+ return
438
+ }
439
+ import('@deepseek-ai/dsh-settings').then(mod => {
440
+ if (typeof mod.installSettingsSection === 'function') {
441
+ mod.installSettingsSection(ctx, SETTINGS_NAMESPACE, Config, config, hooks)
442
+ }
443
+ }).catch(() => {
444
+ // Settings registration is cosmetic: the guard still works from the
445
+ // loader config when the section could not be mounted.
446
+ })
447
+ }
448
+
449
+ /**
450
+ * Resolve the provider/model the judge should call.
451
+ *
452
+ * The judge follows the model the current conversation is actually running:
453
+ * the turn is stopping right now, so that route is by definition working. No
454
+ * fallback and no derivation — a route that cannot be read is a real fault and
455
+ * must surface instead of being papered over with a different model.
456
+ *
457
+ * @param agent - the turn's agent subject.
458
+ * @returns the conversation's own provider/model route.
459
+ */
325
460
  function routeOf(agent) {
326
461
  const config = agent.session.requestHeader()?.config
327
- if (config === undefined) {
328
- throw new Error(`[${name}] no request header yet; set judgeProvider/judgeModel explicitly`)
462
+ if (config?.provider !== undefined && config?.model !== undefined) {
463
+ return { provider: config.provider, model: config.model }
329
464
  }
330
- return { provider: config.provider, model: config.model }
465
+ throw new Error(`[${name}] the session has no request header yet; `
466
+ + 'set judgeProvider/judgeModel explicitly')
331
467
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-loop-continue",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Continue a DeepSeek Harness agent turn when the model narrated but did not call a tool.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -11,14 +11,25 @@
11
11
  },
12
12
  "./package.json": "./package.json"
13
13
  },
14
- "files": ["lib/index.js", "lib/index.d.ts", "cordis.patch.yml", "README.md"],
14
+ "files": [
15
+ "lib/index.js",
16
+ "lib/index.d.ts",
17
+ "cordis.patch.yml",
18
+ "CHANGELOG.md",
19
+ "README.md"
20
+ ],
15
21
  "license": "MIT",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/yunxiyang/dsh-loop-continue.git"
25
+ },
16
26
  "dependencies": {
17
27
  "@deepseek-ai/schemastery": "^3.18.1"
18
28
  },
19
29
  "peerDependencies": {
20
30
  "@deepseek-ai/cordis": "^4.0.1",
21
31
  "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
32
+ "@deepseek-ai/dsh-settings": "^0.1.1-rc.2",
22
33
  "@deepseek-ai/dsh-session": "^0.1.1-rc.2"
23
34
  },
24
35
  "dsh": {