thincoder 0.12.49 → 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 +45 -2
- 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 +5 -8
- 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 +10 -0
- package/src/prompts/engineering.md +81 -14
- package/src/prompts/methodology-template.md +8 -3
- package/src/prompts/system.md +1 -1
- package/src/session.mjs +48 -1
- package/src/tools/system.mjs +3 -1
- package/src/tui/agent-turn.mjs +44 -363
- package/src/tui/cmd-advisor.mjs +20 -2
- package/src/tui/cmd-eng.mjs +44 -7
- 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
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tool-args.mjs — 工具调用参数的可读展示(2026-08-30,对齐 vscode 端卡片头的参数可见性)。
|
|
3
|
+
*
|
|
4
|
+
* vscode 端:tool-call 卡片头 = name + args(80 字符截断,悬停看全量)。
|
|
5
|
+
* TUI 端此前:live 标题行 = 原始 JSON 前 80 字符(长路径截半、不可读);
|
|
6
|
+
* 恢复的历史会话 = ` [tool] name` 完全没有参数(display snapshot 废弃后
|
|
7
|
+
* historyToLines 是恢复的唯一路径,参数可见性随之丢失——用户报告)。
|
|
8
|
+
*
|
|
9
|
+
* 单源 describeToolArgs():按工具挑关键参数的可读单行摘要(标题行/状态栏共用);
|
|
10
|
+
* 未知工具回退紧凑 JSON。恢复路径另外输出全量 pretty JSON 作 dim 行
|
|
11
|
+
* (走既有连续 dim 自动折叠——与工具结果的恢复惯例一致;TUI 无悬停,
|
|
12
|
+
* 全量必须落行)。
|
|
13
|
+
*/
|
|
14
|
+
import { sliceByWidth } from "./render.mjs"
|
|
15
|
+
|
|
16
|
+
/** 单行参数摘要:按工具挑关键字段(vscode 卡片头对齐),未知工具回退 JSON。 */
|
|
17
|
+
export function describeToolArgs(name, args) {
|
|
18
|
+
if (!args || typeof args !== "object") return ""
|
|
19
|
+
const a = args
|
|
20
|
+
switch (name) {
|
|
21
|
+
case "bash": case "cmd-shell": {
|
|
22
|
+
const cmd = String(a.command ?? "").replace(/\s+/g, " ").trim()
|
|
23
|
+
return cmd ? cmd + (a.workdir ? ` (in ${a.workdir})` : "") : ""
|
|
24
|
+
}
|
|
25
|
+
case "read": case "write": case "edit": case "hashline_edit": {
|
|
26
|
+
const p = a.path ? String(a.path) : ""
|
|
27
|
+
const extras = [
|
|
28
|
+
a.offset ? `offset ${a.offset}` : "",
|
|
29
|
+
a.limit ? `limit ${a.limit}` : "",
|
|
30
|
+
name === "edit" && a.old_string ? `old: ${String(a.old_string).replace(/\s+/g, " ").trim().slice(0, 30)}` : "",
|
|
31
|
+
].filter(Boolean).join(", ")
|
|
32
|
+
return p ? `"${p}"${extras ? " · " + extras : ""}` : extras
|
|
33
|
+
}
|
|
34
|
+
case "grep": case "glob": case "code_search": case "doc_search": case "search": {
|
|
35
|
+
const pat = a.pattern ?? a.query ?? ""
|
|
36
|
+
const p = a.path ? ` in "${a.path}"` : ""
|
|
37
|
+
return `/${String(pat)}/${p}`
|
|
38
|
+
}
|
|
39
|
+
case "websearch": return String(a.query ?? "")
|
|
40
|
+
case "subagent": case "coder": case "explore": case "plan": case "eng-coder": {
|
|
41
|
+
const task = String(a.task ?? "").replace(/\s+/g, " ").trim()
|
|
42
|
+
return task ? task.slice(0, 60) + (task.length > 60 ? "…" : "") : ""
|
|
43
|
+
}
|
|
44
|
+
case "advisor": return String(a.type ?? "review")
|
|
45
|
+
case "read_image": return String(a.path ?? "")
|
|
46
|
+
case "question": return String(a.question ?? "").replace(/\s+/g, " ").trim().slice(0, 60)
|
|
47
|
+
case "memory_put": return String(a.title ?? "")
|
|
48
|
+
case "memory_search": return String(a.query ?? "")
|
|
49
|
+
case "lsp": return [a.subcommand, a.uri].filter(Boolean).map(String).join(" ")
|
|
50
|
+
case "repo_outline": return a.path ? String(a.path) : ""
|
|
51
|
+
case "checkpoint": return [a.checkpointAction ?? a.action, a.checkpointId, a.path].filter(Boolean).map(String).join(" ")
|
|
52
|
+
case "git": return [a.action, a.ref, a.name, a.message].filter(Boolean).map(String).join(" ").slice(0, 60)
|
|
53
|
+
default: {
|
|
54
|
+
// MCP 工具 / 未知工具:单行紧凑 JSON(vscode 卡片头同样给原始 JSON 截断)。
|
|
55
|
+
// 硬上限 160:超大 arguments(如 execute 的千字符 code)绝不进单行头。
|
|
56
|
+
const s = JSON.stringify(a)
|
|
57
|
+
return s.length > 160 ? sliceByWidth(s, 156) + "…" : s.length > 80 ? sliceByWidth(s, 78) + "…" : s
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 恢复路径用:全量参数 pretty JSON 的 dim 行(非空才输出)。
|
|
63
|
+
* 与工具结果的恢复惯例一致——完整落行,超长由连续 dim 折叠收纳。 */
|
|
64
|
+
export function toolArgsLines(args) {
|
|
65
|
+
if (!args || typeof args !== "object" || Object.keys(args).length === 0) return []
|
|
66
|
+
return JSON.stringify(args, null, 2).split("\n")
|
|
67
|
+
}
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tool-events.mjs — runAgentTurn 的工具事件回调构造(自 agent-turn.mjs 拆出,2026-08-30,
|
|
3
|
+
* 满足 500 行硬限)。只做「事件 → TUI 状态/对话行」的映射:
|
|
4
|
+
*
|
|
5
|
+
* - onToken/onReasoning : 子agent 前缀分流(routeSub*)→ 主流 streaming/reasoning
|
|
6
|
+
* - onToolCall : 状态栏 + `❯ name args` 标题行 + 计时
|
|
7
|
+
* - onToolResult : 子agent 完成冻结(finishSubTask + freezeDoneSubTasks)、
|
|
8
|
+
* _live 行清理、done 行、advisor 评审冻结框
|
|
9
|
+
* - onToolOutput : advisor 有序块缓冲 / `_live` 滚动预览(N 行 + `│ …` 折叠)
|
|
10
|
+
* - 其余 : usage 累计、等待提示、task 面板、回合末增量落盘
|
|
11
|
+
*
|
|
12
|
+
* flushStream 同时返回给调用方(回合循环 / onTurnEnd 共用)。纯回调装配,无终端副作用
|
|
13
|
+
* (除经 deps 注入的 pushLine/render)。
|
|
14
|
+
*/
|
|
15
|
+
import { sliceByWidth } from "./render.mjs"
|
|
16
|
+
import { C } from "./ansi.mjs"
|
|
17
|
+
import { formatToolSummary } from "./tool-summaries.mjs"
|
|
18
|
+
import { describeToolArgs, toolArgsLines } from "./tool-args.mjs"
|
|
19
|
+
import { ADVISOR_THINKING_PLACEHOLDER, resolveAdvisorProvider } from "../advisor/run.mjs"
|
|
20
|
+
import {
|
|
21
|
+
SUBAGENT_ROLES, routeSubToken, routeSubReasoning, routeSubToolCall,
|
|
22
|
+
routeSubToolOutput, finishSubTask, finishSubTasksByRole, finishSubTaskByModel, freezeDoneSubTasks,
|
|
23
|
+
} from "./subagent-blocks.mjs"
|
|
24
|
+
import { TURN_CAP_MARK } from "../agent/spawn-child.mjs"
|
|
25
|
+
|
|
26
|
+
/** Tool execution start timestamps (performance.now ms). Keyed by tool_call id
|
|
27
|
+
* when available (parallel same-name tools each get their own tick — the
|
|
28
|
+
* P0-3 fix, 2026-08-30), falling back to a per-name FIFO queue for callers
|
|
29
|
+
* without ids (subagent relay). FIFO shift assumes near-call-order completion;
|
|
30
|
+
* a parallel same-name batch finishing out of order swaps durations between
|
|
31
|
+
* siblings — same magnitude, both keep an elapsed (display-level, acceptable). */
|
|
32
|
+
|
|
33
|
+
// Named caps (consult P2, 2026-08-30): inline 200/8/120/3/5 were magic numbers.
|
|
34
|
+
const TOOL_OUTPUT_LINE_CAP = 200 // per-call streaming output ring buffer
|
|
35
|
+
const SUBAGENT_PREVIEW_LINES = 8 // report preview rows in the conversation
|
|
36
|
+
const PREVIEW_LINE_CHARS = 120 // per-line preview slice
|
|
37
|
+
const REMINDER_CAP = 3 // max pending reminders shown on turn end
|
|
38
|
+
const REMINDER_PERSIST_TURNS = 5 // persist reminders every N turns
|
|
39
|
+
|
|
40
|
+
const _toolTicks = new Map()
|
|
41
|
+
|
|
42
|
+
function tickStart(name, toolId) {
|
|
43
|
+
const key = toolId ?? name
|
|
44
|
+
if (toolId) { _toolTicks.set(key, performance.now()); return }
|
|
45
|
+
const q = _toolTicks.get(key) ?? []
|
|
46
|
+
q.push(performance.now())
|
|
47
|
+
_toolTicks.set(key, q)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Settle one pending tick: by tool_call id, or per-name FIFO. Returns start or null. */
|
|
51
|
+
function tickTake(name, toolId) {
|
|
52
|
+
const key = toolId ?? name
|
|
53
|
+
const v = _toolTicks.get(key)
|
|
54
|
+
if (v === undefined) return null
|
|
55
|
+
if (toolId) { _toolTicks.delete(key); return v }
|
|
56
|
+
if (v.length === 0) { _toolTicks.delete(key); return null }
|
|
57
|
+
const started = v.shift()
|
|
58
|
+
if (v.length === 0) _toolTicks.delete(key)
|
|
59
|
+
return started
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** P0-2 sweep (2026-08-30 consult): an interrupted turn (Ctrl+C / error) leaves
|
|
63
|
+
* running tool-block carriers without an onToolResult — their header would say
|
|
64
|
+
* "running" forever. runAgentTurn's finally calls this: mark them done with an
|
|
65
|
+
* "(interrupted)" status and clear the tick table so no stale start time leaks
|
|
66
|
+
* into the next turn. Mirrors freezeAllSubTasks for the tool-block family. */
|
|
67
|
+
export function sweepToolBlocks(state) {
|
|
68
|
+
for (const l of state.lines ?? []) {
|
|
69
|
+
const b = l._toolBlock
|
|
70
|
+
if (b && !b.done) {
|
|
71
|
+
b.done = true
|
|
72
|
+
b.summary = "(interrupted)"
|
|
73
|
+
b.interrupted = true
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
_toolTicks.clear()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Shared display guard for tool results — LIVE and RESTORE use the same
|
|
80
|
+
* function (P1, 2026-08-30 consult: restore lacked the live path's guards).
|
|
81
|
+
* 1) Multimodal results (read_image) embed FULL base64 images in the JSON —
|
|
82
|
+
* the human needs only the text part (model gets images via the multimodal
|
|
83
|
+
* channel); 2) results beyond maxRows are truncated in the block (full
|
|
84
|
+
* text always lives in history for the model). Returns row array. */
|
|
85
|
+
export function slimToolResultForDisplay(result, maxRows = 400) {
|
|
86
|
+
let displayResult = result
|
|
87
|
+
try {
|
|
88
|
+
const parsed = JSON.parse(result)
|
|
89
|
+
if (parsed?.images?.length) displayResult = parsed.text ?? result
|
|
90
|
+
} catch { /* not JSON — show as-is */ }
|
|
91
|
+
const rows = String(displayResult).split("\n").filter((l) => l.trim())
|
|
92
|
+
return rows.length > maxRows
|
|
93
|
+
? [...rows.slice(0, maxRows), `… (result truncated at ${maxRows} rows — full text in history)`]
|
|
94
|
+
: rows
|
|
95
|
+
}
|
|
96
|
+
/** Mark the dispatch-level tool carrier done when its result is consumed by a
|
|
97
|
+
* dedicated branch (subagent/escalate/advisor blocks) instead of the carrier
|
|
98
|
+
* body — without this the turn sweep mislabels successful calls as
|
|
99
|
+
* "(interrupted)" (consult P1, 2026-08-30). */
|
|
100
|
+
function settleToolBlock(state, name, toolId, summary) {
|
|
101
|
+
const block = findToolBlock(state, name, toolId)
|
|
102
|
+
if (block) {
|
|
103
|
+
block.done = true
|
|
104
|
+
block.summary = summary
|
|
105
|
+
const started = tickTake(name, toolId)
|
|
106
|
+
block.elapsed = started !== null ? Math.round(performance.now() - started) : null
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
/** Find the live tool-block carrier for a tool event: exact id match when the
|
|
113
|
+
* callback carries one (P0-3 — parallel same-name tools route to their own
|
|
114
|
+
* block); falls back to the last unfinished block of that name. */
|
|
115
|
+
function findToolBlock(state, name, toolId) {
|
|
116
|
+
const lines = state.lines ?? []
|
|
117
|
+
if (toolId !== undefined && toolId !== null) {
|
|
118
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
119
|
+
if (lines[i]._toolBlock?.id === toolId) return lines[i]._toolBlock
|
|
120
|
+
}
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
124
|
+
const b = lines[i]._toolBlock
|
|
125
|
+
if (b && b.name === name && !b.done) return b
|
|
126
|
+
}
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
/** Build the agent callbacks + the shared flushStream for one turn.
|
|
132
|
+
* deps: { agent, state, pushLine, render, scheduleRender, ensureAssistantLabel,
|
|
133
|
+
* askPermission, askQuestion, summarize, saveSessionImpl } */
|
|
134
|
+
export function buildToolCallbacks(deps) {
|
|
135
|
+
const { agent, state, pushLine, render, scheduleRender, ensureAssistantLabel, askPermission, askQuestion, summarize, saveSessionImpl } = deps
|
|
136
|
+
|
|
137
|
+
// NOTE: advisor buffers (_advisorThink/advisorStreaming) are cleared here too.
|
|
138
|
+
// Timing safety: onToolResult flushes _advisorThink into history and empties
|
|
139
|
+
// the buffers BEFORE onTurnEnd can call flushStream (tool result is
|
|
140
|
+
// dispatched inside executeToolCalls; onTurnEnd fires after the turn loop
|
|
141
|
+
// resumes). If a future change calls flushStream mid-advisor-execution the
|
|
142
|
+
// in-progress thinking WOULD be lost — keep the ordering, or flush here too.
|
|
143
|
+
const flushStream = () => {
|
|
144
|
+
if (state.reasoning) {
|
|
145
|
+
pushLine(state.reasoning, C.reason, "thinking")
|
|
146
|
+
// Reasoning folds IMMEDIATELY on flush (user ruling 2026-08-30: the old
|
|
147
|
+
// "stay expanded until next turn" auto-expand was a leftover of the
|
|
148
|
+
// rejected pre-fold plan — thinking must be DEFAULT FOLDED in the exact
|
|
149
|
+
// unified form: named header + tail 3, expand ≤60%, click back). Same
|
|
150
|
+
// shape as the restore path — zero exceptions on either path.
|
|
151
|
+
state.reasoning = ""
|
|
152
|
+
}
|
|
153
|
+
if (state.streaming) {
|
|
154
|
+
pushLine(state.streaming, C.text, "text")
|
|
155
|
+
state.streaming = ""
|
|
156
|
+
}
|
|
157
|
+
state._advisorBlocks = []
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const callbacks = {
|
|
161
|
+
onToken: (t) => {
|
|
162
|
+
// Subagent streaming: prefix format role#id/ → route into the child's activity
|
|
163
|
+
// block (D4). Event tokens (⟦ev⟧…) update ONLY the header state — never the
|
|
164
|
+
// block content, never the main stream (D1). Routing details in subagent-blocks.
|
|
165
|
+
if (routeSubToken(state, t, scheduleRender)) return
|
|
166
|
+
ensureAssistantLabel()
|
|
167
|
+
state.streaming += t
|
|
168
|
+
scheduleRender()
|
|
169
|
+
},
|
|
170
|
+
onReasoning: (t) => {
|
|
171
|
+
// Subagent reasoning tokens also carry role#id/ prefix — appended into the
|
|
172
|
+
// block buffer as kind=think (F2: same treatment as the main reasoning stream;
|
|
173
|
+
// previously the token only created the entry and the content was discarded).
|
|
174
|
+
if (routeSubReasoning(state, t, scheduleRender)) return
|
|
175
|
+
ensureAssistantLabel()
|
|
176
|
+
state.reasoning += t
|
|
177
|
+
scheduleRender()
|
|
178
|
+
},
|
|
179
|
+
onToolCall: (name, args, toolId) => {
|
|
180
|
+
// Subagent tool call: prefix role#id/toolName → open a fresh tool block and
|
|
181
|
+
// set currentTool for the header summary line.
|
|
182
|
+
if (routeSubToolCall(state, name, args, scheduleRender)) return
|
|
183
|
+
// Redundant with flushStream() below (it clears both buffers) — kept as
|
|
184
|
+
// defense-in-depth so a future flushStream change cannot leak advisor
|
|
185
|
+
// buffers into the next tool's view.
|
|
186
|
+
if (name === "advisor") { state._advisorBlocks = [] }
|
|
187
|
+
flushStream()
|
|
188
|
+
ensureAssistantLabel()
|
|
189
|
+
state.currentTool = name
|
|
190
|
+
// Advisor's effective model (resolved once for the status line + inline title below).
|
|
191
|
+
const advModel = name === "advisor" ? (() => { try { return resolveAdvisorProvider(agent).model } catch { return null } })() : null
|
|
192
|
+
// Update status bar with current tool and key arguments for user visibility
|
|
193
|
+
if (name === "bash" && args.command) {
|
|
194
|
+
const cmd = args.command.replace(/\s+/g, " ").trim()
|
|
195
|
+
state.status = `Running: ${cmd.length > 50 ? cmd.slice(0, 50) + "…" : cmd}`
|
|
196
|
+
} else if ((name === "read" || name === "write" || name === "edit" || name === "grep" || name === "glob") && args.path) {
|
|
197
|
+
state.status = `${name}: ${args.path}`
|
|
198
|
+
} else if (name === "grep" && args.pattern) {
|
|
199
|
+
state.status = `grep: ${args.pattern}`
|
|
200
|
+
} else if (name === "glob" && args.pattern) {
|
|
201
|
+
state.status = `glob: ${args.pattern}`
|
|
202
|
+
} else if (name === "websearch" && args.query) {
|
|
203
|
+
state.status = `search: ${args.query.length > 40 ? args.query.slice(0, 40) + "…" : args.query}`
|
|
204
|
+
} else if (name === "advisor") {
|
|
205
|
+
state.status = `advisor review (round ${(agent._advisorRound || 0) + 1}${advModel ? " · " + advModel : ""})`
|
|
206
|
+
} else {
|
|
207
|
+
state.status = `tool: ${name}`
|
|
208
|
+
}
|
|
209
|
+
// Advisor: tag the round in the tool title — the model's own "第N轮" narration
|
|
210
|
+
// is unreliable (it glues onto the previous line), so the round belongs here.
|
|
211
|
+
// Also show the advisor's effective model (it may differ from the main agent's).
|
|
212
|
+
const roundTag = name === "advisor" ? ` (round ${(agent._advisorRound || 0) + 1}${advModel ? " · " + advModel : ""})` : ""
|
|
213
|
+
// Readable key-args summary (vscode card-header parity, 2026-08-30) —
|
|
214
|
+
// replaces the raw JSON.stringify-80 slice: long paths landed mid-string,
|
|
215
|
+
// and the crucial argument was often past the cut. Unknown/MCP tools
|
|
216
|
+
// fall back to compact JSON inside describeToolArgs.
|
|
217
|
+
const argSummary = describeToolArgs(name, args)
|
|
218
|
+
// ONE BLOCK PER TOOL CALL (user ruling 2026-08-30: "为什么不把名称和参数行
|
|
219
|
+
// 直接作为流式输出 block 的 title" — the four-piece ❯ title / _live scroll /
|
|
220
|
+
// done-line arrangement was pre-fold-era residue). The carrier line holds
|
|
221
|
+
// the whole call: header = name+args+live status, body = args JSON +
|
|
222
|
+
// streaming output + result. buildConvLines renders it via the shared
|
|
223
|
+
// fold-block component; restore (historyToLines) emits the SAME carrier.
|
|
224
|
+
state.lines.push({
|
|
225
|
+
text: "", color: C.tool,
|
|
226
|
+
_lineId: (state._lineIdCounter = (state._lineIdCounter ?? 0) + 1),
|
|
227
|
+
_toolBlock: {
|
|
228
|
+
name, roundTag, id: toolId,
|
|
229
|
+
argsSummary: argSummary,
|
|
230
|
+
argsJson: toolArgsLines(args),
|
|
231
|
+
output: [],
|
|
232
|
+
result: null,
|
|
233
|
+
summary: null,
|
|
234
|
+
started: performance.now(),
|
|
235
|
+
done: false,
|
|
236
|
+
},
|
|
237
|
+
})
|
|
238
|
+
tickStart(name, toolId)
|
|
239
|
+
},
|
|
240
|
+
onToolResult: (name, result, toolId) => {
|
|
241
|
+
state.currentTool = null
|
|
242
|
+
// Subagent complete: mark the earliest running child as done — the block
|
|
243
|
+
// persists (✓ frozen elapsed header, expandable) as the ONLY carrier of the
|
|
244
|
+
// child's activity (D4: no 3-second cleanup anymore). The report preview
|
|
245
|
+
// (max 8 lines) still enters the conversation via the existing path below.
|
|
246
|
+
// Block buffers survive the turn (no wipe in runAgentTurn start/finally):
|
|
247
|
+
// child tool calls never enter the parent's history, so the block is the
|
|
248
|
+
// only trace of what the child did — memory bounded by the N2 line cap.
|
|
249
|
+
const isSubagent = name === "subagent"
|
|
250
|
+
if (isSubagent) {
|
|
251
|
+
// The dispatch-level tool-block carrier for this call would otherwise
|
|
252
|
+
// never be marked done (its result lands in the subagent block, not the
|
|
253
|
+
// carrier) and the turn sweep would mislabel it "(interrupted)" — every
|
|
254
|
+
// successful subagent call showed that banner (consult P1, 2026-08-30).
|
|
255
|
+
settleToolBlock(state, name, toolId, "completed")
|
|
256
|
+
finishSubTask(state, SUBAGENT_ROLES, result.includes(TURN_CAP_MARK) ? "turn cap reached — work may be partial" : null)
|
|
257
|
+
// Freeze the finished blocks into the conversation stream (user report
|
|
258
|
+
// 2026-08-30): a pinned tail section left every ✓ block stuck above the
|
|
259
|
+
// input box forever. As lines they scroll away, stay expandable via the
|
|
260
|
+
// dim auto-fold, and subTasks releases the entry.
|
|
261
|
+
freezeDoneSubTasks(state)
|
|
262
|
+
// Subagent report preview (max 8 lines) displayed directly in conversation
|
|
263
|
+
const lines = result.split("\n")
|
|
264
|
+
const preview = lines.slice(0, SUBAGENT_PREVIEW_LINES).map((l) => l.slice(0, PREVIEW_LINE_CHARS)).join("\n")
|
|
265
|
+
if (preview) pushLine(preview, C.dim)
|
|
266
|
+
if (lines.length > SUBAGENT_PREVIEW_LINES) pushLine(` ... (${lines.length - SUBAGENT_PREVIEW_LINES} more lines)`, C.dim)
|
|
267
|
+
} else if (name === "escalate") {
|
|
268
|
+
// 飞刀 post-op report landed → freeze its block into the conversation too.
|
|
269
|
+
settleToolBlock(state, name, toolId, "completed")
|
|
270
|
+
finishSubTask(state, ["escalate"], result.includes(TURN_CAP_MARK) ? "turn cap reached — work may be partial" : null)
|
|
271
|
+
freezeDoneSubTasks(state)
|
|
272
|
+
} else if (name === "consult_check" || name === "consult_stop") {
|
|
273
|
+
// Consult session-level settle (2026-08-30 consult review): a session
|
|
274
|
+
// spawns N parallel children, so completion must settle ALL of them.
|
|
275
|
+
// The single-shot finishSubTask here only froze the EARLIEST running
|
|
276
|
+
// block — the other N-1 stayed "running" pinned above the input box
|
|
277
|
+
// all through the final answer, then got mislabeled "interrupted".
|
|
278
|
+
// - individual reply (done:false): settle precisely by r.model —
|
|
279
|
+
// models settle out of order; the earliest-running heuristic froze
|
|
280
|
+
// the wrong block.
|
|
281
|
+
// - done:true / stopped: settle every remaining consult block.
|
|
282
|
+
try {
|
|
283
|
+
const r = JSON.parse(result)
|
|
284
|
+
if (r?.done || r?.stopped !== undefined) {
|
|
285
|
+
finishSubTasksByRole(state, ["consult"], null)
|
|
286
|
+
freezeDoneSubTasks(state)
|
|
287
|
+
} else if (r?.model) {
|
|
288
|
+
finishSubTaskByModel(state, "consult", r.model)
|
|
289
|
+
freezeDoneSubTasks(state)
|
|
290
|
+
}
|
|
291
|
+
} catch {
|
|
292
|
+
if (name === "consult_stop") {
|
|
293
|
+
finishSubTasksByRole(state, ["consult"], null)
|
|
294
|
+
freezeDoneSubTasks(state)
|
|
295
|
+
}
|
|
296
|
+
} /* non-JSON result — leave blocks as-is */
|
|
297
|
+
}
|
|
298
|
+
if (!isSubagent && name !== "advisor") {
|
|
299
|
+
// Result lands INSIDE the block (restore parity — the restored carrier
|
|
300
|
+
// carries the same fields). The done line is gone: status/elapsed live
|
|
301
|
+
// in the header now.
|
|
302
|
+
const block = findToolBlock(state, name, toolId)
|
|
303
|
+
if (block) {
|
|
304
|
+
// Multimodal tool results (read_image) embed the FULL base64 image in
|
|
305
|
+
// the result JSON — thousands of rows into the block body = a full
|
|
306
|
+
// screen of garbage (user report 2026-08-30). The model gets the image
|
|
307
|
+
// via the multimodal channel (agent.mjs), the human needs only the
|
|
308
|
+
// text part: strip image parts from the displayed result.
|
|
309
|
+
block.result = slimToolResultForDisplay(result)
|
|
310
|
+
block.summary = formatToolSummary(name, result)
|
|
311
|
+
block.done = true
|
|
312
|
+
const started = tickTake(name, toolId)
|
|
313
|
+
block.elapsed = started !== null ? Math.round(performance.now() - started) : null
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (name === "advisor") {
|
|
317
|
+
// Same carrier settle as subagent/escalate — the advisor result lives in
|
|
318
|
+
// the frozen box, but the dispatch-level tool carrier must still be
|
|
319
|
+
// marked done (consult P1, 2026-08-30: sweep mislabeled it interrupted).
|
|
320
|
+
settleToolBlock(state, name, toolId, "completed")
|
|
321
|
+
// The review's thinking must survive into the conversation history like
|
|
322
|
+
// the main agent's reasoning (flushStream does for state.reasoning) —
|
|
323
|
+
// discarding it left the thought process visible only mid-review, then
|
|
324
|
+
// gone. Flush BEFORE the done line so the block sits above it.
|
|
325
|
+
// 2026-08-30: flushed as a COLLAPSIBLE box (frozen-folded semantics,
|
|
326
|
+
// aligned with subagent blocks) instead of a flat auto-expanded line —
|
|
327
|
+
// the flat form flooded the conversation. Full text still lives in the
|
|
328
|
+
// tool result message; the box is the reviewable record. Live
|
|
329
|
+
// _advisorBlocks keep rendering the running view until cleared in the
|
|
330
|
+
// turn finally.
|
|
331
|
+
const blocks = state._advisorBlocks ?? []
|
|
332
|
+
if (blocks.length > 0) {
|
|
333
|
+
const text = blocks
|
|
334
|
+
.map((b) => b.text.replaceAll(ADVISOR_THINKING_PLACEHOLDER, ""))
|
|
335
|
+
.join("")
|
|
336
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
337
|
+
.trim()
|
|
338
|
+
if (text) {
|
|
339
|
+
state.lines.push({
|
|
340
|
+
text: "advisor review",
|
|
341
|
+
color: C.dim,
|
|
342
|
+
_frozenAdvisor: text,
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (isSubagent) {
|
|
348
|
+
tickTake(name) // subagent: no per-call block — settle the tick
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
onToolOutput: (name, chunk, toolId) => {
|
|
352
|
+
// All tools use inline conversation blocks — panel area is abolished.
|
|
353
|
+
// Stream up to N preview lines (config ?? per-tool ?? 5); the full result
|
|
354
|
+
// is in the tool message.
|
|
355
|
+
const part = typeof chunk === "string"
|
|
356
|
+
? { kind: "text", text: chunk.trimEnd() }
|
|
357
|
+
: { kind: chunk?.kind ?? "text", text: String(chunk?.text ?? "").trimEnd() }
|
|
358
|
+
if (!part.text) return
|
|
359
|
+
// Subagent tool output (D1: childCallbacks.onToolOutput relays under the
|
|
360
|
+
// prefixed name "role#id/toolName") → append into the CURRENT tool block of
|
|
361
|
+
// that child's activity buffer. Render throttled at 250ms (N1); the data
|
|
362
|
+
// append itself is never delayed.
|
|
363
|
+
if (routeSubToolOutput(state, name, part, scheduleRender)) return
|
|
364
|
+
if (name === "advisor") {
|
|
365
|
+
// Accumulate to buffer — formatTables + wrapText in render-conversation
|
|
366
|
+
// handles markdown formatting, same as main agent response.
|
|
367
|
+
// NOTE: the advisor tool ALWAYS emits {kind, text} objects (run.mjs's
|
|
368
|
+
// emit() wrapper) — a raw string chunk is never think; if that ever
|
|
369
|
+
// changes, plain-string think would land in advisorStreaming.
|
|
370
|
+
// ORDERED block buffer — preserves the interleaved emission order
|
|
371
|
+
// (think → tool → think → … → final). Two separate buffers (_advisorThink
|
|
372
|
+
// vs advisorStreaming) rendered think-block-then-main-block, which
|
|
373
|
+
// regrouped ALL thinking above ALL tool progress — the alternating
|
|
374
|
+
// timeline was destroyed. Consecutive chunks of the same kind merge
|
|
375
|
+
// into one block; kind flips start a new block; render walks the
|
|
376
|
+
// blocks in order with per-kind colors.
|
|
377
|
+
const isString = typeof chunk === "string"
|
|
378
|
+
const raw = isString ? chunk : String(chunk?.text ?? "")
|
|
379
|
+
const kind = isString ? "text" : (chunk?.kind ?? "text")
|
|
380
|
+
const blocks = state._advisorBlocks ??= []
|
|
381
|
+
const last = blocks.at(-1)
|
|
382
|
+
if (last && last.kind === kind) last.text += raw
|
|
383
|
+
else blocks.push({ kind, text: raw })
|
|
384
|
+
scheduleRender()
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
// Append into the CURRENT tool block's output buffer (the block is the
|
|
388
|
+
// display; no _live scroll lines anymore). N2-style cap keeps memory
|
|
389
|
+
// bounded: keep the LAST 200 output lines per call.
|
|
390
|
+
const block = findToolBlock(state, name, toolId)
|
|
391
|
+
if (block) {
|
|
392
|
+
for (const line of part.text.split("\n")) {
|
|
393
|
+
const trimmed = line.trimEnd()
|
|
394
|
+
if (trimmed) block.output.push(trimmed)
|
|
395
|
+
}
|
|
396
|
+
if (block.output.length > TOOL_OUTPUT_LINE_CAP) {
|
|
397
|
+
block.output.splice(0, block.output.length - TOOL_OUTPUT_LINE_CAP)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
scheduleRender()
|
|
401
|
+
},
|
|
402
|
+
onPermissionRequest: (name, args) => askPermission(name, args),
|
|
403
|
+
onQuestion: (text, options) => askQuestion(text, options),
|
|
404
|
+
onCompress: () => {
|
|
405
|
+
pushLine(" [context] Context too long, auto-compacted (early conversation summarized by LLM, task state preserved)", C.warn)
|
|
406
|
+
},
|
|
407
|
+
// Async distillation landed (SEND-STALL-DISTILL §2.3): the machine line was replaced by
|
|
408
|
+
// the compressed version — persist it so the session file ends up compressed. Silent:
|
|
409
|
+
// a save failure must never surface after the turn already returned.
|
|
410
|
+
onDistilled: () => {
|
|
411
|
+
try { saveSessionImpl(agent, state.lines) } catch { /* 静默 */ }
|
|
412
|
+
},
|
|
413
|
+
onUsage: (usage) => {
|
|
414
|
+
state.tokens.prompt += usage.prompt_tokens ?? 0
|
|
415
|
+
state.tokens.completion += usage.completion_tokens ?? 0
|
|
416
|
+
state.tokens.cacheHit += usage.prompt_cache_hit_tokens ?? 0
|
|
417
|
+
state.tokens.cacheMiss += usage.prompt_cache_miss_tokens ?? 0
|
|
418
|
+
state.tokens.reasoningTokens += usage.completion_tokens_details?.reasoning_tokens ?? 0
|
|
419
|
+
},
|
|
420
|
+
// Throttle wait (active gate / 429 backoff): show in status bar so user knows it's not frozen
|
|
421
|
+
onWait: ({ phase, seconds }) => {
|
|
422
|
+
if (phase === "gate") state.status = `TPM throttle wait ~${seconds}s`
|
|
423
|
+
else if (phase === "overloaded") state.status = `Server overloaded, retrying in ${seconds}s`
|
|
424
|
+
else state.status = `Rate-limited 429, retry in ${seconds}s`
|
|
425
|
+
render()
|
|
426
|
+
},
|
|
427
|
+
onTaskUpdate: (items) => {
|
|
428
|
+
state.tasks = items
|
|
429
|
+
const done = items.filter((i) => i.status === "done").length
|
|
430
|
+
// Leave trace with current task title: reviewing history shows what was in progress
|
|
431
|
+
const current = items.find((i) => i.status === "in_progress")
|
|
432
|
+
pushLine(` [task] ${done}/${items.length}${current ? ` ▶ ${current.title}` : ""}`, C.dim)
|
|
433
|
+
render()
|
|
434
|
+
},
|
|
435
|
+
// Incremental save: flush to disk every 5 tool turns — mid-crash loss window shrinks from an entire round to a few turns
|
|
436
|
+
onTurnEnd: (() => {
|
|
437
|
+
let n = 0
|
|
438
|
+
return () => {
|
|
439
|
+
// Flush pending reasoning/streaming before the next turn starts.
|
|
440
|
+
// Guard pushbacks (verify/advisor) continue the agent loop without
|
|
441
|
+
// returning to the TUI — without flushing, old thinking bleeds into
|
|
442
|
+
// the next turn and the guard reminder is invisible.
|
|
443
|
+
flushStream()
|
|
444
|
+
// Mirror the last system-reminder from agent.history so guard
|
|
445
|
+
// pushback messages appear in the conversation at the right spot.
|
|
446
|
+
const last = agent.history.at(-1)
|
|
447
|
+
if (last?.role === "user" && typeof last.content === "string" && last.content.startsWith("[System reminder:")) {
|
|
448
|
+
// Reminders can embed long prior tables — show only the first lines
|
|
449
|
+
// (the full text is in agent.history); 3 lines + ellipsis.
|
|
450
|
+
const lines = last.content.split("\n")
|
|
451
|
+
const shown = lines.length > REMINDER_CAP ? lines.slice(0, REMINDER_CAP).join("\n") + "\n…" : last.content
|
|
452
|
+
pushLine(shown, C.warn)
|
|
453
|
+
}
|
|
454
|
+
if (++n % REMINDER_PERSIST_TURNS !== 0) return
|
|
455
|
+
try { saveSessionImpl(agent, state.lines) } catch (e) { console.error(`[session] incremental save failed: ${e.message}`) }
|
|
456
|
+
}
|
|
457
|
+
})(),
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return { callbacks, flushStream }
|
|
461
|
+
}
|