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 +21 -0
- package/README.md +37 -0
- package/cordis.patch.yml +6 -0
- package/lib/index.d.ts +75 -0
- package/lib/index.js +331 -0
- package/package.json +29 -0
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.
|
package/cordis.patch.yml
ADDED
|
@@ -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
|
+
}
|