mini-coder 0.5.13 → 0.6.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.
Files changed (67) hide show
  1. package/README.md +25 -108
  2. package/bin/mc.ts +8 -11
  3. package/bun.lock +79 -269
  4. package/package.json +17 -22
  5. package/src/agent.ts +242 -915
  6. package/src/args.ts +289 -0
  7. package/src/headless.ts +43 -385
  8. package/src/index.ts +29 -836
  9. package/src/oauth.ts +117 -0
  10. package/src/prompt.ts +227 -276
  11. package/src/session.ts +57 -961
  12. package/src/shared.ts +117 -38
  13. package/src/tool-bash.ts +110 -0
  14. package/src/tool-edit.ts +133 -0
  15. package/src/tool-task.ts +114 -0
  16. package/src/tui-components.ts +150 -0
  17. package/src/tui-conversation.ts +262 -0
  18. package/src/tui-editor.ts +29 -0
  19. package/src/tui-overlay.ts +403 -0
  20. package/src/tui.ts +236 -0
  21. package/src/types.ts +160 -0
  22. package/tsconfig.json +17 -0
  23. package/BENCHMARK.md +0 -107
  24. package/LICENSE +0 -9
  25. package/PROGRESS.md +0 -4
  26. package/assets/icon-1-minimal.svg +0 -31
  27. package/assets/icon-2-dark-terminal.svg +0 -48
  28. package/assets/icon-3-gradient-modern.svg +0 -45
  29. package/assets/icon-4-filled-bold.svg +0 -54
  30. package/assets/icon-5-community-badge.svg +0 -63
  31. package/assets/mc-claude-smart.png +0 -0
  32. package/assets/mc-gpt-smart.png +0 -0
  33. package/assets/preview-0-5-0.png +0 -0
  34. package/assets/preview.gif +0 -0
  35. package/benchmark-baseline.sh +0 -15
  36. package/benchmark-loop.sh +0 -19
  37. package/skills-lock.json +0 -15
  38. package/src/cli.ts +0 -134
  39. package/src/errors.ts +0 -15
  40. package/src/git.ts +0 -247
  41. package/src/input.ts +0 -168
  42. package/src/mcp.ts +0 -609
  43. package/src/paths.ts +0 -37
  44. package/src/session-message.ts +0 -393
  45. package/src/settings.ts +0 -449
  46. package/src/skills.ts +0 -271
  47. package/src/submit.ts +0 -371
  48. package/src/text.ts +0 -71
  49. package/src/theme.ts +0 -330
  50. package/src/tool-common.ts +0 -93
  51. package/src/tool-grep.ts +0 -606
  52. package/src/tool-read.ts +0 -313
  53. package/src/tool-shell.ts +0 -1001
  54. package/src/tools.ts +0 -854
  55. package/src/ui/agent.ts +0 -317
  56. package/src/ui/commands.test.ts +0 -913
  57. package/src/ui/commands.ts +0 -834
  58. package/src/ui/conversation.test.ts +0 -585
  59. package/src/ui/conversation.ts +0 -1836
  60. package/src/ui/help.ts +0 -158
  61. package/src/ui/input.test.ts +0 -64
  62. package/src/ui/input.ts +0 -138
  63. package/src/ui/overlay.ts +0 -59
  64. package/src/ui/runtime.ts +0 -69
  65. package/src/ui/status.ts +0 -220
  66. package/src/ui.ts +0 -1190
  67. package/src/version.ts +0 -48
package/src/agent.ts CHANGED
@@ -1,945 +1,272 @@
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,
1
+ import {
2
+ type AssistantMessage,
3
+ type Context,
4
+ type Message,
5
+ streamSimple,
6
+ type TextContent,
7
+ type ToolCall,
8
+ type ToolResultMessage,
22
9
  } from "@mariozechner/pi-ai";
