dsh-loop-continue 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 yunxiyang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,37 @@
1
+ # dsh-loop-continue
2
+
3
+ Continue a DeepSeek Harness agent turn when the model only *narrated* its next
4
+ action and forgot the tool call.
5
+
6
+ The agent loop closes a turn as `completed` the moment a step emits text and no
7
+ tool call. A model that writes "now I will re-apply the change" and stops looks
8
+ finished to the loop, even though the work is half done.
9
+
10
+ This plugin listens on `agent/turn-stopping`, replays the current turn's own
11
+ session log, and only asks a judge model one strict `true`/`false` question
12
+ when the shape matches that failure mode. `true` steers the same turn to run one
13
+ more step; `false` (or an unparseable answer) lets the turn close.
14
+
15
+ ## Config
16
+
17
+ | field | default | meaning |
18
+ |------------------|---------|------------------------------------------------|
19
+ | `maxContinuations` | 10 | hard cap on steering per turn (no infinite loop) |
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 |
24
+ | `judgeMaxTokens` | 64 | judge output cap |
25
+ | `judgeTemperature` | 0 | judge sampling temperature |
26
+ | `steerText` | built-in | message that resumes the turn |
27
+ | `debug` | false | log every evaluation |
28
+
29
+ ## Deterministic gates
30
+
31
+ No model call runs unless the turn *both*:
32
+
33
+ 1. ended on a text-only step (no tool call), and
34
+ 2. called at least one tool earlier (so a plain one-shot answer is untouched).
35
+
36
+ This keeps the extra judge call off ordinary finished turns and only spends it
37
+ where the model plausibly dropped a pending action.
@@ -0,0 +1,6 @@
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).
4
+ - insert:
5
+ - id: loop-continue
6
+ name: 'dsh-loop-continue'
package/lib/index.d.ts ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Continue an agent turn when the model only narrated its next action instead
3
+ * of calling a tool.
4
+ *
5
+ * @module dsh-loop-continue
6
+ */
7
+
8
+ /** Cordis plugin name used by loader diagnostics. */
9
+ export declare const name: 'loop-continue'
10
+
11
+ /** The services this plugin reads from its Cordis context. */
12
+ export declare const inject: ['llm']
13
+
14
+ /** Resolved plugin policy; every field has a default, so all are optional. */
15
+ export interface Config {
16
+ /** Hard cap on steering per turn (no infinite loop). */
17
+ maxContinuations?: number
18
+ /** Newest steps shown to the judge. */
19
+ maxSteps?: number
20
+ /** Cap on the trailing assistant text handed to the judge. */
21
+ maxTailChars?: number
22
+ /** Override provider; null = follow the session route. */
23
+ judgeProvider?: string | null
24
+ /** Override model; null = follow the session route. */
25
+ judgeModel?: string | null
26
+ /** Judge output cap. */
27
+ judgeMaxTokens?: number
28
+ /** Judge sampling temperature. */
29
+ judgeTemperature?: number
30
+ /** Message that resumes a steered turn. */
31
+ steerText?: string
32
+ /** Emit a diagnostic line for every hook evaluation. */
33
+ debug?: boolean
34
+ }
35
+
36
+ /** Reconstructed facts about one step of a turn. */
37
+ export interface StepSummary {
38
+ step: number
39
+ tools: string[]
40
+ text: string
41
+ }
42
+
43
+ /** Deterministic facts pulled from one turn's own log entries. */
44
+ export interface TurnSummary {
45
+ steps: StepSummary[]
46
+ text: string
47
+ userRequest: string
48
+ truncated: boolean
49
+ }
50
+
51
+ /**
52
+ * Reconstruct what one turn actually did from its own log entries.
53
+ *
54
+ * @param events - full session event log snapshot.
55
+ * @param turn - turn whose steps should be summarized.
56
+ * @param maxSteps - how many trailing steps to include.
57
+ */
58
+ export declare function summarizeTurn(events: unknown[], turn: number, maxSteps: number): TurnSummary
59
+
60
+ /**
61
+ * Decide whether one turn's log looks like an unfinished, silently-abandoned
62
+ * narration, before spending an LLM judge call.
63
+ */
64
+ export declare function looksUnfinished(summary: TurnSummary): boolean
65
+
66
+ /** Render the deterministic step summary as compact plain text. */
67
+ export declare function renderSummary(summary: TurnSummary, maxTailChars: number): string
68
+
69
+ /**
70
+ * Parse a strict boolean verdict; anything not clearly `true` counts as false.
71
+ */
72
+ export declare function parseVerdict(text: string): boolean
73
+
74
+ /** Register the turn-stopping guard on a Cordis context. */
75
+ export declare function apply(ctx: unknown, config: Config): void
package/lib/index.js ADDED
@@ -0,0 +1,331 @@
1
+ /**
2
+ * Continue an agent turn when the model only narrated its next action instead
3
+ * of calling a tool.
4
+ *
5
+ * The agent loop ends a turn as `completed` whenever a step produces text but
6
+ * no tool call (packages/core/agent-loop/src/agent.ts). A model that announces
7
+ * "now I will re-apply the change" and forgets the actual call therefore looks
8
+ * finished, and the turn closes mid-task.
9
+ *
10
+ * This plugin listens on `agent/turn-stopping`, reads this turn's own session
11
+ * log, and only when the shape matches that failure mode asks a model one
12
+ * yes/no question. A `true` answer steers, which keeps the SAME turn open and
13
+ * runs one more step.
14
+ *
15
+ * @module dsh-loop-continue
16
+ */
17
+
18
+ import z from '@deepseek-ai/schemastery'
19
+ import { BlockAssembler, createUserMessage } from '@deepseek-ai/dsh-llm'
20
+
21
+ /** Cordis plugin name used by loader diagnostics. */
22
+ export const name = 'loop-continue'
23
+
24
+ /** The hook, LLM service, and session projections this plugin reads. */
25
+ export const inject = ['llm']
26
+
27
+ /** Where a steering message claims to come from. */
28
+ const PLUGIN_SOURCE = { kind: 'plugin', plugin: name }
29
+
30
+ /** How many extra steps one turn may buy, across all of its stopping boundaries. */
31
+ const DEFAULT_MAX_CONTINUATIONS = 10
32
+
33
+ /** How many most-recent steps of the current turn the summary shows. */
34
+ const DEFAULT_MAX_STEPS = 10
35
+
36
+ /** Cap on the trailing assistant text handed to the judge. */
37
+ const DEFAULT_MAX_TAIL_CHARS = 2000
38
+
39
+ export const Config = z.object({
40
+ maxContinuations: z.number().step(1).min(0).default(DEFAULT_MAX_CONTINUATIONS),
41
+ maxSteps: z.number().step(1).min(1).default(DEFAULT_MAX_STEPS),
42
+ maxTailChars: z.number().step(1).min(1).default(DEFAULT_MAX_TAIL_CHARS),
43
+ judgeProvider: z.union([z.string(), z.const(null)]),
44
+ judgeModel: z.union([z.string(), z.const(null)]),
45
+ judgeMaxTokens: z.number().step(1).min(1).default(64),
46
+ judgeTemperature: z.number().default(0),
47
+ /** Steer text sent back to the model; the model then runs one more step. */
48
+ steerText: z.string().default(
49
+ 'You described an action but did not call any tool. Continue the task now: '
50
+ + 'call the tool for the action you just described. Do not narrate — emit the tool call.',
51
+ ),
52
+ /** Emit a diagnostic line for every hook evaluation. */
53
+ debug: z.boolean().default(false),
54
+ })
55
+
56
+ /**
57
+ * Read a session's immutable event log across host core versions.
58
+ *
59
+ * 0.1.1-rc.2 (DSH Desktop 2.0.3) exposes events as a `session.events` getter;
60
+ * 0.1.5-alpha.2 renamed it to `session.snapshotEvents()`. Calling the wrong
61
+ * one yields the "is not a function" failure, so probe both and use whichever
62
+ * the running host actually provides.
63
+ *
64
+ * @param session - the agent's live session.
65
+ * @returns a frozen event array, or an empty array when neither API exists.
66
+ */
67
+ function readEvents(session) {
68
+ if (typeof session?.snapshotEvents === 'function') {
69
+ return session.snapshotEvents()
70
+ }
71
+ if (session && Array.isArray(session.events)) {
72
+ return session.events
73
+ }
74
+ return []
75
+ }
76
+
77
+ /**
78
+ * Reconstruct what actually happened in one turn from its own log entries.
79
+ *
80
+ * Every agent/session event carries `turn`, so the summary is filtered by the
81
+ * turn number the hook was handed. Nothing here is inferred from prose: a step
82
+ * shows a tool call only if an `assistant/message` in that step really carried
83
+ * a `tool-call` block.
84
+ *
85
+ * @param events - full session event log snapshot.
86
+ * @param turn - turn whose steps should be summarized.
87
+ * @param maxSteps - how many trailing steps to include.
88
+ * @returns per-step facts plus the trailing assistant text.
89
+ */
90
+ export function summarizeTurn(events, turn, maxSteps) {
91
+ const steps = []
92
+ let current = null
93
+ let lastText = ''
94
+ let userRequest = ''
95
+
96
+ for (const event of events) {
97
+ const data = event.data
98
+ if (data?.turn !== turn) continue
99
+
100
+ switch (event.type) {
101
+ case 'step/start': {
102
+ current = { step: data.step, tools: [], text: '' }
103
+ steps.push(current)
104
+ break
105
+ }
106
+ case 'assistant/message': {
107
+ const blocks = data.message?.content ?? []
108
+ const text = blocks.filter(b => b.type === 'text').map(b => b.text).join('\n')
109
+ const tools = blocks.filter(b => b.type === 'tool-call').map(b => b.name)
110
+ if (current !== null) {
111
+ current.tools.push(...tools)
112
+ if (text) current.text = text
113
+ }
114
+ if (text) lastText = text
115
+ break
116
+ }
117
+ default:
118
+ break
119
+ }
120
+ }
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".
124
+ for (let i = events.length - 1; i >= 0; i -= 1) {
125
+ const event = events[i]
126
+ 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
132
+ }
133
+
134
+ const window = steps.slice(-maxSteps)
135
+ return { steps: window, text: lastText, userRequest, truncated: steps.length > window.length }
136
+ }
137
+
138
+ /**
139
+ * Decide whether one turn's log looks like an unfinished, silently-abandoned
140
+ * narration.
141
+ *
142
+ * Deterministic gates run before any model call:
143
+ * - the turn must have ended on a text-only step (no tool call),
144
+ * - the turn must have called at least one tool earlier, which excludes a
145
+ * plain question answered directly in one step,
146
+ * - the trailing text must exist and be non-trivial.
147
+ *
148
+ * @param summary - facts produced by {@link summarizeTurn}.
149
+ * @returns whether the LLM judge is worth invoking.
150
+ */
151
+ export function looksUnfinished(summary) {
152
+ const { steps, text } = summary
153
+ if (steps.length === 0) return false
154
+ const last = steps[steps.length - 1]
155
+ if (last.tools.length > 0) return false
156
+ const earlierTools = steps.slice(0, -1).some(s => s.tools.length > 0)
157
+ if (!earlierTools) return false
158
+ return text.trim().length > 0
159
+ }
160
+
161
+ /** Render the deterministic step summary as compact plain text. */
162
+ export function renderSummary(summary, maxTailChars) {
163
+ const lines = summary.steps.map(s => {
164
+ const tools = s.tools.length > 0 ? s.tools.join(', ') : 'no tool call'
165
+ return ` step ${s.step}: ${tools}`
166
+ })
167
+ const tail = summary.text.length > maxTailChars
168
+ ? `${summary.text.slice(-maxTailChars)}`
169
+ : summary.text
170
+ const priorNote = summary.truncated
171
+ ? ` (showing only the last ${summary.steps.length} steps)\n`
172
+ : ''
173
+ return [
174
+ 'Steps actually executed in this turn:',
175
+ ...lines,
176
+ priorNote.trimEnd(),
177
+ '',
178
+ 'Trailing assistant message (text only, no tool call):',
179
+ tail,
180
+ '',
181
+ 'The human request this turn is answering:',
182
+ summary.userRequest || '(unknown)',
183
+ ].filter(line => line !== '').join('\n')
184
+ }
185
+
186
+ /**
187
+ * Parse a strict boolean verdict. Anything that is not clearly true counts as
188
+ * false, so an unparseable answer can never extend a turn.
189
+ */
190
+ export function parseVerdict(text) {
191
+ const normalized = text.trim().toLowerCase()
192
+ const first = normalized.match(/\b(true|false)\b/)?.[1]
193
+ return first === 'true'
194
+ }
195
+
196
+ /**
197
+ * Ask the judge model whether the trailing text promises work that no tool
198
+ * call performed.
199
+ *
200
+ * @param ctx - plugin context exposing `ctx.llm`.
201
+ * @param route - provider/model to call.
202
+ * @param summary - deterministic turn facts.
203
+ * @param config - resolved plugin policy.
204
+ * @param signal - the turn's abort signal.
205
+ * @returns whether the turn should continue.
206
+ */
207
+ async function judge(ctx, route, summary, config, signal) {
208
+ const system = [
209
+ 'You inspect one coding-agent turn that just ended.',
210
+ 'A turn ends when the agent writes text and calls no tool.',
211
+ 'Decide whether that trailing text states an action the agent still',
212
+ '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.',
215
+ 'A finished report, a question to the human, or a final answer means the',
216
+ 'task is finished.',
217
+ 'Reply with exactly one word: true or false.',
218
+ ].join(' ')
219
+
220
+ const messages = [{
221
+ role: 'user',
222
+ content: [{ type: 'text', text: renderSummary(summary, config.maxTailChars) }],
223
+ }]
224
+
225
+ const assembler = new BlockAssembler()
226
+ for await (const chunk of ctx.llm.stream({
227
+ provider: route.provider,
228
+ model: route.model,
229
+ system,
230
+ messages,
231
+ maxTokens: config.judgeMaxTokens,
232
+ temperature: config.judgeTemperature,
233
+ signal,
234
+ })) {
235
+ assembler.push(chunk)
236
+ }
237
+
238
+ const text = assembler.blocks()
239
+ .filter(b => b.type === 'text').map(b => b.text).join('')
240
+ return parseVerdict(text)
241
+ }
242
+
243
+ /**
244
+ * Register the turn-stopping guard.
245
+ * @param ctx - plugin context.
246
+ * @param config - plugin policy.
247
+ */
248
+ 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
+ }
265
+
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)}`)
269
+
270
+ /**
271
+ * Continuations already spent per turn. The turn number is stable while
272
+ * steering keeps the same turn open, so this is a hard per-turn budget; a
273
+ * new human message opens a new turn number and a fresh budget.
274
+ */
275
+ const spent = new Map()
276
+
277
+ ctx.on('agent/turn-stopping', async ({ agent, turn, signal }) => {
278
+ const used = spent.get(turn) ?? 0
279
+ if (used >= resolved.maxContinuations) {
280
+ // Keep the exhausted entry: the hook fires again after every extra step
281
+ // this turn buys, so deleting it here would hand the turn a fresh budget
282
+ // and make maxContinuations unbounded. turn/end clears it instead.
283
+ return
284
+ }
285
+
286
+ const summary = summarizeTurn(readEvents(agent.session), turn, resolved.maxSteps)
287
+ if (!looksUnfinished(summary)) return
288
+
289
+ const route = resolved.judgeProvider !== undefined && resolved.judgeModel !== undefined
290
+ ? { provider: resolved.judgeProvider, model: resolved.judgeModel }
291
+ : routeOf(agent)
292
+
293
+ let verdict = false
294
+ try {
295
+ verdict = await judge(ctx, route, summary, resolved, signal)
296
+ } catch (error) {
297
+ // The judge is advisory: a failure must never wedge the turn.
298
+ if (resolved.debug) {
299
+ ctx.logger?.warn?.(`[${name}] judge failed: ${String(error)}`)
300
+ }
301
+ return
302
+ }
303
+
304
+ if (resolved.debug) {
305
+ ctx.logger?.info?.(`[${name}] turn ${turn} verdict=${String(verdict)} spent=${used}`)
306
+ }
307
+ if (!verdict) {
308
+ return
309
+ }
310
+
311
+ spent.set(turn, used + 1)
312
+ agent.steer(createUserMessage({
313
+ content: [{ type: 'text', text: resolved.steerText }],
314
+ source: PLUGIN_SOURCE,
315
+ }))
316
+ })
317
+
318
+ /** Drop bookkeeping once a turn truly closes. */
319
+ ctx.on('session/event', (_session, event) => {
320
+ if (event.type === 'turn/end') spent.delete(event.data.turn)
321
+ })
322
+ }
323
+
324
+ /** Read the turn's effective provider/model from the session request header. */
325
+ function routeOf(agent) {
326
+ const config = agent.session.requestHeader()?.config
327
+ if (config === undefined) {
328
+ throw new Error(`[${name}] no request header yet; set judgeProvider/judgeModel explicitly`)
329
+ }
330
+ return { provider: config.provider, model: config.model }
331
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "dsh-loop-continue",
3
+ "version": "0.1.0",
4
+ "description": "Continue a DeepSeek Harness agent turn when the model narrated but did not call a tool.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "default": "./lib/index.js"
11
+ },
12
+ "./package.json": "./package.json"
13
+ },
14
+ "files": ["lib/index.js", "lib/index.d.ts", "cordis.patch.yml", "README.md"],
15
+ "license": "MIT",
16
+ "dependencies": {
17
+ "@deepseek-ai/schemastery": "^3.18.1"
18
+ },
19
+ "peerDependencies": {
20
+ "@deepseek-ai/cordis": "^4.0.1",
21
+ "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
22
+ "@deepseek-ai/dsh-session": "^0.1.1-rc.2"
23
+ },
24
+ "dsh": {
25
+ "bundle": {
26
+ "patch": "./cordis.patch.yml"
27
+ }
28
+ }
29
+ }