thincoder 0.12.50 → 0.12.51
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 +34 -3
- package/package.json +4 -3
- package/src/acp/bridge.mjs +4 -0
- package/src/agent/dispatch.mjs +19 -7
- package/src/agent/helpers.mjs +12 -0
- package/src/agent/record-results.mjs +130 -0
- package/src/agent/setup.mjs +3 -6
- package/src/agent/spawn-child.mjs +159 -0
- package/src/agent-tools/consult.mjs +95 -73
- package/src/agent-tools/escalate.mjs +53 -62
- package/src/agent-tools/subagent.mjs +39 -38
- package/src/agent.mjs +25 -109
- package/src/generate-title.mjs +30 -1
- package/src/prompts/advisor-round1.md +5 -6
- package/src/prompts/advisor-round2.md +3 -4
- package/src/prompts/advisor-round3.md +3 -4
- package/src/prompts/eng-coder.md +9 -0
- package/src/prompts/engineering.md +61 -9
- package/src/prompts/system.md +1 -1
- package/src/session.mjs +40 -1
- package/src/tools/system.mjs +3 -1
- package/src/tui/agent-turn.mjs +44 -363
- package/src/tui/dims.mjs +74 -0
- package/src/tui/fold-block.mjs +208 -0
- package/src/tui/index.mjs +53 -16
- package/src/tui/key-handler-search.mjs +1 -1
- package/src/tui/key-handler.mjs +9 -6
- package/src/tui/layout.mjs +21 -20
- package/src/tui/mouse.mjs +8 -6
- package/src/tui/pickers.mjs +1 -1
- package/src/tui/render-conversation.mjs +368 -113
- package/src/tui/render-frame.mjs +9 -88
- package/src/tui/render-loop.mjs +12 -8
- package/src/tui/render.mjs +5 -0
- package/src/tui/startup.mjs +67 -13
- package/src/tui/subagent-blocks.mjs +326 -0
- package/src/tui/tool-args.mjs +67 -0
- package/src/tui/tool-events.mjs +461 -0
|
@@ -11,9 +11,14 @@
|
|
|
11
11
|
* CONSULT_BASE overlay }); activity streams to the parent TUI via the relay
|
|
12
12
|
* prefix `consult#<id>/` (same channel subagent uses), not onSubagent/onToolPanel.
|
|
13
13
|
*/
|
|
14
|
-
import { createAgent, runAgent, readonlyToolNames
|
|
14
|
+
import { createAgent, runAgent, readonlyToolNames } from "../agent.mjs"
|
|
15
15
|
import { resolveChildProvider } from "./subagent.mjs"
|
|
16
|
-
import {
|
|
16
|
+
import { makeRelay, wrapChildCallbacks, runWithContinue, ensureChildApiKey, clampEffort } from "../agent/spawn-child.mjs"
|
|
17
|
+
|
|
18
|
+
// Named consult defaults (consult P2, 2026-08-30).
|
|
19
|
+
const CONSULT_TIMEOUT_MS = 600_000 // default consult lifecycle timeout
|
|
20
|
+
const CONSULT_TURNS = 40 // default per-child turn cap
|
|
21
|
+
|
|
17
22
|
|
|
18
23
|
function consultLabel(m) {
|
|
19
24
|
return `${m.provider}:${m.model}`
|
|
@@ -21,7 +26,9 @@ function consultLabel(m) {
|
|
|
21
26
|
|
|
22
27
|
/** Narrow the configured consultModels pool to a requested subset.
|
|
23
28
|
* Each selector is "provider:model", a bare provider name, or a bare model name
|
|
24
|
-
* (case-insensitive).
|
|
29
|
+
* (case-insensitive). A trailing " (effort)" suffix is tolerated (round2 复核
|
|
30
|
+
* 对齐 escalate.mjs:withPool 列表会带 " (high)" 后缀,模型照抄应可匹配).
|
|
31
|
+
* Returns { models, error } — error set when a selector matches
|
|
25
32
|
* nothing (surface the typo rather than silently dropping it). Absent/empty selectors
|
|
26
33
|
* → the full pool. */
|
|
27
34
|
function selectConsultModels(pool, selectors) {
|
|
@@ -31,7 +38,8 @@ function selectConsultModels(pool, selectors) {
|
|
|
31
38
|
const seen = new Set()
|
|
32
39
|
const unknowns = []
|
|
33
40
|
for (const raw of list) {
|
|
34
|
-
|
|
41
|
+
// eslint-disable-next-line no-control-regex -- fixed non-control suffix
|
|
42
|
+
const s = String(raw).replace(/\s+\([^)]*\)\s*$/, "").trim().toLowerCase()
|
|
35
43
|
const matches = pool.filter((m) =>
|
|
36
44
|
consultLabel(m).toLowerCase() === s ||
|
|
37
45
|
String(m.provider ?? "").toLowerCase() === s ||
|
|
@@ -85,11 +93,18 @@ export function makeMainHistoryTool(parentAgent) {
|
|
|
85
93
|
: ""
|
|
86
94
|
return `--- [${m.role}] ---\n${content}${calls ? "\n" + calls : ""}`
|
|
87
95
|
}
|
|
88
|
-
const BUDGET = 60_000
|
|
96
|
+
const BUDGET = 60_000 // per-consult token budget (tokens)
|
|
89
97
|
let out = ""
|
|
90
98
|
for (let i = slice.length - 1; i >= 0; i--) {
|
|
91
99
|
const line = render(slice[i])
|
|
92
|
-
if (out.length + line.length > BUDGET) {
|
|
100
|
+
if (out.length + line.length > BUDGET) {
|
|
101
|
+
// A single message over the whole budget: truncate IT (it is the newest
|
|
102
|
+
// and most relevant) instead of dropping everything with a misleading
|
|
103
|
+
// "earlier messages trimmed" note. Older accumulation still trims.
|
|
104
|
+
if (out === "") { out = line.slice(0, BUDGET) + "\n(… truncated — single message exceeded budget " + BUDGET + " chars)"; break }
|
|
105
|
+
out = `(earlier messages trimmed — budget ${BUDGET} chars)\n\n` + out
|
|
106
|
+
break
|
|
107
|
+
}
|
|
93
108
|
out = out ? line + "\n\n" + out : line
|
|
94
109
|
}
|
|
95
110
|
return out
|
|
@@ -119,7 +134,7 @@ function settleChild(session, id, label, ok, payload) {
|
|
|
119
134
|
|
|
120
135
|
async function runConsultChild(ctx, session, id, m, problem, ctrl) {
|
|
121
136
|
const agent = ctx.agent
|
|
122
|
-
const timeoutMs = agent?.config?.agent?.consultTimeoutMs ??
|
|
137
|
+
const timeoutMs = agent?.config?.agent?.consultTimeoutMs ?? CONSULT_TIMEOUT_MS
|
|
123
138
|
let timedOut = false
|
|
124
139
|
const armWatchdog = () => {
|
|
125
140
|
const t = setTimeout(() => {
|
|
@@ -135,7 +150,7 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
|
|
|
135
150
|
// Provider resolution: consultModels entries are { provider, model, effort? } — resolve
|
|
136
151
|
// via the subagent's provider resolver ("provider:model" handles cross-provider picks).
|
|
137
152
|
const provider = resolveChildProvider(agent, `${m.provider}:${m.model}`)
|
|
138
|
-
if (!provider
|
|
153
|
+
if (!ensureChildApiKey(provider)) {
|
|
139
154
|
// resolveChildProvider may still lack a key; fail loudly like the plugin precheck
|
|
140
155
|
// (settleChild turns this message into a clear failed reply instead of a raw 401)
|
|
141
156
|
throw new Error(`consult model ${label} has no API key — check providers[${m.provider}].apiKey in config.json`)
|
|
@@ -145,14 +160,7 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
|
|
|
145
160
|
// Symmetric with escalate.mjs; 2026-08-16 a real consult died on qwen3.8-max
|
|
146
161
|
// effort "high" (enum is xhigh/medium/low). Out-of-enum: DROP the effort entirely
|
|
147
162
|
// (the provider preset default may ALSO be out-of-enum for this override model).
|
|
148
|
-
|
|
149
|
-
const enumList = specForModel(m.model).reasoningEffortEnum
|
|
150
|
-
if (enumList && !enumList.includes(m.effort)) {
|
|
151
|
-
delete provider.reasoningEffort
|
|
152
|
-
} else {
|
|
153
|
-
provider.reasoningEffort = m.effort
|
|
154
|
-
}
|
|
155
|
-
}
|
|
163
|
+
clampEffort(provider, m.model, m.effort)
|
|
156
164
|
|
|
157
165
|
// Read-only consultant: filter the parent tool set down to readonly tools + main_history.
|
|
158
166
|
const allowed = readonlyToolNames(agent.tools ?? [])
|
|
@@ -172,57 +180,57 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
|
|
|
172
180
|
role: "consult",
|
|
173
181
|
})
|
|
174
182
|
|
|
175
|
-
// Activity relay
|
|
176
|
-
// (same channel subagent uses — parallel consultants stay independent)
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
ctx.callbacks?.onToken?.(relayPrefix + "[model]" + (provider.model ?? ""))
|
|
182
|
-
const childCallbacks = {
|
|
183
|
-
onToken: ctx.callbacks?.onToken ? (t) => ctx.callbacks.onToken(`${relayPrefix}${t}`) : null,
|
|
184
|
-
onReasoning: ctx.callbacks?.onReasoning ? (r) => ctx.callbacks.onReasoning(`${relayPrefix}${r}`) : null,
|
|
185
|
-
onToolCall: ctx.callbacks?.onToolCall ? (name, args) => ctx.callbacks.onToolCall(`${relayPrefix}${name}`, args) : null,
|
|
186
|
-
}
|
|
183
|
+
// Activity relay via the unified spawn-child pipeline (§7.2 D3): `consult#<subId>/`
|
|
184
|
+
// prefix (same channel subagent uses — parallel consultants stay independent) +
|
|
185
|
+
// onToolOutput passthrough so the consultant's tool output lands in its TUI block.
|
|
186
|
+
const relayPrefix = makeRelay(agent, "consult", ctx.callbacks?.onToken, provider.model ?? "")
|
|
187
|
+
const childCallbacks = wrapChildCallbacks(relayPrefix, ctx.callbacks ?? {})
|
|
188
|
+
let declined = false // review #1: guard against double-settle when onDeclined fired
|
|
187
189
|
|
|
188
|
-
// Turn-cap continue loop (TURN-CAP-CONTINUE.md)
|
|
189
|
-
// the SAME y/n panel the main agent uses
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
190
|
+
// Turn-cap continue loop (TURN-CAP-CONTINUE.md) via runWithContinue (§7.2 D3): hitting
|
|
191
|
+
// the cap asks the user via the SAME y/n panel the main agent uses — unlimited
|
|
192
|
+
// continues, each with a fresh turn budget AND a re-armed wall-clock watchdog (a
|
|
193
|
+
// continue is a fresh budget, the clock restarts too). Parallel consultants serialize
|
|
194
|
+
// their prompts through a session-level queue. Declined / headless → failed reply
|
|
195
|
+
// (partial diagnosis).
|
|
194
196
|
const runner = ctx.runAgent ?? runAgent
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
return
|
|
205
|
-
} catch (e) {
|
|
206
|
-
if (e instanceof ContinueError) {
|
|
207
|
-
let go = false
|
|
208
|
-
if (ctx.onPermissionRequest) {
|
|
197
|
+
try {
|
|
198
|
+
const result = await runWithContinue(
|
|
199
|
+
(childAgent, input, cbs, opts) => runner(childAgent, input, cbs, opts),
|
|
200
|
+
child, "# Problem\n" + problem,
|
|
201
|
+
childCallbacks,
|
|
202
|
+
{ depth: 1, maxTurns: agent?.config?.agent?.consultTurns ?? CONSULT_TURNS, signal: ctrl.signal },
|
|
203
|
+
{
|
|
204
|
+
askContinue: (e) => {
|
|
205
|
+
if (!ctx.onPermissionRequest) return Promise.resolve(false)
|
|
209
206
|
const ask = () => ctx.onPermissionRequest("continue", { turns: e.turn, agent: label })
|
|
210
207
|
session.continueQueue = (session.continueQueue ?? Promise.resolve()).then(ask, ask)
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
208
|
+
return session.continueQueue.then((go) => {
|
|
209
|
+
if (go) {
|
|
210
|
+
clearTimeout(watchdog)
|
|
211
|
+
timedOut = false // fresh budget → fresh clock
|
|
212
|
+
watchdog = armWatchdog()
|
|
213
|
+
}
|
|
214
|
+
return go
|
|
215
|
+
})
|
|
216
|
+
},
|
|
217
|
+
onDeclined: (e) => {
|
|
218
|
+
declined = true
|
|
219
|
+
settleChild(session, id, label, false, `turn cap reached (${e.turn} turns) — stopped, diagnosis may be partial`)
|
|
220
|
+
return undefined
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
)
|
|
224
|
+
// Review #1 fix: onDeclined already settled this child as a failed reply —
|
|
225
|
+
// settling again here would push a phantom empty success reply and decrement
|
|
226
|
+
// `pending` twice (negative pending → consult_check's two exits both
|
|
227
|
+
// unreachable → permanent block until user abort).
|
|
228
|
+
if (!declined) settleChild(session, id, label, true, String(result ?? ""))
|
|
229
|
+
} catch (e) {
|
|
230
|
+
// Runner errors (incl. the watchdog's abort) settle as a failed reply — the
|
|
231
|
+
// continue/declined paths are already handled inside runWithContinue.
|
|
232
|
+
const note = timedOut ? `consultation timed out after ${Math.round(timeoutMs / 60000)}min (agent.consultTimeoutMs)` : e?.message ?? String(e)
|
|
233
|
+
settleChild(session, id, label, false, note)
|
|
226
234
|
}
|
|
227
235
|
} catch (e) {
|
|
228
236
|
// Errors BEFORE the runner (provider resolution, createAgent) or a throwing
|
|
@@ -243,6 +251,11 @@ export function cleanupConsultSessions(agent) {
|
|
|
243
251
|
for (const w of s.waiters?.splice(0) ?? []) { try { w() } catch { /* noop */ } }
|
|
244
252
|
}
|
|
245
253
|
agent._consultSessions?.clear()
|
|
254
|
+
// NOTE: deliberately void (consult P3, 2026-08-30). The { stopped: true } marker
|
|
255
|
+
// only reaches the TUI via the consult_stop TOOL return (onToolResult freezes
|
|
256
|
+
// blocks on tool calls) — cleanup runs from the turn finally, where the block
|
|
257
|
+
// freeze is owned by freezeAllSubTasks + sweepToolBlocks, so a return here is
|
|
258
|
+
// dead weight. Blocks still get frozen on interrupt via that sweep.
|
|
246
259
|
}
|
|
247
260
|
|
|
248
261
|
export const consultStartTool = {
|
|
@@ -313,14 +326,19 @@ export const consultCheckTool = {
|
|
|
313
326
|
"have settled. The reply is raw and unjudged — verify/adopt it with your own tools. When done is true, no more " +
|
|
314
327
|
"replies are coming.\n" +
|
|
315
328
|
"Call it ALONE in a turn — do NOT batch it with calls that depend on its reply (readonly tools run in parallel).\n" +
|
|
329
|
+
"Replies arrive in arrival order: call it repeatedly (n = 1, 2, 3, …) until done is true.\n" +
|
|
316
330
|
"Parameters:\n" +
|
|
317
|
-
"- id (required): the consult id from consult_start"
|
|
331
|
+
"- id (required): the consult id from consult_start\n" +
|
|
332
|
+
"- n (required): the 1-based read number for this consult — pass 1 on the first check, 2 on the next, and so on. It exists so consecutive checks are distinct tool calls (loop detectors) and the transcript reads as a sequence.",
|
|
318
333
|
parameters: {
|
|
319
334
|
type: "object",
|
|
320
|
-
properties: {
|
|
321
|
-
|
|
335
|
+
properties: {
|
|
336
|
+
id: { type: "string", description: "Consult id" },
|
|
337
|
+
n: { type: "number", description: "1-based read number: 1 for the first check, incrementing with each subsequent check of the same consult" },
|
|
338
|
+
},
|
|
339
|
+
required: ["id", "n"],
|
|
322
340
|
},
|
|
323
|
-
async execute({ id }, ctx) {
|
|
341
|
+
async execute({ id, n: _n }, ctx) {
|
|
324
342
|
const s = ctx.agent?._consultSessions?.get(String(id))
|
|
325
343
|
if (!s) return JSON.stringify({ error: "unknown consult id" })
|
|
326
344
|
const abortAll = () => { for (const c of s.controllers) { try { c.abort() } catch { /* noop */ } } }
|
|
@@ -367,18 +385,22 @@ export const consultStopTool = {
|
|
|
367
385
|
"Terminate the still-running consultations of a session once a reply is good enough — saves tokens and time. " +
|
|
368
386
|
"Already-answered replies stay available for consult_check.\n" +
|
|
369
387
|
"Parameters:\n" +
|
|
370
|
-
"- id (required): the consult id from consult_start"
|
|
388
|
+
"- id (required): the consult id from consult_start\n" +
|
|
389
|
+
"- n (required): incrementing call number for this consult (next value after the last consult_check/consult_stop) — keeps repeated calls distinct.",
|
|
371
390
|
parameters: {
|
|
372
391
|
type: "object",
|
|
373
|
-
properties: {
|
|
374
|
-
|
|
392
|
+
properties: {
|
|
393
|
+
id: { type: "string", description: "Consult id" },
|
|
394
|
+
n: { type: "number", description: "Incrementing call number for this consult (see consult_check)" },
|
|
395
|
+
},
|
|
396
|
+
required: ["id", "n"],
|
|
375
397
|
},
|
|
376
|
-
async execute({ id }, ctx) {
|
|
398
|
+
async execute({ id, n }, ctx) {
|
|
377
399
|
const s = ctx.agent?._consultSessions?.get(String(id))
|
|
378
400
|
if (!s) return JSON.stringify({ error: "unknown consult id" })
|
|
379
|
-
const
|
|
401
|
+
const abandoned = s.pending
|
|
380
402
|
s.stopped = true
|
|
381
403
|
for (const c of s.controllers) { try { c.abort() } catch { /* already settled */ } }
|
|
382
|
-
return JSON.stringify({ stopped: n })
|
|
404
|
+
return JSON.stringify({ stopped: n, abandoned })
|
|
383
405
|
},
|
|
384
406
|
}
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
* CLI mergeChildMutations(parent, child) (agent object, not a state sink).
|
|
17
17
|
*/
|
|
18
18
|
import { isAbsolute, relative } from "node:path"
|
|
19
|
-
import { createAgent, runAgent,
|
|
19
|
+
import { createAgent, runAgent, CODER_OVERLAY, DEFAULT_SUBAGENT_TURNS } from "../agent.mjs"
|
|
20
20
|
import { resolveChildProvider, mergeChildMutations } from "./subagent.mjs"
|
|
21
|
-
import {
|
|
21
|
+
import { makeRelay, wrapChildCallbacks, runWithContinue, ensureChildApiKey, clampEffort, TURN_CAP_MARK } from "../agent/spawn-child.mjs"
|
|
22
22
|
|
|
23
23
|
const label = (m) => `${m.provider}:${m.model}`
|
|
24
24
|
|
|
@@ -70,31 +70,21 @@ export const escalateTool = {
|
|
|
70
70
|
} catch (e) {
|
|
71
71
|
return `Error: ${e.message}`
|
|
72
72
|
}
|
|
73
|
-
if (!provider
|
|
73
|
+
if (!ensureChildApiKey(provider)) {
|
|
74
74
|
return `Error: provider "${pick.provider}" has no API key — set it in config.json before flying it in`
|
|
75
75
|
}
|
|
76
76
|
let effortNote = ""
|
|
77
|
-
if (pick.effort) {
|
|
78
|
-
//
|
|
79
|
-
// value makes provider/core.mjs throw on EVERY chat call (candidate dies on takeoff).
|
|
80
|
-
// Out-of-enum: DROP the effort entirely (the provider preset default may ALSO be
|
|
77
|
+
if (pick.effort && !clampEffort(provider, pick.model, pick.effort)) {
|
|
78
|
+
// Out-of-enum effort dropped (see clampEffort): the provider preset default may ALSO be
|
|
81
79
|
// out-of-enum for this override model — e.g. qwenplan preset default "high" is
|
|
82
|
-
// invalid for qwen3.8-max, enum xhigh/medium/low
|
|
83
|
-
|
|
84
|
-
if (enumList && !enumList.includes(pick.effort)) {
|
|
85
|
-
effortNote = ` (effort "${pick.effort}" unsupported by ${pick.model}, dropped)`
|
|
86
|
-
delete provider.reasoningEffort
|
|
87
|
-
} else {
|
|
88
|
-
provider.reasoningEffort = pick.effort
|
|
89
|
-
}
|
|
80
|
+
// invalid for qwen3.8-max, enum xhigh/medium/low.
|
|
81
|
+
effortNote = ` (effort "${pick.effort}" unsupported by ${pick.model}, dropped)`
|
|
90
82
|
}
|
|
91
83
|
|
|
92
|
-
parent._subAgentCounter = (parent._subAgentCounter ?? 0) + 1
|
|
93
|
-
const subId = parent._subAgentCounter
|
|
94
84
|
const tag = label(pick)
|
|
95
|
-
const relayPrefix =
|
|
96
|
-
// Report the escalated model to the display layer (it may differ from the parent's)
|
|
97
|
-
|
|
85
|
+
const relayPrefix = makeRelay(parent, "escalate", ctx.callbacks?.onToken, provider.model ?? tag)
|
|
86
|
+
// Report the escalated model to the display layer (it may differ from the parent's)
|
|
87
|
+
// — makeRelay already emitted the `[model]` metadata token above.
|
|
98
88
|
|
|
99
89
|
// No wall-clock watchdog — turn cap only, exactly like subagent (the verified write
|
|
100
90
|
// path). Rationale (2026-08-16): a fixed wall-clock aborts NORMAL-but-slow surgery —
|
|
@@ -103,12 +93,12 @@ export const escalateTool = {
|
|
|
103
93
|
// signal propagates directly below). maxTurns is the cost budget; hitting it asks
|
|
104
94
|
// the user whether to continue (main-agent parity), falling back to partial work.
|
|
105
95
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
96
|
+
// No custom onToken here (consult P2, 2026-08-30): wrapChildCallbacks already
|
|
97
|
+
// applies the prefixed relay + D7 sentinel strip for the display path, and
|
|
98
|
+
// runWithContinue owns the capture (stripEventTokensForCapture) for the
|
|
99
|
+
// partial-output return — a hand-rolled duplicate ran the strip twice and
|
|
100
|
+
// maintained a second output buffer.
|
|
101
|
+
const childCallbacks = wrapChildCallbacks(relayPrefix, ctx.callbacks ?? {})
|
|
112
102
|
|
|
113
103
|
// Declared outside try so the catch can merge mutations even on a partial failure.
|
|
114
104
|
let child = null
|
|
@@ -127,48 +117,49 @@ export const escalateTool = {
|
|
|
127
117
|
const runner = ctx.runAgent ?? runAgent
|
|
128
118
|
const runOpts = {
|
|
129
119
|
depth: 1,
|
|
130
|
-
maxTurns: parent.config?.agent?.subagentTurns ??
|
|
120
|
+
maxTurns: parent.config?.agent?.subagentTurns ?? DEFAULT_SUBAGENT_TURNS, // review #7: constant, not literal (single source with subagent.mjs)
|
|
131
121
|
signal: ctx.signal ?? null,
|
|
132
122
|
}
|
|
133
|
-
// Turn-cap continue, main-agent parity (tui/agent-turn.mjs):
|
|
134
|
-
// ContinueError, ask the user through the SAME channel as child
|
|
135
|
-
// (ctx.onPermissionRequest). The name "continue" renders the TUI's
|
|
136
|
-
// Continue panel
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
// (headless) or a declined prompt falls through to the partial-work return.
|
|
123
|
+
// Turn-cap continue via runWithContinue (§7.2 D3), main-agent parity (tui/agent-turn.mjs):
|
|
124
|
+
// when the child hits ContinueError, ask the user through the SAME channel as child
|
|
125
|
+
// write approval (ctx.onPermissionRequest). The name "continue" renders the TUI's
|
|
126
|
+
// dedicated y/n Continue panel. The resumed run passes resume:true, so runAgent does
|
|
127
|
+
// NOT re-inject the task text (setup.mjs skips input on resume) and keeps the child's
|
|
128
|
+
// history + mutation bookkeeping, with a fresh maxTurns budget per run. No permission
|
|
129
|
+
// handler (headless) or a declined prompt falls through to the partial-work return.
|
|
141
130
|
// Continues are UNLIMITED — the user can decline at any prompt.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
// Escalate mutations are the parent's mutations: verify/advisor guards must see them
|
|
152
|
-
mergeChildMutations(parent, child)
|
|
153
|
-
return `escalate (${tag})${effortNote} post-op report:\n${report || output.slice(0, 4000)}${touchedFilesNote(child, parent.cwd)}`
|
|
154
|
-
} catch (e) {
|
|
155
|
-
// Even a failed surgery may have written files — merge whatever the child touched.
|
|
156
|
-
mergeChildMutations(parent, child)
|
|
157
|
-
const msg = e?.message ?? String(e)
|
|
158
|
-
if (ctx.signal?.aborted || e?.name === "AbortError") throw e
|
|
159
|
-
if (e instanceof ContinueError) {
|
|
160
|
-
if (ctx.onPermissionRequest) {
|
|
161
|
-
const go = await ctx.onPermissionRequest("continue", { turns: e.turn, agent: tag })
|
|
162
|
-
if (go) continue // fresh maxTurns budget; task NOT re-injected (resume:true)
|
|
163
|
-
}
|
|
164
|
-
return `escalate (${tag}) stopped: turn cap reached (${e.turn} turns) — work may be partial; review recent_changes before deciding next steps.\nPartial output: ${output.slice(0, 2000)}`
|
|
131
|
+
const report = await runWithContinue(
|
|
132
|
+
async (childAgent, input, cbs, opts) => {
|
|
133
|
+
// Merge mid-run mutations even when the run throws — the outer catch keeps
|
|
134
|
+
// handling createAgent failures; AbortError still propagates (user Stop).
|
|
135
|
+
try {
|
|
136
|
+
return await runner(childAgent, input, cbs, opts)
|
|
137
|
+
} catch (e) {
|
|
138
|
+
mergeChildMutations(parent, childAgent)
|
|
139
|
+
throw e
|
|
165
140
|
}
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
|
|
141
|
+
},
|
|
142
|
+
child, task, { ...childCallbacks, onPermissionRequest: parent.autoApprove ? async () => true : (ctx.onPermissionRequest ?? null) },
|
|
143
|
+
runOpts,
|
|
144
|
+
{
|
|
145
|
+
// escalate has NO permQueue: prompts go straight to the user (T-L spec).
|
|
146
|
+
askContinue: (e) => (ctx.onPermissionRequest
|
|
147
|
+
? ctx.onPermissionRequest("continue", { turns: e.turn, agent: tag })
|
|
148
|
+
: Promise.resolve(false)),
|
|
149
|
+
onDeclined: (e, output) => `escalate (${tag}) ${TURN_CAP_MARK} (${e.turn} turns) — work may be partial; review recent_changes before deciding next steps.\nPartial output: ${output.slice(0, 2000)}`,
|
|
150
|
+
},
|
|
151
|
+
).catch((e) => {
|
|
152
|
+
// Generic run failure (not ContinueError): match the original loop's return shape —
|
|
153
|
+
// error text + partial output, mutations already merged in the runner wrapper.
|
|
154
|
+
if (ctx.signal?.aborted || e?.name === "AbortError") throw e
|
|
155
|
+
return `escalate (${tag}) error: ${e?.message ?? String(e)}\nPartial output: ${(child._capturedOutput ?? "").slice(0, 2000)}`
|
|
156
|
+
})
|
|
157
|
+
// Escalate mutations are the parent's mutations: verify/advisor guards must see them
|
|
158
|
+
mergeChildMutations(parent, child)
|
|
159
|
+
return `escalate (${tag})${effortNote} post-op report:\n${report || (child._capturedOutput ?? "").slice(0, 4000)}${touchedFilesNote(child, parent.cwd)}`
|
|
169
160
|
} catch (e) {
|
|
170
161
|
// Reached only when createAgent itself fails or the continue prompt throws —
|
|
171
|
-
// run failures are handled inside the
|
|
162
|
+
// run failures are handled above (mutations merge inside the runner wrapper).
|
|
172
163
|
if (child) mergeChildMutations(parent, child)
|
|
173
164
|
if (ctx.signal?.aborted || e?.name === "AbortError") throw e
|
|
174
165
|
return `escalate (${tag}) error: ${e?.message ?? String(e)}`
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import {
|
|
2
|
-
createAgent, runAgent,
|
|
2
|
+
createAgent, runAgent,
|
|
3
3
|
readonlyToolNames, collectGitContext, escapeXml,
|
|
4
4
|
EXPLORE_OVERLAY, CODER_OVERLAY, PLAN_OVERLAY, ENG_CODER_OVERLAY,
|
|
5
5
|
MIN_REPORT_CHARS, REPORT_CONTINUATION, DEFAULT_SUBAGENT_TURNS,
|
|
6
6
|
} from "../agent.mjs"
|
|
7
|
+
import { makeRelay, wrapChildCallbacks, runWithContinue, TURN_CAP_MARK } from "../agent/spawn-child.mjs"
|
|
7
8
|
import { validateDesignToken } from "./advisor.mjs"
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* subagent tool: spawn a child agent to handle an independent subtask (isolated context, only the report is returned).
|
|
11
12
|
* - role: "explore" — read-only tools, search/read/analyze (suitable for codebase exploration)
|
|
12
13
|
* - role: "coder" — full tool set, self-contained implementation tasks (suitable for isolated coding)
|
|
13
|
-
* - no role specified —
|
|
14
|
+
* - no role specified — invalid by design since the 2026-08-25 fail-closed gate
|
|
15
|
+
* (role is mandatory; "no role → same tool set as parent" was removed with the
|
|
16
|
+
* coder-leak fix and the header text above predates it)
|
|
14
17
|
* - parallel subagent calls via the parallel channel (parallel: true)
|
|
15
18
|
* - non-recursive: child agents do not get the subagent tool (depth > 0 is not injected)
|
|
16
19
|
*/
|
|
@@ -169,54 +172,48 @@ export const subagentTool = {
|
|
|
169
172
|
if (gitCtx) input = `<untrusted_git_context>\n${escapeXml(gitCtx)}\n</untrusted_git_context>\n\n${input}`
|
|
170
173
|
}
|
|
171
174
|
|
|
172
|
-
// Relay content/reasoning
|
|
173
|
-
// Prefix includes a unique id: parallel child agents
|
|
175
|
+
// Relay content/reasoning/tool/output to the parent TUI via the unified spawn-child
|
|
176
|
+
// pipeline (AGENT-LOOP.md §7.2 D3). Prefix includes a unique id: parallel child agents
|
|
177
|
+
// with the same role stay independent and don't overwrite each other.
|
|
174
178
|
// Format: role#id/ → onToken("coder#2/writing..."), onToolCall("coder#2/read", args)
|
|
175
|
-
|
|
176
|
-
const subId = parent._subAgentCounter
|
|
177
|
-
const relayPrefix = `${role ?? "sub"}#${subId}/`
|
|
178
|
-
// Report the subagent's effective model to the display layer (it may differ from the
|
|
179
|
-
// parent's). Emitted as a `[model]` metadata token via the relay prefix — the TUI/webview
|
|
180
|
-
// parse it into the subagent block's header instead of showing it as content.
|
|
181
|
-
ctx.callbacks?.onToken?.(relayPrefix + "[model]" + (childProvider.model ?? ""))
|
|
179
|
+
const relayPrefix = makeRelay(parent, role ?? "sub", ctx.callbacks?.onToken, childProvider.model ?? "")
|
|
182
180
|
const childOpts = {
|
|
183
181
|
onPermissionRequest: childPermission,
|
|
184
|
-
|
|
185
|
-
? (t) => ctx.callbacks.onToken(`${relayPrefix}${t}`)
|
|
186
|
-
: null,
|
|
187
|
-
onReasoning: ctx.callbacks?.onReasoning
|
|
188
|
-
? (t) => ctx.callbacks.onReasoning(`${relayPrefix}${t}`)
|
|
189
|
-
: null,
|
|
190
|
-
onToolCall: ctx.callbacks?.onToolCall
|
|
191
|
-
? (name, args) => ctx.callbacks.onToolCall(`${relayPrefix}${name}`, args)
|
|
192
|
-
: null,
|
|
182
|
+
...wrapChildCallbacks(relayPrefix, ctx.callbacks),
|
|
193
183
|
}
|
|
194
184
|
const childRunOpts = buildChildRunOpts(ctx)
|
|
195
185
|
let report = ""
|
|
196
|
-
// Turn-cap continue loop (TURN-CAP-CONTINUE.md)
|
|
197
|
-
// the SAME y/n panel the main agent uses
|
|
186
|
+
// Turn-cap continue loop (TURN-CAP-CONTINUE.md) via runWithContinue (§7.2 D3):
|
|
187
|
+
// hitting the cap asks the user via the SAME y/n panel the main agent uses —
|
|
198
188
|
// unlimited continues, resume:true keeps the child's history + mutation bookkeeping,
|
|
199
189
|
// fresh budget each run. Prompts queue through parent._permQueue (same as write
|
|
200
190
|
// approval) so parallel children never pop two panels at once. Declined / headless
|
|
201
191
|
// → partial-work return. Non-ContinueError errors still propagate (dispatch.mjs
|
|
202
192
|
// turns them into Error tool results — unchanged behavior).
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if (!(e instanceof ContinueError)) throw e
|
|
209
|
-
let go = false
|
|
210
|
-
if (ctx.onPermissionRequest) {
|
|
211
|
-
const ask = () => ctx.onPermissionRequest("continue", { turns: e.turn, agent: `${role ?? "sub"}#${subId}` })
|
|
212
|
-
parent._permQueue = (parent._permQueue ?? Promise.resolve()).then(ask, ask)
|
|
213
|
-
go = await parent._permQueue
|
|
214
|
-
}
|
|
215
|
-
if (go) continue
|
|
216
|
-
if (role === "eng-coder" && child._mutatedThisRun) mergeChildMutations(parent, child)
|
|
217
|
-
return `Subagent (${role}) stopped: turn cap reached (${e.turn} turns) — work may be partial; review recent_changes before deciding next steps.\nPartial output: ${report || ""}`
|
|
218
|
-
}
|
|
193
|
+
const askSubagentContinue = (e) => {
|
|
194
|
+
if (!ctx.onPermissionRequest) return Promise.resolve(false)
|
|
195
|
+
const ask = () => ctx.onPermissionRequest("continue", { turns: e.turn, agent: relayPrefix.slice(0, -1) })
|
|
196
|
+
parent._permQueue = (parent._permQueue ?? Promise.resolve()).then(ask, ask)
|
|
197
|
+
return parent._permQueue
|
|
219
198
|
}
|
|
199
|
+
const declined = { partial: null }
|
|
200
|
+
report = await runWithContinue(
|
|
201
|
+
(child, input, cbs, opts) => runAgent(child, input, cbs, opts), // opts = childRunOpts + resume (managed by the pipeline)
|
|
202
|
+
child, input, childOpts, childRunOpts,
|
|
203
|
+
{
|
|
204
|
+
askContinue: askSubagentContinue,
|
|
205
|
+
onDeclined: (e, output) => {
|
|
206
|
+
if (role === "eng-coder" && child._mutatedThisRun) mergeChildMutations(parent, child)
|
|
207
|
+
// Early return semantics (unchanged from the inline loop): the declined
|
|
208
|
+
// partial-work message is returned WITHOUT the MIN_REPORT_CHARS expansion —
|
|
209
|
+
// re-prompting a capped child for a longer report is wrong.
|
|
210
|
+
// Review #2 fix: use the pipeline-captured output (the `report` variable is
|
|
211
|
+
// still "" at this point — runWithContinue hasn't returned yet).
|
|
212
|
+
declined.partial = `Subagent (${role}) ${TURN_CAP_MARK} (${e.turn} turns) — work may be partial; review recent_changes before deciding next steps.\nPartial output: ${output || ""}`
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
)
|
|
216
|
+
if (declined.partial !== null) return declined.partial
|
|
220
217
|
|
|
221
218
|
// Report too short = incomplete handoff: send back for expansion once (inspired by kimi-code's summaryPolicy: min 200 chars, retry 1 time).
|
|
222
219
|
// The child agent's history is still intact; the continuation instruction is appended as new input so it can see its own earlier work.
|
|
@@ -230,6 +227,10 @@ export const subagentTool = {
|
|
|
230
227
|
// promised in the engineering prompt.
|
|
231
228
|
// CRITICAL: Only merge if child actually mutated files (defense-in-depth against
|
|
232
229
|
// runAgent throwing before any writes occurred).
|
|
230
|
+
// Review #8 clarification: eng-coder ONLY is intentional — the mechanical
|
|
231
|
+
// two-gate merge exists for engineering mode; plain `coder` children carry
|
|
232
|
+
// their own verify/advisor self-review discipline (per tool description), and
|
|
233
|
+
// normal mode has no parent advisor/verify gate to feed.
|
|
233
234
|
if (role === "eng-coder" && child._mutatedThisRun) {
|
|
234
235
|
mergeChildMutations(parent, child)
|
|
235
236
|
}
|