23
- import { streamSimple } from "@mariozechner/pi-ai";
24
- import { appendMessage } from "./session.ts";
25
- import { getTodoItems, type ToolExecResult } from "./tools.ts";
26
-
27
- // ---------------------------------------------------------------------------
28
- // Types
29
- // ---------------------------------------------------------------------------
30
-
31
- /**
32
- * A tool execution handler.
33
- *
34
- * Called by the agent loop when the model invokes a tool. Arguments are
35
- * the JSON object parsed by pi-ai from the model's tool call.
36
- */
37
- /** Callback used by tool handlers to report progressive output updates. */
38
- export type ToolUpdateCallback = (result: ToolExecResult) => void;
39
-
40
- export type ToolHandler = (
41
- args: Record<string, unknown>,
42
- cwd: string,
43
- signal?: AbortSignal,
44
- onUpdate?: ToolUpdateCallback,
45
- ) => Promise<ToolExecResult> | ToolExecResult;
46
-
47
- export type { ToolExecResult };
48
-
49
- /** Events emitted during the agent loop for UI updates. */
50
- export type AgentEvent =
51
- | {
52
- type: "text_delta";
53
- delta: string;
54
- content: AssistantMessage["content"];
55
- }
56
- | {
57
- type: "thinking_delta";
58
- delta: string;
59
- content: AssistantMessage["content"];
60
- }
61
- | {
62
- type: "toolcall_start";
63
- toolCallId: string;
64
- name: string;
65
- args: Record<string, unknown>;
66
- content: AssistantMessage["content"];
67
- }
68
- | {
69
- type: "toolcall_delta";
70
- toolCallId: string;
71
- name: string;
72
- args: Record<string, unknown>;
73
- delta: string;
74
- content: AssistantMessage["content"];
75
- }
76
- | {
77
- type: "toolcall_end";
78
- toolCallId: string;
79
- name: string;
80
- args: Record<string, unknown>;
81
- content: AssistantMessage["content"];
82
- }
83
- | { type: "user_message"; message: UserMessage }
84
- | { type: "assistant_message"; message: AssistantMessage }
85
- | {
86
- type: "tool_start";
87
- toolCallId: string;
88
- name: string;
89
- args: Record<string, unknown>;
90
- }
91
- | {
92
- type: "tool_delta";
93
- toolCallId: string;
94
- name: string;
95
- result: ToolExecResult;
96
- }
97
- | {
98
- type: "tool_end";
99
- toolCallId: string;
100
- name: string;
101
- result: ToolExecResult;
102
- }
103
- | { type: "tool_result"; message: ToolResultMessage }
104
- | { type: "done"; message: AssistantMessage }
105
- | { type: "error"; message: AssistantMessage }
106
- | { type: "aborted"; message: AssistantMessage };
107
-
108
- /** Options for the agent loop. */
109
- interface RunAgentOpts {
110
- /** Open database handle. */
111
- db: Database;
112
- /** Current session ID. */
113
- sessionId: string;
114
- /** Turn number for this agent loop (all messages share this turn). */
115
- turn: number;
116
- /** The model to stream with. */
117
- model: Model<string>;
118
- /** The assembled system prompt. */
119
- systemPrompt: string;
120
- /** Tool definitions sent to the model. */
121
- tools: Tool[];
122
- /** Tool name → handler map for executing tool calls. */
123
- toolHandlers: Map<string, ToolHandler>;
124
- /** Current message history (mutated in-place as messages are appended). */
125
- messages: Message[];
126
- /** Working directory for tool execution. */
127
- cwd: string;
128
- /** API key for the provider. */
129
- apiKey?: string;
130
- /** Reasoning effort level (e.g. "low", "medium", "high", "xhigh"). */
131
- effort?: ThinkingLevel;
132
- /** Abort signal for interruption. */
133
- signal?: AbortSignal;
134
- /** Callback for UI events. */
135
- onEvent?: (event: AgentEvent) => void;
136
- /** Dequeue the next queued steering message, if one is waiting. */
137
- takeQueuedUserMessage?: () => UserMessage | null;
138
- }
139
-
140
- /** Result of the agent loop. */
141
- interface AgentLoopResult {
142
- /** The updated message history. */
143
- messages: Message[];
144
- /** How the loop ended. */
145
- stopReason: "stop" | "length" | "error" | "aborted";
146
- }
147
-
148
- function cloneAssistantContent(
149
- content: AssistantMessage["content"],
150
- ): AssistantMessage["content"] {
151
- return content.map((block) => {
152
- if (block.type === "toolCall") {
153
- return {
154
- ...block,
155
- arguments: structuredClone(block.arguments),
156
- };
157
- }
158
- return { ...block };
159
- });
160
- }
161
-
162
- interface MergedAssistantBlock {
163
- block: AssistantMessage["content"][number];
164
- partialStep: number;
165
- finalStep: number;
166
- }
167
-
168
- function mergeAssistantBlocks(
169
- partialBlock: AssistantMessage["content"][number] | undefined,
170
- finalBlock: AssistantMessage["content"][number] | undefined,
171
- ): MergedAssistantBlock | null {
172
- if (!partialBlock && !finalBlock) {
173
- return null;
174
- }
175
- if (!partialBlock && finalBlock) {
176
- return { block: finalBlock, partialStep: 0, finalStep: 1 };
177
- }
178
- if (partialBlock && !finalBlock) {
179
- return { block: partialBlock, partialStep: 1, finalStep: 0 };
180
- }
181
- if (!partialBlock || !finalBlock) {
182
- return null;
183
- }
184
- if (partialBlock.type === finalBlock.type) {
185
- const shouldPreservePartialThinking =
186
- partialBlock.type === "thinking" &&
187
- finalBlock.type === "thinking" &&
188
- partialBlock.thinking &&
189
- !finalBlock.thinking;
190
- return {
191
- block: shouldPreservePartialThinking ? partialBlock : finalBlock,
192
- partialStep: 1,
193
- finalStep: 1,
194
- };
195
- }
196
- if (partialBlock.type === "thinking") {
197
- return { block: partialBlock, partialStep: 1, finalStep: 0 };
198
- }
199
- return { block: finalBlock, partialStep: 1, finalStep: 1 };
200
- }
201
-
202
- /** Merge streamed partial assistant content into the final message content. */
203
- function mergeAssistantContent(
204
- partialContent: AssistantMessage["content"],
205
- finalContent: AssistantMessage["content"],
206
- ): AssistantMessage["content"] {
207
- if (partialContent.length === 0) return finalContent;
208
- if (finalContent.length === 0) return partialContent;
209
-
210
- const merged: AssistantMessage["content"] = [];
211
- let partialIndex = 0;
212
- let finalIndex = 0;
213
-
214
- while (
215
- partialIndex < partialContent.length ||
216
- finalIndex < finalContent.length
217
- ) {
218
- const nextBlock = mergeAssistantBlocks(
219
- partialContent[partialIndex],
220
- finalContent[finalIndex],
221
- );
222
- if (!nextBlock) {
223
- break;
10
+ import { getApiKey } from "./oauth";
11
+ import type {
12
+ AgentContex,
13
+ AgentEvent,
14
+ AgentToolEvent,
15
+ ToolAndRunner,
16
+ } from "./types";
17
+
18
+ // ### JetBrains Junie: Observation Masking
19
+ // Published research found that **simply hiding old tool outputs** matched the quality of full LLM summarization with **zero extra compute**:
20
+ // https://blog.jetbrains.com/research/2025/12/efficient-context-management/
21
+ export function compactContext(messages: Message[]) {
22
+ // TODO: Preserve SKILL.md contents, and exclude them from compaction.
23
+ // Problem: How do we know? we need to check each message result againt it's arguments
24
+ // and then find if by chance it has a read skill command... This is a mess. We we.
25
+ // A better way would be to first add a read(path, lines, offset). And then we know
26
+ // which files are read using it, and can match by path if it's a SKILL.md ending.
27
+ const KEEP_OBSERVATIONS = 10;
28
+
29
+ const toolResultIndices: number[] = [];
30
+ for (let i = 0; i < messages.length; i++) {
31
+ if (messages[i].role !== "toolResult") continue;
32
+ const content = (messages[i] as ToolResultMessage).content;
33
+ if (
34
+ content.length === 1 &&
35
+ content[0].type === "text" &&
36
+ content[0].text.startsWith("Old environment output:")
37
+ ) {
38
+ continue;
224
39
  }
225
- merged.push(nextBlock.block);
226
- partialIndex += nextBlock.partialStep;
227
- finalIndex += nextBlock.finalStep;
40
+ toolResultIndices.push(i);
228
41
  }
229
42
 
230
- return merged;
231
- }
232
-
233
- /** Merge a final assistant message with the richest streamed partial content seen. */
234
- function mergeAssistantMessage(
235
- partialMessage: AssistantMessage,
236
- finalMessage: AssistantMessage,
237
- ): AssistantMessage {
238
- return {
239
- ...finalMessage,
240
- content: mergeAssistantContent(
241
- partialMessage.content,
242
- finalMessage.content,
243
- ),
244
- };
245
- }
246
-
247
- function toolErrorResult(name: string, error: unknown): ToolExecResult {
248
- const message = error instanceof Error ? error.message : String(error);
249
-
250
- return {
251
- content: [{ type: "text", text: `Tool ${name} failed: ${message}` }],
252
- isError: true,
253
- };
254
- }
255
-
256
- function unknownToolResult(name: string): ToolExecResult {
257
- return {
258
- content: [{ type: "text", text: `Unknown tool: ${name}` }],
259
- isError: true,
260
- };
261
- }
262
-
263
- function buildAgentContext(
264
- systemPrompt: string,
265
- messages: Message[],
266
- tools: Tool[],
267
- ) {
268
- return tools.length > 0
269
- ? { systemPrompt, messages, tools }
270
- : { systemPrompt, messages };
271
- }
272
-
273
- function buildStreamOptions(
274
- apiKey: string | undefined,
275
- effort: ThinkingLevel | undefined,
276
- signal: AbortSignal | undefined,
277
- ) {
278
- return {
279
- ...(apiKey ? { apiKey } : {}),
280
- ...(effort ? { reasoning: effort } : {}),
281
- ...(signal ? { signal } : {}),
282
- };
283
- }
284
-
285
- interface StreamedToolCallEventPayload {
286
- toolCallId: string;
287
- name: string;
288
- args: Record<string, unknown>;
289
- content: AssistantMessage["content"];
290
- }
291
-
292
- function buildStreamedToolCallEventPayload(
293
- partial: AssistantMessage,
294
- contentIndex: number,
295
- toolCall?: ToolCall,
296
- ): StreamedToolCallEventPayload | null {
297
- const block = toolCall ?? partial.content[contentIndex];
298
- if (!block || block.type !== "toolCall") {
299
- return null;
300
- }
301
-
302
- return {
303
- toolCallId: block.id,
304
- name: block.name,
305
- args: structuredClone(block.arguments),
306
- content: cloneAssistantContent(partial.content),
307
- };
308
- }
309
-
310
- function handleAssistantStreamEvent(
311
- event: AssistantMessageEvent,
312
- onEvent: RunAgentOpts["onEvent"],
313
- ): AssistantMessage | null {
314
- switch (event.type) {
315
- case "text_delta":
316
- onEvent?.({
317
- type: "text_delta",
318
- delta: event.delta,
319
- content: cloneAssistantContent(event.partial.content),
320
- });
321
- return null;
322
- case "thinking_delta":
323
- onEvent?.({
324
- type: "thinking_delta",
325
- delta: event.delta,
326
- content: cloneAssistantContent(event.partial.content),
327
- });
328
- return null;
329
- case "toolcall_start": {
330
- const payload = buildStreamedToolCallEventPayload(
331
- event.partial,
332
- event.contentIndex,
333
- );
334
- if (payload) {
335
- onEvent?.({
336
- type: "toolcall_start",
337
- ...payload,
338
- });
43
+ if (toolResultIndices.length <= KEEP_OBSERVATIONS) return;
44
+
45
+ for (let i = 0; i < toolResultIndices.length - KEEP_OBSERVATIONS; i++) {
46
+ const idx = toolResultIndices[i];
47
+ const msg = messages[idx] as ToolResultMessage;
48
+ let lines = 0;
49
+ let images = 0;
50
+ for (const c of msg.content) {
51
+ if (c.type === "text") {
52
+ lines += c.text.split(/\r?\n/).length;
53
+ } else if (c.type === "image") {
54
+ images++;
339
55
  }
340
- return null;
341
56
  }
342
- case "toolcall_delta": {
343
- const payload = buildStreamedToolCallEventPayload(
344
- event.partial,
345
- event.contentIndex,
346
- );
347
- if (payload) {
348
- onEvent?.({
349
- type: "toolcall_delta",
350
- delta: event.delta,
351
- ...payload,
352
- });
353
- }
354
- return null;
355
- }
356
- case "toolcall_end": {
357
- const payload = buildStreamedToolCallEventPayload(
358
- event.partial,
359
- event.contentIndex,
360
- event.toolCall,
361
- );
362
- if (payload) {
363
- onEvent?.({
364
- type: "toolcall_end",
365
- ...payload,
366
- });
367
- }
368
- return null;
57
+ let text = `Old environment output: (${lines} lines omitted)`;
58
+ if (images > 0) {
59
+ text += ` (${images} image${images > 1 ? "s" : ""} omitted)`;
369
60
  }
370
- case "done":
371
- return event.message;
372
- case "error":
373
- return event.error;
374
- case "start":
375
- case "text_start":
376
- case "text_end":
377
- case "thinking_start":
378
- case "thinking_end":
379
- return null;
61
+ msg.content = [{ type: "text", text }];
380
62
  }
381
63
  }
382
64
 
383
- function buildIncompleteAssistantMessage(
384
- opts: Pick<RunAgentOpts, "model" | "signal">,
385
- partialAssistantMessage?: AssistantMessage,
386
- ): AssistantMessage {
387
- const stopReason = opts.signal?.aborted ? "aborted" : "error";
388
- const errorMessage =
389
- stopReason === "aborted"
390
- ? "Request was aborted"
391
- : "Stream ended without a final assistant message";
392
-
393
- return {
394
- role: "assistant",
395
- content: partialAssistantMessage
396
- ? cloneAssistantContent(partialAssistantMessage.content)
397
- : [],
398
- api: partialAssistantMessage?.api ?? opts.model.api,
399
- provider: partialAssistantMessage?.provider ?? opts.model.provider,
400
- model: partialAssistantMessage?.model ?? opts.model.id,
401
- usage: partialAssistantMessage?.usage ?? {
402
- input: 0,
403
- output: 0,
404
- cacheRead: 0,
405
- cacheWrite: 0,
406
- totalTokens: 0,
407
- cost: {
408
- input: 0,
409
- output: 0,
410
- cacheRead: 0,
411
- cacheWrite: 0,
412
- total: 0,
413
- },
414
- },
415
- stopReason,
416
- errorMessage,
417
- timestamp: Date.now(),
65
+ export async function* streamAgent(
66
+ agentCtx: AgentContex,
67
+ ): AsyncGenerator<AgentEvent> {
68
+ const llmCtx: Context = {
69
+ systemPrompt: agentCtx.systemPrompt,
70
+ tools: agentCtx.tools.map((t) => t.tool),
71
+ messages: agentCtx.messages,
418
72
  };
419
- }
420
73
 
421
- async function resolveStreamResultSoon(
422
- streamResult: Promise<AssistantMessage>,
423
- ): Promise<AssistantMessage | undefined> {
424
- const pending = Symbol("pending");
425
- const result = await Promise.race([
426
- streamResult,
427
- new Promise<typeof pending>((resolve) => {
428
- setTimeout(() => resolve(pending), 0);
429
- }),
430
- ]);
431
- return result === pending ? undefined : result;
432
- }
433
-
434
- async function streamAssistantMessage(
435
- opts: Pick<
436
- RunAgentOpts,
437
- | "model"
438
- | "systemPrompt"
439
- | "tools"
440
- | "messages"
441
- | "apiKey"
442
- | "effort"
443
- | "signal"
444
- | "onEvent"
445
- >,
446
- ): Promise<AssistantMessage> {
447
- const eventStream = streamSimple(
448
- opts.model,
449
- buildAgentContext(opts.systemPrompt, opts.messages, opts.tools),
450
- buildStreamOptions(opts.apiKey, opts.effort, opts.signal),
451
- );
452
- const streamResult = eventStream.result();
453
- let settledStreamResult: AssistantMessage | undefined;
454
- void streamResult.then((message) => {
455
- settledStreamResult = message;
456
- });
74
+ // Important for refreshing tokens.
75
+ const apiKey = await getApiKey(agentCtx.options);
457
76
 
458
- let assistantMessage: AssistantMessage | undefined;
459
- let partialAssistantMessage: AssistantMessage | undefined;
77
+ // Main agent loop, continues until llm sends a response other than toolCall or has no tool calls.
78
+ while (true) {
79
+ const s = streamSimple(agentCtx.options.model, llmCtx, {
80
+ reasoning: agentCtx.options.effort,
81
+ signal: agentCtx.signal,
82
+ apiKey,
83
+ });
460
84
 
461
- for await (const event of eventStream) {
462
- if ("partial" in event) {
463
- partialAssistantMessage = event.partial;
85
+ let partial: AssistantMessage | null = null;
86
+ let added = false;
87
+
88
+ for await (const e of s) {
89
+ switch (e.type) {
90
+ case "start":
91
+ partial = e.partial;
92
+ llmCtx.messages.push(e.partial);
93
+ added = true;
94
+ yield { type: "message_start", partial };
95
+ break;
96
+
97
+ case "text_start":
98
+ case "text_delta":
99
+ case "text_end":
100
+ case "thinking_start":
101
+ case "thinking_delta":
102
+ case "thinking_end":
103
+ case "toolcall_start":
104
+ case "toolcall_delta":
105
+ case "toolcall_end":
106
+ if (partial) {
107
+ partial = e.partial;
108
+ llmCtx.messages[llmCtx.messages.length - 1] = partial;
109
+ yield { type: "message_update", partial };
110
+ }
111
+ break;
112
+
113
+ case "error": {
114
+ const finalMessage = await s.result();
115
+ if (added) {
116
+ llmCtx.messages[llmCtx.messages.length - 1] = finalMessage;
117
+ } else {
118
+ llmCtx.messages.push(finalMessage);
119
+ yield { type: "message_start", partial: { ...finalMessage } };
120
+ }
121
+
122
+ yield { type: "message_end", message: finalMessage };
123
+ return;
124
+ }
125
+ }
464
126
  }
465
127
 
466
- assistantMessage =
467
- handleAssistantStreamEvent(event, opts.onEvent) ?? assistantMessage;
468
- }
469
-
470
- // `end(result)` resolves the final result without emitting a terminal event.
471
- // Some wrappers settle that promise on the next task, so give it one more
472
- // turn before treating the stream as incomplete.
473
- await Promise.resolve();
474
- if (!assistantMessage && !settledStreamResult) {
475
- settledStreamResult = await resolveStreamResultSoon(streamResult);
476
- }
477
-
478
- const finalAssistantMessage =
479
- assistantMessage ??
480
- settledStreamResult ??
481
- buildIncompleteAssistantMessage(opts, partialAssistantMessage);
482
- if (!partialAssistantMessage) {
483
- return finalAssistantMessage;
484
- }
485
- return mergeAssistantMessage(partialAssistantMessage, finalAssistantMessage);
486
- }
487
-
488
- function appendUserMessage(
489
- db: Database,
490
- sessionId: string,
491
- messages: Message[],
492
- userMessage: UserMessage,
493
- onEvent: RunAgentOpts["onEvent"],
494
- ): number {
495
- messages.push(userMessage);
496
- const turn = appendMessage(db, sessionId, userMessage);
497
- onEvent?.({ type: "user_message", message: userMessage });
498
- return turn;
499
- }
500
-
501
- function appendAssistantMessage(
502
- db: Database,
503
- sessionId: string,
504
- messages: Message[],
505
- assistantMessage: AssistantMessage,
506
- turn: number,
507
- onEvent: RunAgentOpts["onEvent"],
508
- ): void {
509
- messages.push(assistantMessage);
510
- appendMessage(db, sessionId, assistantMessage, turn);
511
- onEvent?.({ type: "assistant_message", message: assistantMessage });
512
- }
513
-
514
- function resolveLoopStopReason(
515
- assistantMessage: AssistantMessage,
516
- messages: Message[],
517
- onEvent: RunAgentOpts["onEvent"],
518
- ): AgentLoopResult | null {
519
- if (
520
- assistantMessage.stopReason === "error" ||
521
- assistantMessage.stopReason === "aborted"
522
- ) {
523
- const eventType =
524
- assistantMessage.stopReason === "error" ? "error" : "aborted";
525
- onEvent?.({ type: eventType, message: assistantMessage });
526
- return { messages, stopReason: assistantMessage.stopReason };
527
- }
528
-
529
- return null;
530
- }
531
-
532
- function consumeQueuedUserMessage(
533
- db: Database,
534
- sessionId: string,
535
- messages: Message[],
536
- takeQueuedUserMessage: RunAgentOpts["takeQueuedUserMessage"],
537
- onEvent: RunAgentOpts["onEvent"],
538
- ): number | null {
539
- const queuedUserMessage = takeQueuedUserMessage?.();
540
- if (!queuedUserMessage) {
541
- return null;
542
- }
543
-
544
- return appendUserMessage(db, sessionId, messages, queuedUserMessage, onEvent);
545
- }
546
-
547
- function getAssistantToolCalls(message: AssistantMessage): ToolCall[] {
548
- return message.content.filter((content): content is ToolCall => {
549
- return content.type === "toolCall";
550
- });
551
- }
552
-
553
- async function executeToolCall(
554
- toolCall: ToolCall,
555
- opts: Pick<RunAgentOpts, "toolHandlers" | "cwd" | "signal" | "onEvent">,
556
- ): Promise<ToolExecResult> {
557
- const handler = opts.toolHandlers.get(toolCall.name);
558
- if (!handler) {
559
- return unknownToolResult(toolCall.name);
560
- }
561
-
562
- opts.onEvent?.({
563
- type: "tool_start",
564
- toolCallId: toolCall.id,
565
- name: toolCall.name,
566
- args: toolCall.arguments,
567
- });
568
-
569
- let result: ToolExecResult;
570
- try {
571
- if (opts.signal?.aborted) {
572
- result = toolErrorResult(
573
- toolCall.name,
574
- new Error("This operation was aborted"),
575
- );
128
+ const message = await s.result();
129
+ if (added) {
130
+ llmCtx.messages[llmCtx.messages.length - 1] = message;
576
131
  } else {
577
- result = await handler(
578
- toolCall.arguments,
579
- opts.cwd,
580
- opts.signal,
581
- (partial) => {
582
- opts.onEvent?.({
583
- type: "tool_delta",
584
- toolCallId: toolCall.id,
585
- name: toolCall.name,
586
- result: partial,
587
- });
588
- },
589
- );
132
+ llmCtx.messages.push(message);
133
+ yield { type: "message_start", partial: { ...message } };
590
134
  }
591
- } catch (error) {
592
- result = toolErrorResult(toolCall.name, error);
593
- }
594
-
595
- opts.onEvent?.({
596
- type: "tool_end",
597
- toolCallId: toolCall.id,
598
- name: toolCall.name,
599
- result,
600
- });
601
- return result;
602
- }
135
+ yield { type: "message_end", message };
603
136
 
604
- function appendToolResultMessage(
605
- db: Database,
606
- sessionId: string,
607
- messages: Message[],
608
- toolCall: ToolCall,
609
- result: ToolExecResult,
610
- turn: number,
611
- onEvent: RunAgentOpts["onEvent"],
612
- ): void {
613
- const toolResultMessage: ToolResultMessage = {
614
- role: "toolResult",
615
- toolCallId: toolCall.id,
616
- toolName: toolCall.name,
617
- content: result.content,
618
- ...(result.details !== undefined ? { details: result.details } : {}),
619
- isError: result.isError,
620
- timestamp: Date.now(),
621
- };
622
-
623
- messages.push(toolResultMessage);
624
- appendMessage(db, sessionId, toolResultMessage, turn);
625
- onEvent?.({ type: "tool_result", message: toolResultMessage });
626
- }
137
+ const toolCalls = message.content.filter((c) => c.type === "toolCall");
627
138
 
628
- function getIncompleteTodos(messages: readonly Message[]) {
629
- return getTodoItems(messages).filter((todo) => todo.status !== "completed");
630
- }
139
+ // Stop on errors or no tools to call.
140
+ if (message.stopReason !== "toolUse" || toolCalls.length === 0) {
141
+ break;
142
+ }
631
143
 
632
- function getTodoReminderSignature(
633
- todos: ReturnType<typeof getIncompleteTodos>,
634
- ): string {
635
- return JSON.stringify(
636
- [...todos].sort((left, right) => {
637
- const contentOrder = left.content.localeCompare(right.content);
638
- if (contentOrder !== 0) {
639
- return contentOrder;
144
+ const seenToolIds = new Map<string, number>();
145
+ if (toolCalls.length > 0) {
146
+ const ts = toolRunner(toolCalls, agentCtx.tools, agentCtx.signal);
147
+
148
+ for await (const e of ts) {
149
+ if (e.type === "tool_update") {
150
+ // Update contex with update or add new.
151
+ const existing = seenToolIds.get(e.partial.toolCallId);
152
+ if (existing && existing >= 0) {
153
+ const m = llmCtx.messages[existing];
154
+ if (m.role === "toolResult") {
155
+ llmCtx.messages[existing] = {
156
+ ...m,
157
+ content: [...m.content, ...e.partial.content],
158
+ isError: e.partial.isError,
159
+ };
160
+ }
161
+
162
+ yield {
163
+ type: "tool_message_update",
164
+ partial: e.partial,
165
+ };
166
+ } else {
167
+ llmCtx.messages.push(e.partial);
168
+ seenToolIds.set(e.partial.toolCallId, llmCtx.messages.length - 1);
169
+
170
+ yield {
171
+ type: "tool_message_start",
172
+ partial: e.partial,
173
+ };
174
+ }
175
+ } else if (e.type === "tool_result") {
176
+ // Update context with full message and yield
177
+ const idx = llmCtx.messages.findIndex(
178
+ (m) =>
179
+ m.role === "toolResult" && m.toolCallId === e.message.toolCallId,
180
+ );
181
+ if (idx >= 0) {
182
+ llmCtx.messages[idx] = e.message;
183
+ } else {
184
+ llmCtx.messages.push(e.message);
185
+ }
186
+
187
+ yield {
188
+ type: "tool_message_end",
189
+ message: e.message,
190
+ };
191
+ }
640
192
  }
641
- return left.status.localeCompare(right.status);
642
- }),
643
- );
644
- }
645
-
646
- function wrapSystemReminder(content: string): string {
647
- return `<system_reminder>\n${content}\n</system_reminder>`;
648
- }
649
-
650
- function createTodoReminderMessage(
651
- messages: readonly Message[],
652
- remindedTodoSignatures: Set<string>,
653
- ): UserMessage | null {
654
- const incompleteTodos = getIncompleteTodos(messages);
655
- if (incompleteTodos.length === 0) {
656
- return null;
657
- }
658
-
659
- const signature = getTodoReminderSignature(incompleteTodos);
660
- if (remindedTodoSignatures.has(signature)) {
661
- return null;
662
- }
663
- remindedTodoSignatures.add(signature);
664
-
665
- const lines = [
666
- "You have pending todo items that must be completed before finishing the task:",
667
- "",
668
- ...incompleteTodos.map((todo) => {
669
- const status = todo.status === "in_progress" ? "IN_PROGRESS" : "PENDING";
670
- return `- [${status}] ${todo.content}`;
671
- }),
672
- "",
673
- "Please complete all pending items before finishing.",
674
- ];
675
-
676
- return {
677
- role: "user",
678
- content: wrapSystemReminder(lines.join("\n")),
679
- timestamp: Date.now(),
680
- };
681
- }
682
-
683
- interface StoppedAssistantResolution {
684
- /** Next turn number when a queued steering message was consumed. */
685
- nextTurn: number | null;
686
- /** Ephemeral context messages to include on the next model request. */
687
- pendingContextMessages: Message[];
688
- /** Final loop result when the turn should stop immediately. */
689
- finalResult: AgentLoopResult | null;
690
- }
691
-
692
- function resolveStoppedAssistantMessage(
693
- assistantMessage: AssistantMessage,
694
- stopReason: "stop" | "length",
695
- opts: Pick<
696
- RunAgentOpts,
697
- "db" | "sessionId" | "messages" | "takeQueuedUserMessage" | "onEvent"
698
- >,
699
- remindedTodoSignatures: Set<string>,
700
- ): StoppedAssistantResolution {
701
- const queuedTurn = consumeQueuedUserMessage(
702
- opts.db,
703
- opts.sessionId,
704
- opts.messages,
705
- opts.takeQueuedUserMessage,
706
- opts.onEvent,
707
- );
708
- if (queuedTurn !== null) {
709
- return {
710
- nextTurn: queuedTurn,
711
- pendingContextMessages: [],
712
- finalResult: null,
713
- };
714
- }
715
-
716
- const todoReminder = createTodoReminderMessage(
717
- opts.messages,
718
- remindedTodoSignatures,
719
- );
720
- if (todoReminder) {
721
- return {
722
- nextTurn: null,
723
- pendingContextMessages: [todoReminder],
724
- finalResult: null,
725
- };
726
- }
727
-
728
- opts.onEvent?.({ type: "done", message: assistantMessage });
729
- return {
730
- nextTurn: null,
731
- pendingContextMessages: [],
732
- finalResult: {
733
- messages: opts.messages,
734
- stopReason,
735
- },
736
- };
737
- }
738
-
739
- async function executeAssistantToolCalls(
740
- assistantMessage: AssistantMessage,
741
- opts: Pick<
742
- RunAgentOpts,
743
- | "db"
744
- | "sessionId"
745
- | "messages"
746
- | "toolHandlers"
747
- | "cwd"
748
- | "signal"
749
- | "onEvent"
750
- | "model"
751
- >,
752
- turn: number,
753
- ): Promise<AgentLoopResult | null> {
754
- for (const toolCall of getAssistantToolCalls(assistantMessage)) {
755
- const result = await executeToolCall(toolCall, opts);
756
- appendToolResultMessage(
757
- opts.db,
758
- opts.sessionId,
759
- opts.messages,
760
- toolCall,
761
- result,
762
- turn,
763
- opts.onEvent,
764
- );
765
-
766
- if (opts.signal?.aborted) {
767
- opts.onEvent?.({
768
- type: "aborted",
769
- message: buildIncompleteAssistantMessage(
770
- { model: opts.model, signal: opts.signal },
771
- assistantMessage,
772
- ),
773
- });
774
- return { messages: opts.messages, stopReason: "aborted" };
775
193
  }
776
194
  }
777
-
778
- return null;
779
- }
780
-
781
- interface AgentIterationOutcome {
782
- /** Final loop result when the run should stop immediately. */
783
- finalResult: AgentLoopResult | null;
784
- /** Next turn number when a queued steering message starts a new turn. */
785
- nextTurn: number;
786
- /** Ephemeral context messages for the next model request. */
787
- pendingContextMessages: Message[];
788
- }
789
-
790
- async function resolveAgentIteration(
791
- assistantMessage: AssistantMessage,
792
- currentTurn: number,
793
- remindedTodoSignatures: Set<string>,
794
- opts: Pick<
795
- RunAgentOpts,
796
- | "db"
797
- | "sessionId"
798
- | "messages"
799
- | "toolHandlers"
800
- | "cwd"
801
- | "signal"
802
- | "onEvent"
803
- | "model"
804
- | "takeQueuedUserMessage"
805
- >,
806
- ): Promise<AgentIterationOutcome> {
807
- const stopResult = resolveLoopStopReason(
808
- assistantMessage,
809
- opts.messages,
810
- opts.onEvent,
811
- );
812
- if (stopResult) {
813
- return {
814
- finalResult: stopResult,
815
- nextTurn: currentTurn,
816
- pendingContextMessages: [],
817
- };
818
- }
819
-
820
- if (
821
- assistantMessage.stopReason === "stop" ||
822
- assistantMessage.stopReason === "length"
823
- ) {
824
- const stopResolution = resolveStoppedAssistantMessage(
825
- assistantMessage,
826
- assistantMessage.stopReason,
827
- {
828
- db: opts.db,
829
- sessionId: opts.sessionId,
830
- messages: opts.messages,
831
- takeQueuedUserMessage: opts.takeQueuedUserMessage,
832
- onEvent: opts.onEvent,
833
- },
834
- remindedTodoSignatures,
835
- );
836
- return {
837
- finalResult: stopResolution.finalResult,
838
- nextTurn: stopResolution.nextTurn ?? currentTurn,
839
- pendingContextMessages: stopResolution.pendingContextMessages,
840
- };
841
- }
842
-
843
- const toolStopResult = await executeAssistantToolCalls(
844
- assistantMessage,
845
- opts,
846
- currentTurn,
847
- );
848
- if (toolStopResult) {
849
- return {
850
- finalResult: toolStopResult,
851
- nextTurn: currentTurn,
852
- pendingContextMessages: [],
853
- };
854
- }
855
-
856
- const queuedTurn = consumeQueuedUserMessage(
857
- opts.db,
858
- opts.sessionId,
859
- opts.messages,
860
- opts.takeQueuedUserMessage,
861
- opts.onEvent,
862
- );
863
- return {
864
- finalResult: null,
865
- nextTurn: queuedTurn ?? currentTurn,
866
- pendingContextMessages: [],
867
- };
868
195
  }
869
196
 
870
- // ---------------------------------------------------------------------------
871
- // Agent loop
872
- // ---------------------------------------------------------------------------
873
-
874
- /**
875
- * Run the agent loop for a single turn.
876
- *
877
- * Streams an LLM response, executes any tool calls, appends all messages
878
- * to the session (sharing the same turn number), and loops back when the
879
- * model requests tool use. Returns when the model stops, hits a length
880
- * limit, errors, or is aborted.
881
- *
882
- * @param opts - Agent loop options.
883
- * @returns The loop result with updated messages and stop reason.
884
- */
885
- export async function runAgentLoop(
886
- opts: RunAgentOpts,
887
- ): Promise<AgentLoopResult> {
888
- const {
889
- db,
890
- sessionId,
891
- turn,
892
- model,
893
- messages,
894
- signal,
895
- onEvent,
896
- toolHandlers,
897
- cwd,
898
- takeQueuedUserMessage,
899
- } = opts;
900
- let currentTurn = turn;
901
- let pendingContextMessages: Message[] = [];
902
- const remindedTodoSignatures = new Set<string>();
903
-
904
- while (true) {
905
- const assistantMessage = await streamAssistantMessage({
906
- ...opts,
907
- messages:
908
- pendingContextMessages.length > 0
909
- ? [...messages, ...pendingContextMessages]
910
- : messages,
911
- });
912
- pendingContextMessages = [];
913
- appendAssistantMessage(
914
- db,
915
- sessionId,
916
- messages,
917
- assistantMessage,
918
- currentTurn,
919
- onEvent,
920
- );
921
-
922
- const iterationOutcome = await resolveAgentIteration(
923
- assistantMessage,
924
- currentTurn,
925
- remindedTodoSignatures,
926
- {
927
- db,
928
- sessionId,
929
- messages,
930
- toolHandlers,
931
- cwd,
932
- signal,
933
- onEvent,
934
- model,
935
- takeQueuedUserMessage,
936
- },
937
- );
938
- if (iterationOutcome.finalResult) {
939
- return iterationOutcome.finalResult;
197
+ // This should stay stateless, don't accumulate anything at this layer, just proxy
198
+ // wrapped runners events.
199
+ async function* toolRunner(
200
+ toolCalls: ToolCall[],
201
+ tools: ToolAndRunner[],
202
+ signal?: AbortSignal,
203
+ ): AsyncGenerator<AgentToolEvent> {
204
+ for (const call of toolCalls) {
205
+ const timestamp = Date.now();
206
+ const tool = tools.find((t) => t.tool.name === call.name);
207
+
208
+ if (!tool) {
209
+ yield {
210
+ type: "tool_result",
211
+ message: {
212
+ role: "toolResult",
213
+ toolCallId: call.id,
214
+ toolName: call.name,
215
+ content: [{ type: "text", text: "Error: Tool not found" }],
216
+ isError: true,
217
+ timestamp,
218
+ },
219
+ };
220
+ continue;
940
221
  }
941
222
 
942
- currentTurn = iterationOutcome.nextTurn;
943
- pendingContextMessages = iterationOutcome.pendingContextMessages;
223
+ try {
224
+ for await (const e of tool.runner(call.arguments, signal)) {
225
+ // yield deltas for output, full output on result.
226
+ const content: TextContent = { type: "text", text: e.text };
227
+
228
+ if (e.type === "output") {
229
+ // handle deltas
230
+ yield {
231
+ type: "tool_update",
232
+ partial: {
233
+ role: "toolResult",
234
+ toolCallId: call.id,
235
+ toolName: call.name,
236
+ content: [content],
237
+ isError: false,
238
+ timestamp,
239
+ },
240
+ };
241
+ } else if (e.type === "result") {
242
+ // handle final message
243
+ yield {
244
+ type: "tool_result",
245
+ message: {
246
+ role: "toolResult",
247
+ toolCallId: call.id,
248
+ toolName: call.name,
249
+ content: [content],
250
+ isError: false,
251
+ timestamp,
252
+ },
253
+ };
254
+ }
255
+ }
256
+ } catch (err) {
257
+ // We don't validate arguments, so it's better to show the errors.
258
+ const error = err instanceof Error ? err.message : "Unknown error";
259
+ yield {
260
+ type: "tool_result",
261
+ message: {
262
+ role: "toolResult",
263
+ toolCallId: call.id,
264
+ toolName: call.name,
265
+ content: [{ type: "text", text: `Error: ${error}` }],
266
+ isError: true,
267
+ timestamp,
268
+ },
269
+ };
270
+ }
944
271
  }
945
272
  }