min-agent 0.1.0

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/src/agent.ts ADDED
@@ -0,0 +1,609 @@
1
+ import { streamText, stepCountIs, type Tool, type ModelMessage, type LanguageModelUsage } from "ai"
2
+ import { readFileSync, existsSync } from "fs"
3
+ import path from "path"
4
+ import { resolveModel } from "./provider.js"
5
+ import { createTools } from "./tools/index.js"
6
+ import { initMcp, shutdownMcp, getMcpTools, loadMcpConfig, getMcpStatus } from "./mcp.js"
7
+ import { discoverSkills, getSkillsTool, getSkillsSystemPrompt, getSkills } from "./skills.js"
8
+ import { loadInstructions } from "./instructions.js"
9
+ import { getMemorySystemPrompt, getMemoryTools } from "./memory.js"
10
+ import { needsCompaction, compactMessages, estimateTokens } from "./compaction.js"
11
+ import { loadPluginTools } from "./plugins.js"
12
+ import { MarkdownRenderer } from "./markdown.js"
13
+ import { ThinkingBodySplitter, stripThinkingFromAssistantText } from "./assistant-stream.js"
14
+ import { printHeader, printDivider, printToolCall, printToolResult, printDone } from "./output.js"
15
+ import readline from "readline"
16
+
17
+ const MAX_STEPS = 30
18
+
19
+ function dimStyle(): { dim: string; reset: string } {
20
+ if ("NO_COLOR" in process.env) return { dim: "", reset: "" }
21
+ return { dim: "\x1b[90m", reset: "\x1b[0m" }
22
+ }
23
+
24
+ /** Shown immediately so the terminal does not look frozen while MCP / rules load. */
25
+ function printInitLoading() {
26
+ const { dim, reset } = dimStyle()
27
+ console.log(`${dim}⟳ 正在初始化(MCP、技能、规则)…${reset}`)
28
+ }
29
+
30
+ function printInitReady() {
31
+ const { dim, reset } = dimStyle()
32
+ console.log(`${dim}✓ 就绪${reset}`)
33
+ }
34
+
35
+ /** Stream thinking to stderr (dim). Set MIN_AGENT_SHOW_THINKING=0 to hide. */
36
+ function writeThinkingDelta(text: string) {
37
+ if (!text) return
38
+ if (process.env.MIN_AGENT_SHOW_THINKING === "0" || process.env.MIN_AGENT_SHOW_THINKING === "false") return
39
+ const noColor = "NO_COLOR" in process.env
40
+ const dim = noColor ? "" : "\x1b[2m"
41
+ const reset = noColor ? "" : "\x1b[0m"
42
+ process.stderr.write(`${dim}${text}${reset}`)
43
+ }
44
+
45
+ function buildSystemPrompt(instructions: string[]): string {
46
+ const parts = [
47
+ `You are a helpful coding agent. You can read files, write files, run shell commands, search the web, and search the codebase to help the user with software engineering tasks.`,
48
+ "",
49
+ "Be concise and direct. When you run a command, briefly explain why.",
50
+ "Use the available tools to complete tasks. When multiple independent operations are needed, call tools in parallel.",
51
+ "When the user asks about current events, news, or anything requiring up-to-date information, use the web_search tool.",
52
+ "",
53
+ `Working directory: ${process.cwd()}`,
54
+ `Platform: ${process.platform}`,
55
+ `Date: ${new Date().toDateString()}`,
56
+ "",
57
+ "min-agent: when the user asks to configure or install this agent (MCP, skills, rules, CLI, HTTP API, etc.), use the **read** tool on `README.md` in the working directory first, then follow what it says.",
58
+ ]
59
+
60
+ const skillsPrompt = getSkillsSystemPrompt()
61
+ if (skillsPrompt) {
62
+ parts.push("", skillsPrompt)
63
+ }
64
+
65
+ const memoryPrompt = getMemorySystemPrompt()
66
+ if (memoryPrompt) {
67
+ parts.push("", memoryPrompt)
68
+ }
69
+
70
+ if (instructions.length > 0) {
71
+ parts.push("", "# User Instructions", "")
72
+ parts.push(...instructions)
73
+ }
74
+
75
+ return parts.join("\n")
76
+ }
77
+
78
+ /** Build user message content, optionally with images */
79
+ export async function buildUserContent(message: string, imagePaths?: string[]): Promise<any> {
80
+ if (!imagePaths || imagePaths.length === 0) return message
81
+
82
+ const parts: any[] = [{ type: "text", text: message }]
83
+
84
+ for (const imgPath of imagePaths) {
85
+ const resolved = path.resolve(process.cwd(), imgPath)
86
+ if (!existsSync(resolved)) {
87
+ console.error(`\x1b[33m Warning: Image not found: ${imgPath}\x1b[0m`)
88
+ continue
89
+ }
90
+ const data = readFileSync(resolved)
91
+ const ext = path.extname(resolved).toLowerCase()
92
+ const mimeMap: Record<string, string> = {
93
+ ".png": "image/png",
94
+ ".jpg": "image/jpeg",
95
+ ".jpeg": "image/jpeg",
96
+ ".gif": "image/gif",
97
+ ".webp": "image/webp",
98
+ }
99
+ const mimeType = mimeMap[ext] ?? "image/png"
100
+ parts.push({
101
+ type: "image",
102
+ image: data,
103
+ mimeType,
104
+ })
105
+ console.log(`\x1b[90m 📎 ${imgPath}\x1b[0m`)
106
+ }
107
+
108
+ return parts
109
+ }
110
+
111
+ /** Single-shot: send one message, get response, exit */
112
+ export async function runAgent(message: string, modelId?: string, imagePaths?: string[]) {
113
+ printHeader(modelId)
114
+ printDivider()
115
+ printInitLoading()
116
+
117
+ await initMcp()
118
+ discoverSkills()
119
+ const instructions = await loadInstructions()
120
+
121
+ printInitReady()
122
+ console.log(`\x1b[36m> ${message}\x1b[0m\n`)
123
+
124
+ const content = await buildUserContent(message, imagePaths)
125
+ const messages: ModelMessage[] = [{ role: "user", content }]
126
+ await runOnce(messages, instructions, modelId)
127
+
128
+ await shutdownMcp()
129
+ }
130
+
131
+ /** Interactive multi-turn chat session */
132
+ export async function runChat(modelId?: string, resumeSessionId?: string) {
133
+ printHeader(modelId)
134
+ printDivider()
135
+ printInitLoading()
136
+
137
+ await initMcp()
138
+ discoverSkills()
139
+ const instructions = await loadInstructions()
140
+
141
+ printInitReady()
142
+
143
+ let messages: ModelMessage[] = []
144
+ let sessionId: string | undefined = resumeSessionId
145
+
146
+ // Resume existing session
147
+ if (resumeSessionId) {
148
+ const { loadSession } = await import("./sessions.js")
149
+ const session = loadSession(resumeSessionId)
150
+ if (session) {
151
+ messages = session.messages
152
+ sessionId = resumeSessionId
153
+ console.log(`\x1b[90m Resumed session: ${session.meta.title} (${messages.length} messages)\x1b[0m`)
154
+ }
155
+ }
156
+
157
+ const rl = readline.createInterface({
158
+ input: process.stdin,
159
+ output: process.stdout,
160
+ prompt: "\x1b[36m> \x1b[0m",
161
+ })
162
+
163
+ // Handle Ctrl+C: abort current generation, don't exit
164
+ let abortController: AbortController | null = null
165
+ process.on("SIGINT", () => {
166
+ if (abortController) {
167
+ abortController.abort()
168
+ abortController = null
169
+ console.log("\n\x1b[90m(interrupted)\x1b[0m\n")
170
+ rl.prompt()
171
+ } else {
172
+ // No active generation, exit
173
+ console.log()
174
+ rl.close()
175
+ }
176
+ })
177
+
178
+ console.log("\x1b[90m输入消息开始对话,输入 /help 查看命令,/exit 退出\x1b[0m\n")
179
+ rl.prompt()
180
+
181
+ for await (const line of rl) {
182
+ const input = line.trim()
183
+ if (!input) {
184
+ rl.prompt()
185
+ continue
186
+ }
187
+
188
+ // Handle slash commands
189
+ if (input.startsWith("/")) {
190
+ const handled = await handleSlashCommand(input, messages, instructions, modelId, rl)
191
+ if (handled === "exit") break
192
+ rl.prompt()
193
+ continue
194
+ }
195
+
196
+ console.log()
197
+ messages.push({ role: "user", content: input })
198
+ abortController = new AbortController()
199
+ await runOnce(messages, instructions, modelId, abortController.signal)
200
+ abortController = null
201
+ console.log()
202
+ rl.prompt()
203
+ }
204
+
205
+ // Auto-save session on exit
206
+ if (messages.length > 0) {
207
+ const { saveSession } = await import("./sessions.js")
208
+ sessionId = saveSession(messages, sessionId)
209
+ console.log(`\x1b[90m Session saved: ${sessionId}\x1b[0m`)
210
+ }
211
+
212
+ rl.close()
213
+ printDivider()
214
+ console.log("\x1b[90mBye!\x1b[0m")
215
+ await shutdownMcp()
216
+ }
217
+
218
+ async function handleSlashCommand(
219
+ input: string,
220
+ messages: ModelMessage[],
221
+ instructions: string[],
222
+ modelId: string | undefined,
223
+ rl: readline.Interface,
224
+ ): Promise<"exit" | "handled"> {
225
+ const [cmd, ...rest] = input.slice(1).split(/\s+/)
226
+ const arg = rest.join(" ")
227
+
228
+ switch (cmd) {
229
+ case "exit":
230
+ case "quit":
231
+ case "q":
232
+ return "exit"
233
+
234
+ case "clear":
235
+ messages.length = 0
236
+ console.log("\x1b[90m ✓ Conversation cleared\x1b[0m")
237
+ return "handled"
238
+
239
+ case "compact":
240
+ if (messages.length < 4) {
241
+ console.log("\x1b[90m Not enough messages to compact\x1b[0m")
242
+ } else {
243
+ console.log("\x1b[90m ⟳ Compacting...\x1b[0m")
244
+ const model = resolveModel(modelId)
245
+ const result = await compactMessages(messages, model, { keepRecentTurns: 2 })
246
+ messages.length = 0
247
+ messages.push(...result.messages)
248
+ console.log(`\x1b[90m ✓ Compacted (${estimateTokens(messages)} tokens estimated)\x1b[0m`)
249
+ }
250
+ return "handled"
251
+
252
+ case "model":
253
+ if (arg) {
254
+ const config = await import("./config.js")
255
+ const cfg = config.loadConfig()
256
+ if (cfg.provider) {
257
+ cfg.provider.defaultModel = arg
258
+ config.saveConfig(cfg)
259
+ console.log(`\x1b[90m ✓ Default model set to: ${arg}\x1b[0m`)
260
+ }
261
+ } else {
262
+ const config = await import("./config.js")
263
+ const cfg = config.loadConfig()
264
+ console.log(`\x1b[90m Current model: ${cfg.provider?.defaultModel ?? "not set"}\x1b[0m`)
265
+ }
266
+ return "handled"
267
+
268
+ case "models": {
269
+ const config = await import("./config.js")
270
+ const cfg = config.loadConfig()
271
+ if (!cfg.provider?.baseURL || !cfg.provider?.apiKey) {
272
+ console.log("\x1b[90m Not configured. Run: min-agent setup\x1b[0m")
273
+ return "handled"
274
+ }
275
+
276
+ console.log("\x1b[90m Fetching models...\x1b[0m")
277
+ const models = await config.fetchModels(cfg.provider.baseURL, cfg.provider.apiKey)
278
+ if (models.length === 0) {
279
+ console.log("\x1b[90m No models found or unable to fetch model list\x1b[0m")
280
+ } else {
281
+ console.log(`\x1b[90m Available models (${models.length}):\x1b[0m`)
282
+ for (const m of models) {
283
+ const marker = m === cfg.provider.defaultModel ? " ← default" : ""
284
+ console.log(`\x1b[90m - ${m}${marker}\x1b[0m`)
285
+ }
286
+ }
287
+ return "handled"
288
+ }
289
+
290
+ case "memory":
291
+ if (arg) {
292
+ const { addMemory } = await import("./memory.js")
293
+ addMemory(arg)
294
+ console.log(`\x1b[90m ✓ Memory saved: "${arg}"\x1b[0m`)
295
+ } else {
296
+ const { loadMemories } = await import("./memory.js")
297
+ const memories = loadMemories()
298
+ if (memories.length === 0) {
299
+ console.log("\x1b[90m No memories stored\x1b[0m")
300
+ } else {
301
+ for (let i = 0; i < memories.length; i++) {
302
+ const m = memories[i]
303
+ const tags = m.tags.length > 0 ? ` [${m.tags.join(", ")}]` : ""
304
+ console.log(`\x1b[90m #${i + 1}: ${m.content}${tags}\x1b[0m`)
305
+ }
306
+ }
307
+ }
308
+ return "handled"
309
+
310
+ case "skills": {
311
+ discoverSkills()
312
+ const skills = getSkills()
313
+ if (skills.length === 0) {
314
+ console.log("\x1b[90m No skills found\x1b[0m")
315
+ } else {
316
+ console.log(`\x1b[90m Available skills (${skills.length}):\x1b[0m`)
317
+ for (const skill of skills) {
318
+ console.log(`\x1b[90m - ${skill.name}: ${skill.description}\x1b[0m`)
319
+ }
320
+ }
321
+ return "handled"
322
+ }
323
+
324
+ case "mcp": {
325
+ const config = loadMcpConfig()
326
+ const servers = Object.entries(config.mcpServers)
327
+ if (servers.length === 0) {
328
+ console.log("\x1b[90m No MCP servers configured\x1b[0m")
329
+ } else {
330
+ const status = getMcpStatus()
331
+ console.log(`\x1b[90m MCP servers (${servers.length}):\x1b[0m`)
332
+ for (const [name, cfg] of servers) {
333
+ const disabled = cfg.enabled === false
334
+ const connected = status[name]?.connected ?? false
335
+ const toolCount = status[name]?.tools.length ?? 0
336
+ const state = disabled ? "disabled" : connected ? "connected" : "disconnected"
337
+ const toolsText = toolCount > 0 ? `, ${toolCount} tools` : ""
338
+ console.log(`\x1b[90m - ${name}: ${state}${toolsText}\x1b[0m`)
339
+ }
340
+ }
341
+ return "handled"
342
+ }
343
+
344
+ case "tokens":
345
+ console.log(`\x1b[90m Estimated tokens in context: ${estimateTokens(messages)}\x1b[0m`)
346
+ console.log(`\x1b[90m Messages: ${messages.length}\x1b[0m`)
347
+ return "handled"
348
+
349
+ case "help":
350
+ console.log(`\x1b[90m Slash commands:
351
+ /clear Clear conversation history
352
+ /compact Force context compaction
353
+ /model [name] Show or change current model
354
+ /models List available models from provider
355
+ /memory [text] List memories or save a new one
356
+ /skills List discovered skills
357
+ /mcp List MCP servers and connection status
358
+ /tokens Show estimated token usage
359
+ /help Show this help
360
+ /exit Exit the chat\x1b[0m`)
361
+ return "handled"
362
+
363
+ default:
364
+ console.log(`\x1b[90m Unknown command: /${cmd}. Type /help for available commands.\x1b[0m`)
365
+ return "handled"
366
+ }
367
+ }
368
+
369
+ /** When set, `runOnce` does not write to TTY; use for HTTP / programmatic callers. */
370
+ export interface RunOnceCallbacks {
371
+ onAssistantDisplayDelta?: (delta: string) => void
372
+ onThinkingDelta?: (delta: string) => void
373
+ onToolCall?: (toolName: string, input: unknown) => void
374
+ onToolResult?: (toolName: string, output: unknown) => void
375
+ onCompaction?: (line: string) => void
376
+ onStreamError?: (message: string) => void
377
+ onRunFinish?: (info: {
378
+ stepCount: number
379
+ usage: LanguageModelUsage | undefined
380
+ hasError: boolean
381
+ aborted: boolean
382
+ }) => void
383
+ }
384
+
385
+ export async function runOnce(
386
+ messages: ModelMessage[],
387
+ instructions: string[],
388
+ modelId?: string,
389
+ abortSignal?: AbortSignal,
390
+ callbacks?: RunOnceCallbacks,
391
+ ) {
392
+ const model = resolveModel(modelId)
393
+ const api = !!callbacks
394
+
395
+ // Auto-compact if context is getting too large
396
+ if (needsCompaction(messages)) {
397
+ if (api) {
398
+ callbacks!.onCompaction?.("compacting_start")
399
+ } else {
400
+ console.log("\x1b[90m⟳ Compacting context...\x1b[0m")
401
+ }
402
+ const result = await compactMessages(messages, model)
403
+ if (result.compacted) {
404
+ messages.length = 0
405
+ messages.push(...result.messages)
406
+ if (api) {
407
+ callbacks!.onCompaction?.(`compacted_ok estimated_tokens=${estimateTokens(messages)}`)
408
+ } else {
409
+ console.log(`\x1b[90m ✓ Compacted (${estimateTokens(messages)} tokens estimated)\x1b[0m`)
410
+ }
411
+ }
412
+ }
413
+
414
+ // Merge all tools: builtin + MCP + skill + memory + plugins
415
+ const builtinTools = createTools()
416
+ const mcpTools = getMcpTools()
417
+ const memoryTools = getMemoryTools()
418
+ const pluginTools = await loadPluginTools()
419
+ const skills = getSkills()
420
+ const allTools: Record<string, Tool> = { ...builtinTools, ...memoryTools, ...pluginTools }
421
+
422
+ for (const [id, t] of Object.entries(mcpTools)) {
423
+ allTools[id] = t
424
+ }
425
+ if (skills.length > 0) {
426
+ allTools["skill"] = getSkillsTool()
427
+ }
428
+
429
+ let stepCount = 0
430
+ let hasError = false
431
+
432
+ const result = streamText({
433
+ model,
434
+ system: buildSystemPrompt(instructions),
435
+ messages,
436
+ tools: allTools,
437
+ stopWhen: stepCountIs(MAX_STEPS),
438
+ maxRetries: 3,
439
+ abortSignal,
440
+ onStepFinish() {
441
+ stepCount++
442
+ },
443
+ onError() {},
444
+ })
445
+
446
+ Promise.resolve(result.usage).catch(() => {})
447
+
448
+ let rawText = ""
449
+ let assistantText = ""
450
+ const md = new MarkdownRenderer()
451
+ const thinkingSplit = new ThinkingBodySplitter()
452
+
453
+ const emitThinking = (t: string) => {
454
+ if (!t) return
455
+ if (callbacks?.onThinkingDelta) callbacks.onThinkingDelta(t)
456
+ else writeThinkingDelta(t)
457
+ }
458
+
459
+ try {
460
+ for await (const event of result.fullStream) {
461
+ switch (event.type) {
462
+ case "text-delta": {
463
+ const { display, thinking } = thinkingSplit.feed(event.text)
464
+ emitThinking(thinking)
465
+ if (display) {
466
+ rawText += display
467
+ assistantText += display
468
+ if (callbacks?.onAssistantDisplayDelta) callbacks.onAssistantDisplayDelta(display)
469
+ else {
470
+ const formatted = md.write(display)
471
+ if (formatted) process.stdout.write(formatted)
472
+ }
473
+ }
474
+ break
475
+ }
476
+
477
+ case "tool-call": {
478
+ const splitFlush = thinkingSplit.flush()
479
+ emitThinking(splitFlush.thinking)
480
+ if (splitFlush.display) {
481
+ rawText += splitFlush.display
482
+ assistantText += splitFlush.display
483
+ if (callbacks?.onAssistantDisplayDelta) callbacks.onAssistantDisplayDelta(splitFlush.display)
484
+ else {
485
+ const extra = md.write(splitFlush.display)
486
+ if (extra) process.stdout.write(extra)
487
+ }
488
+ }
489
+ if (!callbacks) {
490
+ const flushed = md.flush()
491
+ if (flushed) process.stdout.write(flushed)
492
+ if (rawText.trim()) console.log()
493
+ }
494
+ rawText = ""
495
+ if (callbacks?.onToolCall) callbacks.onToolCall(event.toolName, event.input)
496
+ else printToolCall(event.toolName, event.input)
497
+ break
498
+ }
499
+
500
+ case "tool-result":
501
+ if (callbacks?.onToolResult) callbacks.onToolResult(event.toolName, event.output)
502
+ else printToolResult(event.toolName, event.output)
503
+ break
504
+
505
+ case "error":
506
+ hasError = true
507
+ const errorMsg = String(event.error)
508
+ if (callbacks?.onStreamError) {
509
+ callbacks.onStreamError(errorMsg)
510
+ } else if (errorMsg.includes("Forbidden") || errorMsg.includes("Unauthorized") || errorMsg.includes("API key")) {
511
+ console.error(`\x1b[31mAuthentication error: Check your API key.\x1b[0m`)
512
+ } else {
513
+ console.error(`\x1b[31mError: ${errorMsg}\x1b[0m`)
514
+ }
515
+ break
516
+
517
+ case "finish":
518
+ break
519
+ }
520
+ }
521
+
522
+ const splitEnd = thinkingSplit.flush()
523
+ emitThinking(splitEnd.thinking)
524
+ if (splitEnd.display) {
525
+ rawText += splitEnd.display
526
+ assistantText += splitEnd.display
527
+ if (callbacks?.onAssistantDisplayDelta) callbacks.onAssistantDisplayDelta(splitEnd.display)
528
+ else {
529
+ const tail = md.write(splitEnd.display)
530
+ if (tail) process.stdout.write(tail)
531
+ }
532
+ }
533
+ if (!callbacks) {
534
+ const remaining = md.flush()
535
+ if (remaining) process.stdout.write(remaining)
536
+ if (rawText.trim()) console.log()
537
+ }
538
+
539
+ const cleanedAssistant = stripThinkingFromAssistantText(assistantText)
540
+ if (cleanedAssistant.trim()) {
541
+ messages.push({ role: "assistant", content: cleanedAssistant })
542
+ }
543
+
544
+ let usage: LanguageModelUsage | undefined
545
+ try {
546
+ usage = await result.usage
547
+ } catch {
548
+ usage = undefined
549
+ }
550
+
551
+ if (callbacks?.onRunFinish) {
552
+ callbacks.onRunFinish({ stepCount, usage, hasError, aborted: false })
553
+ } else {
554
+ if (hasError) {
555
+ printDivider()
556
+ return
557
+ }
558
+ printDivider()
559
+ printDone(stepCount, usage!)
560
+ }
561
+ } catch (err: any) {
562
+ if (!callbacks && rawText.trim()) console.log()
563
+ if (err.name === "AbortError" || abortSignal?.aborted) {
564
+ const splitAbort = thinkingSplit.flush()
565
+ emitThinking(splitAbort.thinking)
566
+ if (splitAbort.display) {
567
+ assistantText += splitAbort.display
568
+ if (callbacks?.onAssistantDisplayDelta) callbacks.onAssistantDisplayDelta(splitAbort.display)
569
+ else {
570
+ const w = md.write(splitAbort.display)
571
+ if (w) process.stdout.write(w)
572
+ }
573
+ }
574
+ if (!callbacks) process.stdout.write(md.flush())
575
+ const cleaned = stripThinkingFromAssistantText(assistantText)
576
+ if (cleaned.trim()) {
577
+ messages.push({ role: "assistant", content: cleaned })
578
+ }
579
+ let usage: LanguageModelUsage | undefined
580
+ try {
581
+ usage = await result.usage
582
+ } catch {
583
+ usage = undefined
584
+ }
585
+ callbacks?.onRunFinish?.({ stepCount, usage, hasError: false, aborted: true })
586
+ return
587
+ }
588
+ if (!callbacks) printDivider()
589
+ const msg = err.message ?? String(err)
590
+ if (callbacks?.onStreamError) {
591
+ callbacks.onStreamError(msg)
592
+ } else if (msg.includes("API key") || msg.includes("Unauthorized") || msg.includes("Forbidden")) {
593
+ console.error(`\x1b[31mAuthentication error: Check your API key.\x1b[0m`)
594
+ } else if (msg.includes("429") || msg.includes("rate limit") || msg.includes("Rate limit")) {
595
+ console.error(`\x1b[31mRate limited after retries. Please wait and try again.\x1b[0m`)
596
+ } else if (msg.includes("timeout") || msg.includes("ETIMEDOUT") || msg.includes("ECONNRESET")) {
597
+ console.error(`\x1b[31mNetwork error (retries exhausted): ${msg}\x1b[0m`)
598
+ } else {
599
+ console.error(`\x1b[31mError: ${msg}\x1b[0m`)
600
+ }
601
+ let usage: LanguageModelUsage | undefined
602
+ try {
603
+ usage = await result.usage
604
+ } catch {
605
+ usage = undefined
606
+ }
607
+ callbacks?.onRunFinish?.({ stepCount, usage, hasError: true, aborted: false })
608
+ }
609
+ }