mini-coder 0.5.14 → 0.6.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.
Files changed (70) hide show
  1. package/README.md +26 -109
  2. package/bin/mc.ts +8 -11
  3. package/bun.lock +79 -269
  4. package/nono-mini-coder.json +42 -0
  5. package/package.json +17 -22
  6. package/src/agent.ts +243 -1403
  7. package/src/args.ts +289 -0
  8. package/src/headless.ts +41 -359
  9. package/src/index.ts +29 -1016
  10. package/src/oauth.ts +117 -0
  11. package/src/prompt.ts +219 -284
  12. package/src/session.ts +55 -1306
  13. package/src/shared.ts +117 -38
  14. package/src/tool-bash.ts +110 -0
  15. package/src/tool-edit.ts +133 -0
  16. package/src/tool-read.ts +80 -293
  17. package/src/tui-components.ts +150 -0
  18. package/src/tui-conversation.ts +271 -0
  19. package/src/tui-editor.ts +29 -0
  20. package/src/tui-overlay.ts +403 -0
  21. package/src/tui.ts +228 -0
  22. package/src/types.ts +164 -0
  23. package/tsconfig.json +17 -0
  24. package/BENCHMARK.md +0 -107
  25. package/LICENSE +0 -9
  26. package/PROGRESS.md +0 -5
  27. package/assets/icon-1-minimal.svg +0 -31
  28. package/assets/icon-2-dark-terminal.svg +0 -48
  29. package/assets/icon-3-gradient-modern.svg +0 -45
  30. package/assets/icon-4-filled-bold.svg +0 -54
  31. package/assets/icon-5-community-badge.svg +0 -63
  32. package/assets/mc-claude-smart.png +0 -0
  33. package/assets/mc-gpt-smart.png +0 -0
  34. package/assets/preview-0-5-0.png +0 -0
  35. package/assets/preview.gif +0 -0
  36. package/benchmark-baseline.sh +0 -15
  37. package/benchmark-loop.sh +0 -19
  38. package/skills-lock.json +0 -15
  39. package/src/assistant-output.ts +0 -73
  40. package/src/cli.ts +0 -134
  41. package/src/delegation.ts +0 -238
  42. package/src/errors.ts +0 -15
  43. package/src/git.ts +0 -247
  44. package/src/input.ts +0 -168
  45. package/src/mcp.ts +0 -609
  46. package/src/paths.ts +0 -37
  47. package/src/session-message.ts +0 -385
  48. package/src/settings.ts +0 -449
  49. package/src/skills.ts +0 -271
  50. package/src/submit.ts +0 -376
  51. package/src/text.ts +0 -71
  52. package/src/theme.ts +0 -330
  53. package/src/tool-common.ts +0 -93
  54. package/src/tool-delegate.ts +0 -125
  55. package/src/tool-grep.ts +0 -606
  56. package/src/tool-shell.ts +0 -1051
  57. package/src/tools.ts +0 -1179
  58. package/src/ui/agent.ts +0 -320
  59. package/src/ui/commands.test.ts +0 -957
  60. package/src/ui/commands.ts +0 -848
  61. package/src/ui/conversation.test.ts +0 -585
  62. package/src/ui/conversation.ts +0 -1836
  63. package/src/ui/help.ts +0 -158
  64. package/src/ui/input.test.ts +0 -64
  65. package/src/ui/input.ts +0 -138
  66. package/src/ui/overlay.ts +0 -59
  67. package/src/ui/runtime.ts +0 -69
  68. package/src/ui/status.ts +0 -220
  69. package/src/ui.ts +0 -1190
  70. package/src/version.ts +0 -48
