@oh-my-pi/pi-agent-core 3.15.0 → 3.20.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/README.md CHANGED
@@ -224,26 +224,31 @@ const unsubscribe = agent.subscribe((event) => {
224
224
  unsubscribe();
225
225
  ```
226
226
 
227
- ## Message Queue
227
+ ## Steering & Follow-up
228
228
 
229
- Queue messages to inject during tool execution (for user interruptions):
229
+ Queue messages to inject during tool execution (steering) or after the agent would otherwise stop (follow-up):
230
230
 
231
231
  ```typescript
232
- agent.setQueueMode("one-at-a-time");
232
+ agent.setSteeringMode("one-at-a-time");
233
+ agent.setInterruptMode("immediate");
233
234
 
234
235
  // While agent is running tools
235
- agent.queueMessage({
236
+ agent.steer({
236
237
  role: "user",
237
238
  content: "Stop! Do this instead.",
238
239
  timestamp: Date.now(),
239
240
  });
240
- ```
241
241
 
242
- When queued messages are detected after a tool completes:
242
+ // Queue a follow-up to run after the current turn completes
243
+ agent.followUp({
244
+ role: "user",
245
+ content: "After that, summarize the changes.",
246
+ timestamp: Date.now(),
247
+ });
248
+ ```
243
249
 
244
- 1. Remaining tools are skipped with error results
245
- 2. Queued message is injected
246
- 3. LLM responds to the interruption
250
+ Steering messages are checked after each tool call by default. Set `interruptMode` to `"wait"` to defer
251
+ steering until the current turn completes.
247
252
 
248
253
  ## Custom Message Types
249
254
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-agent-core",
3
- "version": "3.15.0",
3
+ "version": "3.20.0",
4
4
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -13,8 +13,8 @@
13
13
  "test": "vitest --run"
14
14
  },
15
15
  "dependencies": {
16
- "@oh-my-pi/pi-ai": "3.15.0",
17
- "@oh-my-pi/pi-tui": "3.15.0"
16
+ "@oh-my-pi/pi-ai": "3.20.0",
17
+ "@oh-my-pi/pi-tui": "3.20.0"
18
18
  },
19
19
  "keywords": [
20
20
  "ai",
package/src/agent-loop.ts CHANGED
@@ -109,73 +109,90 @@ async function runLoop(
109
109
  stream: EventStream<AgentEvent, AgentMessage[]>,
110
110
  streamFn?: StreamFn,
111
111
  ): Promise<void> {
112
- let hasMoreToolCalls = true;
113
112
  let firstTurn = true;
114
- let queuedMessages: AgentMessage[] = (await config.getQueuedMessages?.()) || [];
115
- let queuedAfterTools: AgentMessage[] | null = null;
116
-
117
- while (hasMoreToolCalls || queuedMessages.length > 0) {
118
- if (!firstTurn) {
119
- stream.push({ type: "turn_start" });
120
- } else {
121
- firstTurn = false;
122
- }
113
+ // Check for steering messages at start (user may have typed while waiting)
114
+ let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || [];
115
+
116
+ // Outer loop: continues when queued follow-up messages arrive after agent would stop
117
+ while (true) {
118
+ let hasMoreToolCalls = true;
119
+ let steeringAfterTools: AgentMessage[] | null = null;
120
+
121
+ // Inner loop: process tool calls and steering messages
122
+ while (hasMoreToolCalls || pendingMessages.length > 0) {
123
+ if (!firstTurn) {
124
+ stream.push({ type: "turn_start" });
125
+ } else {
126
+ firstTurn = false;
127
+ }
123
128
 
124
- // Process queued messages (inject before next assistant response)
125
- if (queuedMessages.length > 0) {
126
- for (const message of queuedMessages) {
127
- stream.push({ type: "message_start", message });
128
- stream.push({ type: "message_end", message });
129
- currentContext.messages.push(message);
130
- newMessages.push(message);
129
+ // Process pending messages (inject before next assistant response)
130
+ if (pendingMessages.length > 0) {
131
+ for (const message of pendingMessages) {
132
+ stream.push({ type: "message_start", message });
133
+ stream.push({ type: "message_end", message });
134
+ currentContext.messages.push(message);
135
+ newMessages.push(message);
136
+ }
137
+ pendingMessages = [];
131
138
  }
132
- queuedMessages = [];
133
- }
134
139
 
135
- // Stream assistant response
136
- const message = await streamAssistantResponse(currentContext, config, signal, stream, streamFn);
137
- newMessages.push(message);
140
+ // Stream assistant response
141
+ const message = await streamAssistantResponse(currentContext, config, signal, stream, streamFn);
142
+ newMessages.push(message);
138
143
 
139
- if (message.stopReason === "error" || message.stopReason === "aborted") {
140
- stream.push({ type: "turn_end", message, toolResults: [] });
141
- stream.push({ type: "agent_end", messages: newMessages });
142
- stream.end(newMessages);
143
- return;
144
- }
144
+ if (message.stopReason === "error" || message.stopReason === "aborted") {
145
+ stream.push({ type: "turn_end", message, toolResults: [] });
146
+ stream.push({ type: "agent_end", messages: newMessages });
147
+ stream.end(newMessages);
148
+ return;
149
+ }
145
150
 
146
- // Check for tool calls
147
- const toolCalls = message.content.filter((c) => c.type === "toolCall");
148
- hasMoreToolCalls = toolCalls.length > 0;
151
+ // Check for tool calls
152
+ const toolCalls = message.content.filter((c) => c.type === "toolCall");
153
+ hasMoreToolCalls = toolCalls.length > 0;
154
+
155
+ const toolResults: ToolResultMessage[] = [];
156
+ if (hasMoreToolCalls) {
157
+ const toolExecution = await executeToolCalls(
158
+ currentContext.tools,
159
+ message,
160
+ signal,
161
+ stream,
162
+ config.getSteeringMessages,
163
+ config.getToolContext,
164
+ config.interruptMode,
165
+ );
166
+ toolResults.push(...toolExecution.toolResults);
167
+ steeringAfterTools = toolExecution.steeringMessages ?? null;
168
+
169
+ for (const result of toolResults) {
170
+ currentContext.messages.push(result);
171
+ newMessages.push(result);
172
+ }
173
+ }
149
174
 
150
- const toolResults: ToolResultMessage[] = [];
151
- if (hasMoreToolCalls) {
152
- const toolExecution = await executeToolCalls(
153
- currentContext.tools,
154
- message,
155
- signal,
156
- stream,
157
- config.getQueuedMessages,
158
- config.getToolContext,
159
- config.interruptMode,
160
- );
161
- toolResults.push(...toolExecution.toolResults);
162
- queuedAfterTools = toolExecution.queuedMessages ?? null;
175
+ stream.push({ type: "turn_end", message, toolResults });
163
176
 
164
- for (const result of toolResults) {
165
- currentContext.messages.push(result);
166
- newMessages.push(result);
177
+ // Get steering messages after turn completes
178
+ if (steeringAfterTools && steeringAfterTools.length > 0) {
179
+ pendingMessages = steeringAfterTools;
180
+ steeringAfterTools = null;
181
+ } else {
182
+ pendingMessages = (await config.getSteeringMessages?.()) || [];
167
183
  }
168
184
  }
169
185
 
170
- stream.push({ type: "turn_end", message, toolResults });
171
-
172
- // Get queued messages after turn completes
173
- if (queuedAfterTools && queuedAfterTools.length > 0) {
174
- queuedMessages = queuedAfterTools;
175
- queuedAfterTools = null;
176
- } else {
177
- queuedMessages = (await config.getQueuedMessages?.()) || [];
186
+ // Agent would stop here. Check for follow-up messages.
187
+ const followUpMessages = (await config.getFollowUpMessages?.()) || [];
188
+ if (followUpMessages.length > 0) {
189
+ // Set as pending so inner loop processes them
190
+ pendingMessages = followUpMessages;
191
+ continue;
178
192
  }
193
+
194
+ // No more messages, exit
195
+ break;
179
196
  }
180
197
 
181
198
  stream.push({ type: "agent_end", messages: newMessages });
@@ -225,9 +242,35 @@ async function streamAssistantResponse(
225
242
  let addedPartial = false;
226
243
 
227
244
  for await (const event of response) {
228
- // Check abort early - allows TTSR and other abort sources to break immediately
245
+ // Check for abort signal before processing each event
229
246
  if (signal?.aborted) {
230
- break;
247
+ const abortedMessage: AssistantMessage = partialMessage
248
+ ? { ...partialMessage, stopReason: "aborted" }
249
+ : {
250
+ role: "assistant",
251
+ content: [],
252
+ api: config.model.api,
253
+ provider: config.model.provider,
254
+ model: config.model.id,
255
+ usage: {
256
+ input: 0,
257
+ output: 0,
258
+ cacheRead: 0,
259
+ cacheWrite: 0,
260
+ totalTokens: 0,
261
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
262
+ },
263
+ stopReason: "aborted",
264
+ timestamp: Date.now(),
265
+ };
266
+ if (addedPartial) {
267
+ context.messages[context.messages.length - 1] = abortedMessage;
268
+ } else {
269
+ context.messages.push(abortedMessage);
270
+ stream.push({ type: "message_start", message: { ...abortedMessage } });
271
+ }
272
+ stream.push({ type: "message_end", message: abortedMessage });
273
+ return abortedMessage;
231
274
  }
232
275
 
233
276
  switch (event.type) {
@@ -273,22 +316,6 @@ async function streamAssistantResponse(
273
316
  return finalMessage;
274
317
  }
275
318
  }
276
-
277
- // Check abort after processing - allows handlers to abort mid-stream
278
- if (signal?.aborted) {
279
- break;
280
- }
281
- }
282
-
283
- // If we broke out due to abort, return an aborted message
284
- if (signal?.aborted && partialMessage) {
285
- const abortedMessage: AssistantMessage = {
286
- ...partialMessage,
287
- stopReason: "aborted",
288
- };
289
- context.messages[context.messages.length - 1] = abortedMessage;
290
- stream.push({ type: "message_end", message: abortedMessage });
291
- return abortedMessage;
292
319
  }
293
320
 
294
321
  return await response.result();
@@ -302,13 +329,14 @@ async function executeToolCalls(
302
329
  assistantMessage: AssistantMessage,
303
330
  signal: AbortSignal | undefined,
304
331
  stream: EventStream<AgentEvent, AgentMessage[]>,
305
- getQueuedMessages?: AgentLoopConfig["getQueuedMessages"],
332
+ getSteeringMessages?: AgentLoopConfig["getSteeringMessages"],
306
333
  getToolContext?: AgentLoopConfig["getToolContext"],
307
- interruptMode?: AgentLoopConfig["interruptMode"],
308
- ): Promise<{ toolResults: ToolResultMessage[]; queuedMessages?: AgentMessage[] }> {
334
+ interruptMode: AgentLoopConfig["interruptMode"] = "immediate",
335
+ ): Promise<{ toolResults: ToolResultMessage[]; steeringMessages?: AgentMessage[] }> {
309
336
  const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall");
310
337
  const results: ToolResultMessage[] = [];
311
- let queuedMessages: AgentMessage[] | undefined;
338
+ let steeringMessages: AgentMessage[] | undefined;
339
+ const shouldInterruptImmediately = interruptMode !== "wait";
312
340
 
313
341
  for (let index = 0; index < toolCalls.length; index++) {
314
342
  const toolCall = toolCalls[index];
@@ -322,17 +350,14 @@ async function executeToolCalls(
322
350
  });
323
351
 
324
352
  let result: AgentToolResult<any>;
353
+ let isError = false;
325
354
 
326
- const details: { toolCallId: string; toolName: string; isError?: boolean } = {
327
- toolCallId: toolCall.id,
328
- toolName: toolCall.name,
329
- };
330
355
  try {
331
356
  if (!tool) throw new Error(`Tool ${toolCall.name} not found`);
332
357
 
333
358
  const validatedArgs = validateToolArguments(tool, toolCall);
334
- const toolContext = getToolContext?.();
335
359
 
360
+ const toolContext = getToolContext ? getToolContext() : undefined;
336
361
  result = await tool.execute(
337
362
  toolCall.id,
338
363
  validatedArgs,
@@ -353,33 +378,36 @@ async function executeToolCalls(
353
378
  content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
354
379
  details: {},
355
380
  };
356
- details.isError = true;
381
+ isError = true;
357
382
  }
358
383
 
359
384
  stream.push({
360
385
  type: "tool_execution_end",
386
+ toolCallId: toolCall.id,
387
+ toolName: toolCall.name,
361
388
  result,
362
- ...details,
389
+ isError,
363
390
  });
364
391
 
365
392
  const toolResultMessage: ToolResultMessage = {
366
393
  role: "toolResult",
394
+ toolCallId: toolCall.id,
395
+ toolName: toolCall.name,
367
396
  content: result.content,
368
397
  details: result.details,
398
+ isError,
369
399
  timestamp: Date.now(),
370
- ...details,
371
400
  };
372
401
 
373
402
  results.push(toolResultMessage);
374
403
  stream.push({ type: "message_start", message: toolResultMessage });
375
404
  stream.push({ type: "message_end", message: toolResultMessage });
376
405
 
377
- // Check for queued messages - skip remaining tools if user interrupted
378
- // Only interrupt mid-execution if interruptMode is "immediate" (default)
379
- if (interruptMode !== "wait" && getQueuedMessages) {
380
- const queued = await getQueuedMessages();
381
- if (queued.length > 0) {
382
- queuedMessages = queued;
406
+ // Check for steering messages - skip remaining tools if user interrupted
407
+ if (shouldInterruptImmediately && getSteeringMessages) {
408
+ const steering = await getSteeringMessages();
409
+ if (steering.length > 0) {
410
+ steeringMessages = steering;
383
411
  const remainingCalls = toolCalls.slice(index + 1);
384
412
  for (const skipped of remainingCalls) {
385
413
  results.push(skipToolCall(skipped, stream));
@@ -389,7 +417,7 @@ async function executeToolCalls(
389
417
  }
390
418
  }
391
419
 
392
- return { toolResults: results, queuedMessages };
420
+ return { toolResults: results, steeringMessages };
393
421
  }
394
422
 
395
423
  function skipToolCall(
package/src/agent.ts CHANGED
@@ -48,13 +48,19 @@ export interface AgentOptions {
48
48
  transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
49
49
 
50
50
  /**
51
- * Queue mode: "all" = send all queued messages at once, "one-at-a-time" = one per turn
51
+ * Steering mode: "all" = send all steering messages at once, "one-at-a-time" = one per turn
52
52
  */
53
- queueMode?: "all" | "one-at-a-time";
53
+ steeringMode?: "all" | "one-at-a-time";
54
54
 
55
55
  /**
56
- * Interrupt mode: "immediate" = check queue after each tool (interrupt remaining),
57
- * "wait" = only process queue after turn completes
56
+ * Follow-up mode: "all" = send all follow-up messages at once, "one-at-a-time" = one per turn
57
+ */
58
+ followUpMode?: "all" | "one-at-a-time";
59
+
60
+ /**
61
+ * When to interrupt tool execution for steering messages.
62
+ * - "immediate": check after each tool call (default)
63
+ * - "wait": defer steering until the current turn completes
58
64
  */
59
65
  interruptMode?: "immediate" | "wait";
60
66
 
@@ -71,6 +77,7 @@ export interface AgentOptions {
71
77
 
72
78
  /**
73
79
  * Provides tool execution context, resolved per tool call.
80
+ * Use for late-bound UI or session state access.
74
81
  */
75
82
  getToolContext?: () => AgentToolContext | undefined;
76
83
  }
@@ -92,8 +99,10 @@ export class Agent {
92
99
  private abortController?: AbortController;
93
100
  private convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
94
101
  private transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
95
- private messageQueue: AgentMessage[] = [];
96
- private queueMode: "all" | "one-at-a-time";
102
+ private steeringQueue: AgentMessage[] = [];
103
+ private followUpQueue: AgentMessage[] = [];
104
+ private steeringMode: "all" | "one-at-a-time";
105
+ private followUpMode: "all" | "one-at-a-time";
97
106
  private interruptMode: "immediate" | "wait";
98
107
  public streamFn: StreamFn;
99
108
  public getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
@@ -105,7 +114,8 @@ export class Agent {
105
114
  this._state = { ...this._state, ...opts.initialState };
106
115
  this.convertToLlm = opts.convertToLlm || defaultConvertToLlm;
107
116
  this.transformContext = opts.transformContext;
108
- this.queueMode = opts.queueMode || "one-at-a-time";
117
+ this.steeringMode = opts.steeringMode || "one-at-a-time";
118
+ this.followUpMode = opts.followUpMode || "one-at-a-time";
109
119
  this.interruptMode = opts.interruptMode || "immediate";
110
120
  this.streamFn = opts.streamFn || streamSimple;
111
121
  this.getApiKey = opts.getApiKey;
@@ -134,12 +144,20 @@ export class Agent {
134
144
  this._state.thinkingLevel = l;
135
145
  }
136
146
 
137
- setQueueMode(mode: "all" | "one-at-a-time") {
138
- this.queueMode = mode;
147
+ setSteeringMode(mode: "all" | "one-at-a-time") {
148
+ this.steeringMode = mode;
149
+ }
150
+
151
+ getSteeringMode(): "all" | "one-at-a-time" {
152
+ return this.steeringMode;
139
153
  }
140
154
 
141
- getQueueMode(): "all" | "one-at-a-time" {
142
- return this.queueMode;
155
+ setFollowUpMode(mode: "all" | "one-at-a-time") {
156
+ this.followUpMode = mode;
157
+ }
158
+
159
+ getFollowUpMode(): "all" | "one-at-a-time" {
160
+ return this.followUpMode;
143
161
  }
144
162
 
145
163
  setInterruptMode(mode: "immediate" | "wait") {
@@ -162,24 +180,49 @@ export class Agent {
162
180
  this._state.messages = [...this._state.messages, m];
163
181
  }
164
182
 
165
- queueMessage(m: AgentMessage) {
166
- this.messageQueue.push(m);
183
+ popMessage(): AgentMessage | undefined {
184
+ const messages = this._state.messages.slice(0, -1);
185
+ const removed = this._state.messages.at(-1);
186
+ this._state.messages = messages;
187
+
188
+ if (removed && this._state.streamMessage === removed) {
189
+ this._state.streamMessage = null;
190
+ }
191
+
192
+ return removed;
167
193
  }
168
194
 
169
- clearMessageQueue() {
170
- this.messageQueue = [];
195
+ /**
196
+ * Queue a steering message to interrupt the agent mid-run.
197
+ * Delivered after current tool execution, skips remaining tools.
198
+ */
199
+ steer(m: AgentMessage) {
200
+ this.steeringQueue.push(m);
171
201
  }
172
202
 
173
- clearMessages() {
174
- this._state.messages = [];
203
+ /**
204
+ * Queue a follow-up message to be processed after the agent finishes.
205
+ * Delivered only when agent has no more tool calls or steering messages.
206
+ */
207
+ followUp(m: AgentMessage) {
208
+ this.followUpQueue.push(m);
175
209
  }
176
210
 
177
- /** Remove and return the last message from the message list */
178
- popMessage(): AgentMessage | undefined {
179
- if (this._state.messages.length === 0) return undefined;
180
- const popped = this._state.messages[this._state.messages.length - 1];
181
- this._state.messages = this._state.messages.slice(0, -1);
182
- return popped;
211
+ clearSteeringQueue() {
212
+ this.steeringQueue = [];
213
+ }
214
+
215
+ clearFollowUpQueue() {
216
+ this.followUpQueue = [];
217
+ }
218
+
219
+ clearAllQueues() {
220
+ this.steeringQueue = [];
221
+ this.followUpQueue = [];
222
+ }
223
+
224
+ clearMessages() {
225
+ this._state.messages = [];
183
226
  }
184
227
 
185
228
  abort() {
@@ -196,13 +239,20 @@ export class Agent {
196
239
  this._state.streamMessage = null;
197
240
  this._state.pendingToolCalls = new Set<string>();
198
241
  this._state.error = undefined;
199
- this.messageQueue = [];
242
+ this.steeringQueue = [];
243
+ this.followUpQueue = [];
200
244
  }
201
245
 
202
246
  /** Send a prompt with an AgentMessage */
203
247
  async prompt(message: AgentMessage | AgentMessage[]): Promise<void>;
204
248
  async prompt(input: string, images?: ImageContent[]): Promise<void>;
205
249
  async prompt(input: string | AgentMessage | AgentMessage[], images?: ImageContent[]) {
250
+ if (this._state.isStreaming) {
251
+ throw new Error(
252
+ "Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion.",
253
+ );
254
+ }
255
+
206
256
  const model = this._state.model;
207
257
  if (!model) throw new Error("No model configured");
208
258
 
@@ -231,6 +281,10 @@ export class Agent {
231
281
 
232
282
  /** Continue from current context (for retry after overflow) */
233
283
  async continue() {
284
+ if (this._state.isStreaming) {
285
+ throw new Error("Agent is already processing. Wait for completion before continuing.");
286
+ }
287
+
234
288
  const messages = this._state.messages;
235
289
  if (messages.length === 0) {
236
290
  throw new Error("No messages to continue from");
@@ -276,23 +330,37 @@ export class Agent {
276
330
  const config: AgentLoopConfig = {
277
331
  model,
278
332
  reasoning,
333
+ interruptMode: this.interruptMode,
279
334
  convertToLlm: this.convertToLlm,
280
335
  transformContext: this.transformContext,
281
336
  getApiKey: this.getApiKey,
282
337
  getToolContext: this.getToolContext,
283
- interruptMode: this.interruptMode,
284
- getQueuedMessages: async () => {
285
- if (this.queueMode === "one-at-a-time") {
286
- if (this.messageQueue.length > 0) {
287
- const first = this.messageQueue[0];
288
- this.messageQueue = this.messageQueue.slice(1);
338
+ getSteeringMessages: async () => {
339
+ if (this.steeringMode === "one-at-a-time") {
340
+ if (this.steeringQueue.length > 0) {
341
+ const first = this.steeringQueue[0];
342
+ this.steeringQueue = this.steeringQueue.slice(1);
343
+ return [first];
344
+ }
345
+ return [];
346
+ } else {
347
+ const steering = this.steeringQueue.slice();
348
+ this.steeringQueue = [];
349
+ return steering;
350
+ }
351
+ },
352
+ getFollowUpMessages: async () => {
353
+ if (this.followUpMode === "one-at-a-time") {
354
+ if (this.followUpQueue.length > 0) {
355
+ const first = this.followUpQueue[0];
356
+ this.followUpQueue = this.followUpQueue.slice(1);
289
357
  return [first];
290
358
  }
291
359
  return [];
292
360
  } else {
293
- const queued = this.messageQueue.slice();
294
- this.messageQueue = [];
295
- return queued;
361
+ const followUp = this.followUpQueue.slice();
362
+ this.followUpQueue = [];
363
+ return followUp;
296
364
  }
297
365
  },
298
366
  };
package/src/types.ts CHANGED
@@ -22,6 +22,13 @@ export type StreamFn = (
22
22
  export interface AgentLoopConfig extends SimpleStreamOptions {
23
23
  model: Model<any>;
24
24
 
25
+ /**
26
+ * When to interrupt tool execution for steering messages.
27
+ * - "immediate": check after each tool call (default)
28
+ * - "wait": defer steering until the current turn completes
29
+ */
30
+ interruptMode?: "immediate" | "wait";
31
+
25
32
  /**
26
33
  * Converts AgentMessage[] to LLM-compatible Message[] before each LLM call.
27
34
  *
@@ -32,7 +39,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
32
39
  * @example
33
40
  * ```typescript
34
41
  * convertToLlm: (messages) => messages.flatMap(m => {
35
- * if (m.role === "hookMessage") {
42
+ * if (m.role === "custom") {
36
43
  * // Convert custom message to user message
37
44
  * return [{ role: "user", content: m.content, timestamp: m.timestamp }];
38
45
  * }
@@ -75,20 +82,22 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
75
82
  getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
76
83
 
77
84
  /**
78
- * Returns queued messages to inject into the conversation.
85
+ * Returns steering messages to inject into the conversation mid-run.
79
86
  *
80
- * Called after each turn to check for user interruptions or injected messages.
81
- * If messages are returned, they're added to the context before the next LLM call.
87
+ * Called after each tool execution to check for user interruptions unless interruptMode is "wait".
88
+ * If messages are returned, remaining tool calls are skipped and
89
+ * these messages are added to the context before the next LLM call.
82
90
  */
83
- getQueuedMessages?: () => Promise<AgentMessage[]>;
91
+ getSteeringMessages?: () => Promise<AgentMessage[]>;
84
92
 
85
93
  /**
86
- * Controls when queued messages interrupt tool execution.
94
+ * Returns follow-up messages to process after the agent would otherwise stop.
87
95
  *
88
- * - "immediate" (default): Check queue after each tool, interrupt remaining tools if messages exist
89
- * - "wait": Only process queued messages after the entire turn completes
96
+ * Called when the agent has no more tool calls and no steering messages.
97
+ * If messages are returned, they're added to the context and the agent
98
+ * continues with another turn.
90
99
  */
91
- interruptMode?: "immediate" | "wait";
100
+ getFollowUpMessages?: () => Promise<AgentMessage[]>;
92
101
 
93
102
  /**
94
103
  * Provides tool execution context, resolved per tool call.
@@ -159,6 +168,8 @@ export interface RenderResultOptions {
159
168
  expanded: boolean;
160
169
  /** Whether this is a partial/streaming result */
161
170
  isPartial: boolean;
171
+ /** Current spinner frame index for animated elements (optional) */
172
+ spinnerFrame?: number;
162
173
  }
163
174
 
164
175
  /**