thincoder 0.12.29 → 0.12.30
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/README.md +1 -0
- package/package.json +1 -1
- package/src/agent/setup.mjs +28 -6
- package/src/agent-tools/consult.mjs +288 -0
- package/src/agent-tools/escalate.mjs +152 -0
- package/src/agent.mjs +13 -4
- package/src/config.mjs +14 -0
- package/src/prompts/consult-base.md +23 -0
- package/src/prompts/discipline.md +3 -0
- package/src/tui/cmd-config.mjs +88 -4
- package/src/tui/slash-commands.mjs +1 -1
package/README.md
CHANGED
|
@@ -21,6 +21,7 @@ Design philosophy (the entire meaning of the name): if the Node standard library
|
|
|
21
21
|
- **Two-phase tool scheduling**: permission prompts serialized, read-only tools parallelized, side-effect tools serialized
|
|
22
22
|
- **Session persistence** ⭐0.5.0: unlimited archive slots, `/session` to switch anytime, tool results visible after restore. Process-level isolation — multiple instances in the same directory each get their own session slot
|
|
23
23
|
- **Concurrent subagents**: three roles — `explore`/`plan`/`coder` — dispatched in parallel, streaming output visible, reports land in the conversation; per-subagent model override (`subagent` tool `model` arg or `agent.subagentModel` config — e.g. discuss with `glm-5.2`, let `deepseek-v4-flash` implement)
|
|
24
|
+
- **Multi-model consultation + 飞刀 (escalate)** ⭐0.12.30: `consult_start`/`consult_check`/`consult_stop` run several configured models in parallel as independent read-only consultants (each with its own TUI activity card, `main_history` access to the failure trail); `escalate` flies in a stronger model for a single expert implementation run with full write access. Candidate pool = `agent.consultModels` ([{ provider, model, effort? }], up to 5); budgets via `agent.consultTurns` / `agent.consultTimeoutMs`
|
|
24
25
|
- **Plan Mode**: read-only exploration + design, implement after user approval
|
|
25
26
|
- **AUTO mode**: `/auto` full authorization, no confirmations on long tasks
|
|
26
27
|
- **Task tracking**: `task` tool breaks down multi-step work, status bar ✓n/m live progress, auto-filters completed items
|
package/package.json
CHANGED
package/src/agent/setup.mjs
CHANGED
|
@@ -168,6 +168,17 @@ export async function prepareRun(agent, input, callbacks, {
|
|
|
168
168
|
// task/plan tools are injected with the main loop; subagent/skill/goal/verify only at top level
|
|
169
169
|
// eng-coder subagents get advisor for mandatory design review before coding
|
|
170
170
|
const { planTool, subagentTool, taskTool, skillTool, goalTool, verifyTool, recentChangesTool, timerTool, advisorTool, engTool } = await import("../agent-tools.mjs")
|
|
171
|
+
const { consultStartTool, consultCheckTool, consultStopTool } = await import("../agent-tools/consult.mjs")
|
|
172
|
+
const { escalateTool } = await import("../agent-tools/escalate.mjs")
|
|
173
|
+
const { CONSULT_BASE } = await import("../agent.mjs")
|
|
174
|
+
// withPool: decorate consult_start/escalate descriptions with the CURRENT candidate pool
|
|
175
|
+
// so the model knows which models it can pick (CLI parity with the plugin).
|
|
176
|
+
const withPool = (tool) => {
|
|
177
|
+
const models = agent.config?.agent?.consultModels ?? []
|
|
178
|
+
const list = models.map((m) => `${m.provider}:${m.model}${m.effort ? ` (${m.effort})` : ""}`).join(", ")
|
|
179
|
+
if (!list) return tool
|
|
180
|
+
return { ...tool, description: tool.description + `\nCurrently configured consultants (this tool's pool): ${list}` }
|
|
181
|
+
}
|
|
171
182
|
// Role enum is mutually exclusive: normal mode has "coder", engineering mode has "eng-coder"
|
|
172
183
|
const subagentRoles = (depth === 0 && agent.config?.agent?.engineering)
|
|
173
184
|
? {
|
|
@@ -192,13 +203,19 @@ export async function prepareRun(agent, input, callbacks, {
|
|
|
192
203
|
},
|
|
193
204
|
} : subagentTool
|
|
194
205
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
206
|
+
// Consult/escalate tools registered only when configured — an unconfigured pool would
|
|
207
|
+
// otherwise make the model call them and eat an error turn (plugin parity).
|
|
208
|
+
const consultTools = (agent.config?.agent?.consultModels ?? []).length
|
|
209
|
+
? [withPool(consultStartTool), consultCheckTool, consultStopTool, withPool(escalateTool)]
|
|
210
|
+
: []
|
|
211
|
+
const depthOnly = depth === 0 ? [filteredSubagent, skillTool, goalTool, engTool, verifyTool, recentChangesTool, advisorTool, ...consultTools]
|
|
212
|
+
// Write-permission coder sub-agents (subagent role="coder" + escalate): the
|
|
213
|
+
// system prompt names verify (system.md) and advisor (discipline.md) — without them an
|
|
214
|
+
// escalate hit "unknown tool" and fell back to bash node --check / npm test to
|
|
215
|
+
// self-verify (2026-08-16 deepseek escalate diagnosis; plugin parity).
|
|
200
216
|
: agent._role === "eng-coder" ? [advisorTool, verifyTool]
|
|
201
217
|
: agent._role === "coder" ? [verifyTool, advisorTool]
|
|
218
|
+
: agent._role === "consult" ? [recentChangesTool]
|
|
202
219
|
: []
|
|
203
220
|
const tools = [...agent.tools, taskTool, planTool, timerTool, ...depthOnly]
|
|
204
221
|
const toolSchemas = tools.map(toOpenAISchema)
|
|
@@ -208,7 +225,12 @@ export async function prepareRun(agent, input, callbacks, {
|
|
|
208
225
|
// system prompt
|
|
209
226
|
const needsDiscipline = depth === 0 || agent._role === "coder" || agent._role === "eng-coder"
|
|
210
227
|
let base
|
|
211
|
-
if (
|
|
228
|
+
if (agent._role === "consult") {
|
|
229
|
+
// consult children: a lean, purpose-built base prompt (consult-base.md) — NOT the full
|
|
230
|
+
// main-agent system.md (whose coding-agent persona, checklist/task/verify workflows and
|
|
231
|
+
// tool references conflict with a read-only diagnosis and cost tokens every turn).
|
|
232
|
+
base = CONSULT_BASE
|
|
233
|
+
} else if ((depth === 0 || agent._role === "eng-coder") && agent.config?.agent?.engineering) {
|
|
212
234
|
// Engineering mode: strict methodology, NO standard discipline injection.
|
|
213
235
|
// Falling back to standard discipline on METHODOLOGY.md absence would leak
|
|
214
236
|
// advisor enforcement into engineering mode — the two prompt sets stay separate.
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* consult.mjs — multi-model consultation ("会诊", docs/design/CONSULTATION.md). CLI port.
|
|
3
|
+
*
|
|
4
|
+
* Three tools: consult_start (non-blocking spawn) / consult_check (read the next
|
|
5
|
+
* reply as it arrives) / consult_stop (abort the rest). The mechanism does ZERO
|
|
6
|
+
* judging — the main agent reads replies and verifies with its own tools.
|
|
7
|
+
*
|
|
8
|
+
* CLI adaptation (vs the VS Code plugin): the child runner is CLI's runAgent
|
|
9
|
+
* (runAgent(child, input, callbacks, opts) — an agent object, not provider+cwd);
|
|
10
|
+
* children are built with createAgent({ role: "consult", readonly tools,
|
|
11
|
+
* CONSULT_BASE overlay }); activity streams to the parent TUI via the relay
|
|
12
|
+
* prefix `consult#<id>/` (same channel subagent uses), not onSubagent/onToolPanel.
|
|
13
|
+
*/
|
|
14
|
+
import { createAgent, runAgent, readonlyToolNames } from "../agent.mjs"
|
|
15
|
+
import { resolveChildProvider } from "./subagent.mjs"
|
|
16
|
+
|
|
17
|
+
function consultLabel(m) {
|
|
18
|
+
return `${m.provider}:${m.model}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Read-only tool injected into consultation children (via createAgent's tools).
|
|
22
|
+
* Lets the consultant pull the main agent's conversation history on demand —
|
|
23
|
+
* the failure trail is first-class evidence, not a retelling. */
|
|
24
|
+
export function makeMainHistoryTool(parentAgent) {
|
|
25
|
+
return {
|
|
26
|
+
name: "main_history",
|
|
27
|
+
readonly: true,
|
|
28
|
+
description:
|
|
29
|
+
"Read the main agent's conversation history — what has been tried, the exact errors, recent context. " +
|
|
30
|
+
"Use it to ground your analysis in the actual failure trail instead of guessing.\n" +
|
|
31
|
+
"Parameters:\n" +
|
|
32
|
+
"- limit: Number of recent messages to return (default 20, max 100)",
|
|
33
|
+
parameters: {
|
|
34
|
+
type: "object",
|
|
35
|
+
properties: { limit: { type: "number", description: "Recent messages (default 20, max 100)" } },
|
|
36
|
+
},
|
|
37
|
+
async execute({ limit }) {
|
|
38
|
+
const n = Math.min(Math.max(limit ?? 20, 1), 100)
|
|
39
|
+
const h = parentAgent?.history ?? []
|
|
40
|
+
const slice = h.slice(-n)
|
|
41
|
+
if (slice.length === 0) return "(empty history)"
|
|
42
|
+
const render = (m) => {
|
|
43
|
+
let content
|
|
44
|
+
if (typeof m.content === "string") content = m.content
|
|
45
|
+
else if (Array.isArray(m.content)) {
|
|
46
|
+
content = m.content.map((part) => {
|
|
47
|
+
if (part?.type === "image_url" || part?.type === "image") return "[image omitted]"
|
|
48
|
+
if (part?.type === "text") return part.text ?? ""
|
|
49
|
+
return JSON.stringify(part)
|
|
50
|
+
}).join("\n")
|
|
51
|
+
} else content = m.content == null ? "" : JSON.stringify(m.content)
|
|
52
|
+
const calls = Array.isArray(m.tool_calls)
|
|
53
|
+
? m.tool_calls.map((c) => `[tool: ${c.function?.name ?? c.name}(${String(c.function?.arguments ?? c.args ?? "").slice(0, 200)})]`).join("\n")
|
|
54
|
+
: ""
|
|
55
|
+
return `--- [${m.role}] ---\n${content}${calls ? "\n" + calls : ""}`
|
|
56
|
+
}
|
|
57
|
+
const BUDGET = 60_000
|
|
58
|
+
let out = ""
|
|
59
|
+
for (let i = slice.length - 1; i >= 0; i--) {
|
|
60
|
+
const line = render(slice[i])
|
|
61
|
+
if (out.length + line.length > BUDGET) { out = `(earlier messages trimmed — budget ${BUDGET} chars)\n\n` + out; break }
|
|
62
|
+
out = out ? line + "\n\n" + out : line
|
|
63
|
+
}
|
|
64
|
+
return out
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Wake every parked consult_check waiter. */
|
|
70
|
+
function wakeWaiters(session) {
|
|
71
|
+
const w = session.waiters.splice(0)
|
|
72
|
+
for (const resolve of w) { try { resolve(false) } catch { /* noop */ } }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function settleChild(session, id, label, ok, payload) {
|
|
76
|
+
if (ok) {
|
|
77
|
+
session.received++
|
|
78
|
+
session.replies.push({ model: label, reply: payload })
|
|
79
|
+
} else if (session.stopped) {
|
|
80
|
+
session.terminated = (session.terminated ?? 0) + 1
|
|
81
|
+
} else {
|
|
82
|
+
session.failed++
|
|
83
|
+
session.replies.push({ model: label, reply: `(consultation failed: ${payload})`, failed: true })
|
|
84
|
+
}
|
|
85
|
+
session.pending--
|
|
86
|
+
wakeWaiters(session)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function runConsultChild(ctx, session, id, m, problem, ctrl) {
|
|
90
|
+
const agent = ctx.agent
|
|
91
|
+
const timeoutMs = agent?.config?.agent?.consultTimeoutMs ?? 600_000
|
|
92
|
+
let timedOut = false
|
|
93
|
+
const watchdog = setTimeout(() => {
|
|
94
|
+
timedOut = true
|
|
95
|
+
try { ctrl.abort() } catch { /* already settled */ }
|
|
96
|
+
}, timeoutMs)
|
|
97
|
+
const label = consultLabel(m)
|
|
98
|
+
try {
|
|
99
|
+
// Provider resolution: consultModels entries are { provider, model, effort? } — resolve
|
|
100
|
+
// via the subagent's provider resolver ("provider:model" handles cross-provider picks).
|
|
101
|
+
const provider = resolveChildProvider(agent, `${m.provider}:${m.model}`)
|
|
102
|
+
if (!provider?.apiKey?.trim() && !process.env.THINCODER_API_KEY) {
|
|
103
|
+
// resolveChildProvider may still lack a key; fail loudly like the plugin precheck
|
|
104
|
+
}
|
|
105
|
+
if (m.effort) provider.reasoningEffort = m.effort
|
|
106
|
+
|
|
107
|
+
// Read-only consultant: filter the parent tool set down to readonly tools + main_history.
|
|
108
|
+
const allowed = readonlyToolNames(agent.tools ?? [])
|
|
109
|
+
const tools = [
|
|
110
|
+
...(agent.tools ?? []).filter((t) => allowed.has(t.name)),
|
|
111
|
+
makeMainHistoryTool(agent),
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
const child = createAgent({
|
|
115
|
+
provider,
|
|
116
|
+
tools,
|
|
117
|
+
config: agent.config,
|
|
118
|
+
cwd: agent.cwd,
|
|
119
|
+
memory: agent.memory,
|
|
120
|
+
// No overlay: setup.mjs already selects CONSULT_BASE as the base prompt for
|
|
121
|
+
// role "consult" (overlay + base would concatenate it twice).
|
|
122
|
+
role: "consult",
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// Activity relay: `consult#<subId>/` prefix → the parent TUI's subTasks panel
|
|
126
|
+
// (same channel subagent uses — parallel consultants stay independent).
|
|
127
|
+
agent._subAgentCounter = (agent._subAgentCounter ?? 0) + 1
|
|
128
|
+
const subId = agent._subAgentCounter
|
|
129
|
+
const relayPrefix = `consult#${subId}/`
|
|
130
|
+
const childCallbacks = {
|
|
131
|
+
onToken: ctx.callbacks?.onToken ? (t) => ctx.callbacks.onToken(`${relayPrefix}${t}`) : null,
|
|
132
|
+
onReasoning: ctx.callbacks?.onReasoning ? (r) => ctx.callbacks.onReasoning(`${relayPrefix}${r}`) : null,
|
|
133
|
+
onToolCall: ctx.callbacks?.onToolCall ? (name, args) => ctx.callbacks.onToolCall(`${relayPrefix}${name}`, args) : null,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const runner = ctx.runAgent ?? runAgent
|
|
137
|
+
const result = await runner(child, "# Problem\n" + problem, childCallbacks, {
|
|
138
|
+
depth: 1,
|
|
139
|
+
maxTurns: agent?.config?.agent?.consultTurns ?? 40,
|
|
140
|
+
signal: ctrl.signal,
|
|
141
|
+
})
|
|
142
|
+
settleChild(session, id, label, true, String(result ?? ""))
|
|
143
|
+
} catch (e) {
|
|
144
|
+
const note = timedOut ? `consultation timed out after ${Math.round(timeoutMs / 60000)}min (agent.consultTimeoutMs)` : e?.message ?? String(e)
|
|
145
|
+
settleChild(session, id, label, false, note)
|
|
146
|
+
} finally {
|
|
147
|
+
clearTimeout(watchdog)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Turn-end cleanup (called from runAgent's finally): abort every leftover
|
|
152
|
+
* consultation controller, wake parked waiters, clear the session map. */
|
|
153
|
+
export function cleanupConsultSessions(agent) {
|
|
154
|
+
for (const s of agent._consultSessions?.values() ?? []) {
|
|
155
|
+
s.stopped = true
|
|
156
|
+
for (const c of s.controllers ?? []) { try { c.abort() } catch { /* already settled */ } }
|
|
157
|
+
for (const w of s.waiters?.splice(0) ?? []) { try { w() } catch { /* noop */ } }
|
|
158
|
+
}
|
|
159
|
+
agent._consultSessions?.clear()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export const consultStartTool = {
|
|
163
|
+
name: "consult_start",
|
|
164
|
+
readonly: false,
|
|
165
|
+
sideEffectExempt: true,
|
|
166
|
+
description:
|
|
167
|
+
"Start a parallel multi-model consultation for a hard problem you are stuck on (repeated failures, no headway). " +
|
|
168
|
+
"Several configured models (agent.consultModels) analyze the same problem INDEPENDENTLY and in parallel. " +
|
|
169
|
+
"Non-blocking: returns immediately with a consult id. Then call consult_check(id) to read each reply as it " +
|
|
170
|
+
"arrives, judge/verify it yourself with your own tools, and call consult_stop(id) once a reply is good enough.\n" +
|
|
171
|
+
"Parameters:\n" +
|
|
172
|
+
"- problem (required): a brief — the symptom, what you already tried (failure trail), and entry-point files. " +
|
|
173
|
+
"Do NOT paste raw error logs; consultants pull the main session history themselves via their main_history tool.",
|
|
174
|
+
parameters: {
|
|
175
|
+
type: "object",
|
|
176
|
+
properties: { problem: { type: "string", description: "Problem brief (symptom + failure trail + entry files)" } },
|
|
177
|
+
required: ["problem"],
|
|
178
|
+
},
|
|
179
|
+
async execute({ problem }, ctx) {
|
|
180
|
+
if (typeof problem !== "string" || !problem.trim()) return "Error: problem is required and must be a non-empty string"
|
|
181
|
+
const agent = ctx.agent
|
|
182
|
+
if (!agent) return "Error: consult requires an agent context"
|
|
183
|
+
const models = agent.config?.agent?.consultModels ?? []
|
|
184
|
+
if (!Array.isArray(models) || models.length === 0)
|
|
185
|
+
return "Consultation is not configured — add agent.consultModels ([{ provider, model }], up to 5) to ~/.thincoder/config.json"
|
|
186
|
+
if (models.length > 5) return `Error: consultModels supports at most 5 models (got ${models.length})`
|
|
187
|
+
|
|
188
|
+
agent._consultSessions ??= new Map()
|
|
189
|
+
const id = String((agent._consultIdCounter = (agent._consultIdCounter ?? 0) + 1))
|
|
190
|
+
const session = {
|
|
191
|
+
id, controllers: [], replies: [], pending: 0, waiters: [],
|
|
192
|
+
failed: 0, terminated: 0, stopped: false, received: 0, total: models.length,
|
|
193
|
+
models: models.map(consultLabel),
|
|
194
|
+
}
|
|
195
|
+
agent._consultSessions.set(id, session)
|
|
196
|
+
|
|
197
|
+
for (const m of models) {
|
|
198
|
+
session.pending++
|
|
199
|
+
const ctrl = new AbortController()
|
|
200
|
+
session.controllers.push(ctrl)
|
|
201
|
+
if (ctx.signal) {
|
|
202
|
+
if (ctx.signal.aborted) ctrl.abort()
|
|
203
|
+
else ctx.signal.addEventListener("abort", () => ctrl.abort(), { once: true })
|
|
204
|
+
}
|
|
205
|
+
// Fire and forget — each child settles itself into the session queue.
|
|
206
|
+
runConsultChild(ctx, session, id, m, problem, ctrl)
|
|
207
|
+
}
|
|
208
|
+
return JSON.stringify({ id, models: session.models })
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export const consultCheckTool = {
|
|
213
|
+
name: "consult_check",
|
|
214
|
+
readonly: true,
|
|
215
|
+
description:
|
|
216
|
+
"Read the NEXT consultation reply (whichever model answered first). Blocks until a reply arrives or all models " +
|
|
217
|
+
"have settled. The reply is raw and unjudged — verify/adopt it with your own tools. When done is true, no more " +
|
|
218
|
+
"replies are coming.\n" +
|
|
219
|
+
"Call it ALONE in a turn — do NOT batch it with calls that depend on its reply (readonly tools run in parallel).\n" +
|
|
220
|
+
"Parameters:\n" +
|
|
221
|
+
"- id (required): the consult id from consult_start",
|
|
222
|
+
parameters: {
|
|
223
|
+
type: "object",
|
|
224
|
+
properties: { id: { type: "string", description: "Consult id" } },
|
|
225
|
+
required: ["id"],
|
|
226
|
+
},
|
|
227
|
+
async execute({ id }, ctx) {
|
|
228
|
+
const s = ctx.agent?._consultSessions?.get(String(id))
|
|
229
|
+
if (!s) return JSON.stringify({ error: "unknown consult id" })
|
|
230
|
+
const abortAll = () => { for (const c of s.controllers) { try { c.abort() } catch { /* noop */ } } }
|
|
231
|
+
if (ctx.signal?.aborted) abortAll()
|
|
232
|
+
|
|
233
|
+
for (;;) {
|
|
234
|
+
if (s.replies.length > 0) {
|
|
235
|
+
const r = s.replies.shift()
|
|
236
|
+
return JSON.stringify({
|
|
237
|
+
reply: r.reply, model: r.model, failedReply: r.failed === true,
|
|
238
|
+
received: s.received,
|
|
239
|
+
failed: s.failed,
|
|
240
|
+
terminated: s.terminated ?? 0, total: s.total,
|
|
241
|
+
done: s.replies.length === 0 && s.pending === 0,
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
if (s.pending === 0) {
|
|
245
|
+
return JSON.stringify({ done: true, received: s.received, failed: s.failed, total: s.total })
|
|
246
|
+
}
|
|
247
|
+
const stopped = await new Promise((resolve) => {
|
|
248
|
+
function cleanup() {
|
|
249
|
+
const i = s.waiters.indexOf(w)
|
|
250
|
+
if (i >= 0) s.waiters.splice(i, 1)
|
|
251
|
+
ctx.signal?.removeEventListener("abort", onAbort)
|
|
252
|
+
}
|
|
253
|
+
function w() { cleanup(); resolve(false) }
|
|
254
|
+
function onAbort() { cleanup(); abortAll(); resolve(true) }
|
|
255
|
+
s.waiters.push(w)
|
|
256
|
+
if (ctx.signal) {
|
|
257
|
+
if (ctx.signal.aborted) { onAbort(); return }
|
|
258
|
+
ctx.signal.addEventListener("abort", onAbort, { once: true })
|
|
259
|
+
}
|
|
260
|
+
})
|
|
261
|
+
if (stopped) return JSON.stringify({ done: true, stopped: true, received: s.received, failed: s.failed, total: s.total })
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export const consultStopTool = {
|
|
267
|
+
name: "consult_stop",
|
|
268
|
+
readonly: false,
|
|
269
|
+
sideEffectExempt: true,
|
|
270
|
+
description:
|
|
271
|
+
"Terminate the still-running consultations of a session once a reply is good enough — saves tokens and time. " +
|
|
272
|
+
"Already-answered replies stay available for consult_check.\n" +
|
|
273
|
+
"Parameters:\n" +
|
|
274
|
+
"- id (required): the consult id from consult_start",
|
|
275
|
+
parameters: {
|
|
276
|
+
type: "object",
|
|
277
|
+
properties: { id: { type: "string", description: "Consult id" } },
|
|
278
|
+
required: ["id"],
|
|
279
|
+
},
|
|
280
|
+
async execute({ id }, ctx) {
|
|
281
|
+
const s = ctx.agent?._consultSessions?.get(String(id))
|
|
282
|
+
if (!s) return JSON.stringify({ error: "unknown consult id" })
|
|
283
|
+
const n = s.pending
|
|
284
|
+
s.stopped = true
|
|
285
|
+
for (const c of s.controllers) { try { c.abort() } catch { /* already settled */ } }
|
|
286
|
+
return JSON.stringify({ stopped: n })
|
|
287
|
+
},
|
|
288
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* escalate.mjs — 飞刀 (the "flying knife", docs/design/ESCALATE.md). CLI port.
|
|
3
|
+
*
|
|
4
|
+
* Hand an implementation task to a STRONGER model — like a hospital flying in an
|
|
5
|
+
* outside expert (飞刀): the expert arrives, operates personally (WRITE access),
|
|
6
|
+
* hands back the post-op report, leaves. Complementary to consult (parallel
|
|
7
|
+
* READ-ONLY opinions for judgment calls).
|
|
8
|
+
*
|
|
9
|
+
* Candidate pool = all consultModels rows. The tool is only registered when the
|
|
10
|
+
* pool is non-empty (setup.mjs).
|
|
11
|
+
*
|
|
12
|
+
* CLI adaptation (vs the VS Code plugin): the child runner is CLI's runAgent
|
|
13
|
+
* (runAgent(child, input, callbacks, opts) — an agent object, not provider+cwd);
|
|
14
|
+
* the child is createAgent({ role: "coder", CODER_OVERLAY }); activity streams to
|
|
15
|
+
* the parent TUI via the relay prefix `escalate#<id>/`; mutations merge via the
|
|
16
|
+
* CLI mergeChildMutations(parent, child) (agent object, not a state sink).
|
|
17
|
+
*/
|
|
18
|
+
import { isAbsolute, relative } from "node:path"
|
|
19
|
+
import { createAgent, runAgent, ContinueError, CODER_OVERLAY } from "../agent.mjs"
|
|
20
|
+
import { resolveChildProvider, mergeChildMutations } from "./subagent.mjs"
|
|
21
|
+
|
|
22
|
+
const label = (m) => `${m.provider}:${m.model}`
|
|
23
|
+
|
|
24
|
+
export const escalateTool = {
|
|
25
|
+
name: "escalate",
|
|
26
|
+
sideEffectExempt: true, // the child's mutations are tracked and reviewed, like subagent
|
|
27
|
+
description:
|
|
28
|
+
"TERMINOLOGY (one word for one thing): 'escalate' is the ONLY name — the tool, and the " +
|
|
29
|
+
"role of the expert sub-agent it spawns, are both called 'escalate'; 飞刀 is the Chinese " +
|
|
30
|
+
"alias. When the user says 飞刀 / escalate / 'fly in <model>', call THIS tool directly — " +
|
|
31
|
+
"never via a script importing this module. " +
|
|
32
|
+
"Hand an implementation task to a stronger model (飞刀 — a flown-in expert). " +
|
|
33
|
+
"It gets WRITE access and does the work itself — reads, edits, runs tests — then returns " +
|
|
34
|
+
"a post-op report (what changed, why, verification). You review the report and report to " +
|
|
35
|
+
"the user. Use it when YOU judge the task calls for stronger hands (complex multi-file " +
|
|
36
|
+
"refactoring, an intractable bug, intricate algorithm work — or work beyond your " +
|
|
37
|
+
"comfortable ability). Early or late, your judgment; the cost is one expert run, " +
|
|
38
|
+
"comparable to doing it yourself. For parallel READ-ONLY opinions use consult_start instead. " +
|
|
39
|
+
"Not available in engineering mode (implementation goes through eng-coder subagents there).\n" +
|
|
40
|
+
"Parameters:\n" +
|
|
41
|
+
"- task (required): the task description — goal, constraints, entry files, acceptance criteria\n" +
|
|
42
|
+
"- model (optional): pick a specific consultant as 'provider:model'; default = the first consult model",
|
|
43
|
+
parameters: {
|
|
44
|
+
type: "object",
|
|
45
|
+
properties: {
|
|
46
|
+
task: { type: "string", description: "Task description with acceptance criteria" },
|
|
47
|
+
model: { type: "string", description: "Candidate 'provider:model' from the consult models (optional)" },
|
|
48
|
+
},
|
|
49
|
+
required: ["task"],
|
|
50
|
+
},
|
|
51
|
+
async execute({ task, model }, ctx) {
|
|
52
|
+
const parent = ctx.agent
|
|
53
|
+
if ((ctx.depth ?? 0) > 0) return "Error: escalate is only available at depth 0 (an escalate's work cannot be delegated again)"
|
|
54
|
+
if (parent?.config?.agent?.engineering) {
|
|
55
|
+
return "Error: engineering mode is ON — escalate is unavailable (it spawns a coder sub-agent, which engineering mode forbids). Use subagent with role='eng-coder' and a designToken from advisor(type='design') instead."
|
|
56
|
+
}
|
|
57
|
+
const pool = parent?.config?.agent?.consultModels ?? []
|
|
58
|
+
if (pool.length === 0) return "Error: no escalate candidates — configure at least one consult model (agent.consultModels)"
|
|
59
|
+
|
|
60
|
+
const wanted = typeof model === "string" ? model.replace(/\s+\([^)]*\)\s*$/, "").trim() : model
|
|
61
|
+
const pick = wanted ? pool.find((m) => label(m) === wanted) : pool[0]
|
|
62
|
+
if (!pick) {
|
|
63
|
+
return `Error: "${model}" is not a consult candidate. Available: ${pool.map(label).join(", ")}`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let provider
|
|
67
|
+
try {
|
|
68
|
+
provider = resolveChildProvider(parent, `${pick.provider}:${pick.model}`)
|
|
69
|
+
} catch (e) {
|
|
70
|
+
return `Error: ${e.message}`
|
|
71
|
+
}
|
|
72
|
+
if (!provider?.apiKey?.trim() && !process.env.THINCODER_API_KEY) {
|
|
73
|
+
return `Error: provider "${pick.provider}" has no API key — set it in config.json (or THINCODER_API_KEY) before flying it in`
|
|
74
|
+
}
|
|
75
|
+
if (pick.effort) provider.reasoningEffort = pick.effort
|
|
76
|
+
|
|
77
|
+
parent._subAgentCounter = (parent._subAgentCounter ?? 0) + 1
|
|
78
|
+
const subId = parent._subAgentCounter
|
|
79
|
+
const tag = label(pick)
|
|
80
|
+
const relayPrefix = `escalate#${subId}/`
|
|
81
|
+
|
|
82
|
+
const timeoutMs = parent?.config?.agent?.consultTimeoutMs ?? 600_000
|
|
83
|
+
let timedOut = false
|
|
84
|
+
const ctrl = new AbortController()
|
|
85
|
+
const watchdog = setTimeout(() => {
|
|
86
|
+
timedOut = true
|
|
87
|
+
try { ctrl.abort() } catch { /* already settled */ }
|
|
88
|
+
}, timeoutMs)
|
|
89
|
+
if (ctx.signal) {
|
|
90
|
+
if (ctx.signal.aborted) ctrl.abort()
|
|
91
|
+
else ctx.signal.addEventListener("abort", () => ctrl.abort(), { once: true })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let output = ""
|
|
95
|
+
const childCallbacks = {
|
|
96
|
+
onToken: ctx.callbacks?.onToken ? (t) => { output += t; ctx.callbacks.onToken(`${relayPrefix}${t}`) } : (t) => { output += t },
|
|
97
|
+
onReasoning: ctx.callbacks?.onReasoning ? (r) => ctx.callbacks.onReasoning(`${relayPrefix}${r}`) : null,
|
|
98
|
+
onToolCall: ctx.callbacks?.onToolCall ? (name, args) => ctx.callbacks.onToolCall(`${relayPrefix}${name}`, args) : null,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Declared outside try so the catch can merge mutations even on a partial failure.
|
|
102
|
+
let child = null
|
|
103
|
+
try {
|
|
104
|
+
// Full write path (role "coder"): permission gate via the parent's onPermissionRequest,
|
|
105
|
+
// recent-changes tracking, mutations merge into the parent below.
|
|
106
|
+
child = createAgent({
|
|
107
|
+
provider,
|
|
108
|
+
tools: parent.tools,
|
|
109
|
+
config: parent.config,
|
|
110
|
+
cwd: parent.cwd,
|
|
111
|
+
memory: parent.memory,
|
|
112
|
+
overlay: CODER_OVERLAY,
|
|
113
|
+
role: "coder",
|
|
114
|
+
})
|
|
115
|
+
const runner = ctx.runAgent ?? runAgent
|
|
116
|
+
const report = await runner(child, task, {
|
|
117
|
+
...childCallbacks,
|
|
118
|
+
onPermissionRequest: ctx.onPermissionRequest ?? null,
|
|
119
|
+
}, {
|
|
120
|
+
depth: 1,
|
|
121
|
+
maxTurns: parent.config?.agent?.subagentTurns ?? 100,
|
|
122
|
+
signal: ctrl.signal,
|
|
123
|
+
})
|
|
124
|
+
// Escalate mutations are the parent's mutations: verify/advisor guards must see them
|
|
125
|
+
mergeChildMutations(parent, child)
|
|
126
|
+
return `escalate (${tag}) post-op report:\n${report || output.slice(0, 4000)}${touchedFilesNote(child, parent.cwd)}`
|
|
127
|
+
} catch (e) {
|
|
128
|
+
// Even a failed surgery may have written files — merge whatever the child touched.
|
|
129
|
+
if (child) mergeChildMutations(parent, child)
|
|
130
|
+
const msg = e?.message ?? String(e)
|
|
131
|
+
if (ctx.signal?.aborted || (!timedOut && e?.name === "AbortError")) throw e
|
|
132
|
+
if (e instanceof ContinueError) {
|
|
133
|
+
return `escalate (${tag}) stopped: turn cap reached (${e.turns} turns) — work may be partial; review recent_changes before deciding next steps.\nPartial output: ${output.slice(0, 2000)}`
|
|
134
|
+
}
|
|
135
|
+
const note = timedOut ? `timed out after ${Math.round(timeoutMs / 60000)}min (agent.consultTimeoutMs)` : msg
|
|
136
|
+
return `escalate (${tag}) error: ${note}\nPartial output: ${output.slice(0, 2000)}`
|
|
137
|
+
} finally {
|
|
138
|
+
clearTimeout(watchdog)
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Relative touched-file list appended to every return (child paths are absolute). */
|
|
144
|
+
function touchedFilesNote(child, cwd) {
|
|
145
|
+
const touched = child?._touchedFiles ?? []
|
|
146
|
+
if (touched.length === 0) return ""
|
|
147
|
+
const shown = touched.map((f) => {
|
|
148
|
+
const r = relative(cwd ?? process.cwd(), f)
|
|
149
|
+
return r && !r.startsWith("..") && !isAbsolute(r) ? r : f
|
|
150
|
+
})
|
|
151
|
+
return `\nTouched files: ${shown.join(", ")}`
|
|
152
|
+
}
|
package/src/agent.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { executeToolCalls } from "./agent/dispatch.mjs"
|
|
|
13
13
|
import { prepareRun } from "./agent/setup.mjs"
|
|
14
14
|
import { injectPostTurn, STALL_WINDOW_SIZE, STALL_THRESHOLD, GOAL_BUDGET_WARN_RATIO } from "./agent/post-turn.mjs"
|
|
15
15
|
import { handleCompletion } from "./agent/completion.mjs"
|
|
16
|
+
import { cleanupConsultSessions } from "./agent-tools/consult.mjs"
|
|
16
17
|
import {
|
|
17
18
|
escapeXml, tryCanonicalize, repairHistory, listWorkDir,
|
|
18
19
|
readonlyToolNames, collectGitContext, loadProjectInstructions,
|
|
@@ -26,15 +27,17 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
|
26
27
|
const SYSTEM_PROMPT = readFileSync(join(__dirname, "prompts", "system.md"), "utf8")
|
|
27
28
|
const DISCIPLINE_RULES = readFileSync(join(__dirname, "prompts", "discipline.md"), "utf8")
|
|
28
29
|
const MAIN_OVERLAY = readFileSync(join(__dirname, "prompts", "main.md"), "utf8")
|
|
29
|
-
let _EXPLORE, _CODER, _PLAN, _ENG_CODER
|
|
30
|
+
let _EXPLORE, _CODER, _PLAN, _ENG_CODER, _CONSULT_BASE
|
|
30
31
|
try { _EXPLORE = readFileSync(join(__dirname, "prompts", "explore.md"), "utf8") } catch { _EXPLORE = "" }
|
|
31
32
|
try { _CODER = readFileSync(join(__dirname, "prompts", "coder.md"), "utf8") } catch { _CODER = "" }
|
|
32
33
|
try { _PLAN = readFileSync(join(__dirname, "prompts", "plan.md"), "utf8") } catch { _PLAN = "" }
|
|
33
34
|
try { _ENG_CODER = readFileSync(join(__dirname, "prompts", "eng-coder.md"), "utf8") } catch { _ENG_CODER = "" }
|
|
35
|
+
try { _CONSULT_BASE = readFileSync(join(__dirname, "prompts", "consult-base.md"), "utf8") } catch { _CONSULT_BASE = "" }
|
|
34
36
|
export const EXPLORE_OVERLAY = _EXPLORE
|
|
35
37
|
export const CODER_OVERLAY = _CODER
|
|
36
38
|
export const PLAN_OVERLAY = _PLAN
|
|
37
39
|
export const ENG_CODER_OVERLAY = _ENG_CODER
|
|
40
|
+
export const CONSULT_BASE = _CONSULT_BASE
|
|
38
41
|
|
|
39
42
|
// exported for consumption by agent-tools.mjs
|
|
40
43
|
export {
|
|
@@ -142,7 +145,8 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
142
145
|
tools: toolSchemas,
|
|
143
146
|
}
|
|
144
147
|
|
|
145
|
-
|
|
148
|
+
try {
|
|
149
|
+
for (let turn = 0; turn < maxTurns; turn++) {
|
|
146
150
|
// Update turn counter for status bar display
|
|
147
151
|
agent._currentTurn = turn + 1
|
|
148
152
|
agent._maxTurns = maxTurns
|
|
@@ -439,7 +443,12 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
439
443
|
}
|
|
440
444
|
|
|
441
445
|
injectPostTurn(agent, results, recentCallSigs, callbacks, turn)
|
|
442
|
-
|
|
446
|
+
}
|
|
443
447
|
|
|
444
|
-
|
|
448
|
+
throw new ContinueError(maxTurns)
|
|
449
|
+
} finally {
|
|
450
|
+
// Turn-end cleanup: abort any leftover consultation children (consult_start spawns
|
|
451
|
+
// fire-and-forget runners; a completed turn must not let them keep burning tokens).
|
|
452
|
+
cleanupConsultSessions(agent)
|
|
453
|
+
}
|
|
445
454
|
}
|
package/src/config.mjs
CHANGED
|
@@ -49,6 +49,11 @@ const DEFAULTS = {
|
|
|
49
49
|
goalTurns: 200,
|
|
50
50
|
compactThreshold: 100000,
|
|
51
51
|
verifyGuard: false, // push model back to verify when files were mutated but verify not run (opt-in)
|
|
52
|
+
// Multi-model consultation ("会诊") + escalate ("飞刀") — CLI parity with the VS Code plugin.
|
|
53
|
+
// consultModels: candidate pool for BOTH consult and escalate ({ provider, model, effort? }, up to 5).
|
|
54
|
+
consultModels: [],
|
|
55
|
+
consultTurns: 40, // per-consultant tool-turn budget (diagnosis tasks)
|
|
56
|
+
consultTimeoutMs: 600000, // wall-clock ceiling per consultant (10min)
|
|
52
57
|
streamRules: [], // time-traveling stream rules: [{ pattern: "regex", message: "reminder", action: "abort"|"warn", repeat: "always"|"once" }]
|
|
53
58
|
advisor: { enabled: false }, // code review; { enabled: true, provider: "deepseek", model: "deepseek-chat", thinking: { type: "enabled" }, reasoningEffort: "max", guard: true }
|
|
54
59
|
autoThink: false, // auto-classify task difficulty and set reasoning effort per-turn
|
|
@@ -234,6 +239,15 @@ export function loadConfig() {
|
|
|
234
239
|
embedding: { ...DEFAULTS.embedding, ...config.embedding },
|
|
235
240
|
}
|
|
236
241
|
|
|
242
|
+
// Consult/escalate pool validation (CLI parity with the plugin): up to 5 candidates.
|
|
243
|
+
const cm = merged.agent.consultModels
|
|
244
|
+
if (cm !== undefined && !Array.isArray(cm)) {
|
|
245
|
+
throw new Error(`agent.consultModels must be an array of { provider, model } entries (got ${typeof cm})`)
|
|
246
|
+
}
|
|
247
|
+
if (Array.isArray(cm) && cm.length > 5) {
|
|
248
|
+
throw new Error(`agent.consultModels supports at most 5 models (got ${cm.length})`)
|
|
249
|
+
}
|
|
250
|
+
|
|
237
251
|
// Backward compatibility: promote root-level config fields to agent sub-object
|
|
238
252
|
if (config.verifyGuard !== undefined) {
|
|
239
253
|
merged.agent.verifyGuard = config.verifyGuard
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
You are one of several independent expert consultants analyzing the same problem in parallel — each on a different model. Your value is a perspective the main agent may be missing.
|
|
2
|
+
|
|
3
|
+
**Language:** reply in the user's language; keep code, commands, identifiers, file paths, and technical terms in their original form.
|
|
4
|
+
|
|
5
|
+
**Rules:**
|
|
6
|
+
- You are READ-ONLY: analyze and recommend, never modify files. The main agent implements.
|
|
7
|
+
- You have a `main_history` tool — pull the main agent's conversation history (what was tried, exact errors) BEFORE theorizing. Ground your analysis in the actual failure trail.
|
|
8
|
+
- Do not wait for or coordinate with the other consultants; they cannot see you.
|
|
9
|
+
- Work within your budget (~40 tool turns, up to ~10 minutes wall-clock): pull main_history first, read the 2–5 entry-point files it points at, and STOP. Reading targeted files is the expected behavior; full-repo scans are over budget — but do NOT skip reading entirely and theorize from the brief alone.
|
|
10
|
+
- Brief paths can be wrong (missing a directory prefix, renamed files) — verify with glob/ls before concluding a file "does not exist".
|
|
11
|
+
- Prefer local files first; use web search only when the question needs external facts (an API's current behavior, an upstream doc) — never to rediscover what is in the repo.
|
|
12
|
+
- Be concrete: root cause first, then a specific, actionable fix. If verification is possible, state exactly how the main agent can verify your recommendation (commands, files to check, expected outcome).
|
|
13
|
+
- Be honest: do not fabricate file contents or line numbers you did not actually read.
|
|
14
|
+
|
|
15
|
+
Structure your final answer as:
|
|
16
|
+
## Diagnosis
|
|
17
|
+
(root cause analysis)
|
|
18
|
+
## Recommendation
|
|
19
|
+
(the concrete fix)
|
|
20
|
+
## Verification
|
|
21
|
+
(how to prove it — commands / files / expected outcome; omit only if the question is purely conceptual)
|
|
22
|
+
|
|
23
|
+
Keep the whole answer concise — it is pasted verbatim into the main agent's context, so ~500 words is ideal; no filler.
|
|
@@ -11,5 +11,8 @@ Debugging strategy:
|
|
|
11
11
|
- Fix one thing at a time. Don't change multiple things at once.
|
|
12
12
|
- Don't get stuck reading code — write tests, add logs. Trust the runtime over your theories.
|
|
13
13
|
|
|
14
|
+
UI & interface design:
|
|
15
|
+
- When a value has a FIXED set of choices (an enum, a level, a mode, a flag), present it as OPTIONS — picker / menu / choices / buttons — never as free-text input. Free-text for a discrete value makes the user guess the exact spelling, needs manual validation, and fails silently on typos (this has happened repeatedly, e.g. reasoning-effort levels typed by hand). Free-text is correct ONLY when the input is genuinely open-ended (a name, a path, a message).
|
|
16
|
+
|
|
14
17
|
Review discipline (standard mode only — engineering mode has its own review timing rules):
|
|
15
18
|
- **Advisor:** call after changing code. Must provide scope: `paths` (files/dirs to review) or `documents` (context). Response table: `| # | Action | Detail |`. Round 2 verifies the prior issue table + flags obvious new issues; round 3+ strictly verifies only the prior issue table (no new-issue hunting). Max 5 rounds total.
|
package/src/tui/cmd-config.mjs
CHANGED
|
@@ -142,16 +142,88 @@ export async function handleConfigCommand(ctx, args = []) {
|
|
|
142
142
|
}
|
|
143
143
|
if (sub) { pushLine("Usage: /config [embedkey]", C.error); return }
|
|
144
144
|
|
|
145
|
+
/** 会诊/飞刀候选池子菜单:列出 / 添加 / 编辑 effort / 删除 consultModels 条目。 */
|
|
146
|
+
async function pickEffort(current) {
|
|
147
|
+
const levels = ["none", "min", "low", "medium", "high", "max"]
|
|
148
|
+
const entries = levels.map((l) => ({ type: "item", text: l === current ? `${l} ← current` : l, action: l }))
|
|
149
|
+
const c = await showPicker("Reasoning effort", entries, { defaultIndex: Math.max(0, levels.indexOf(current ?? "none")) })
|
|
150
|
+
return c ? c.action : null // Esc → null (keep unchanged)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function consultMenu() {
|
|
154
|
+
let idx = 0
|
|
155
|
+
for (;;) {
|
|
156
|
+
const cm = agent.config?.agent?.consultModels ?? []
|
|
157
|
+
const entries = [
|
|
158
|
+
{ type: "header", text: `Consult/escalate pool: ${cm.length} model(s) (max 5)` },
|
|
159
|
+
...cm.map((m, i) => ({ type: "item", text: `${m.provider}:${m.model}${m.effort ? ` (${m.effort})` : ""}`, action: "edit", index: i })),
|
|
160
|
+
{ type: "item", text: cm.length ? "+ Add model" : "+ Add model (none yet)", action: "add" },
|
|
161
|
+
]
|
|
162
|
+
const c = await showPicker("Consult models", entries, { defaultIndex: idx })
|
|
163
|
+
if (!c) return // Esc → 返回主菜单
|
|
164
|
+
if (c.action === "add") {
|
|
165
|
+
if (cm.length >= 5) { pushLine("At most 5 consult models", C.error); continue }
|
|
166
|
+
const pEntries = agent.providers.map((p) => ({ type: "item", text: `${p.name.padEnd(14)} ${p.model}`, action: "pick", provider: p.name, model: p.model }))
|
|
167
|
+
const p = await showPicker("Add consult model — pick provider", pEntries, {})
|
|
168
|
+
if (!p) continue
|
|
169
|
+
const modelIn = await askQuestion(`Model for ${p.provider} (default: ${p.model}):`)
|
|
170
|
+
const mname = modelIn?.trim() || p.model
|
|
171
|
+
const effort = await pickEffort(null)
|
|
172
|
+
const entry = { provider: p.provider, model: mname }
|
|
173
|
+
if (effort && effort !== "none") entry.effort = effort
|
|
174
|
+
const next = [...cm, entry]
|
|
175
|
+
await saveProxy((raw) => { raw.agent ??= {}; raw.agent.consultModels = next })
|
|
176
|
+
pushLabel("❯ Config", ansi.bold + C.tool)
|
|
177
|
+
pushLine(`Added ${entry.provider}:${entry.model}${entry.effort ? ` (${entry.effort})` : ""}`, C.tool)
|
|
178
|
+
idx = 0
|
|
179
|
+
} else if (c.action === "edit") {
|
|
180
|
+
// Per-model sub-menu: change effort or remove.
|
|
181
|
+
const m = cm[c.index]
|
|
182
|
+
const tag = `${m.provider}:${m.model}`
|
|
183
|
+
const subEntries = [
|
|
184
|
+
{ type: "header", text: `${tag} — effort: ${m.effort ?? "(none)"}` },
|
|
185
|
+
{ type: "item", text: `Change effort (current: ${m.effort ?? "none"})`, action: "effort" },
|
|
186
|
+
{ type: "item", text: "Remove", action: "remove" },
|
|
187
|
+
]
|
|
188
|
+
const s = await showPicker(tag, subEntries, {})
|
|
189
|
+
if (!s) continue
|
|
190
|
+
if (s.action === "remove") {
|
|
191
|
+
const next = cm.filter((_, i) => i !== c.index)
|
|
192
|
+
await saveProxy((raw) => { raw.agent ??= {}; raw.agent.consultModels = next })
|
|
193
|
+
pushLabel("❯ Config", ansi.bold + C.tool)
|
|
194
|
+
pushLine(`Removed ${tag}`, C.tool)
|
|
195
|
+
} else if (s.action === "effort") {
|
|
196
|
+
const effort = await pickEffort(m.effort)
|
|
197
|
+
if (effort === null) { continue } // Esc 保持
|
|
198
|
+
const next = cm.map((x, i) => {
|
|
199
|
+
if (i !== c.index) return x
|
|
200
|
+
if (effort === "none") { const { effort, ...rest } = x; return rest }
|
|
201
|
+
return { ...x, effort }
|
|
202
|
+
})
|
|
203
|
+
await saveProxy((raw) => { raw.agent ??= {}; raw.agent.consultModels = next })
|
|
204
|
+
pushLabel("❯ Config", ansi.bold + C.tool)
|
|
205
|
+
const after = next[c.index]
|
|
206
|
+
pushLine(`${tag} effort = ${after?.effort ?? "none"}`, C.tool)
|
|
207
|
+
}
|
|
208
|
+
idx = 0
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
145
213
|
// ── Main config loop ──
|
|
146
214
|
let running = true
|
|
147
215
|
let mainIdx = 0 // 记住上次选中位置,改完一项回主菜单时恢复
|
|
148
216
|
while (running) {
|
|
217
|
+
const consultCount = (ac.consultModels ?? []).length
|
|
149
218
|
const mainEntries = [
|
|
150
|
-
{ type: "header", text: `proxy=${proxySummary()} | maxTurns=${ac.maxTurns ?? 100} | compactThreshold=${ac.compactThreshold ?? 100000} | verifyGuard=${ac.verifyGuard === true ? "on" : "off"} | embedding=${agent.memory?.embedder ? "on" : "off"}` },
|
|
219
|
+
{ type: "header", text: `proxy=${proxySummary()} | maxTurns=${ac.maxTurns ?? 100} | compactThreshold=${ac.compactThreshold ?? 100000} | verifyGuard=${ac.verifyGuard === true ? "on" : "off"} | consult=${consultCount} model(s) | embedding=${agent.memory?.embedder ? "on" : "off"}` },
|
|
151
220
|
{ type: "item", text: `agent.maxTurns = ${ac.maxTurns ?? 100}`, action: "agent.maxTurns" },
|
|
152
221
|
{ type: "item", text: `agent.subagentTurns = ${ac.subagentTurns ?? 100}`, action: "agent.subagentTurns" },
|
|
153
222
|
{ type: "item", text: `agent.compactThreshold = ${ac.compactThreshold ?? 100000}${agent.config?.agent?.compactThresholdAuto ? " (auto)" : ""}`, action: "agent.compactThreshold" },
|
|
154
223
|
{ type: "item", text: `agent.verifyGuard = ${ac.verifyGuard === true ? "on" : "off"}`, action: "agent.verifyGuard" },
|
|
224
|
+
{ type: "item", text: `agent.consultModels = ${consultCount} model(s)${consultCount ? ` (${(ac.consultModels ?? []).map((m) => m.provider + ":" + m.model).join(", ")})` : ""}`, action: "consult" },
|
|
225
|
+
{ type: "item", text: `agent.consultTurns = ${ac.consultTurns ?? 40}`, action: "agent.consultTurns" },
|
|
226
|
+
{ type: "item", text: `agent.consultTimeoutMs = ${Math.round((ac.consultTimeoutMs ?? 600000) / 60000)} min`, action: "agent.consultTimeoutMs" },
|
|
155
227
|
{ type: "item", text: "Set embedding API key", action: "embedkey" },
|
|
156
228
|
{ type: "item", text: `proxy = ${proxySummary()}`, action: "proxy" },
|
|
157
229
|
{ type: "item", text: "View full config", action: "view" },
|
|
@@ -169,6 +241,9 @@ export async function handleConfigCommand(ctx, args = []) {
|
|
|
169
241
|
pushLine(`agent.subagentTurns: ${ac.subagentTurns ?? 100}`, C.dim)
|
|
170
242
|
pushLine(`agent.compactThreshold: ${ac.compactThreshold ?? 100000}${agent.config?.agent?.compactThresholdAuto ? " (auto)" : ""}`, C.dim)
|
|
171
243
|
pushLine(`agent.verifyGuard: ${ac.verifyGuard === true ? "on" : "off"}`, C.dim)
|
|
244
|
+
pushLine(`agent.consultModels: ${(ac.consultModels ?? []).map((m) => `${m.provider}:${m.model}${m.effort ? ` (${m.effort})` : ""}`).join(", ") || "(none)"}`, C.dim)
|
|
245
|
+
pushLine(`agent.consultTurns: ${ac.consultTurns ?? 40}`, C.dim)
|
|
246
|
+
pushLine(`agent.consultTimeoutMs: ${Math.round((ac.consultTimeoutMs ?? 600000) / 60000)} min`, C.dim)
|
|
172
247
|
pushLine(`embedding: ${agent.memory?.embedder ? `enabled (${ec.model ?? ""})` : "disabled (FTS only)"}`, C.dim)
|
|
173
248
|
pushLine(`proxy: ${proxySummary()}`, C.dim)
|
|
174
249
|
pushLine(`Config file: ${configPath}`, C.dim)
|
|
@@ -181,6 +256,11 @@ export async function handleConfigCommand(ctx, args = []) {
|
|
|
181
256
|
continue
|
|
182
257
|
}
|
|
183
258
|
|
|
259
|
+
if (choice.action === "consult") {
|
|
260
|
+
await consultMenu()
|
|
261
|
+
continue
|
|
262
|
+
}
|
|
263
|
+
|
|
184
264
|
if (choice.action === "embedkey") {
|
|
185
265
|
if (await setEmbedKey()) running = false
|
|
186
266
|
continue
|
|
@@ -205,23 +285,27 @@ export async function handleConfigCommand(ctx, args = []) {
|
|
|
205
285
|
|
|
206
286
|
// Numeric config items
|
|
207
287
|
const label = choice.action
|
|
288
|
+
const isTimeout = label === "agent.consultTimeoutMs"
|
|
208
289
|
const current = label === "agent.maxTurns" ? (ac.maxTurns ?? 100)
|
|
209
290
|
: label === "agent.subagentTurns" ? (ac.subagentTurns ?? 100)
|
|
210
291
|
: label === "agent.compactThreshold" ? (ac.compactThreshold ?? 100000)
|
|
292
|
+
: label === "agent.consultTurns" ? (ac.consultTurns ?? 40)
|
|
293
|
+
: isTimeout ? Math.round((ac.consultTimeoutMs ?? 600000) / 60000)
|
|
211
294
|
: ""
|
|
212
|
-
const val = await askQuestion(`${label} (current: ${current}):`)
|
|
295
|
+
const val = await askQuestion(`${label} (current: ${current}${isTimeout ? " min" : ""}):`)
|
|
213
296
|
if (!val) continue
|
|
214
297
|
try {
|
|
215
298
|
const num = Number(val)
|
|
216
299
|
if (isNaN(num)) { pushLine("Value must be a number", C.error); continue }
|
|
300
|
+
const stored = isTimeout ? Math.round(num * 60000) : num
|
|
217
301
|
await saveProxy((raw) => {
|
|
218
302
|
const keys = label.split(".")
|
|
219
303
|
let obj = raw
|
|
220
304
|
for (let i = 0; i < keys.length - 1; i++) { obj[keys[i]] ??= {}; obj = obj[keys[i]] }
|
|
221
|
-
obj[keys[keys.length - 1]] =
|
|
305
|
+
obj[keys[keys.length - 1]] = stored
|
|
222
306
|
})
|
|
223
307
|
pushLabel("❯ Config", ansi.bold + C.tool)
|
|
224
|
-
pushLine(`${label} = ${val}`, C.tool)
|
|
308
|
+
pushLine(`${label} = ${isTimeout ? `${val} min (${stored} ms)` : val}`, C.tool)
|
|
225
309
|
pushLine("(restart to apply)", C.dim)
|
|
226
310
|
running = false
|
|
227
311
|
} catch (error) { pushLine(`Save failed: ${error.message}`, C.error) }
|
|
@@ -46,7 +46,7 @@ export const SLASH_COMMANDS = [
|
|
|
46
46
|
{ name: "/goal", group: "Agent", desc: "set/view/cancel long-term goal" },
|
|
47
47
|
{ name: "/think", group: "Agent", desc: "thinking mode & reasoning effort" },
|
|
48
48
|
{ name: "/upgrade", group: "System", desc: "check for updates & upgrade" },
|
|
49
|
-
{ name: "/config", group: "System", desc: "agent config (embedding, proxy, turns,
|
|
49
|
+
{ name: "/config", group: "System", desc: "agent config (embedding, proxy, turns, threshold, consult pool)" },
|
|
50
50
|
{ name: "/new", group: "Session", desc: "new session (old one archived to slot)" },
|
|
51
51
|
{ name: "/session", group: "Session", desc: "list/switch archived sessions" },
|
|
52
52
|
{ name: "/clear", group: "Session", desc: "clear screen" },
|