goatchain 0.0.1
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 +529 -0
- package/cli/args.mjs +113 -0
- package/cli/clack.mjs +111 -0
- package/cli/clipboard.mjs +320 -0
- package/cli/files.mjs +247 -0
- package/cli/index.mjs +299 -0
- package/cli/itermPaste.mjs +147 -0
- package/cli/persist.mjs +205 -0
- package/cli/repl.mjs +3141 -0
- package/cli/sdk.mjs +341 -0
- package/cli/sessionTransfer.mjs +118 -0
- package/cli/turn.mjs +751 -0
- package/cli/ui.mjs +138 -0
- package/cli.mjs +5 -0
- package/dist/index.cjs +4860 -0
- package/dist/index.d.cts +3479 -0
- package/dist/index.d.ts +3479 -0
- package/dist/index.js +4795 -0
- package/package.json +68 -0
package/cli/turn.mjs
ADDED
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run one interactive turn, supporting tool-approval pause/resume via `requires_action`.
|
|
3
|
+
*
|
|
4
|
+
* This is intentionally UI-agnostic: the caller provides `askApproval(prompt)`.
|
|
5
|
+
*/
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import { ansi, formatOneLine } from './ui.mjs'
|
|
8
|
+
|
|
9
|
+
export async function runTurnWithApprovals({
|
|
10
|
+
agent,
|
|
11
|
+
sessionId,
|
|
12
|
+
history,
|
|
13
|
+
input,
|
|
14
|
+
signal,
|
|
15
|
+
askApproval,
|
|
16
|
+
askApprovalForTool,
|
|
17
|
+
approvalStrategy = 'high_risk',
|
|
18
|
+
autoApprove = false,
|
|
19
|
+
emoji = process.env.GOATCHAIN_EMOJI !== '0',
|
|
20
|
+
showTools = process.env.GOATCHAIN_SHOW_TOOLS !== '0',
|
|
21
|
+
toolStyle = process.env.GOATCHAIN_TOOL_STYLE ?? 'inline',
|
|
22
|
+
showStatus = process.env.GOATCHAIN_SHOW_STATUS !== '0',
|
|
23
|
+
showActivity = process.env.GOATCHAIN_SHOW_ACTIVITY !== '0',
|
|
24
|
+
showStatusLine = process.env.GOATCHAIN_SHOW_STATUSLINE !== '0',
|
|
25
|
+
promptContinue = process.env.GOATCHAIN_PROMPT_CONTINUE !== '0',
|
|
26
|
+
autoContinue = process.env.GOATCHAIN_AUTO_CONTINUE === '1',
|
|
27
|
+
write = (s) => process.stdout.write(s),
|
|
28
|
+
}) {
|
|
29
|
+
const useEmoji = Boolean(emoji) && process.stdout.isTTY && process.env.TERM !== 'dumb'
|
|
30
|
+
const icon = (s) => (useEmoji ? `${s} ` : '')
|
|
31
|
+
|
|
32
|
+
let atLineStart = true
|
|
33
|
+
/** @type {null | { start: () => void, stop: () => void, set: (p: string, d?: string) => void, onOutput: () => void, onPauseForInput: () => void }} */
|
|
34
|
+
let activity = null
|
|
35
|
+
|
|
36
|
+
const out = (s) => {
|
|
37
|
+
activity?.onOutput()
|
|
38
|
+
const str = String(s ?? '')
|
|
39
|
+
write(str)
|
|
40
|
+
const lastNl = str.lastIndexOf('\n')
|
|
41
|
+
if (lastNl === -1) {
|
|
42
|
+
atLineStart = false
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
atLineStart = lastNl === str.length - 1
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const ensureNewline = () => {
|
|
49
|
+
if (!atLineStart)
|
|
50
|
+
out('\n')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** @type {any[]} */
|
|
54
|
+
const messagesToAppend = []
|
|
55
|
+
|
|
56
|
+
/** @type {{ text: string, toolCalls: any[], flushedToolCallAssistant: boolean }} */
|
|
57
|
+
let iteration = { text: '', toolCalls: [], flushedToolCallAssistant: false }
|
|
58
|
+
/** @type {Map<string, string>} */
|
|
59
|
+
let toolNameById = new Map()
|
|
60
|
+
/** @type {Map<string, string>} */
|
|
61
|
+
let toolArgsSummaryById = new Map()
|
|
62
|
+
/** @type {Set<string>} */
|
|
63
|
+
let printedToolCallIds = new Set()
|
|
64
|
+
/** @type {Set<string>} */
|
|
65
|
+
const executedToolNames = new Set()
|
|
66
|
+
/** @type {{ stopReason?: string, modelStopReason?: string, tools: Array<{ toolName: string, argsSummary?: string, resultSummary?: string }> }} */
|
|
67
|
+
const meta = { tools: [] }
|
|
68
|
+
|
|
69
|
+
let approveAllToolsThisTurn = false
|
|
70
|
+
|
|
71
|
+
const byteLen = (s) => {
|
|
72
|
+
try {
|
|
73
|
+
return Buffer.byteLength(String(s ?? ''), 'utf8')
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return String(s ?? '').length
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const humanBytes = (n) => {
|
|
81
|
+
const num = Number(n)
|
|
82
|
+
if (!Number.isFinite(num) || num < 0)
|
|
83
|
+
return ''
|
|
84
|
+
if (num < 1024)
|
|
85
|
+
return `${num} B`
|
|
86
|
+
if (num < 1024 * 1024)
|
|
87
|
+
return `${(num / 1024).toFixed(2)} KB`
|
|
88
|
+
return `${(num / (1024 * 1024)).toFixed(2)} MB`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const summarizeArgsForDisplay = (toolName, args) => {
|
|
92
|
+
const name = String(toolName ?? '')
|
|
93
|
+
const a = args && typeof args === 'object' ? args : {}
|
|
94
|
+
|
|
95
|
+
if (name === 'Write') {
|
|
96
|
+
const filePath = typeof a.file_path === 'string' ? a.file_path : ''
|
|
97
|
+
const size = typeof a.content === 'string' ? humanBytes(byteLen(a.content)) : ''
|
|
98
|
+
return {
|
|
99
|
+
summary: `${filePath}${size ? ` (content ${size})` : ''}`,
|
|
100
|
+
lines: [
|
|
101
|
+
filePath ? `file: ${filePath}` : undefined,
|
|
102
|
+
size ? `content: ${size}` : undefined,
|
|
103
|
+
].filter(Boolean),
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (name === 'Edit') {
|
|
108
|
+
const filePath = typeof a.file_path === 'string' ? a.file_path : ''
|
|
109
|
+
const edits = Array.isArray(a.edits) ? a.edits : []
|
|
110
|
+
const first = edits[0] && typeof edits[0] === 'object' ? edits[0] : undefined
|
|
111
|
+
const oldText = first && typeof first.oldText === 'string' ? first.oldText : ''
|
|
112
|
+
const newText = first && typeof first.newText === 'string' ? first.newText : ''
|
|
113
|
+
const firstPreview = oldText || newText
|
|
114
|
+
? `first edit: ${formatOneLine(oldText, 60)} → ${formatOneLine(newText, 60)}`
|
|
115
|
+
: undefined
|
|
116
|
+
return {
|
|
117
|
+
summary: `${filePath}${edits.length ? ` (${edits.length} edits)` : ''}`,
|
|
118
|
+
lines: [
|
|
119
|
+
filePath ? `file: ${filePath}` : undefined,
|
|
120
|
+
edits.length ? `edits: ${edits.length}` : undefined,
|
|
121
|
+
firstPreview,
|
|
122
|
+
].filter(Boolean),
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (name === 'Read') {
|
|
127
|
+
const filePath = typeof a.file_path === 'string' ? a.file_path : ''
|
|
128
|
+
const offset = typeof a.offset === 'number' ? a.offset : undefined
|
|
129
|
+
const limit = typeof a.limit === 'number' ? a.limit : undefined
|
|
130
|
+
const range = offset || limit
|
|
131
|
+
? ` (${offset ? `from ${offset}` : ''}${offset && limit ? ', ' : ''}${limit ? `limit ${limit}` : ''})`
|
|
132
|
+
: ''
|
|
133
|
+
return {
|
|
134
|
+
summary: `${filePath}${range}`,
|
|
135
|
+
lines: [
|
|
136
|
+
filePath ? `file: ${filePath}` : undefined,
|
|
137
|
+
offset ? `offset: ${offset}` : undefined,
|
|
138
|
+
limit ? `limit: ${limit}` : undefined,
|
|
139
|
+
].filter(Boolean),
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (name === 'Glob') {
|
|
144
|
+
const pattern = typeof a.pattern === 'string' ? a.pattern : ''
|
|
145
|
+
return {
|
|
146
|
+
summary: pattern,
|
|
147
|
+
lines: [pattern ? `pattern: ${pattern}` : undefined].filter(Boolean),
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (name === 'Grep') {
|
|
152
|
+
const pattern = typeof a.pattern === 'string' ? a.pattern : ''
|
|
153
|
+
const outputMode = typeof a.output_mode === 'string' ? a.output_mode : ''
|
|
154
|
+
const path = typeof a.path === 'string' ? a.path : ''
|
|
155
|
+
return {
|
|
156
|
+
summary: `${pattern}${path ? ` in ${path}` : ''}`,
|
|
157
|
+
lines: [
|
|
158
|
+
pattern ? `pattern: ${formatOneLine(pattern, 120)}` : undefined,
|
|
159
|
+
path ? `path: ${path}` : undefined,
|
|
160
|
+
outputMode ? `mode: ${outputMode}` : undefined,
|
|
161
|
+
].filter(Boolean),
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (name === 'WebSearch') {
|
|
166
|
+
const q = typeof a.query === 'string' ? a.query : ''
|
|
167
|
+
return {
|
|
168
|
+
summary: formatOneLine(q, 120),
|
|
169
|
+
lines: [q ? `query: ${formatOneLine(q, 200)}` : undefined].filter(Boolean),
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Generic fallback: show keys but avoid huge payloads.
|
|
174
|
+
const keys = Object.keys(a)
|
|
175
|
+
const short = keys.slice(0, 6).join(', ')
|
|
176
|
+
return {
|
|
177
|
+
summary: short ? `{${short}${keys.length > 6 ? ', …' : ''}}` : '{}',
|
|
178
|
+
lines: short ? [`args: { ${short}${keys.length > 6 ? ', …' : ''} }`] : [],
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const createActivity = () => {
|
|
183
|
+
if ((!showActivity && !showStatusLine) || !process.stdout.isTTY)
|
|
184
|
+
return null
|
|
185
|
+
|
|
186
|
+
const baseTitle = process.env.GOATCHAIN_TITLE ?? 'GoatChain'
|
|
187
|
+
let phase = 'working'
|
|
188
|
+
let detail = ''
|
|
189
|
+
let tick = 0
|
|
190
|
+
let timer = null
|
|
191
|
+
let spinnerVisible = false
|
|
192
|
+
let lastOutputAt = Date.now()
|
|
193
|
+
|
|
194
|
+
const dots = () => {
|
|
195
|
+
tick = (tick + 1) % 4
|
|
196
|
+
return '.'.repeat(tick)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const phaseIcon = (p) => {
|
|
200
|
+
if (!useEmoji)
|
|
201
|
+
return ''
|
|
202
|
+
if (p === 'thinking')
|
|
203
|
+
return '🤔'
|
|
204
|
+
if (p === 'tool_call')
|
|
205
|
+
return '🛠️'
|
|
206
|
+
if (p === 'responding')
|
|
207
|
+
return '✍️'
|
|
208
|
+
if (p === 'working')
|
|
209
|
+
return '…'
|
|
210
|
+
return ''
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const label = () => {
|
|
214
|
+
const pi = phaseIcon(phase)
|
|
215
|
+
const p = pi ? `${pi} ${phase}` : phase
|
|
216
|
+
return detail ? `${p}: ${detail}` : p
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const setTitle = (text) => {
|
|
220
|
+
if (!showActivity)
|
|
221
|
+
return
|
|
222
|
+
process.stdout.write(`\u001B]0;${text}\u0007`)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const showSpinner = () => {
|
|
226
|
+
if (!showStatusLine)
|
|
227
|
+
return
|
|
228
|
+
if (Date.now() - lastOutputAt < 450)
|
|
229
|
+
return
|
|
230
|
+
// Put spinner on its own line at the bottom of current output, update in-place.
|
|
231
|
+
if (!spinnerVisible) {
|
|
232
|
+
if (!atLineStart) {
|
|
233
|
+
process.stdout.write('\n')
|
|
234
|
+
atLineStart = true
|
|
235
|
+
}
|
|
236
|
+
spinnerVisible = true
|
|
237
|
+
}
|
|
238
|
+
const d = dots()
|
|
239
|
+
const txt = `${label()}${d}`
|
|
240
|
+
process.stdout.write(`\r\u001B[2K${ansi.dim(formatOneLine(txt, 120))}`)
|
|
241
|
+
atLineStart = false
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const hideSpinner = () => {
|
|
245
|
+
if (!spinnerVisible)
|
|
246
|
+
return
|
|
247
|
+
process.stdout.write('\r\u001B[2K')
|
|
248
|
+
spinnerVisible = false
|
|
249
|
+
atLineStart = true
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const render = () => {
|
|
253
|
+
const d = dots()
|
|
254
|
+
const t = `${baseTitle} — ${label()}${d}`
|
|
255
|
+
setTitle(t)
|
|
256
|
+
showSpinner()
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const start = () => {
|
|
260
|
+
if (timer)
|
|
261
|
+
return
|
|
262
|
+
render()
|
|
263
|
+
timer = setInterval(render, 250)
|
|
264
|
+
if (typeof timer?.unref === 'function')
|
|
265
|
+
timer.unref()
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const stop = () => {
|
|
269
|
+
if (timer) {
|
|
270
|
+
clearInterval(timer)
|
|
271
|
+
timer = null
|
|
272
|
+
}
|
|
273
|
+
hideSpinner()
|
|
274
|
+
setTitle(baseTitle)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
start,
|
|
279
|
+
stop,
|
|
280
|
+
set(nextPhase, nextDetail) {
|
|
281
|
+
phase = String(nextPhase ?? 'working')
|
|
282
|
+
detail = String(nextDetail ?? '')
|
|
283
|
+
},
|
|
284
|
+
onOutput() {
|
|
285
|
+
lastOutputAt = Date.now()
|
|
286
|
+
hideSpinner()
|
|
287
|
+
},
|
|
288
|
+
onPauseForInput() {
|
|
289
|
+
hideSpinner()
|
|
290
|
+
},
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const resetIteration = () => {
|
|
295
|
+
iteration = { text: '', toolCalls: [], flushedToolCallAssistant: false }
|
|
296
|
+
toolNameById = new Map()
|
|
297
|
+
toolArgsSummaryById = new Map()
|
|
298
|
+
printedToolCallIds = new Set()
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const flushAssistantToolCallsMessageIfNeeded = () => {
|
|
302
|
+
if (iteration.flushedToolCallAssistant)
|
|
303
|
+
return
|
|
304
|
+
if (!Array.isArray(iteration.toolCalls) || iteration.toolCalls.length === 0)
|
|
305
|
+
return
|
|
306
|
+
messagesToAppend.push({
|
|
307
|
+
role: 'assistant',
|
|
308
|
+
content: iteration.text || '',
|
|
309
|
+
tool_calls: iteration.toolCalls,
|
|
310
|
+
})
|
|
311
|
+
iteration.flushedToolCallAssistant = true
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const pushToolMessage = (toolCallId, result) => {
|
|
315
|
+
const content = typeof result === 'string' ? result : JSON.stringify(result)
|
|
316
|
+
messagesToAppend.push({
|
|
317
|
+
role: 'tool',
|
|
318
|
+
tool_call_id: toolCallId,
|
|
319
|
+
content,
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const previewToolArgs = (toolCall) => {
|
|
324
|
+
try {
|
|
325
|
+
const name = toolCall?.function?.name ?? 'unknown'
|
|
326
|
+
const raw = toolCall?.function?.arguments
|
|
327
|
+
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw
|
|
328
|
+
return summarizeArgsForDisplay(name, parsed).summary
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
return ''
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const previewToolResult = (result) => {
|
|
336
|
+
try {
|
|
337
|
+
if (result && typeof result === 'object') {
|
|
338
|
+
const sc = result.structuredContent && typeof result.structuredContent === 'object'
|
|
339
|
+
? result.structuredContent
|
|
340
|
+
: null
|
|
341
|
+
|
|
342
|
+
// Glob: { files, totalMatches, truncated }
|
|
343
|
+
if (sc && 'totalMatches' in sc && 'files' in sc && Array.isArray(sc.files)) {
|
|
344
|
+
const totalMatches = Number(sc.totalMatches ?? sc.files.length)
|
|
345
|
+
const shown = sc.files.length
|
|
346
|
+
const truncated = sc.truncated === true
|
|
347
|
+
const parts = [
|
|
348
|
+
`Found ${Number.isFinite(totalMatches) ? totalMatches : shown} file(s)`,
|
|
349
|
+
truncated ? `(showing ${shown})` : shown !== totalMatches ? `(showing ${shown})` : '',
|
|
350
|
+
].filter(Boolean)
|
|
351
|
+
return parts.join(' ')
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Read: { fileSize, totalLines, linesReturned, truncated, isBinary, mimeType }
|
|
355
|
+
if (sc && 'fileSize' in sc && ('totalLines' in sc || 'linesReturned' in sc)) {
|
|
356
|
+
const totalLines = Number(sc.totalLines ?? 0)
|
|
357
|
+
const linesReturned = Number(sc.linesReturned ?? 0)
|
|
358
|
+
const truncated = sc.truncated === true
|
|
359
|
+
const fileSize = Number(sc.fileSize ?? 0)
|
|
360
|
+
const parts = [
|
|
361
|
+
'Read file',
|
|
362
|
+
Number.isFinite(linesReturned) && Number.isFinite(totalLines) && totalLines > 0
|
|
363
|
+
? `(${linesReturned}/${totalLines} lines)`
|
|
364
|
+
: linesReturned > 0
|
|
365
|
+
? `(${linesReturned} lines)`
|
|
366
|
+
: '',
|
|
367
|
+
Number.isFinite(fileSize) && fileSize > 0 ? `(${humanBytes(fileSize)})` : '',
|
|
368
|
+
truncated ? '(truncated)' : '',
|
|
369
|
+
].filter(Boolean)
|
|
370
|
+
return parts.join(' ')
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Write: { success, filePath, bytesWritten, overwritten }
|
|
374
|
+
if (sc && 'bytesWritten' in sc && 'filePath' in sc) {
|
|
375
|
+
const filePath = String(sc.filePath ?? '')
|
|
376
|
+
const bytes = Number(sc.bytesWritten ?? 0)
|
|
377
|
+
const overwritten = sc.overwritten === true
|
|
378
|
+
const parts = [
|
|
379
|
+
overwritten ? 'Overwrote' : 'Wrote',
|
|
380
|
+
filePath || 'file',
|
|
381
|
+
bytes > 0 ? `(${humanBytes(bytes)})` : '',
|
|
382
|
+
].filter(Boolean)
|
|
383
|
+
return parts.join(' ')
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Fallback: first text block
|
|
387
|
+
if (Array.isArray(result.content)) {
|
|
388
|
+
const first = result.content.find(b => b && typeof b === 'object' && b.type === 'text' && typeof b.text === 'string')
|
|
389
|
+
if (first && typeof first.text === 'string')
|
|
390
|
+
return formatOneLine(first.text, 200)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return formatOneLine(typeof result === 'string' ? result : JSON.stringify(result), 200)
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
return ''
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const maybeApprove = async (event) => {
|
|
401
|
+
if (approveAllToolsThisTurn)
|
|
402
|
+
return { approved: true }
|
|
403
|
+
|
|
404
|
+
if (typeof askApprovalForTool === 'function') {
|
|
405
|
+
const toolName = event.toolName
|
|
406
|
+
const info = summarizeArgsForDisplay(toolName, event.args)
|
|
407
|
+
const actionRaw = await askApprovalForTool({
|
|
408
|
+
toolName,
|
|
409
|
+
riskLevel: event.riskLevel,
|
|
410
|
+
args: event.args,
|
|
411
|
+
display: info,
|
|
412
|
+
})
|
|
413
|
+
if (typeof actionRaw === 'boolean')
|
|
414
|
+
return { approved: actionRaw }
|
|
415
|
+
const action = String(actionRaw ?? '').trim().toLowerCase()
|
|
416
|
+
if (action === 'approve_all' || action === 'all') {
|
|
417
|
+
approveAllToolsThisTurn = true
|
|
418
|
+
return { approved: true }
|
|
419
|
+
}
|
|
420
|
+
if (action === 'approve' || action === 'yes' || action === 'y')
|
|
421
|
+
return { approved: true }
|
|
422
|
+
if (action === 'abort')
|
|
423
|
+
throw new Error('Aborted by user')
|
|
424
|
+
return { approved: false }
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (!askApproval)
|
|
428
|
+
return { approved: false, reason: 'No approval handler available' }
|
|
429
|
+
const toolName = event.toolName
|
|
430
|
+
const risk = event.riskLevel ? ` (${event.riskLevel})` : ''
|
|
431
|
+
const info = summarizeArgsForDisplay(toolName, event.args)
|
|
432
|
+
const header = ansi.highlight(` approval required `)
|
|
433
|
+
const title = `${header}${useEmoji ? ` ${icon('🛑').trimEnd()}` : ''} ${ansi.bold(String(toolName ?? 'tool'))}${risk}\n`
|
|
434
|
+
const body = (info.lines.length > 0 ? info.lines : ['(no args)'])
|
|
435
|
+
.map(l => `- ${l}`)
|
|
436
|
+
.join('\n')
|
|
437
|
+
const q = `\n${title}${ansi.dim(body)}\n${ansi.bold(`${icon('✅').trimEnd()}Allow?`)} [y/N] `
|
|
438
|
+
const answer = await askApproval(q)
|
|
439
|
+
const ok = String(answer ?? '').trim().toLowerCase()
|
|
440
|
+
return { approved: ok === 'y' || ok === 'yes' }
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const printToolBlock = (title, lines, padAfter = false) => {
|
|
444
|
+
if (!showTools)
|
|
445
|
+
return
|
|
446
|
+
ensureNewline()
|
|
447
|
+
out(`${ansi.dim(`┌─ ${useEmoji ? '🛠️ ' : ''}${title}`)}\n`)
|
|
448
|
+
for (const line of lines) {
|
|
449
|
+
if (line)
|
|
450
|
+
out(`${ansi.dim(`│ ${line}`)}\n`)
|
|
451
|
+
}
|
|
452
|
+
out(`${ansi.dim('└─')}\n`)
|
|
453
|
+
if (padAfter)
|
|
454
|
+
out('\n')
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const shortPath = (p) => {
|
|
458
|
+
const s = String(p ?? '')
|
|
459
|
+
if (!s)
|
|
460
|
+
return ''
|
|
461
|
+
try {
|
|
462
|
+
const rel = s.startsWith('/')
|
|
463
|
+
? path.relative(process.cwd(), s)
|
|
464
|
+
: s
|
|
465
|
+
return formatOneLine(rel, 80)
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
return formatOneLine(s, 80)
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const printToolInlineCall = (toolName, argsSummary) => {
|
|
473
|
+
if (!showTools)
|
|
474
|
+
return
|
|
475
|
+
ensureNewline()
|
|
476
|
+
const name = ansi.bold(String(toolName ?? 'tool'))
|
|
477
|
+
const a = String(argsSummary ?? '').trim()
|
|
478
|
+
const tag = useEmoji ? `${icon('🛠️').trimEnd()} [tool]` : '[tool]'
|
|
479
|
+
const lhs = [ansi.dim(tag), name, a ? ansi.dim(a) : ''].filter(Boolean).join(' ')
|
|
480
|
+
out(`${ansi.dim('•')} ${lhs}\n`)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const printToolInlineResult = (resultSummary) => {
|
|
484
|
+
if (!showTools)
|
|
485
|
+
return
|
|
486
|
+
const r = String(resultSummary ?? '').trim() || '[no output]'
|
|
487
|
+
out(`${ansi.dim(' └')} ${ansi.dim(`${useEmoji ? '📄 ' : ''}${r}`)}\n`)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const toolResultIcon = (result) => {
|
|
491
|
+
if (!useEmoji)
|
|
492
|
+
return ''
|
|
493
|
+
if (result && typeof result === 'object') {
|
|
494
|
+
if (result.isError === true)
|
|
495
|
+
return '❌'
|
|
496
|
+
const sc = result.structuredContent && typeof result.structuredContent === 'object'
|
|
497
|
+
? result.structuredContent
|
|
498
|
+
: null
|
|
499
|
+
if (sc && sc.truncated === true)
|
|
500
|
+
return '⚠️'
|
|
501
|
+
}
|
|
502
|
+
return '✅'
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const looksUnfinished = (text) => {
|
|
506
|
+
const t = String(text ?? '').trim()
|
|
507
|
+
if (!t)
|
|
508
|
+
return false
|
|
509
|
+
if (/[。.!??!]$/.test(t))
|
|
510
|
+
return false
|
|
511
|
+
if (/(我将|我會|我会|接下来|下一步|然后|让我|开始创建|我将创建|我会创建)/.test(t))
|
|
512
|
+
return true
|
|
513
|
+
if ((/(\.html|\.ts|tank-battle)/i.test(t)) && (/(创建|写入|生成|create|write|implement)/i.test(t)))
|
|
514
|
+
return true
|
|
515
|
+
return false
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const runSegment = async (segmentIter) => {
|
|
519
|
+
let doneReason = ''
|
|
520
|
+
let modelStopReason = ''
|
|
521
|
+
activity = createActivity()
|
|
522
|
+
activity?.start()
|
|
523
|
+
for await (const event of segmentIter) {
|
|
524
|
+
if (event.type === 'thinking_start') {
|
|
525
|
+
activity?.set('thinking', '')
|
|
526
|
+
}
|
|
527
|
+
else if (event.type === 'thinking_end') {
|
|
528
|
+
activity?.set('working', '')
|
|
529
|
+
}
|
|
530
|
+
else if (event.type === 'iteration_start') {
|
|
531
|
+
resetIteration()
|
|
532
|
+
activity?.set('thinking', '')
|
|
533
|
+
}
|
|
534
|
+
else if (event.type === 'text_delta') {
|
|
535
|
+
iteration.text += event.delta
|
|
536
|
+
out(event.delta)
|
|
537
|
+
activity?.set('responding', '')
|
|
538
|
+
}
|
|
539
|
+
else if (event.type === 'tool_call_start') {
|
|
540
|
+
activity?.set('tool_call', event.toolName ?? '')
|
|
541
|
+
}
|
|
542
|
+
else if (event.type === 'tool_call_end') {
|
|
543
|
+
iteration.toolCalls.push(event.toolCall)
|
|
544
|
+
const name = event.toolCall?.function?.name ?? 'unknown'
|
|
545
|
+
if (event.toolCall?.id)
|
|
546
|
+
toolNameById.set(event.toolCall.id, name)
|
|
547
|
+
let parsedArgs = {}
|
|
548
|
+
try {
|
|
549
|
+
const raw = event.toolCall?.function?.arguments
|
|
550
|
+
parsedArgs = typeof raw === 'string' ? JSON.parse(raw) : (raw ?? {})
|
|
551
|
+
}
|
|
552
|
+
catch {
|
|
553
|
+
parsedArgs = { _raw: event.toolCall?.function?.arguments }
|
|
554
|
+
}
|
|
555
|
+
const info = summarizeArgsForDisplay(name, parsedArgs)
|
|
556
|
+
if (event.toolCall?.id) {
|
|
557
|
+
// Prefer a readable path summary for common file tools.
|
|
558
|
+
if (name === 'Read' || name === 'Write' || name === 'Edit') {
|
|
559
|
+
const filePath = typeof parsedArgs.file_path === 'string' ? parsedArgs.file_path : ''
|
|
560
|
+
if (name === 'Write') {
|
|
561
|
+
const size = typeof parsedArgs.content === 'string' ? humanBytes(byteLen(parsedArgs.content)) : ''
|
|
562
|
+
toolArgsSummaryById.set(event.toolCall.id, `${shortPath(filePath)}${size ? ` (${size})` : ''}`.trim())
|
|
563
|
+
}
|
|
564
|
+
else if (name === 'Edit') {
|
|
565
|
+
const edits = Array.isArray(parsedArgs.edits) ? parsedArgs.edits.length : 0
|
|
566
|
+
toolArgsSummaryById.set(event.toolCall.id, `${shortPath(filePath)}${edits ? ` (${edits} edits)` : ''}`.trim())
|
|
567
|
+
}
|
|
568
|
+
else {
|
|
569
|
+
// Read
|
|
570
|
+
toolArgsSummaryById.set(event.toolCall.id, shortPath(filePath) || '')
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
else {
|
|
574
|
+
toolArgsSummaryById.set(event.toolCall.id, info.summary || '')
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
if (toolStyle === 'box') {
|
|
578
|
+
printToolBlock(`${ansi.bold(name)}${info.summary ? ` ${ansi.dim(info.summary)}` : ''}`, info.lines.length ? info.lines : [info.summary || '(no args)'])
|
|
579
|
+
}
|
|
580
|
+
else if (event.toolCall?.id && !printedToolCallIds.has(event.toolCall.id)) {
|
|
581
|
+
printedToolCallIds.add(event.toolCall.id)
|
|
582
|
+
const argsSummary = toolArgsSummaryById.get(event.toolCall.id) ?? ''
|
|
583
|
+
printToolInlineCall(name, argsSummary || info.summary || '')
|
|
584
|
+
meta.tools.push({ toolName: name, argsSummary: argsSummary || info.summary || '' })
|
|
585
|
+
}
|
|
586
|
+
activity?.set('tool_call', event.toolCall?.function?.name ?? '')
|
|
587
|
+
}
|
|
588
|
+
else if (event.type === 'tool_result') {
|
|
589
|
+
flushAssistantToolCallsMessageIfNeeded()
|
|
590
|
+
pushToolMessage(event.tool_call_id, event.result)
|
|
591
|
+
const prev = previewToolResult(event.result)
|
|
592
|
+
const toolName = toolNameById.get(event.tool_call_id)
|
|
593
|
+
if (toolName)
|
|
594
|
+
executedToolNames.add(toolName)
|
|
595
|
+
if (toolStyle !== 'box') {
|
|
596
|
+
// If we didn't print the call line (e.g. legacy tool events), do it now.
|
|
597
|
+
if (toolName && !printedToolCallIds.has(event.tool_call_id)) {
|
|
598
|
+
printedToolCallIds.add(event.tool_call_id)
|
|
599
|
+
const argsSummary = toolArgsSummaryById.get(event.tool_call_id) ?? ''
|
|
600
|
+
printToolInlineCall(toolName, argsSummary)
|
|
601
|
+
meta.tools.push({ toolName, argsSummary })
|
|
602
|
+
}
|
|
603
|
+
const argsSummary = toolArgsSummaryById.get(event.tool_call_id) ?? ''
|
|
604
|
+
void argsSummary
|
|
605
|
+
const status = toolResultIcon(event.result)
|
|
606
|
+
printToolInlineResult(`${status ? `${status} ` : ''}${prev || '[no output]'}`)
|
|
607
|
+
if (toolName) {
|
|
608
|
+
const last = meta.tools.slice().reverse().find(t => t.toolName === toolName && !t.resultSummary)
|
|
609
|
+
if (last)
|
|
610
|
+
last.resultSummary = prev || '[no output]'
|
|
611
|
+
else
|
|
612
|
+
meta.tools.push({ toolName, argsSummary, resultSummary: prev || '[no output]' })
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
else {
|
|
616
|
+
const status = toolResultIcon(event.result)
|
|
617
|
+
const title = toolName ? `tool result ${ansi.bold(toolName)}${status ? ` ${status}` : ''}` : `tool result${status ? ` ${status}` : ''}`
|
|
618
|
+
printToolBlock(title, [prev || '[no output]'])
|
|
619
|
+
}
|
|
620
|
+
activity?.set('thinking', '')
|
|
621
|
+
}
|
|
622
|
+
else if (event.type === 'requires_action' && event.kind === 'tool_approval') {
|
|
623
|
+
flushAssistantToolCallsMessageIfNeeded()
|
|
624
|
+
// Stop any spinner/title updates while waiting for user input.
|
|
625
|
+
// Otherwise the status line can overwrite the readline prompt and make input impossible.
|
|
626
|
+
activity?.stop()
|
|
627
|
+
activity = null
|
|
628
|
+
ensureNewline()
|
|
629
|
+
|
|
630
|
+
const decision = await maybeApprove(event)
|
|
631
|
+
const checkpoint = event.checkpoint
|
|
632
|
+
if (!checkpoint) {
|
|
633
|
+
throw new Error('Approval requested but no checkpoint provided; configure a checkpoint store or enable inline checkpoint')
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return runSegment(
|
|
637
|
+
agent.streamFromCheckpoint({
|
|
638
|
+
checkpoint,
|
|
639
|
+
signal,
|
|
640
|
+
toolContext: {
|
|
641
|
+
approval: {
|
|
642
|
+
decisions: {
|
|
643
|
+
[event.tool_call_id]: decision,
|
|
644
|
+
},
|
|
645
|
+
},
|
|
646
|
+
},
|
|
647
|
+
}),
|
|
648
|
+
)
|
|
649
|
+
}
|
|
650
|
+
else if (event.type === 'iteration_end') {
|
|
651
|
+
if (event.willContinue === false) {
|
|
652
|
+
messagesToAppend.push({ role: 'assistant', content: iteration.text || '' })
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
else if (event.type === 'done') {
|
|
656
|
+
doneReason = event.stopReason ? String(event.stopReason) : ''
|
|
657
|
+
modelStopReason = event.modelStopReason ? String(event.modelStopReason) : ''
|
|
658
|
+
break
|
|
659
|
+
}
|
|
660
|
+
else if (event.type === 'error') {
|
|
661
|
+
const msg = event.error instanceof Error ? event.error.message : String(event.error)
|
|
662
|
+
ensureNewline()
|
|
663
|
+
out(`${ansi.red(`${icon('❌').trimEnd()}[error] ${msg}`)}\n`)
|
|
664
|
+
doneReason = 'error'
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
activity?.stop()
|
|
669
|
+
return { doneReason, modelStopReason }
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
let lastDoneReason = ''
|
|
673
|
+
let lastModelStopReason = ''
|
|
674
|
+
|
|
675
|
+
const first = await runSegment(agent.stream({
|
|
676
|
+
sessionId,
|
|
677
|
+
input,
|
|
678
|
+
messages: history,
|
|
679
|
+
signal,
|
|
680
|
+
toolContext: {
|
|
681
|
+
approval: { strategy: approvalStrategy, autoApprove },
|
|
682
|
+
},
|
|
683
|
+
}))
|
|
684
|
+
lastDoneReason = first.doneReason
|
|
685
|
+
lastModelStopReason = first.modelStopReason
|
|
686
|
+
|
|
687
|
+
// If the model ended in a "plan-y" way without writing files, optionally prompt to continue.
|
|
688
|
+
const finalAssistant = [...messagesToAppend].reverse().find(m => m && typeof m === 'object' && m.role === 'assistant')
|
|
689
|
+
const finalText = typeof finalAssistant?.content === 'string' ? finalAssistant.content : ''
|
|
690
|
+
const autoContinueUnfinished = autoContinue
|
|
691
|
+
if (
|
|
692
|
+
(autoContinueUnfinished || (promptContinue && typeof askApproval === 'function'))
|
|
693
|
+
&& !executedToolNames.has('Write')
|
|
694
|
+
&& !executedToolNames.has('Edit')
|
|
695
|
+
&& looksUnfinished(finalText)
|
|
696
|
+
) {
|
|
697
|
+
let shouldContinue = autoContinueUnfinished
|
|
698
|
+
if (!shouldContinue) {
|
|
699
|
+
const ans = await askApproval(`${ansi.dim('\nThe model seems to have stopped before implementing.')} Continue? [Y/n] `)
|
|
700
|
+
const ok = String(ans ?? '').trim().toLowerCase()
|
|
701
|
+
shouldContinue = ok === '' || ok === 'y' || ok === 'yes'
|
|
702
|
+
}
|
|
703
|
+
if (shouldContinue) {
|
|
704
|
+
const continuePrompt = '继续。不要只说计划:请立刻开始实现,使用 Write/Edit 创建或修改文件。'
|
|
705
|
+
// Persist the continuation user message in the caller history.
|
|
706
|
+
messagesToAppend.push({ role: 'user', content: continuePrompt })
|
|
707
|
+
// Build the full message history including the initial user input.
|
|
708
|
+
const base = [...history, { role: 'user', content: input }, ...messagesToAppend.slice(0, -1)]
|
|
709
|
+
const second = await runSegment(agent.stream({
|
|
710
|
+
sessionId,
|
|
711
|
+
input: continuePrompt,
|
|
712
|
+
messages: base,
|
|
713
|
+
signal,
|
|
714
|
+
toolContext: {
|
|
715
|
+
approval: { strategy: approvalStrategy, autoApprove },
|
|
716
|
+
},
|
|
717
|
+
}))
|
|
718
|
+
lastDoneReason = second.doneReason
|
|
719
|
+
lastModelStopReason = second.modelStopReason
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
meta.stopReason = lastDoneReason
|
|
724
|
+
meta.modelStopReason = lastModelStopReason
|
|
725
|
+
|
|
726
|
+
if (showStatus) {
|
|
727
|
+
ensureNewline()
|
|
728
|
+
const parts = []
|
|
729
|
+
if (lastDoneReason && lastDoneReason !== 'final_response')
|
|
730
|
+
parts.push(`stopReason=${lastDoneReason}`)
|
|
731
|
+
if (lastModelStopReason && lastModelStopReason !== 'final')
|
|
732
|
+
parts.push(`modelStopReason=${lastModelStopReason}`)
|
|
733
|
+
const label = `${icon('✅').trimEnd()}done${parts.length ? ` (${parts.join(' ')})` : ''}`
|
|
734
|
+
out(`${ansi.dim(`─ ${label} ─`)}\n`)
|
|
735
|
+
|
|
736
|
+
if (lastModelStopReason === 'length') {
|
|
737
|
+
out(`${ansi.dim(`${icon('💡').trimEnd()}hint: increase maxTokens via /set maxTokens 2048 if output is truncated`)}\n`)
|
|
738
|
+
}
|
|
739
|
+
else {
|
|
740
|
+
const finalAssistant2 = [...messagesToAppend].reverse().find(m => m && typeof m === 'object' && m.role === 'assistant')
|
|
741
|
+
const finalText2 = typeof finalAssistant2?.content === 'string' ? finalAssistant2.content : ''
|
|
742
|
+
if (!executedToolNames.has('Write') && !executedToolNames.has('Edit') && looksUnfinished(finalText2 || iteration.text || '')) {
|
|
743
|
+
out(`${ansi.dim(`${icon('💡').trimEnd()}hint: response ended before any Write/Edit; ask “继续并开始写文件” or increase maxTokens`)}\n`)
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
ensureNewline()
|
|
749
|
+
out('\n')
|
|
750
|
+
return { messagesToAppend, meta }
|
|
751
|
+
}
|