package/src/agent.ts CHANGED
@@ -1,1438 +1,278 @@
1
- /**
2
- * Core agent loop.
3
- *
4
- * Streams LLM responses, executes tool calls, appends messages to the
5
- * session history, and loops until the model stops or the user interrupts.
6
- * Uses pi-ai's {@link streamSimple} for model communication.
7
- *
8
- * @module
9
- */
10
-
11
- import type { Database } from "bun:sqlite";
12
- import type {
13
- AssistantMessage,
14
- AssistantMessageEvent,
15
- Message,
16
- Model,
17
- ThinkingLevel,
18
- Tool,
19
- ToolCall,
20
- ToolResultMessage,
21
- UserMessage,
22
- } from "@mariozechner/pi-ai";
23
- import { streamSimple } from "@mariozechner/pi-ai";
24
1
  import {
25
- appendMessage,
26
- appendSessionCompaction,
27
- computeContextTokens,
28
- computeSessionStats,
29
- loadCompactedModelMessages,
30
- loadMessages,
31
- loadUncompactedModelMessages,
32
- } from "./session.ts";
33
- import { collapseWhitespaceToNull, joinTextBlocks } from "./text.ts";
34
- import { getTodoItems, type ToolExecResult } from "./tools.ts";
35
-
36
- const CONTEXT_COMPACTION_THRESHOLD = 0.9;
37
- const CONTEXT_COMPACTION_FRACTION = 0.4;
38
- const DEFAULT_CURRENT_USER_REQUEST =
39
- "Continue the current task using the earlier conversation context.";
40
- const COMPACTION_RECOVERY_PREFIX =
41
- "These earlier messages were compacted out of the active context.";
42
-
43
- const COMPACTION_SUMMARY_SYSTEM_PROMPT = [
44
- "You summarize earlier conversation context for a coding agent.",
45
- "Write a concise replacement summary for the provided earlier messages so the agent can continue the current request after those messages are removed from active context.",
46
- "Keep only concrete facts that may still matter: user goals, decisions, constraints, files or paths, commands run, tool results, errors, and unfinished work.",
47
- "Prefer short bullet points.",
48
- "Do not invent details or mention information that is not supported by the provided messages.",
49
- ].join("\n");
50
-
51
- // ---------------------------------------------------------------------------
52
- // Types
53
- // ---------------------------------------------------------------------------
54
-
55
- /**
56
- * A tool execution handler.
57
- *
58
- * Called by the agent loop when the model invokes a tool. Arguments are
59
- * the JSON object parsed by pi-ai from the model's tool call.
60
- */
61
- /** Callback used by tool handlers to report progressive output updates. */
62
- export type ToolUpdateCallback = (result: ToolExecResult) => void;
63
-
64
- export type ToolHandler = (
65
- args: Record<string, unknown>,
66
- cwd: string,
67
- signal?: AbortSignal,
68
- onUpdate?: ToolUpdateCallback,
69
- ) => Promise<ToolExecResult> | ToolExecResult;
70
-
71
- export type { ToolExecResult };
72
-
73
- /** Events emitted during the agent loop for UI updates. */
74
- export type AgentEvent =
75
- | {
76
- type: "text_delta";
77
- delta: string;
78
- content: AssistantMessage["content"];
79
- }
80
- | {
81
- type: "thinking_delta";
82
- delta: string;
83
- content: AssistantMessage["content"];
84
- }
85
- | {
86
- type: "toolcall_start";
87
- toolCallId: string;
88
- name: string;
89
- args: Record<string, unknown>;
90
- content: AssistantMessage["content"];
91
- }
92
- | {
93
- type: "toolcall_delta";
94
- toolCallId: string;
95
- name: string;
96
- args: Record<string, unknown>;
97
- delta: string;
98
- content: AssistantMessage["content"];
99
- }
100
- | {
101
- type: "toolcall_end";
102
- toolCallId: string;
103
- name: string;
104
- args: Record<string, unknown>;
105
- content: AssistantMessage["content"];
106
- }
107
- | { type: "user_message"; message: UserMessage }
108
- | { type: "assistant_message"; message: AssistantMessage }
109
- | {
110
- type: "tool_start";
111
- toolCallId: string;
112
- name: string;
113
- args: Record<string, unknown>;
114
- }
115
- | {
116
- type: "tool_delta";
117
- toolCallId: string;
118
- name: string;
119
- result: ToolExecResult;
120
- }
121
- | {
122
- type: "tool_end";
123
- toolCallId: string;
124
- name: string;
125
- result: ToolExecResult;
126
- }
127
- | { type: "tool_result"; message: ToolResultMessage }
128
- | {
129
- type: "context_compacted";
130
- contextTokens: number;
131
- stats: ReturnType<typeof computeSessionStats>;
132
- }
133
- | { type: "done"; message: AssistantMessage }
134
- | { type: "error"; message: AssistantMessage }
135
- | { type: "aborted"; message: AssistantMessage };
136
-
137
- /** Options for the agent loop. */
138
- interface RunAgentOpts {
139
- /** Open database handle. */
140
- db: Database;
141
- /** Current session ID. */
142
- sessionId: string;
143
- /** Turn number for this agent loop (all messages share this turn). */
144
- turn: number;
145
- /** The model to stream with. */
146
- model: Model<string>;
147
- /** The assembled system prompt. */
148
- systemPrompt: string;
149
- /** Tool definitions sent to the model. */
150
- tools: Tool[];
151
- /** Tool name → handler map for executing tool calls. */
152
- toolHandlers: Map<string, ToolHandler>;
153
- /** Current message history (mutated in-place as messages are appended). */
154
- messages: Message[];
155
- /** Working directory for tool execution. */
156
- cwd: string;
157
- /** API key for the provider. */
158
- apiKey?: string;
159
- /** Reasoning effort level (e.g. "low", "medium", "high", "xhigh"). */
160
- effort?: ThinkingLevel;
161
- /** Abort signal for interruption. */
162
- signal?: AbortSignal;
163
- /** Callback for UI events. */
164
- onEvent?: (event: AgentEvent) => void;
165
- /** Dequeue the next queued steering message, if one is waiting. */
166
- takeQueuedUserMessage?: () => UserMessage | null;
167
- }
168
-
169
- /** Result of the agent loop. */
170
- interface AgentLoopResult {
171
- /** The updated message history. */
172
- messages: Message[];
173
- /** How the loop ended. */
174
- stopReason: "stop" | "length" | "error" | "aborted";
175
- }
176
-
177
- function cloneAssistantContent(
178
- content: AssistantMessage["content"],
179
- ): AssistantMessage["content"] {
180
- return content.map((block) => {
181
- if (block.type === "toolCall") {
182
- return {
183
- ...block,
184
- arguments: structuredClone(block.arguments),
185
- };
186
- }
187
- return { ...block };
188
- });
189
- }
190
-
191
- interface MergedAssistantBlock {
192
- block: AssistantMessage["content"][number];
193
- partialStep: number;
194
- finalStep: number;
195
- }
196
-
197
- function mergeAssistantBlocks(
198
- partialBlock: AssistantMessage["content"][number] | undefined,
199
- finalBlock: AssistantMessage["content"][number] | undefined,
200
- ): MergedAssistantBlock | null {
201
- if (!partialBlock && !finalBlock) {
202
- return null;
203
- }
204
- if (!partialBlock && finalBlock) {
205
- return { block: finalBlock, partialStep: 0, finalStep: 1 };
206
- }
207
- if (partialBlock && !finalBlock) {
208
- return { block: partialBlock, partialStep: 1, finalStep: 0 };
209
- }
210
- if (!partialBlock || !finalBlock) {
211
- return null;
212
- }
213
- if (partialBlock.type === finalBlock.type) {
214
- const shouldPreservePartialThinking =
215
- partialBlock.type === "thinking" &&
216
- finalBlock.type === "thinking" &&
217
- partialBlock.thinking &&
218
- !finalBlock.thinking;
219
- return {
220
- block: shouldPreservePartialThinking ? partialBlock : finalBlock,
221
- partialStep: 1,
222
- finalStep: 1,
223
- };
224
- }
225
- if (partialBlock.type === "thinking") {
226
- return { block: partialBlock, partialStep: 1, finalStep: 0 };
227
- }
228
- return { block: finalBlock, partialStep: 1, finalStep: 1 };
229
- }
230
-
231
- /** Merge streamed partial assistant content into the final message content. */
232
- function mergeAssistantContent(
233
- partialContent: AssistantMessage["content"],
234
- finalContent: AssistantMessage["content"],
235
- ): AssistantMessage["content"] {
236
- if (partialContent.length === 0) return finalContent;
237
- if (finalContent.length === 0) return partialContent;
238
-
239
- const merged: AssistantMessage["content"] = [];
240
- let partialIndex = 0;
241
- let finalIndex = 0;
242
-
243
- while (
244
- partialIndex < partialContent.length ||
245
- finalIndex < finalContent.length
246
- ) {
247
- const nextBlock = mergeAssistantBlocks(
248
- partialContent[partialIndex],
249
- finalContent[finalIndex],
250
- );
251
- if (!nextBlock) {
252
- break;
253
- }
254
- merged.push(nextBlock.block);
255
- partialIndex += nextBlock.partialStep;
256
- finalIndex += nextBlock.finalStep;
257
- }
258
-
259
- return merged;
260
- }
261
-
262
- /** Merge a final assistant message with the richest streamed partial content seen. */
263
- function mergeAssistantMessage(
264
- partialMessage: AssistantMessage,
265
- finalMessage: AssistantMessage,
266
- ): AssistantMessage {
267
- return {
268
- ...finalMessage,
269
- content: mergeAssistantContent(
270
- partialMessage.content,
271
- finalMessage.content,
272
- ),
273
- };
274
- }
275
-
276
- function toolErrorResult(name: string, error: unknown): ToolExecResult {
277
- const message = error instanceof Error ? error.message : String(error);
278
-
279
- return {
280
- content: [{ type: "text", text: `Tool ${name} failed: ${message}` }],
281
- isError: true,
282
- };
283
- }
284
-
285
- function unknownToolResult(name: string): ToolExecResult {
286
- return {
287
- content: [{ type: "text", text: `Unknown tool: ${name}` }],
288
- isError: true,
289
- };
290
- }
291
-
292
- const MAX_CONTEXT_IMAGE_BLOCKS = 12;
293
- const OMITTED_CONTEXT_IMAGE_PLACEHOLDER =
294
- "[Earlier image omitted from model context to stay within image-input limits.]";
295
- const UNSUPPORTED_CONTEXT_IMAGE_PLACEHOLDER =
296
- "[Image omitted from model context because the active model does not support image input.]";
297
-
298
- function sanitizeMessageContentForContext(
299
- message: Message,
300
- imageBudget: { remaining: number; supportsImages: boolean },
301
- ): Message {
302
- if (message.role === "assistant" || !Array.isArray(message.content)) {
303
- return message;
304
- }
305
-
306
- let changed = false;
307
- const sanitized: typeof message.content = [];
308
-
309
- for (const block of message.content) {
310
- if (block.type !== "image") {
311
- sanitized.push(block);
312
- continue;
313
- }
314
-
315
- const placeholder = imageBudget.supportsImages
316
- ? OMITTED_CONTEXT_IMAGE_PLACEHOLDER
317
- : UNSUPPORTED_CONTEXT_IMAGE_PLACEHOLDER;
318
-
319
- if (imageBudget.supportsImages && imageBudget.remaining > 0) {
320
- imageBudget.remaining -= 1;
321
- sanitized.push(block);
322
- continue;
323
- }
324
-
325
- changed = true;
326
- const lastBlock = sanitized.at(-1);
327
- if (lastBlock?.type === "text" && lastBlock.text === placeholder) {
328
- continue;
329
- }
330
-
331
- sanitized.push({ type: "text", text: placeholder });
332
- }
333
-
334
- if (!changed) {
335
- return message;
336
- }
337
-
338
- return { ...message, content: sanitized };
339
- }
340
-
341
- function sanitizeContextMessages(
342
- messages: Message[],
343
- modelSupportsImages: boolean,
344
- ): { messages: Message[]; changed: boolean } {
345
- const imageBudget = {
346
- remaining: modelSupportsImages ? MAX_CONTEXT_IMAGE_BLOCKS : 0,
347
- supportsImages: modelSupportsImages,
348
- };
349
- const sanitized = new Array<Message>(messages.length);
350
- let changed = false;
351
-
352
- for (let index = messages.length - 1; index >= 0; index -= 1) {
353
- const message = sanitizeMessageContentForContext(
354
- messages[index]!,
355
- imageBudget,
356
- );
357
- if (message !== messages[index]) {
358
- changed = true;
359
- }
360
- sanitized[index] = message;
361
- }
362
-
363
- return { messages: sanitized, changed };
364
- }
365
-
366
- function computeRequestContextTokens(
367
- messages: Message[],
368
- modelSupportsImages: boolean,
369
- ): number {
370
- const sanitized = sanitizeContextMessages(messages, modelSupportsImages);
371
- return computeContextTokens(sanitized.messages, {
372
- ignoreAssistantUsage: sanitized.changed,
373
- });
374
- }
375
-
376
- function buildAgentContext(
377
- systemPrompt: string,
378
- messages: Message[],
379
- tools: Tool[],
380
- modelSupportsImages: boolean,
381
- ) {
382
- const contextMessages = sanitizeContextMessages(
383
- messages,
384
- modelSupportsImages,
385
- ).messages;
386
- return tools.length > 0
387
- ? { systemPrompt, messages: contextMessages, tools }
388
- : { systemPrompt, messages: contextMessages };
389
- }
390
-
391
- function buildStreamOptions(
392
- apiKey: string | undefined,
393
- effort: ThinkingLevel | undefined,
394
- signal: AbortSignal | undefined,
395
- ) {
396
- return {
397
- ...(apiKey ? { apiKey } : {}),
398
- ...(effort ? { reasoning: effort } : {}),
399
- ...(signal ? { signal } : {}),
400
- };
401
- }
402
-
403
- function stripSyntheticSystemMessageBlock(text: string): string {
404
- return text.replace(/\n?<system-message>[\s\S]*?<\/system-message>/g, "");
405
- }
406
-
407
- function describeUserContent(content: UserMessage["content"]): string | null {
408
- if (typeof content === "string") {
409
- return collapseWhitespaceToNull(stripSyntheticSystemMessageBlock(content));
410
- }
411
-
412
- const text = collapseWhitespaceToNull(joinTextBlocks(content));
413
- const imageCount = content.filter((block) => block.type === "image").length;
414
- const parts = [
415
- text,
416
- imageCount > 0
417
- ? `[${imageCount} image${imageCount === 1 ? "" : "s"} attached]`
418
- : null,
419
- ].filter(
420
- (part): part is string => typeof part === "string" && part.length > 0,
421
- );
422
-
423
- return parts.length > 0 ? parts.join("\n") : null;
424
- }
425
-
426
- function resolveCurrentUserRequest(userMessage: UserMessage): string {
427
- return (
428
- describeUserContent(userMessage.content) ?? DEFAULT_CURRENT_USER_REQUEST
429
- );
430
- }
431
-
432
- function isSystemReminderMessage(message: Message): boolean {
433
- return (
434
- message.role === "user" &&
435
- typeof message.content === "string" &&
436
- message.content.startsWith("<system_reminder>")
437
- );
438
- }
439
-
440
- function isCompactionSummaryMessage(message: Message): boolean {
441
- return (
442
- message.role === "user" &&
443
- typeof message.content === "string" &&
444
- message.content.includes("<system-message>") &&
445
- message.content.includes(COMPACTION_RECOVERY_PREFIX)
446
- );
447
- }
448
-
449
- function extractCurrentUserRequest(messages: readonly Message[]): string {
450
- for (let index = messages.length - 1; index >= 0; index -= 1) {
451
- const message = messages[index];
2
+ type AssistantMessage,
3
+ type Context,
4
+ type ImageContent,
5
+ type Message,
6
+ streamSimple,
7
+ type TextContent,
8
+ type ToolCall,
9
+ type ToolResultMessage,
10
+ } from "@mariozechner/pi-ai";
11
+ import { getApiKey } from "./oauth";
12
+ import type {
13
+ AgentContex,
14
+ AgentEvent,
15
+ AgentToolEvent,
16
+ ToolAndRunner,
17
+ } from "./types";
18
+
19
+ // ### JetBrains Junie: Observation Masking
20
+ // Published research found that **simply hiding old tool outputs** matched the quality of full LLM summarization with **zero extra compute**:
21
+ // https://blog.jetbrains.com/research/2025/12/efficient-context-management/
22
+ export function compactContext(messages: Message[]) {
23
+ // TODO: Preserve SKILL.md contents, and exclude them from compaction.
24
+ // Problem: How do we know? we need to check each message result againt it's arguments
25
+ // and then find if by chance it has a read skill command... This is a mess. We we.
26
+ // A better way would be to first add a read(path, lines, offset). And then we know
27
+ // which files are read using it, and can match by path if it's a SKILL.md ending.
28
+ const KEEP_OBSERVATIONS = 10;
29
+
30
+ const toolResultIndices: number[] = [];
31
+ for (let i = 0; i < messages.length; i++) {
32
+ if (messages[i].role !== "toolResult") continue;
33
+ const content = (messages[i] as ToolResultMessage).content;
452
34
  if (
453
- !message ||
454
- message.role !== "user" ||
455
- isSystemReminderMessage(message) ||
456
- isCompactionSummaryMessage(message)
35
+ content.length === 1 &&
36
+ content[0].type === "text" &&
37
+ content[0].text.startsWith("Old environment output:")
457
38
  ) {
458
39
  continue;
459
40
  }
460
-
461
- const content = describeUserContent(message.content);
462
- if (content) {
463
- return content;
464
- }
465
- }
466
-
467
- return DEFAULT_CURRENT_USER_REQUEST;
468
- }
469
-
470
- function createCompactionInstructionMessage(
471
- currentUserRequest: string,
472
- ): UserMessage {
473
- return {
474
- role: "user",
475
- content: [
476
- "Current user request:",
477
- currentUserRequest,
478
- "",
479
- "Summarize the earlier conversation messages above so the coding agent can continue the current request after those messages are removed from active context.",
480
- ].join("\n"),
481
- timestamp: Date.now(),
482
- };
483
- }
484
-
485
- function extractAssistantTextContent(message: AssistantMessage): string | null {
486
- const text = message.content
487
- .flatMap((block) => {
488
- return block.type === "text" ? [block.text] : [];
489
- })
490
- .join("\n")
491
- .trim();
492
- return text.length > 0 ? text : null;
493
- }
494
-
495
- function buildCompactionSummaryMessage(
496
- summaryText: string,
497
- dbPath: string,
498
- sessionId: string,
499
- ): UserMessage {
500
- return {
501
- role: "user",
502
- content: [
503
- summaryText,
504
- "",
505
- "<system-message>",
506
- `${COMPACTION_RECOVERY_PREFIX} If you need the exact originals again, read them from the SQLite database at ${dbPath} for session ${sessionId}.`,
507
- "</system-message>",
508
- ].join("\n"),
509
- timestamp: Date.now(),
510
- };
511
- }
512
-
513
- function computeCompactionMessageCount(messageCount: number): number {
514
- if (messageCount <= 1) {
515
- return 0;
516
- }
517
-
518
- return Math.max(
519
- 1,
520
- Math.min(
521
- messageCount - 1,
522
- Math.floor(messageCount * CONTEXT_COMPACTION_FRACTION),
523
- ),
524
- );
525
- }
526
-
527
- function advanceCompleteContextSpan(
528
- messages: readonly Message[],
529
- startIndex: number,
530
- ): number | null {
531
- const message = messages[startIndex];
532
- if (!message) {
533
- return null;
534
- }
535
- if (message.role === "toolResult") {
536
- return null;
537
- }
538
- if (message.role !== "assistant") {
539
- return startIndex + 1;
540
- }
541
-
542
- const toolCalls = getAssistantToolCalls(message);
543
- if (toolCalls.length === 0) {
544
- return startIndex + 1;
545
- }
546
-
547
- let cursor = startIndex + 1;
548
- const pendingToolCallIds = new Set(toolCalls.map((toolCall) => toolCall.id));
549
- while (pendingToolCallIds.size > 0) {
550
- const nextMessage = messages[cursor];
551
- if (!nextMessage || nextMessage.role !== "toolResult") {
552
- return null;
553
- }
554
- if (!pendingToolCallIds.delete(nextMessage.toolCallId)) {
555
- return null;
556
- }
557
- cursor += 1;
558
- }
559
-
560
- return cursor;
561
- }
562
-
563
- function collectCompactionBoundaryCounts(
564
- messages: readonly Message[],
565
- ): number[] {
566
- const boundaries: number[] = [];
567
- let index = 0;
568
-
569
- while (index < messages.length) {
570
- const nextIndex = advanceCompleteContextSpan(messages, index);
571
- if (nextIndex === null) {
572
- return [];
573
- }
574
- index = nextIndex;
575
- if (index < messages.length) {
576
- boundaries.push(index);
577
- }
41
+ toolResultIndices.push(i);
578
42
  }
579
43
 
580
- return boundaries;
581
- }
44
+ if (toolResultIndices.length <= KEEP_OBSERVATIONS) return;
582
45
 
583
- function computeSafeCompactionMessageCount(
584
- messages: readonly Message[],
585
- ): number {
586
- const targetCount = computeCompactionMessageCount(messages.length);
587
- if (targetCount === 0) {
588
- return 0;
589
- }
590
-
591
- const safeBoundaryCounts = collectCompactionBoundaryCounts(messages);
592
- if (safeBoundaryCounts.length === 0) {
593
- return 0;
594
- }
595
-
596
- let nearestLowerBoundary = 0;
597
- for (const boundaryCount of safeBoundaryCounts) {
598
- if (boundaryCount === targetCount) {
599
- return boundaryCount;
46
+ for (let i = 0; i < toolResultIndices.length - KEEP_OBSERVATIONS; i++) {
47
+ const idx = toolResultIndices[i];
48
+ const msg = messages[idx] as ToolResultMessage;
49
+ let lines = 0;
50
+ let images = 0;
51
+ for (const c of msg.content) {
52
+ if (c.type === "text") {
53
+ lines += c.text.split(/\r?\n/).length;
54
+ } else if (c.type === "image") {
55
+ images++;
56
+ }
600
57
  }
601
- if (boundaryCount < targetCount) {
602
- nearestLowerBoundary = boundaryCount;
603
- continue;
58
+ let text = `Old environment output: (${lines} lines omitted)`;
59
+ if (images > 0) {
60
+ text += ` (${images} image${images > 1 ? "s" : ""} omitted)`;
604
61
  }
605
- return nearestLowerBoundary > 0 ? nearestLowerBoundary : boundaryCount;
62
+ msg.content = [{ type: "text", text }];
606
63
  }
607
-
608
- return nearestLowerBoundary;
609
64
  }
610
65
 
611
- interface CompactionSummaryResult {
612
- /** Replacement summary text for the compacted context prefix. */
613
- text: string;
614
- /** Usage consumed by the hidden summarization request. */
615
- usage: AssistantMessage["usage"];
616
- }
617
-
618
- async function summarizeCompactionChunk(
619
- messages: Message[],
620
- currentUserRequest: string,
621
- opts: Pick<RunAgentOpts, "model" | "apiKey" | "effort" | "signal">,
622
- ): Promise<CompactionSummaryResult | null> {
623
- const summaryMessage = await streamAssistantMessage({
624
- model: opts.model,
625
- systemPrompt: COMPACTION_SUMMARY_SYSTEM_PROMPT,
626
- tools: [],
627
- messages: [
628
- ...messages,
629
- createCompactionInstructionMessage(currentUserRequest),
630
- ],
631
- apiKey: opts.apiKey,
632
- effort: opts.effort,
633
- signal: opts.signal,
634
- });
635
-
636
- if (
637
- summaryMessage.stopReason === "error" ||
638
- summaryMessage.stopReason === "aborted"
639
- ) {
640
- return null;
641
- }
642
-
643
- const text = extractAssistantTextContent(summaryMessage);
644
- if (!text) {
645
- return null;
646
- }
647
-
648
- return {
649
- text,
650
- usage: summaryMessage.usage,
66
+ export async function* streamAgent(
67
+ agentCtx: AgentContex,
68
+ ): AsyncGenerator<AgentEvent> {
69
+ const llmCtx: Context = {
70
+ systemPrompt: agentCtx.systemPrompt,
71
+ tools: agentCtx.tools.map((t) => t.tool),
72
+ messages: agentCtx.messages,
651
73
  };
652
- }
653
74
 
654
- async function maybeCompactContext(
655
- messages: Message[],
656
- pendingContextMessages: readonly Message[],
657
- currentUserRequest: string,
658
- opts: Pick<
659
- RunAgentOpts,
660
- "db" | "sessionId" | "model" | "apiKey" | "effort" | "signal" | "onEvent"
661
- >,
662
- ): Promise<Message[]> {
663
- let compactedMessages = messages;
75
+ // Important for refreshing tokens.
76
+ const apiKey = await getApiKey(agentCtx.options);
664
77
 
665
- while (!opts.signal?.aborted) {
666
- const contextTokens = computeRequestContextTokens(
667
- [...compactedMessages, ...pendingContextMessages],
668
- opts.model.input.includes("image"),
669
- );
670
- if (
671
- contextTokens / opts.model.contextWindow <
672
- CONTEXT_COMPACTION_THRESHOLD
673
- ) {
674
- return compactedMessages;
675
- }
676
-
677
- const candidateRows = loadUncompactedModelMessages(opts.db, opts.sessionId);
678
- const compactCount = computeSafeCompactionMessageCount(
679
- candidateRows.map((row) => row.message),
680
- );
681
- if (compactCount === 0) {
682
- return compactedMessages;
683
- }
684
-
685
- const summaryResult = await summarizeCompactionChunk(
686
- candidateRows.slice(0, compactCount).map((row) => row.message),
687
- currentUserRequest,
688
- opts,
689
- );
690
- if (!summaryResult) {
691
- return compactedMessages;
692
- }
693
-
694
- appendSessionCompaction(
695
- opts.db,
696
- opts.sessionId,
697
- candidateRows[compactCount - 1]!.id,
698
- buildCompactionSummaryMessage(
699
- summaryResult.text,
700
- opts.db.filename,
701
- opts.sessionId,
702
- ),
703
- summaryResult.usage,
704
- );
705
-
706
- compactedMessages = loadCompactedModelMessages(opts.db, opts.sessionId);
707
- opts.onEvent?.({
708
- type: "context_compacted",
709
- contextTokens: computeRequestContextTokens(
710
- [...compactedMessages, ...pendingContextMessages],
711
- opts.model.input.includes("image"),
712
- ),
713
- stats: computeSessionStats(opts.db, opts.sessionId),
78
+ // Main agent loop, continues until llm sends a response other than toolCall or has no tool calls.
79
+ while (true) {
80
+ const s = streamSimple(agentCtx.options.model, llmCtx, {
81
+ reasoning: agentCtx.options.effort,
82
+ signal: agentCtx.signal,
83
+ apiKey,
714
84
  });
715
- }
716
-
717
- return compactedMessages;
718
- }
719
-
720
- interface StreamedToolCallEventPayload {
721
- toolCallId: string;
722
- name: string;
723
- args: Record<string, unknown>;
724
- content: AssistantMessage["content"];
725
- }
726
-
727
- function buildStreamedToolCallEventPayload(
728
- partial: AssistantMessage,
729
- contentIndex: number,
730
- toolCall?: ToolCall,
731
- ): StreamedToolCallEventPayload | null {
732
- const block = toolCall ?? partial.content[contentIndex];
733
- if (!block || block.type !== "toolCall") {
734
- return null;
735
- }
736
85
 
737
- return {
738
- toolCallId: block.id,
739
- name: block.name,
740
- args: structuredClone(block.arguments),
741
- content: cloneAssistantContent(partial.content),
742
- };
743
- }
744
-
745
- function handleAssistantStreamEvent(
746
- event: AssistantMessageEvent,
747
- onEvent: RunAgentOpts["onEvent"],
748
- ): AssistantMessage | null {
749
- switch (event.type) {
750
- case "text_delta":
751
- onEvent?.({
752
- type: "text_delta",
753
- delta: event.delta,
754
- content: cloneAssistantContent(event.partial.content),
755
- });
756
- return null;
757
- case "thinking_delta":
758
- onEvent?.({
759
- type: "thinking_delta",
760
- delta: event.delta,
761
- content: cloneAssistantContent(event.partial.content),
762
- });
763
- return null;
764
- case "toolcall_start": {
765
- const payload = buildStreamedToolCallEventPayload(
766
- event.partial,
767
- event.contentIndex,
768
- );
769
- if (payload) {
770
- onEvent?.({
771
- type: "toolcall_start",
772
- ...payload,
773
- });
86
+ let partial: AssistantMessage | null = null;
87
+ let added = false;
88
+
89
+ for await (const e of s) {
90
+ switch (e.type) {
91
+ case "start":
92
+ partial = e.partial;
93
+ llmCtx.messages.push(e.partial);
94
+ added = true;
95
+ yield { type: "message_start", partial };
96
+ break;
97
+
98
+ case "text_start":
99
+ case "text_delta":
100
+ case "text_end":
101
+ case "thinking_start":
102
+ case "thinking_delta":
103
+ case "thinking_end":
104
+ case "toolcall_start":
105
+ case "toolcall_delta":
106
+ case "toolcall_end":
107
+ if (partial) {
108
+ partial = e.partial;
109
+ llmCtx.messages[llmCtx.messages.length - 1] = partial;
110
+ yield { type: "message_update", partial };
111
+ }
112
+ break;
113
+
114
+ case "error": {
115
+ const finalMessage = await s.result();
116
+ if (added) {
117
+ llmCtx.messages[llmCtx.messages.length - 1] = finalMessage;
118
+ } else {
119
+ llmCtx.messages.push(finalMessage);
120
+ yield { type: "message_start", partial: { ...finalMessage } };
121
+ }
122
+
123
+ yield { type: "message_end", message: finalMessage };
124
+ return;
125
+ }
774
126
  }
775
- return null;
776
- }
777
- case "toolcall_delta": {
778
- const payload = buildStreamedToolCallEventPayload(
779
- event.partial,
780
- event.contentIndex,
781
- );
782
- if (payload) {
783
- onEvent?.({
784
- type: "toolcall_delta",
785
- delta: event.delta,
786
- ...payload,
787
- });
788
- }
789
- return null;
790
- }
791
- case "toolcall_end": {
792
- const payload = buildStreamedToolCallEventPayload(
793
- event.partial,
794
- event.contentIndex,
795
- event.toolCall,
796
- );
797
- if (payload) {
798
- onEvent?.({
799
- type: "toolcall_end",
800
- ...payload,
801
- });
802
- }
803
- return null;
804
- }
805
- case "done":
806
- return event.message;
807
- case "error":
808
- return event.error;
809
- case "start":
810
- case "text_start":
811
- case "text_end":
812
- case "thinking_start":
813
- case "thinking_end":
814
- return null;
815
- }
816
- }
817
-
818
- function buildIncompleteAssistantMessage(
819
- opts: Pick<RunAgentOpts, "model" | "signal">,
820
- partialAssistantMessage?: AssistantMessage,
821
- ): AssistantMessage {
822
- const stopReason = opts.signal?.aborted ? "aborted" : "error";
823
- const errorMessage =
824
- stopReason === "aborted"
825
- ? "Request was aborted"
826
- : "Stream ended without a final assistant message";
827
-
828
- return {
829
- role: "assistant",
830
- content: partialAssistantMessage
831
- ? cloneAssistantContent(partialAssistantMessage.content)
832
- : [],
833
- api: partialAssistantMessage?.api ?? opts.model.api,
834
- provider: partialAssistantMessage?.provider ?? opts.model.provider,
835
- model: partialAssistantMessage?.model ?? opts.model.id,
836
- usage: partialAssistantMessage?.usage ?? {
837
- input: 0,
838
- output: 0,
839
- cacheRead: 0,
840
- cacheWrite: 0,
841
- totalTokens: 0,
842
- cost: {
843
- input: 0,
844
- output: 0,
845
- cacheRead: 0,
846
- cacheWrite: 0,
847
- total: 0,
848
- },
849
- },
850
- stopReason,
851
- errorMessage,
852
- timestamp: Date.now(),
853
- };
854
- }
855
-
856
- async function resolveStreamResultSoon(
857
- streamResult: Promise<AssistantMessage>,
858
- ): Promise<AssistantMessage | undefined> {
859
- const pending = Symbol("pending");
860
- const result = await Promise.race([
861
- streamResult,
862
- new Promise<typeof pending>((resolve) => {
863
- setTimeout(() => resolve(pending), 0);
864
- }),
865
- ]);
866
- return result === pending ? undefined : result;
867
- }
868
-
869
- async function streamAssistantMessage(
870
- opts: Pick<
871
- RunAgentOpts,
872
- | "model"
873
- | "systemPrompt"
874
- | "tools"
875
- | "messages"
876
- | "apiKey"
877
- | "effort"
878
- | "signal"
879
- | "onEvent"
880
- >,
881
- ): Promise<AssistantMessage> {
882
- const eventStream = streamSimple(
883
- opts.model,
884
- buildAgentContext(
885
- opts.systemPrompt,
886
- opts.messages,
887
- opts.tools,
888
- opts.model.input.includes("image"),
889
- ),
890
- buildStreamOptions(opts.apiKey, opts.effort, opts.signal),
891
- );
892
- const streamResult = eventStream.result();
893
- let settledStreamResult: AssistantMessage | undefined;
894
- void streamResult.then((message) => {
895
- settledStreamResult = message;
896
- });
897
-
898
- let assistantMessage: AssistantMessage | undefined;
899
- let partialAssistantMessage: AssistantMessage | undefined;
900
-
901
- for await (const event of eventStream) {
902
- if ("partial" in event) {
903
- partialAssistantMessage = event.partial;
904
127
  }
905
128
 
906
- assistantMessage =
907
- handleAssistantStreamEvent(event, opts.onEvent) ?? assistantMessage;
908
- }
909
-
910
- // `end(result)` resolves the final result without emitting a terminal event.
911
- // Some wrappers settle that promise on the next task, so give it one more
912
- // turn before treating the stream as incomplete.
913
- await Promise.resolve();
914
- if (!assistantMessage && !settledStreamResult) {
915
- settledStreamResult = await resolveStreamResultSoon(streamResult);
916
- }
917
-
918
- const finalAssistantMessage =
919
- assistantMessage ??
920
- settledStreamResult ??
921
- buildIncompleteAssistantMessage(opts, partialAssistantMessage);
922
- if (!partialAssistantMessage) {
923
- return finalAssistantMessage;
924
- }
925
- return mergeAssistantMessage(partialAssistantMessage, finalAssistantMessage);
926
- }
927
-
928
- interface AppendedUserTurn {
929
- /** Turn number assigned to the appended user message. */
930
- turn: number;
931
- /** Actual current user request text to keep anchoring future compactions. */
932
- currentUserRequest: string;
933
- }
934
-
935
- function appendUserMessage(
936
- db: Database,
937
- sessionId: string,
938
- messages: Message[],
939
- userMessage: UserMessage,
940
- onEvent: RunAgentOpts["onEvent"],
941
- ): AppendedUserTurn {
942
- messages.push(userMessage);
943
- const turn = appendMessage(db, sessionId, userMessage);
944
- onEvent?.({ type: "user_message", message: userMessage });
945
- return {
946
- turn,
947
- currentUserRequest: resolveCurrentUserRequest(userMessage),
948
- };
949
- }
950
-
951
- function appendAssistantMessage(
952
- db: Database,
953
- sessionId: string,
954
- messages: Message[],
955
- assistantMessage: AssistantMessage,
956
- turn: number,
957
- onEvent: RunAgentOpts["onEvent"],
958
- ): void {
959
- messages.push(assistantMessage);
960
- appendMessage(db, sessionId, assistantMessage, turn);
961
- onEvent?.({ type: "assistant_message", message: assistantMessage });
962
- }
963
-
964
- function resolveLoopStopReason(
965
- assistantMessage: AssistantMessage,
966
- messages: Message[],
967
- onEvent: RunAgentOpts["onEvent"],
968
- ): AgentLoopResult | null {
969
- if (
970
- assistantMessage.stopReason === "error" ||
971
- assistantMessage.stopReason === "aborted"
972
- ) {
973
- const eventType =
974
- assistantMessage.stopReason === "error" ? "error" : "aborted";
975
- onEvent?.({ type: eventType, message: assistantMessage });
976
- return { messages, stopReason: assistantMessage.stopReason };
977
- }
978
-
979
- return null;
980
- }
981
-
982
- function consumeQueuedUserMessage(
983
- db: Database,
984
- sessionId: string,
985
- messages: Message[],
986
- takeQueuedUserMessage: RunAgentOpts["takeQueuedUserMessage"],
987
- onEvent: RunAgentOpts["onEvent"],
988
- ): AppendedUserTurn | null {
989
- const queuedUserMessage = takeQueuedUserMessage?.();
990
- if (!queuedUserMessage) {
991
- return null;
992
- }
993
-
994
- return appendUserMessage(db, sessionId, messages, queuedUserMessage, onEvent);
995
- }
996
-
997
- function getAssistantToolCalls(message: AssistantMessage): ToolCall[] {
998
- return message.content.filter((content): content is ToolCall => {
999
- return content.type === "toolCall";
1000
- });
1001
- }
1002
-
1003
- async function executeToolCall(
1004
- toolCall: ToolCall,
1005
- opts: Pick<RunAgentOpts, "toolHandlers" | "cwd" | "signal" | "onEvent">,
1006
- ): Promise<ToolExecResult> {
1007
- const handler = opts.toolHandlers.get(toolCall.name);
1008
- if (!handler) {
1009
- return unknownToolResult(toolCall.name);
1010
- }
1011
-
1012
- opts.onEvent?.({
1013
- type: "tool_start",
1014
- toolCallId: toolCall.id,
1015
- name: toolCall.name,
1016
- args: toolCall.arguments,
1017
- });
1018
-
1019
- let result: ToolExecResult;
1020
- try {
1021
- if (opts.signal?.aborted) {
1022
- result = toolErrorResult(
1023
- toolCall.name,
1024
- new Error("This operation was aborted"),
1025
- );
129
+ const message = await s.result();
130
+ if (added) {
131
+ llmCtx.messages[llmCtx.messages.length - 1] = message;
1026
132
  } else {
1027
- result = await handler(
1028
- toolCall.arguments,
1029
- opts.cwd,
1030
- opts.signal,
1031
- (partial) => {
1032
- opts.onEvent?.({
1033
- type: "tool_delta",
1034
- toolCallId: toolCall.id,
1035
- name: toolCall.name,
1036
- result: partial,
1037
- });
1038
- },
1039
- );
133
+ llmCtx.messages.push(message);
134
+ yield { type: "message_start", partial: { ...message } };
1040
135
  }
1041
- } catch (error) {
1042
- result = toolErrorResult(toolCall.name, error);
1043
- }
136
+ yield { type: "message_end", message };
1044
137
 
1045
- opts.onEvent?.({
1046
- type: "tool_end",
1047
- toolCallId: toolCall.id,
1048
- name: toolCall.name,
1049
- result,
1050
- });
1051
- return result;
1052
- }
1053
-
1054
- function appendToolResultMessage(
1055
- db: Database,
1056
- sessionId: string,
1057
- messages: Message[],
1058
- toolCall: ToolCall,
1059
- result: ToolExecResult,
1060
- turn: number,
1061
- onEvent: RunAgentOpts["onEvent"],
1062
- ): void {
1063
- const toolResultMessage: ToolResultMessage = {
1064
- role: "toolResult",
1065
- toolCallId: toolCall.id,
1066
- toolName: toolCall.name,
1067
- content: result.content,
1068
- ...(result.details !== undefined ? { details: result.details } : {}),
1069
- isError: result.isError,
1070
- timestamp: Date.now(),
1071
- };
1072
-
1073
- messages.push(toolResultMessage);
1074
- appendMessage(db, sessionId, toolResultMessage, turn);
1075
- onEvent?.({ type: "tool_result", message: toolResultMessage });
1076
- }
138
+ const toolCalls = message.content.filter((c) => c.type === "toolCall");
1077
139
 
1078
- function getIncompleteTodos(
1079
- db: Database,
1080
- sessionId: string,
1081
- messages: readonly Message[],
1082
- ) {
1083
- return getTodoItems([...loadMessages(db, sessionId), ...messages]).filter(
1084
- (todo) => todo.status !== "completed",
1085
- );
1086
- }
140
+ // Stop on errors or no tools to call.
141
+ if (message.stopReason !== "toolUse" || toolCalls.length === 0) {
142
+ break;
143
+ }
1087
144
 
1088
- function getTodoReminderSignature(
1089
- todos: ReturnType<typeof getIncompleteTodos>,
1090
- ): string {
1091
- return JSON.stringify(
1092
- [...todos].sort((left, right) => {
1093
- const contentOrder = left.content.localeCompare(right.content);
1094
- if (contentOrder !== 0) {
1095
- return contentOrder;
145
+ const seenToolIds = new Map<string, number>();
146
+ if (toolCalls.length > 0) {
147
+ const ts = toolRunner(toolCalls, agentCtx.tools, agentCtx.signal);
148
+
149
+ for await (const e of ts) {
150
+ if (e.type === "tool_update") {
151
+ // Update contex with update or add new.
152
+ const existing = seenToolIds.get(e.partial.toolCallId);
153
+ if (existing && existing >= 0) {
154
+ const m = llmCtx.messages[existing];
155
+ if (m.role === "toolResult") {
156
+ llmCtx.messages[existing] = {
157
+ ...m,
158
+ content: [...m.content, ...e.partial.content],
159
+ isError: e.partial.isError,
160
+ };
161
+ }
162
+
163
+ yield {
164
+ type: "tool_message_update",
165
+ partial: e.partial,
166
+ };
167
+ } else {
168
+ llmCtx.messages.push(e.partial);
169
+ seenToolIds.set(e.partial.toolCallId, llmCtx.messages.length - 1);
170
+
171
+ yield {
172
+ type: "tool_message_start",
173
+ partial: e.partial,
174
+ };
175
+ }
176
+ } else if (e.type === "tool_result") {
177
+ // Update context with full message and yield
178
+ const idx = llmCtx.messages.findIndex(
179
+ (m) =>
180
+ m.role === "toolResult" && m.toolCallId === e.message.toolCallId,
181
+ );
182
+ if (idx >= 0) {
183
+ llmCtx.messages[idx] = e.message;
184
+ } else {
185
+ llmCtx.messages.push(e.message);
186
+ }
187
+
188
+ yield {
189
+ type: "tool_message_end",
190
+ message: e.message,
191
+ };
192
+ }
1096
193
  }
1097
- return left.status.localeCompare(right.status);
1098
- }),
1099
- );
1100
- }
1101
-
1102
- function wrapSystemReminder(content: string): string {
1103
- return `<system_reminder>\n${content}\n</system_reminder>`;
1104
- }
1105
-
1106
- function createTodoReminderMessage(
1107
- db: Database,
1108
- sessionId: string,
1109
- messages: readonly Message[],
1110
- remindedTodoSignatures: Set<string>,
1111
- ): UserMessage | null {
1112
- const incompleteTodos = getIncompleteTodos(db, sessionId, messages);
1113
- if (incompleteTodos.length === 0) {
1114
- return null;
1115
- }
1116
-
1117
- const signature = getTodoReminderSignature(incompleteTodos);
1118
- if (remindedTodoSignatures.has(signature)) {
1119
- return null;
1120
- }
1121
- remindedTodoSignatures.add(signature);
1122
-
1123
- const lines = [
1124
- "You have pending todo items that must be completed before finishing the task:",
1125
- "",
1126
- ...incompleteTodos.map((todo) => {
1127
- const status = todo.status === "in_progress" ? "IN_PROGRESS" : "PENDING";
1128
- return `- [${status}] ${todo.content}`;
1129
- }),
1130
- "",
1131
- "Please complete all pending items before finishing.",
1132
- ];
1133
-
1134
- return {
1135
- role: "user",
1136
- content: wrapSystemReminder(lines.join("\n")),
1137
- timestamp: Date.now(),
1138
- };
1139
- }
1140
-
1141
- interface StoppedAssistantResolution {
1142
- /** Next turn number when a queued steering message was consumed. */
1143
- nextTurn: number | null;
1144
- /** Updated current user request when a queued steering message starts a new turn. */
1145
- nextCurrentUserRequest: string | null;
1146
- /** Ephemeral context messages to include on the next model request. */
1147
- pendingContextMessages: Message[];
1148
- /** Final loop result when the turn should stop immediately. */
1149
- finalResult: AgentLoopResult | null;
1150
- }
1151
-
1152
- function resolveStoppedAssistantMessage(
1153
- assistantMessage: AssistantMessage,
1154
- stopReason: "stop" | "length",
1155
- opts: Pick<
1156
- RunAgentOpts,
1157
- "db" | "sessionId" | "messages" | "takeQueuedUserMessage" | "onEvent"
1158
- >,
1159
- remindedTodoSignatures: Set<string>,
1160
- ): StoppedAssistantResolution {
1161
- const queuedTurn = consumeQueuedUserMessage(
1162
- opts.db,
1163
- opts.sessionId,
1164
- opts.messages,
1165
- opts.takeQueuedUserMessage,
1166
- opts.onEvent,
1167
- );
1168
- if (queuedTurn !== null) {
1169
- return {
1170
- nextTurn: queuedTurn.turn,
1171
- nextCurrentUserRequest: queuedTurn.currentUserRequest,
1172
- pendingContextMessages: [],
1173
- finalResult: null,
1174
- };
1175
- }
1176
-
1177
- const todoReminder = createTodoReminderMessage(
1178
- opts.db,
1179
- opts.sessionId,
1180
- opts.messages,
1181
- remindedTodoSignatures,
1182
- );
1183
- if (todoReminder) {
1184
- return {
1185
- nextTurn: null,
1186
- nextCurrentUserRequest: null,
1187
- pendingContextMessages: [todoReminder],
1188
- finalResult: null,
1189
- };
1190
- }
1191
-
1192
- opts.onEvent?.({ type: "done", message: assistantMessage });
1193
- return {
1194
- nextTurn: null,
1195
- nextCurrentUserRequest: null,
1196
- pendingContextMessages: [],
1197
- finalResult: {
1198
- messages: opts.messages,
1199
- stopReason,
1200
- },
1201
- };
1202
- }
1203
-
1204
- async function executeAssistantToolCalls(
1205
- assistantMessage: AssistantMessage,
1206
- opts: Pick<
1207
- RunAgentOpts,
1208
- | "db"
1209
- | "sessionId"
1210
- | "messages"
1211
- | "toolHandlers"
1212
- | "cwd"
1213
- | "signal"
1214
- | "onEvent"
1215
- | "model"
1216
- >,
1217
- turn: number,
1218
- ): Promise<AgentLoopResult | null> {
1219
- for (const toolCall of getAssistantToolCalls(assistantMessage)) {
1220
- const result = await executeToolCall(toolCall, opts);
1221
- appendToolResultMessage(
1222
- opts.db,
1223
- opts.sessionId,
1224
- opts.messages,
1225
- toolCall,
1226
- result,
1227
- turn,
1228
- opts.onEvent,
1229
- );
1230
-
1231
- if (opts.signal?.aborted) {
1232
- opts.onEvent?.({
1233
- type: "aborted",
1234
- message: buildIncompleteAssistantMessage(
1235
- { model: opts.model, signal: opts.signal },
1236
- assistantMessage,
1237
- ),
1238
- });
1239
- return { messages: opts.messages, stopReason: "aborted" };
1240
194
  }
1241
195
  }
1242
-
1243
- return null;
1244
- }
1245
-
1246
- interface AgentIterationOutcome {
1247
- /** Final loop result when the run should stop immediately. */
1248
- finalResult: AgentLoopResult | null;
1249
- /** Next turn number when a queued steering message starts a new turn. */
1250
- nextTurn: number;
1251
- /** Current user request text that should anchor any future compactions. */
1252
- nextCurrentUserRequest: string;
1253
- /** Ephemeral context messages for the next model request. */
1254
- pendingContextMessages: Message[];
1255
- }
1256
-
1257
- async function resolveAgentIteration(
1258
- assistantMessage: AssistantMessage,
1259
- currentTurn: number,
1260
- currentUserRequest: string,
1261
- remindedTodoSignatures: Set<string>,
1262
- opts: Pick<
1263
- RunAgentOpts,
1264
- | "db"
1265
- | "sessionId"
1266
- | "messages"
1267
- | "toolHandlers"
1268
- | "cwd"
1269
- | "signal"
1270
- | "onEvent"
1271
- | "model"
1272
- | "takeQueuedUserMessage"
1273
- >,
1274
- ): Promise<AgentIterationOutcome> {
1275
- const stopResult = resolveLoopStopReason(
1276
- assistantMessage,
1277
- opts.messages,
1278
- opts.onEvent,
1279
- );
1280
- if (stopResult) {
1281
- return {
1282
- finalResult: stopResult,
1283
- nextTurn: currentTurn,
1284
- nextCurrentUserRequest: currentUserRequest,
1285
- pendingContextMessages: [],
1286
- };
1287
- }
1288
-
1289
- if (
1290
- assistantMessage.stopReason === "stop" ||
1291
- assistantMessage.stopReason === "length"
1292
- ) {
1293
- const stopResolution = resolveStoppedAssistantMessage(
1294
- assistantMessage,
1295
- assistantMessage.stopReason,
1296
- {
1297
- db: opts.db,
1298
- sessionId: opts.sessionId,
1299
- messages: opts.messages,
1300
- takeQueuedUserMessage: opts.takeQueuedUserMessage,
1301
- onEvent: opts.onEvent,
1302
- },
1303
- remindedTodoSignatures,
1304
- );
1305
- return {
1306
- finalResult: stopResolution.finalResult,
1307
- nextTurn: stopResolution.nextTurn ?? currentTurn,
1308
- nextCurrentUserRequest:
1309
- stopResolution.nextCurrentUserRequest ?? currentUserRequest,
1310
- pendingContextMessages: stopResolution.pendingContextMessages,
1311
- };
1312
- }
1313
-
1314
- const toolStopResult = await executeAssistantToolCalls(
1315
- assistantMessage,
1316
- opts,
1317
- currentTurn,
1318
- );
1319
- if (toolStopResult) {
1320
- return {
1321
- finalResult: toolStopResult,
1322
- nextTurn: currentTurn,
1323
- nextCurrentUserRequest: currentUserRequest,
1324
- pendingContextMessages: [],
1325
- };
1326
- }
1327
-
1328
- const queuedTurn = consumeQueuedUserMessage(
1329
- opts.db,
1330
- opts.sessionId,
1331
- opts.messages,
1332
- opts.takeQueuedUserMessage,
1333
- opts.onEvent,
1334
- );
1335
- return {
1336
- finalResult: null,
1337
- nextTurn: queuedTurn?.turn ?? currentTurn,
1338
- nextCurrentUserRequest:
1339
- queuedTurn?.currentUserRequest ?? currentUserRequest,
1340
- pendingContextMessages: [],
1341
- };
1342
196
  }
1343
197
 
1344
- // ---------------------------------------------------------------------------
1345
- // Agent loop
1346
- // ---------------------------------------------------------------------------
1347
-
1348
- /**
1349
- * Run the agent loop for a single turn.
1350
- *
1351
- * Streams an LLM response, executes any tool calls, appends all messages
1352
- * to the session (sharing the same turn number), and loops back when the
1353
- * model requests tool use. Returns when the model stops, hits a length
1354
- * limit, errors, or is aborted.
1355
- *
1356
- * @param opts - Agent loop options.
1357
- * @returns The loop result with updated messages and stop reason.
1358
- */
1359
- export async function runAgentLoop(
1360
- opts: RunAgentOpts,
1361
- ): Promise<AgentLoopResult> {
1362
- const {
1363
- db,
1364
- sessionId,
1365
- turn,
1366
- model,
1367
- messages: initialMessages,
1368
- signal,
1369
- onEvent,
1370
- toolHandlers,
1371
- cwd,
1372
- takeQueuedUserMessage,
1373
- } = opts;
1374
- let messages = initialMessages;
1375
- let currentTurn = turn;
1376
- let currentUserRequest = extractCurrentUserRequest(initialMessages);
1377
- let pendingContextMessages: Message[] = [];
1378
- const remindedTodoSignatures = new Set<string>();
1379
-
1380
- while (true) {
1381
- messages = await maybeCompactContext(
1382
- messages,
1383
- pendingContextMessages,
1384
- currentUserRequest,
1385
- {
1386
- db,
1387
- sessionId,
1388
- model,
1389
- apiKey: opts.apiKey,
1390
- effort: opts.effort,
1391
- signal,
1392
- onEvent,
1393
- },
1394
- );
1395
-
1396
- const assistantMessage = await streamAssistantMessage({
1397
- ...opts,
1398
- messages:
1399
- pendingContextMessages.length > 0
1400
- ? [...messages, ...pendingContextMessages]
1401
- : messages,
1402
- });
1403
- pendingContextMessages = [];
1404
- appendAssistantMessage(
1405
- db,
1406
- sessionId,
1407
- messages,
1408
- assistantMessage,
1409
- currentTurn,
1410
- onEvent,
1411
- );
1412
-
1413
- const iterationOutcome = await resolveAgentIteration(
1414
- assistantMessage,
1415
- currentTurn,
1416
- currentUserRequest,
1417
- remindedTodoSignatures,
1418
- {
1419
- db,
1420
- sessionId,
1421
- messages,
1422
- toolHandlers,
1423
- cwd,
1424
- signal,
1425
- onEvent,
1426
- model,
1427
- takeQueuedUserMessage,
1428
- },
1429
- );
1430
- if (iterationOutcome.finalResult) {
1431
- return iterationOutcome.finalResult;
198
+ // This should stay stateless, don't accumulate anything at this layer, just proxy
199
+ // wrapped runners events.
200
+ async function* toolRunner(
201
+ toolCalls: ToolCall[],
202
+ tools: ToolAndRunner[],
203
+ signal?: AbortSignal,
204
+ ): AsyncGenerator<AgentToolEvent> {
205
+ for (const call of toolCalls) {
206
+ const timestamp = Date.now();
207
+ const tool = tools.find((t) => t.tool.name === call.name);
208
+
209
+ if (!tool) {
210
+ yield {
211
+ type: "tool_result",
212
+ message: {
213
+ role: "toolResult",
214
+ toolCallId: call.id,
215
+ toolName: call.name,
216
+ content: [{ type: "text", text: "Error: Tool not found" }],
217
+ isError: true,
218
+ timestamp,
219
+ },
220
+ };
221
+ continue;
1432
222
  }
1433
223
 
1434
- currentTurn = iterationOutcome.nextTurn;
1435
- currentUserRequest = iterationOutcome.nextCurrentUserRequest;
1436
- pendingContextMessages = iterationOutcome.pendingContextMessages;
224
+ try {
225
+ for await (const e of tool.runner(call.arguments, signal)) {
226
+ // yield deltas for output, full output on result.
227
+ const content: TextContent = { type: "text", text: e.text };
228
+
229
+ if (e.type === "output") {
230
+ // handle deltas
231
+ yield {
232
+ type: "tool_update",
233
+ partial: {
234
+ role: "toolResult",
235
+ toolCallId: call.id,
236
+ toolName: call.name,
237
+ content: [content],
238
+ isError: false,
239
+ timestamp,
240
+ },
241
+ };
242
+ } else if (e.type === "result") {
243
+ // handle final message, and check for images
244
+ let img: ImageContent | null = null;
245
+ if (e.image) {
246
+ img = { ...e.image, type: "image" };
247
+ }
248
+
249
+ yield {
250
+ type: "tool_result",
251
+ message: {
252
+ role: "toolResult",
253
+ toolCallId: call.id,
254
+ toolName: call.name,
255
+ content: img ? [content, img] : [content],
256
+ isError: false,
257
+ timestamp,
258
+ },
259
+ };
260
+ }
261
+ }
262
+ } catch (err) {
263
+ // We don't validate arguments, so it's better to show the errors.
264
+ const error = err instanceof Error ? err.message : "Unknown error";
265
+ yield {
266
+ type: "tool_result",
267
+ message: {
268
+ role: "toolResult",
269
+ toolCallId: call.id,
270
+ toolName: call.name,
271
+ content: [{ type: "text", text: `Error: ${error}` }],
272
+ isError: true,
273
+ timestamp,
274
+ },
275
+ };
276
+ }
1437
277
  }
1438
278
  }