thincoder 0.12.59 → 0.12.60
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 +38 -3
- package/README.md +2 -2
- package/bin/thincoder.mjs +80 -19
- package/package.json +4 -3
- package/src/acp/bridge.mjs +7 -4
- package/src/advisor/messages.mjs +24 -4
- package/src/advisor/run.mjs +35 -33
- package/src/advisor.mjs +25 -6
- package/src/agent/completion.mjs +17 -11
- package/src/agent/dispatch.mjs +102 -19
- package/src/agent/helpers.mjs +36 -0
- package/src/agent/record-results.mjs +46 -10
- package/src/agent/run-stages.mjs +227 -0
- package/src/agent/setup-reminders.mjs +62 -0
- package/src/agent/setup.mjs +18 -2
- package/src/agent/spawn-child.mjs +29 -4
- package/src/agent-tools/advisor-async.mjs +456 -0
- package/src/agent-tools/advisor.mjs +110 -108
- package/src/agent-tools/async-settle.mjs +191 -0
- package/src/agent-tools/consult.mjs +121 -102
- package/src/agent-tools/design-token.mjs +104 -0
- package/src/agent-tools/eng.mjs +24 -29
- package/src/agent-tools/escalate-async.mjs +286 -0
- package/src/agent-tools/read-history.mjs +155 -31
- package/src/agent-tools/recent-changes.mjs +2 -1
- package/src/agent-tools/settings.mjs +7 -17
- package/src/agent-tools/subagent-actions.mjs +168 -130
- package/src/agent-tools/subagent-async.mjs +129 -174
- package/src/agent-tools/subagent-panel.mjs +153 -0
- package/src/agent-tools/subagent-run.mjs +202 -0
- package/src/agent-tools/subagent-scheduler.mjs +45 -21
- package/src/agent-tools/subagent-spawn.mjs +406 -0
- package/src/agent-tools/subagent.mjs +107 -555
- package/src/agent-tools/verify.mjs +118 -270
- package/src/agent.mjs +57 -190
- package/src/cli/distill-command.mjs +10 -4
- package/src/cli/make-agent.mjs +3 -1
- package/src/cli/memory-command.mjs +2 -1
- package/src/cli/permission.mjs +2 -2
- package/src/cli/setup-wizard.mjs +17 -12
- package/src/config.mjs +56 -8
- package/src/context.mjs +5 -147
- package/src/crash-reports.mjs +123 -0
- package/src/distill.mjs +11 -11
- package/src/explore-distill.mjs +155 -0
- package/src/memory/code-sync.mjs +2 -1
- package/src/memory/core.mjs +6 -193
- package/src/memory/delete.mjs +234 -0
- package/src/memory/docs.mjs +58 -48
- package/src/memory.mjs +3 -1
- package/src/peer-domains.mjs +265 -0
- package/src/peer-instances.mjs +231 -0
- package/src/prompt-overlays.mjs +25 -0
- package/src/prompts/advisor-design.md +9 -76
- package/src/prompts/advisor-round1.md +9 -68
- package/src/prompts/advisor-round2.md +7 -54
- package/src/prompts/advisor-round3.md +7 -54
- package/src/prompts/coder.md +7 -50
- package/src/prompts/consult-base.md +4 -24
- package/src/prompts/discipline.md +26 -44
- package/src/prompts/eng-coder.md +7 -32
- package/src/prompts/engineering-sub.md +3 -23
- package/src/prompts/engineering.md +53 -306
- package/src/prompts/explore.md +3 -12
- package/src/prompts/main.md +10 -32
- package/src/prompts/methodology-template.md +28 -48
- package/src/prompts/plan.md +2 -9
- package/src/prompts/system.md +16 -35
- package/src/provider/core.mjs +6 -67
- package/src/provider/errors.mjs +76 -0
- package/src/provider/retry.mjs +8 -45
- package/src/session-gc.mjs +214 -0
- package/src/session-guard.mjs +47 -0
- package/src/session-rename.mjs +38 -0
- package/src/session-slots.mjs +181 -58
- package/src/session.mjs +48 -89
- package/src/token-ttl.mjs +273 -0
- package/src/tools/checklist-sync.mjs +181 -0
- package/src/tools/checklist.mjs +52 -39
- package/src/tools/edit-batch.mjs +109 -10
- package/src/tools/edit-diff.mjs +110 -27
- package/src/tools/edit.md +17 -12
- package/src/tools/execute.mjs +31 -4
- package/src/tools/file.mjs +11 -6
- package/src/tools/git.mjs +14 -6
- package/src/tools/glob-dialect.mjs +130 -0
- package/src/tools/glob.md +3 -3
- package/src/tools/grep.md +1 -1
- package/src/tools/index.mjs +5 -6
- package/src/tools/ops.mjs +175 -3
- package/src/tools/patch.mjs +3 -3
- package/src/tools/question.md +3 -0
- package/src/tools/read.md +0 -1
- package/src/tools/shared.mjs +14 -13
- package/src/tools/system.mjs +44 -9
- package/src/tools/wait_for.md +22 -0
- package/src/tui/agent-turn.mjs +17 -228
- package/src/tui/cmd-config.mjs +48 -7
- package/src/tui/cmd-eng.mjs +20 -16
- package/src/tui/cmd-mcp.mjs +8 -2
- package/src/tui/cmd-new.mjs +3 -2
- package/src/tui/cmd-session.mjs +19 -4
- package/src/tui/cmd-think.mjs +10 -10
- package/src/tui/cmd-upgrade.mjs +19 -4
- package/src/tui/config-helpers.mjs +28 -16
- package/src/tui/distill-cmd.mjs +1 -1
- package/src/tui/index.mjs +3 -2
- package/src/tui/interaction.mjs +3 -3
- package/src/tui/mouse.mjs +7 -1
- package/src/tui/pickers.mjs +40 -22
- package/src/tui/render-segments.mjs +27 -10
- package/src/tui/startup.mjs +4 -0
- package/src/tui/subagent-blocks.mjs +95 -263
- package/src/tui/subagent-children.mjs +176 -0
- package/src/tui/subagent-freeze.mjs +172 -0
- package/src/tui/subagent-panel.mjs +61 -23
- package/src/tui/suspension-drive.mjs +351 -0
- package/src/tui/tool-args.mjs +3 -3
- package/src/tui/tool-display.mjs +142 -0
- package/src/tui/tool-events.mjs +37 -173
- package/src/tui/tui-lifecycle.mjs +29 -0
- package/src/tui/update-notice.mjs +4 -0
- package/src/tui/wizard.mjs +12 -6
- package/src/tools/pdf-parse-text.mjs +0 -497
- package/src/tools/pdf-parse-xref.mjs +0 -499
- package/src/tools/pdf.mjs +0 -155
- package/src/tools/read_pdf.md +0 -21
|
@@ -1,20 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* consult.mjs — multi-model consultation ("会诊", docs/design/CONSULTATION.md). CLI port.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Two tools (AGENT-LOOP.md §25 D-R17a — R17, 2026-09-06): consult_start
|
|
5
|
+
* (non-blocking spawn) / consult_stop (cancel a running session). consult_check
|
|
6
|
+
* was RETIRED with the digest auto-injection: the mechanism does ZERO judging —
|
|
7
|
+
* when every model of a session settles (pending 0), the session moves to the
|
|
8
|
+
* pending single container (`_pendingAsyncResults` +role "consult"——
|
|
9
|
+
* ASYNC-RESULT-CONTAINER.md D2——升格完整 entry) and the NEXT run start (user turn
|
|
10
|
+
* or digest auto-turn) injects the full verdict text ("[System reminder:
|
|
11
|
+
* consultation #id finished — N replies …]" — per-model status annotations on
|
|
12
|
+
* partial/full failures) for the main agent to judge and act on in the digestion
|
|
13
|
+
* round. A cancelled session (consult_stop) never reaches the stream.
|
|
7
14
|
*
|
|
8
15
|
* CLI adaptation (vs the VS Code plugin): the child runner is CLI's runAgent
|
|
9
16
|
* (runAgent(child, input, callbacks, opts) — an agent object, not provider+cwd);
|
|
10
17
|
* children are built with createAgent({ role: "consult", readonly tools,
|
|
11
18
|
* CONSULT_BASE overlay }); activity streams to the parent TUI via the relay
|
|
12
|
-
* prefix `consult#<
|
|
19
|
+
* prefix `consult#<childRelayN>/` (one per consultant child — the shared subagent
|
|
20
|
+
* relay channel; the child relay number is NOT the session id — sessions key
|
|
21
|
+
* their own `_consultIdCounter`), not onSubagent/onToolPanel.
|
|
22
|
+
* Each child settles its own TUI block with a ⟦ev⟧done event at settle (R17 —
|
|
23
|
+
* the old in-turn check consumption is gone).
|
|
13
24
|
*/
|
|
14
25
|
import { createAgent, runAgent, readonlyToolNames } from "../agent.mjs"
|
|
15
26
|
import { resolveChildProvider } from "./subagent.mjs"
|
|
27
|
+
import { pushReal } from "../context.mjs"
|
|
28
|
+
import { offloadToolResult, escapeXml } from "../agent/helpers.mjs"
|
|
16
29
|
import { logEvent, errText } from "../log.mjs"
|
|
17
30
|
import { makeRelay, wrapChildCallbacks, runWithContinue, ensureChildApiKey, clampEffort } from "../agent/spawn-child.mjs"
|
|
31
|
+
// ASYNC-RESULT-CONTAINER.md D2/D3/D6:pending 单容器停靠 + settle 公共收尾 + child signal 单点
|
|
32
|
+
import { buildChildSignal, settleAsyncEntry } from "./async-settle.mjs"
|
|
18
33
|
|
|
19
34
|
// Named consult defaults (consult P2, 2026-08-30).
|
|
20
35
|
const CONSULT_TIMEOUT_MS = 600_000 // default consult lifecycle timeout
|
|
@@ -112,13 +127,66 @@ export function makeMainHistoryTool(parentAgent) {
|
|
|
112
127
|
}
|
|
113
128
|
}
|
|
114
129
|
|
|
115
|
-
/**
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
130
|
+
/**
|
|
131
|
+
* Full-session settle routing (R17 — AGENT-LOOP.md §25 D-R17a): a session whose
|
|
132
|
+
* pending count reached 0 has no more replies coming — the session leaves
|
|
133
|
+
* `_consultSessions` and, unless it was cancelled (consult_stop / turn-end
|
|
134
|
+
* abort), moves into the pending single container (`_pendingAsyncResults` +role
|
|
135
|
+
* "consult"——ASYNC-RESULT-CONTAINER.md D2——升格完整 entry:同 subagent/advisor/
|
|
136
|
+
* escalate 的 `{id, role, report, done, ...}` 形态)whose report carries the full
|
|
137
|
+
* per-model verdict text (composed here — all settle states are known,
|
|
138
|
+
* partial/full failures annotated per model). The entry is injected at the next
|
|
139
|
+
* run start (user turn or digest auto-turn — agent.mjs); the suspension driver
|
|
140
|
+
* is woken (settle-event parity with the async pools) so an idle settle still
|
|
141
|
+
* triggers the digestion round (T-R17j).
|
|
142
|
+
* D3:公共收尾统一走 settleAsyncEntry 共享 helper(四族同机制)——consult 族参数:
|
|
143
|
+
* 无池(会话池无条目——settle 即出池)、无 ctx(无 TUI 冻结事件——子块各自 settle 时
|
|
144
|
+
* 已冻结)、无 onAccounting;helper 按 role "consult" 恒停靠 pending(非挂起期也停靠)。
|
|
145
|
+
* Cancelled sessions produce no digest (T-R17c).
|
|
146
|
+
*/
|
|
147
|
+
function sessionSettled(agent, session) {
|
|
148
|
+
agent?._consultSessions?.delete(String(session.id))
|
|
149
|
+
if (session.stopped) return // cancelled — no digest (T-R17c)
|
|
150
|
+
const entry = {
|
|
151
|
+
id: String(session.id),
|
|
152
|
+
role: "consult",
|
|
153
|
+
report: composeConsultDigest(session),
|
|
154
|
+
error: null, done: true, status: "done", cancelled: false,
|
|
155
|
+
relayPrefix: null, startedAt: null, _settle: null, _settleSeq: 0,
|
|
156
|
+
}
|
|
157
|
+
settleAsyncEntry(agent, entry, { pool: null, ctx: null })
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Digest body for a fully-settled session — title + one annotated line per
|
|
161
|
+
* reply (failed replies marked per-model — round2 #7; full text is injected
|
|
162
|
+
* verbatim and may be >64K → offloaded with a preview at injection). */
|
|
163
|
+
export function composeConsultDigest(session) {
|
|
164
|
+
const replies = session.replies ?? []
|
|
165
|
+
const parts = replies.map((r) =>
|
|
166
|
+
r.failed
|
|
167
|
+
? `- [${r.model}] (failed): ${r.reply}`
|
|
168
|
+
: `- [${r.model}]: ${r.reply}`)
|
|
169
|
+
const counts = `${replies.length} of ${session.total} models replied (${session.failed} failed)`
|
|
170
|
+
return `[System reminder: consultation #${session.id} finished — ${counts}]\n${parts.join("\n")}`
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Inject one settled consult family entry into the parent history as a
|
|
175
|
+
* user-role reminder (run-start injection — agent.mjs; same shape rules as
|
|
176
|
+
* injectAsyncResult: XML-escaped, >64K offloaded with preview + path). Consumed
|
|
177
|
+
* = the caller splices the entry out of the pending single container
|
|
178
|
+
* (_pendingAsyncResults——ASYNC-RESULT-CONTAINER.md D2——role 分发注入)。
|
|
179
|
+
*/
|
|
180
|
+
export async function injectConsultResult(agent, entry) {
|
|
181
|
+
const body = entry?.report ?? "(no consultation result)"
|
|
182
|
+
const preview = await offloadToolResult(String(body), `consult-${entry.id ?? "session"}`)
|
|
183
|
+
pushReal(agent, {
|
|
184
|
+
role: "user",
|
|
185
|
+
content: escapeXml(preview),
|
|
186
|
+
})
|
|
119
187
|
}
|
|
120
188
|
|
|
121
|
-
function settleChild(session, id, label, ok, payload) {
|
|
189
|
+
function settleChild(agent, session, id, label, ok, payload, emitDone) {
|
|
122
190
|
if (ok) {
|
|
123
191
|
session.received++
|
|
124
192
|
session.replies.push({ model: label, reply: payload })
|
|
@@ -129,7 +197,8 @@ function settleChild(session, id, label, ok, payload) {
|
|
|
129
197
|
session.replies.push({ model: label, reply: `(consultation failed: ${payload})`, failed: true })
|
|
130
198
|
}
|
|
131
199
|
session.pending--
|
|
132
|
-
|
|
200
|
+
emitDone?.() // per-child TUI block freeze at settle (R17 — child's activity card is done)
|
|
201
|
+
if (session.pending === 0) sessionSettled(agent, session)
|
|
133
202
|
}
|
|
134
203
|
|
|
135
204
|
async function runConsultChild(ctx, session, id, m, problem, ctrl) {
|
|
@@ -161,6 +230,12 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
|
|
|
161
230
|
if (kind === "ok" || kind === "partial") logEvent("child:done", { ...base, kind })
|
|
162
231
|
else logEvent("child:error", { ...base, err: errText(payload, 200) })
|
|
163
232
|
}
|
|
233
|
+
// R17: relay prefix assigned before the child runner arms — the per-child TUI
|
|
234
|
+
// block freeze emits only when a block actually exists (relay established).
|
|
235
|
+
let relayPrefix = null
|
|
236
|
+
const settle = (ok, payload) => settleChild(agent, session, id, label, ok, payload, relayPrefix
|
|
237
|
+
? () => ctx.callbacks?.onToken?.(`${relayPrefix}⟦ev⟧done\x1e0\x1e0\x1edone\x1e`)
|
|
238
|
+
: null)
|
|
164
239
|
try {
|
|
165
240
|
// Provider resolution: consultModels entries are { provider, model, effort? } — resolve
|
|
166
241
|
// via the subagent's provider resolver ("provider:model" handles cross-provider picks).
|
|
@@ -199,7 +274,7 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
|
|
|
199
274
|
// Activity relay via the unified spawn-child pipeline (§7.2 D3): `consult#<subId>/`
|
|
200
275
|
// prefix (same channel subagent uses — parallel consultants stay independent) +
|
|
201
276
|
// onToolOutput passthrough so the consultant's tool output lands in its TUI block.
|
|
202
|
-
|
|
277
|
+
relayPrefix = makeRelay(agent, "consult", ctx.callbacks?.onToken, provider.model ?? "")
|
|
203
278
|
// LOGGING:arm(spawn 事件——relay 建立后;子内事件归属 _logId)
|
|
204
279
|
childLogId = relayPrefix.slice(0, -1)
|
|
205
280
|
child._logId = childLogId
|
|
@@ -238,7 +313,7 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
|
|
|
238
313
|
},
|
|
239
314
|
onDeclined: (e) => {
|
|
240
315
|
declined = true
|
|
241
|
-
|
|
316
|
+
settle(false, `turn cap reached (${e.turn} turns) — stopped, diagnosis may be partial`)
|
|
242
317
|
logSettle("partial", null)
|
|
243
318
|
return undefined
|
|
244
319
|
},
|
|
@@ -246,44 +321,42 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
|
|
|
246
321
|
)
|
|
247
322
|
// Review #1 fix: onDeclined already settled this child as a failed reply —
|
|
248
323
|
// settling again here would push a phantom empty success reply and decrement
|
|
249
|
-
// `pending` twice (negative pending
|
|
250
|
-
// unreachable → permanent block until user abort).
|
|
324
|
+
// `pending` twice (negative pending would re-enter the settle routing).
|
|
251
325
|
if (!declined) {
|
|
252
|
-
|
|
326
|
+
settle(true, String(result ?? ""))
|
|
253
327
|
logSettle("ok", null)
|
|
254
328
|
}
|
|
255
329
|
} catch (e) {
|
|
256
330
|
// Runner errors (incl. the watchdog's abort) settle as a failed reply — the
|
|
257
331
|
// continue/declined paths are already handled inside runWithContinue.
|
|
258
332
|
const note = timedOut ? `consultation timed out after ${Math.round(timeoutMs / 60000)}min (agent.consultTimeoutMs)` : e?.message ?? String(e)
|
|
259
|
-
|
|
333
|
+
settle(false, note)
|
|
260
334
|
logSettle("error", note)
|
|
261
335
|
}
|
|
262
336
|
} catch (e) {
|
|
263
337
|
// Errors BEFORE the runner (provider resolution, createAgent) or a throwing
|
|
264
338
|
// continue-prompt settle as failed replies — the runner's own errors are already
|
|
265
|
-
// handled inside the loop above.
|
|
266
|
-
|
|
339
|
+
// handled inside the loop above. relayPrefix is null on these paths — no TUI
|
|
340
|
+
// block was ever opened, so no freeze event is emitted.
|
|
341
|
+
settle(false, e?.message ?? String(e))
|
|
267
342
|
logSettle("error", e?.message ?? String(e))
|
|
268
343
|
} finally {
|
|
269
344
|
clearTimeout(watchdog)
|
|
270
345
|
}
|
|
271
346
|
}
|
|
272
347
|
|
|
273
|
-
/** Turn-end cleanup (
|
|
274
|
-
*
|
|
348
|
+
/** Turn-end / session-end abort cleanup (R17 — call sites: the Ctrl+C abort
|
|
349
|
+
* branches of finalizeAgentTurn and the suspension driver). Consultation
|
|
350
|
+
* sessions are now cross-turn background work (like async subagents): a NORMAL
|
|
351
|
+
* turn end keeps them alive — this runs only when the user stops everything:
|
|
352
|
+
* every leftover session is marked stopped (its settles never reach the digest
|
|
353
|
+
* stream — T-R17c) and its controllers aborted. */
|
|
275
354
|
export function cleanupConsultSessions(agent) {
|
|
276
355
|
for (const s of agent._consultSessions?.values() ?? []) {
|
|
277
356
|
s.stopped = true
|
|
278
357
|
for (const c of s.controllers ?? []) { try { c.abort() } catch { /* already settled */ } }
|
|
279
|
-
for (const w of s.waiters?.splice(0) ?? []) { try { w() } catch { /* noop */ } }
|
|
280
358
|
}
|
|
281
359
|
agent._consultSessions?.clear()
|
|
282
|
-
// NOTE: deliberately void (consult P3, 2026-08-30). The { stopped: true } marker
|
|
283
|
-
// only reaches the TUI via the consult_stop TOOL return (onToolResult freezes
|
|
284
|
-
// blocks on tool calls) — cleanup runs from the turn finally, where the block
|
|
285
|
-
// freeze is owned by freezeAllSubTasks + sweepToolBlocks, so a return here is
|
|
286
|
-
// dead weight. Blocks still get frozen on interrupt via that sweep.
|
|
287
360
|
}
|
|
288
361
|
|
|
289
362
|
export const consultStartTool = {
|
|
@@ -294,8 +367,11 @@ export const consultStartTool = {
|
|
|
294
367
|
"Start a parallel multi-model consultation (会诊) for a hard problem you are stuck on (repeated failures, no headway). " +
|
|
295
368
|
"Call it directly when the user asks for 会诊 / consult — an explicit user request applies even if you are not 'stuck'. " +
|
|
296
369
|
"Several configured models (agent.consultModels) analyze the same problem INDEPENDENTLY and in parallel. " +
|
|
297
|
-
"Non-blocking: returns immediately with a consult id
|
|
298
|
-
"
|
|
370
|
+
"Non-blocking: returns immediately with a consult id; the consultants keep running in the background across turns. " +
|
|
371
|
+
"When EVERY model has replied (or failed), the full verdict text is delivered to you automatically — as a system " +
|
|
372
|
+
"reminder at the next run start, or digested on its own while the session is idle — judge and adopt each opinion " +
|
|
373
|
+
"yourself with your own tools (opinions are suggestions, not gates). To stop a session early (user changed their " +
|
|
374
|
+
"mind / wants the tokens back), call consult_stop(id) — a stopped session delivers no digest.\n" +
|
|
299
375
|
"Parameters:\n" +
|
|
300
376
|
"- problem (required): a brief — the symptom, what you already tried (failure trail), and entry-point files. " +
|
|
301
377
|
"Do NOT paste raw error logs; consultants pull the main session history themselves via their main_history tool.\n" +
|
|
@@ -325,7 +401,7 @@ export const consultStartTool = {
|
|
|
325
401
|
agent._consultSessions ??= new Map()
|
|
326
402
|
const id = String((agent._consultIdCounter = (agent._consultIdCounter ?? 0) + 1))
|
|
327
403
|
const session = {
|
|
328
|
-
id, controllers: [], replies: [], pending: 0,
|
|
404
|
+
id, controllers: [], replies: [], pending: 0,
|
|
329
405
|
failed: 0, terminated: 0, stopped: false, received: 0, total: run.length,
|
|
330
406
|
models: run.map(consultLabel),
|
|
331
407
|
}
|
|
@@ -335,102 +411,45 @@ export const consultStartTool = {
|
|
|
335
411
|
session.pending++
|
|
336
412
|
const ctrl = new AbortController()
|
|
337
413
|
session.controllers.push(ctrl)
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
414
|
+
// D6 buildChildSignal 单点(ASYNC-RESULT-CONTAINER.md D5——consult 补 _sessionSignal
|
|
415
|
+
// 兜底:挂起会话内的 consult children 持会话 signal,digest 自身 Ctrl+C 不误伤)。
|
|
416
|
+
const baseSignal = buildChildSignal(agent, ctx)
|
|
417
|
+
if (baseSignal) {
|
|
418
|
+
if (baseSignal.aborted) ctrl.abort()
|
|
419
|
+
else baseSignal.addEventListener("abort", () => ctrl.abort(), { once: true })
|
|
341
420
|
}
|
|
342
|
-
// Fire and forget — each child settles itself into the session
|
|
421
|
+
// Fire and forget — each child settles itself into the session; the session
|
|
422
|
+
// routes to the pending single container when every child has settled (R17).
|
|
343
423
|
runConsultChild(ctx, session, id, m, problem, ctrl)
|
|
344
424
|
}
|
|
345
425
|
return JSON.stringify({ id, models: session.models })
|
|
346
426
|
},
|
|
347
427
|
}
|
|
348
428
|
|
|
349
|
-
export const consultCheckTool = {
|
|
350
|
-
name: "consult_check",
|
|
351
|
-
readonly: true,
|
|
352
|
-
description:
|
|
353
|
-
"Read the NEXT consultation reply (whichever model answered first). Blocks until a reply arrives or all models " +
|
|
354
|
-
"have settled. The reply is raw and unjudged — verify/adopt it with your own tools. When done is true, no more " +
|
|
355
|
-
"replies are coming.\n" +
|
|
356
|
-
"Call it ALONE in a turn — do NOT batch it with calls that depend on its reply (readonly tools run in parallel).\n" +
|
|
357
|
-
"Replies arrive in arrival order: call it repeatedly (n = 1, 2, 3, …) until done is true.\n" +
|
|
358
|
-
"Returns JSON: {reply, model, failedReply, received, failed, terminated, total, done} for a reply — or {done: true, received, failed, total} when none are left.\n" +
|
|
359
|
-
"Parameters:\n" +
|
|
360
|
-
"- id (required): the consult id from consult_start\n" +
|
|
361
|
-
"- 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.",
|
|
362
|
-
parameters: {
|
|
363
|
-
type: "object",
|
|
364
|
-
properties: {
|
|
365
|
-
id: { type: "string", description: "Consult id" },
|
|
366
|
-
n: { type: "number", description: "1-based read number: 1 for the first check, incrementing with each subsequent check of the same consult" },
|
|
367
|
-
},
|
|
368
|
-
required: ["id", "n"],
|
|
369
|
-
},
|
|
370
|
-
async execute({ id, n: _n }, ctx) {
|
|
371
|
-
const s = ctx.agent?._consultSessions?.get(String(id))
|
|
372
|
-
if (!s) return JSON.stringify({ error: "unknown consult id" })
|
|
373
|
-
const abortAll = () => { for (const c of s.controllers) { try { c.abort() } catch { /* noop */ } } }
|
|
374
|
-
if (ctx.signal?.aborted) abortAll()
|
|
375
|
-
|
|
376
|
-
for (;;) {
|
|
377
|
-
if (s.replies.length > 0) {
|
|
378
|
-
const r = s.replies.shift()
|
|
379
|
-
return JSON.stringify({
|
|
380
|
-
reply: r.reply, model: r.model, failedReply: r.failed === true,
|
|
381
|
-
received: s.received,
|
|
382
|
-
failed: s.failed,
|
|
383
|
-
terminated: s.terminated ?? 0, total: s.total,
|
|
384
|
-
done: s.replies.length === 0 && s.pending === 0,
|
|
385
|
-
})
|
|
386
|
-
}
|
|
387
|
-
if (s.pending === 0) {
|
|
388
|
-
return JSON.stringify({ done: true, received: s.received, failed: s.failed, total: s.total })
|
|
389
|
-
}
|
|
390
|
-
const stopped = await new Promise((resolve) => {
|
|
391
|
-
function cleanup() {
|
|
392
|
-
const i = s.waiters.indexOf(w)
|
|
393
|
-
if (i >= 0) s.waiters.splice(i, 1)
|
|
394
|
-
ctx.signal?.removeEventListener("abort", onAbort)
|
|
395
|
-
}
|
|
396
|
-
function w() { cleanup(); resolve(false) }
|
|
397
|
-
function onAbort() { cleanup(); abortAll(); resolve(true) }
|
|
398
|
-
s.waiters.push(w)
|
|
399
|
-
if (ctx.signal) {
|
|
400
|
-
if (ctx.signal.aborted) { onAbort(); return }
|
|
401
|
-
ctx.signal.addEventListener("abort", onAbort, { once: true })
|
|
402
|
-
}
|
|
403
|
-
})
|
|
404
|
-
if (stopped) return JSON.stringify({ done: true, stopped: true, received: s.received, failed: s.failed, total: s.total })
|
|
405
|
-
}
|
|
406
|
-
},
|
|
407
|
-
}
|
|
408
|
-
|
|
409
429
|
export const consultStopTool = {
|
|
410
430
|
name: "consult_stop",
|
|
411
431
|
readonly: false,
|
|
412
432
|
sideEffectExempt: true,
|
|
413
433
|
description:
|
|
414
|
-
"
|
|
415
|
-
"
|
|
416
|
-
"
|
|
434
|
+
"Cancel a still-running consultation session (会诊) — the user changed their mind, the problem resolved, or you want the tokens back. " +
|
|
435
|
+
"Aborts every consultant that is still running; a stopped session delivers NO digest (R17 — its already-collected partial replies are dropped). " +
|
|
436
|
+
"Sessions that finished on their own are no longer cancellable — their verdict text is delivered automatically.\n" +
|
|
437
|
+
"Returns JSON {abandoned: <pending count>, cancelled: true} — or {error: \"unknown consult id\"} (already finished/cancelled).\n" +
|
|
417
438
|
"Parameters:\n" +
|
|
418
|
-
"- id (required): the consult id from consult_start
|
|
419
|
-
"- n (required): incrementing call number for this consult (next value after the last consult_check/consult_stop) — keeps repeated calls distinct.",
|
|
439
|
+
"- id (required): the consult id from consult_start",
|
|
420
440
|
parameters: {
|
|
421
441
|
type: "object",
|
|
422
442
|
properties: {
|
|
423
443
|
id: { type: "string", description: "Consult id" },
|
|
424
|
-
n: { type: "number", description: "Incrementing call number for this consult (see consult_check)" },
|
|
425
444
|
},
|
|
426
|
-
required: ["id"
|
|
445
|
+
required: ["id"],
|
|
427
446
|
},
|
|
428
|
-
async execute({ id
|
|
447
|
+
async execute({ id }, ctx) {
|
|
429
448
|
const s = ctx.agent?._consultSessions?.get(String(id))
|
|
430
449
|
if (!s) return JSON.stringify({ error: "unknown consult id" })
|
|
431
450
|
const abandoned = s.pending
|
|
432
451
|
s.stopped = true
|
|
433
452
|
for (const c of s.controllers) { try { c.abort() } catch { /* already settled */ } }
|
|
434
|
-
return JSON.stringify({
|
|
453
|
+
return JSON.stringify({ abandoned, cancelled: true })
|
|
435
454
|
},
|
|
436
455
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design-token.mjs — design-token utilities(2026-09-08 自 advisor-async.mjs 提取——
|
|
3
|
+
* advisor-async 随 DESIGN-TOKEN-SETTLEMENT D1 落盘块再越 500 行硬限;本工具组 verbatim
|
|
4
|
+
* 迁入,零语义变)。
|
|
5
|
+
*
|
|
6
|
+
* 沿革:初由 agent-tools/advisor.mjs 迁至 advisor-async.mjs(sync wrapper 与 async
|
|
7
|
+
* settle 共享一套实现——无 wrapper↔runner 环),再由 advisor-async.mjs 迁入本文件。
|
|
8
|
+
* 既有 import 面不变:advisor-async.mjs re-export 全部 7 个导出(advisor.mjs 与测试
|
|
9
|
+
* 的 import 路径原样保留——advisor.mjs 再转发 validateDesignToken 给 spawn 门禁)。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { randomUUID } from "node:crypto"
|
|
13
|
+
import { tokenExpiryMs } from "../token-ttl.mjs"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* F2c/F2e (§29.1 2026-09-07): the engine-generated Approved suffix — ONE builder
|
|
17
|
+
* shared by the sync settle and the async settle (and the prior stores, which
|
|
18
|
+
* strip it with stripApprovedSuffix — exact-suffix truncation, never a regex
|
|
19
|
+
* guess, zero collateral). The slot count is a point-in-time snapshot taken at
|
|
20
|
+
* settle time — the situation at spawn time may differ (F2d backs that up).
|
|
21
|
+
*/
|
|
22
|
+
export function buildApprovedSuffix(designToken, designId, slotCount) {
|
|
23
|
+
return `Approved. Pass this exact token to eng-coder (designToken parameter): ${designToken}\ndesignId: ${designId} (pass as the designId parameter when spawning eng-coder — optional while this session holds a single design; ${slotCount} approved design slot(s) held as of this approval, and the count may have changed since — with several designs the spawn gate refuses a missing designId and lists the held ids)`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** F2e (§29.1): strip the engine-generated Approved suffix from a report before
|
|
27
|
+
* it becomes a prior — the suffix is deterministic (buildApprovedSuffix), so the
|
|
28
|
+
* truncation is exact; a text not ending in it passes through untouched. */
|
|
29
|
+
export function stripApprovedSuffix(text, suffix) {
|
|
30
|
+
if (typeof text !== "string" || !suffix) return text
|
|
31
|
+
return text.endsWith(suffix) ? text.slice(0, text.length - suffix.length).trim() : text
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const TOKEN_TTL_DEFAULT_MS = 7 * 24 * 3600 * 1000 // 7-day ceiling (v2 2026-08-25)
|
|
35
|
+
|
|
36
|
+
/** Effective token TTL: config override with runtime validation (timeoutMs precedent). */
|
|
37
|
+
export function effectiveTokenTtlMs(agent) {
|
|
38
|
+
const cfg = agent?.config?.agent?.engTokenTtlMs
|
|
39
|
+
return (Number.isFinite(cfg) && cfg > 0) ? cfg : TOKEN_TTL_DEFAULT_MS
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Mint an unsigned design token with expiration (2026-09-06: HMAC layer removed —
|
|
43
|
+
* the token is a FLOW credential: uuid:expiresAt, exact slot match + TTL only). */
|
|
44
|
+
export function generateDesignToken(agent) {
|
|
45
|
+
const uuid = randomUUID()
|
|
46
|
+
const expiresAt = Math.floor(Date.now() + effectiveTokenTtlMs(agent))
|
|
47
|
+
return `${uuid}:${expiresAt}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Validate a design token: format + expiration — ALL fail-closed (v2 2026-08-25).
|
|
51
|
+
* R16: format/expiry semantics live in token-ttl.mjs (tokenExpiryMs — single source
|
|
52
|
+
* shared with restore filtering / enter cleanup / spawn-gate slot deletion — D-R16b). */
|
|
53
|
+
export function validateDesignToken(token) {
|
|
54
|
+
const expiry = tokenExpiryMs(token)
|
|
55
|
+
return expiry !== null && expiry >= Date.now()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Build a [DESIGN-TOKEN:...] regex matching the FULL token (uuid:expiresAt). */
|
|
59
|
+
export function makeDesignTokenRegex(token, flags = "") {
|
|
60
|
+
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
61
|
+
return new RegExp(
|
|
62
|
+
`(?:^|\\s|\`|\\*)\\[DESIGN-TOKEN:\\s*${escaped}\\s*\\](?:\\s|$|\`|\\*)`,
|
|
63
|
+
flags + "ms"
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Shared design-review settlement (sync wrapper + async settle): the token echo
|
|
69
|
+
* IS the verdict — the advisor echoes it only on approval. On echo: slot the
|
|
70
|
+
* token under designId (+ eng-coder gate flag; single-value mirror retired per
|
|
71
|
+
* DESIGN-TOKEN-SETTLEMENT D3) and return
|
|
72
|
+
* the clean output with the Approved suffix; the review instance CLOSES (a
|
|
73
|
+
* later review of the same doc-set starts a fresh full review). On non-echo:
|
|
74
|
+
* strip every dead token occurrence and return the findings text — slots stay
|
|
75
|
+
* untouched (方案 ②: a failed re-review revokes nothing).
|
|
76
|
+
* @returns {{passed: boolean, output: string}}
|
|
77
|
+
*/
|
|
78
|
+
export function settleDesignReview(agent, run, designToken, rawResult) {
|
|
79
|
+
if (!designToken || typeof rawResult !== "string") {
|
|
80
|
+
return { passed: false, output: rawResult ?? "" }
|
|
81
|
+
}
|
|
82
|
+
const tokenPattern = makeDesignTokenRegex(designToken)
|
|
83
|
+
if (!tokenPattern.test(rawResult)) {
|
|
84
|
+
const stripped = rawResult.replace(makeDesignTokenRegex(designToken, "g"), "").trim()
|
|
85
|
+
return { passed: false, output: stripped || "Advisor: design review did not pass." }
|
|
86
|
+
}
|
|
87
|
+
// Echoed the token → review passed. Issue it to the parent for eng-coder.
|
|
88
|
+
agent._engDesignTokens ??= new Map()
|
|
89
|
+
agent._engDesignTokens.set(run.designId, designToken)
|
|
90
|
+
// DESIGN-TOKEN-SETTLEMENT D3(2026-09-08):单值镜像 `_engDesignToken` 退役——
|
|
91
|
+
// 只写多槽 Map(AC3 零镜像写);settle 落盘由调用方(settleAdvisorRun)当场做。
|
|
92
|
+
// Unlock the dispatch design gate for eng-coder SELF-review (defense-in-depth —
|
|
93
|
+
// see the sync wrapper's note: unreachable today, kept for parity).
|
|
94
|
+
if (agent._role === "eng-coder") agent._engDesignReviewed = true
|
|
95
|
+
run.open = false // approval closes this doc-set instance — next review is fresh
|
|
96
|
+
const clean = rawResult.replace(makeDesignTokenRegex(designToken, "g"), "").trim()
|
|
97
|
+
// F2c (§29.1): id echo + omission guidance + point-in-time slot snapshot — the
|
|
98
|
+
// same suffix the F2e prior stores strip with (stored for the exact truncation).
|
|
99
|
+
run.approvedSuffix = buildApprovedSuffix(designToken, run.designId, agent._engDesignTokens.size)
|
|
100
|
+
return {
|
|
101
|
+
passed: true,
|
|
102
|
+
output: `${clean}\n\n${run.approvedSuffix}`,
|
|
103
|
+
}
|
|
104
|
+
}
|
package/src/agent-tools/eng.mjs
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* eng tool: enter/exit engineering mode.
|
|
3
3
|
* In engineering mode the agent follows design-before-code methodology.
|
|
4
|
-
* Toggled here at session level;
|
|
4
|
+
* Toggled here at session level (in-memory flag; the session slot is the sole authority —
|
|
5
|
+
* saveSession round-trips it at turn end; /eng writes the slot directly). The old ctx
|
|
6
|
+
* state-persist hook was removed 2026-09-08 (ENG-SESSION-PROVIDER-CLEANUP D1.2): no
|
|
7
|
+
* provider existed and its legacy payload keys were never read by applySession.
|
|
8
|
+
* R16 (2026-09-06): design tokens are session-level flow credentials — mode toggles
|
|
9
|
+
* do NOT clear them (ON→OFF keeps, OFF→ON does not require a fresh review); only TTL
|
|
10
|
+
* expiry cleans tokens, at three points: restore filtering (session.mjs applySession) /
|
|
11
|
+
* eng enter expired cleanup (below + cmd-eng ON) / spawn-gate expired rejection
|
|
12
|
+
* (subagent-spawn.mjs). See docs/design/ENG-TOKEN-BINDING-TUNING.md §5/§5.1 (F-R16).
|
|
5
13
|
*/
|
|
6
14
|
import { ENG_ON_REMINDER, ENG_OFF_REMINDER } from "../agent.mjs"
|
|
15
|
+
import { purgeExpiredDesignTokens } from "../token-ttl.mjs"
|
|
7
16
|
|
|
8
17
|
export const engTool = {
|
|
9
18
|
name: "eng",
|
|
@@ -22,50 +31,36 @@ export const engTool = {
|
|
|
22
31
|
ctx.agent.config.agent ??= {}
|
|
23
32
|
if (args.action === "exit") {
|
|
24
33
|
ctx.agent.config.agent.engineering = false
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
ctx.agent._engDesignReviewed = false // reset gate state
|
|
34
|
+
// R16 (F-R16a): OFF 不清 token——有效 token 跨模式存活(设计评审过的产物不因
|
|
35
|
+
// 开关重复烧)。过期清理跑在另外三处(恢复过滤 / 开模式 / spawn 门禁拒)。
|
|
28
36
|
ctx.agent._advisorRound = 0 // reset convergence budget
|
|
37
|
+
ctx.agent._advisorRuns = new Map() // §24 D-24b: per-review instances die with the mode (fresh cycles)
|
|
29
38
|
ctx.agent._touchedFiles = [] // clear mutation tracking
|
|
30
39
|
ctx.agent._lastEngState = false
|
|
31
40
|
ctx.agent._pendingReminders = ctx.agent._pendingReminders ?? []
|
|
32
41
|
ctx.agent._pendingReminders.push(ENG_OFF_REMINDER)
|
|
33
|
-
// 持久化工程模式状态到会话
|
|
34
|
-
if (ctx.persistState) {
|
|
35
|
-
await ctx.persistState({
|
|
36
|
-
engineering: false,
|
|
37
|
-
engDesignToken: null,
|
|
38
|
-
engDesignReviewed: false,
|
|
39
|
-
advisorRound: 0,
|
|
40
|
-
touchedFiles: []
|
|
41
|
-
})
|
|
42
|
-
}
|
|
43
42
|
return "Engineering mode exited. Standard discipline now applies. You may edit files directly."
|
|
44
43
|
}
|
|
45
44
|
if (args.action === "enter") {
|
|
46
|
-
// Idempotent enter (v2 2026-08-25): already in engineering mode → no-op
|
|
47
|
-
//
|
|
48
|
-
//
|
|
45
|
+
// Idempotent enter (v2 2026-08-25): already in engineering mode → pure no-op
|
|
46
|
+
// (cleanup only runs on a real off→on transition — T-R16c precondition: first
|
|
47
|
+
// exit/OFF, then enter; an already-on enter must not touch tokens at all).
|
|
49
48
|
if (ctx.agent.config.agent.engineering) {
|
|
50
49
|
return "Engineering mode already active. Existing design tokens stay valid."
|
|
51
50
|
}
|
|
52
51
|
ctx.agent.config.agent.engineering = true
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
// R16 (F-R16b ②): off→on 不重评——只清过期 token(用户裁定"打开工程模式时
|
|
53
|
+
// 应该清理"),有效 token 原样保留——遍历 Map 删过期,返回文案含清理个数。
|
|
54
|
+
const cleared = purgeExpiredDesignTokens(ctx.agent)
|
|
55
|
+
ctx.agent._advisorRuns = new Map() // §24 D-24b: per-review instances die with the mode (fresh cycles)
|
|
55
56
|
ctx.agent._lastEngState = true
|
|
56
57
|
ctx.agent._pendingReminders = ctx.agent._pendingReminders ?? []
|
|
57
58
|
ctx.agent._pendingReminders.push(ENG_ON_REMINDER)
|
|
58
|
-
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
engineering: true,
|
|
62
|
-
engDesignToken: null,
|
|
63
|
-
engDesignReviewed: false,
|
|
64
|
-
advisorRound: 0,
|
|
65
|
-
touchedFiles: []
|
|
66
|
-
})
|
|
59
|
+
let msg = "Engineering mode activated. Design-before-code enforced: write a design document in docs/, run advisor with type='design', get user approval, then implement via eng-coder subagents."
|
|
60
|
+
if (cleared > 0) {
|
|
61
|
+
msg += ` Cleared ${cleared} expired design token${cleared === 1 ? "" : "s"} — 清 ${cleared} 个过期 token,有效 token 保留(TTL 内不重评)。`
|
|
67
62
|
}
|
|
68
|
-
return
|
|
63
|
+
return msg
|
|
69
64
|
}
|
|
70
65
|
return "Invalid action: expected 'enter' or 'exit'"
|
|
71
66
|
},
|