@lenylvt/pi-agent-core 0.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/proxy.ts ADDED
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Proxy stream function for apps that route LLM calls through a server.
3
+ * The server manages auth and proxies requests to LLM providers.
4
+ */
5
+
6
+ // Internal import for JSON parsing utility
7
+ import {
8
+ type AssistantMessage,
9
+ type AssistantMessageEvent,
10
+ type Context,
11
+ EventStream,
12
+ type Model,
13
+ parseStreamingJson,
14
+ type SimpleStreamOptions,
15
+ type StopReason,
16
+ type ToolCall,
17
+ } from "@lenylvt/pi-ai";
18
+
19
+ // Create stream class matching ProxyMessageEventStream
20
+ class ProxyMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
21
+ constructor() {
22
+ super(
23
+ (event) => event.type === "done" || event.type === "error",
24
+ (event) => {
25
+ if (event.type === "done") return event.message;
26
+ if (event.type === "error") return event.error;
27
+ throw new Error("Unexpected event type");
28
+ },
29
+ );
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Proxy event types - server sends these with partial field stripped to reduce bandwidth.
35
+ */
36
+ export type ProxyAssistantMessageEvent =
37
+ | { type: "start" }
38
+ | { type: "text_start"; contentIndex: number }
39
+ | { type: "text_delta"; contentIndex: number; delta: string }
40
+ | { type: "text_end"; contentIndex: number; contentSignature?: string }
41
+ | { type: "thinking_start"; contentIndex: number }
42
+ | { type: "thinking_delta"; contentIndex: number; delta: string }
43
+ | { type: "thinking_end"; contentIndex: number; contentSignature?: string }
44
+ | { type: "toolcall_start"; contentIndex: number; id: string; toolName: string }
45
+ | { type: "toolcall_delta"; contentIndex: number; delta: string }
46
+ | { type: "toolcall_end"; contentIndex: number }
47
+ | {
48
+ type: "done";
49
+ reason: Extract<StopReason, "stop" | "length" | "toolUse">;
50
+ usage: AssistantMessage["usage"];
51
+ }
52
+ | {
53
+ type: "error";
54
+ reason: Extract<StopReason, "aborted" | "error">;
55
+ errorMessage?: string;
56
+ usage: AssistantMessage["usage"];
57
+ };
58
+
59
+ export interface ProxyStreamOptions extends SimpleStreamOptions {
60
+ /** Auth token for the proxy server */
61
+ authToken: string;
62
+ /** Proxy server URL (e.g., "https://genai.example.com") */
63
+ proxyUrl: string;
64
+ }
65
+
66
+ /**
67
+ * Stream function that proxies through a server instead of calling LLM providers directly.
68
+ * The server strips the partial field from delta events to reduce bandwidth.
69
+ * We reconstruct the partial message client-side.
70
+ *
71
+ * Use this as the `streamFn` option when creating an Agent that needs to go through a proxy.
72
+ *
73
+ * @example
74
+ * ```typescript
75
+ * const agent = new Agent({
76
+ * streamFn: (model, context, options) =>
77
+ * streamProxy(model, context, {
78
+ * ...options,
79
+ * authToken: await getAuthToken(),
80
+ * proxyUrl: "https://genai.example.com",
81
+ * }),
82
+ * });
83
+ * ```
84
+ */
85
+ export function streamProxy(model: Model<any>, context: Context, options: ProxyStreamOptions): ProxyMessageEventStream {
86
+ const stream = new ProxyMessageEventStream();
87
+
88
+ (async () => {
89
+ // Initialize the partial message that we'll build up from events
90
+ const partial: AssistantMessage = {
91
+ role: "assistant",
92
+ stopReason: "stop",
93
+ content: [],
94
+ api: model.api,
95
+ provider: model.provider,
96
+ model: model.id,
97
+ usage: {
98
+ input: 0,
99
+ output: 0,
100
+ cacheRead: 0,
101
+ cacheWrite: 0,
102
+ totalTokens: 0,
103
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
104
+ },
105
+ timestamp: Date.now(),
106
+ };
107
+
108
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
109
+
110
+ const abortHandler = () => {
111
+ if (reader) {
112
+ reader.cancel("Request aborted by user").catch(() => {});
113
+ }
114
+ };
115
+
116
+ if (options.signal) {
117
+ options.signal.addEventListener("abort", abortHandler);
118
+ }
119
+
120
+ try {
121
+ const response = await fetch(`${options.proxyUrl}/api/stream`, {
122
+ method: "POST",
123
+ headers: {
124
+ Authorization: `Bearer ${options.authToken}`,
125
+ "Content-Type": "application/json",
126
+ },
127
+ body: JSON.stringify({
128
+ model,
129
+ context,
130
+ options: {
131
+ temperature: options.temperature,
132
+ maxTokens: options.maxTokens,
133
+ reasoning: options.reasoning,
134
+ },
135
+ }),
136
+ signal: options.signal,
137
+ });
138
+
139
+ if (!response.ok) {
140
+ let errorMessage = `Proxy error: ${response.status} ${response.statusText}`;
141
+ try {
142
+ const errorData = (await response.json()) as { error?: string };
143
+ if (errorData.error) {
144
+ errorMessage = `Proxy error: ${errorData.error}`;
145
+ }
146
+ } catch {
147
+ // Couldn't parse error response
148
+ }
149
+ throw new Error(errorMessage);
150
+ }
151
+
152
+ reader = response.body!.getReader();
153
+ const decoder = new TextDecoder();
154
+ let buffer = "";
155
+
156
+ while (true) {
157
+ const { done, value } = await reader.read();
158
+ if (done) break;
159
+
160
+ if (options.signal?.aborted) {
161
+ throw new Error("Request aborted by user");
162
+ }
163
+
164
+ buffer += decoder.decode(value, { stream: true });
165
+ const lines = buffer.split("\n");
166
+ buffer = lines.pop() || "";
167
+
168
+ for (const line of lines) {
169
+ if (line.startsWith("data: ")) {
170
+ const data = line.slice(6).trim();
171
+ if (data) {
172
+ const proxyEvent = JSON.parse(data) as ProxyAssistantMessageEvent;
173
+ const event = processProxyEvent(proxyEvent, partial);
174
+ if (event) {
175
+ stream.push(event);
176
+ }
177
+ }
178
+ }
179
+ }
180
+ }
181
+
182
+ if (options.signal?.aborted) {
183
+ throw new Error("Request aborted by user");
184
+ }
185
+
186
+ stream.end();
187
+ } catch (error) {
188
+ const errorMessage = error instanceof Error ? error.message : String(error);
189
+ const reason = options.signal?.aborted ? "aborted" : "error";
190
+ partial.stopReason = reason;
191
+ partial.errorMessage = errorMessage;
192
+ stream.push({
193
+ type: "error",
194
+ reason,
195
+ error: partial,
196
+ });
197
+ stream.end();
198
+ } finally {
199
+ if (options.signal) {
200
+ options.signal.removeEventListener("abort", abortHandler);
201
+ }
202
+ }
203
+ })();
204
+
205
+ return stream;
206
+ }
207
+
208
+ /**
209
+ * Process a proxy event and update the partial message.
210
+ */
211
+ function processProxyEvent(
212
+ proxyEvent: ProxyAssistantMessageEvent,
213
+ partial: AssistantMessage,
214
+ ): AssistantMessageEvent | undefined {
215
+ switch (proxyEvent.type) {
216
+ case "start":
217
+ return { type: "start", partial };
218
+
219
+ case "text_start":
220
+ partial.content[proxyEvent.contentIndex] = { type: "text", text: "" };
221
+ return { type: "text_start", contentIndex: proxyEvent.contentIndex, partial };
222
+
223
+ case "text_delta": {
224
+ const content = partial.content[proxyEvent.contentIndex];
225
+ if (content?.type === "text") {
226
+ content.text += proxyEvent.delta;
227
+ return {
228
+ type: "text_delta",
229
+ contentIndex: proxyEvent.contentIndex,
230
+ delta: proxyEvent.delta,
231
+ partial,
232
+ };
233
+ }
234
+ throw new Error("Received text_delta for non-text content");
235
+ }
236
+
237
+ case "text_end": {
238
+ const content = partial.content[proxyEvent.contentIndex];
239
+ if (content?.type === "text") {
240
+ content.textSignature = proxyEvent.contentSignature;
241
+ return {
242
+ type: "text_end",
243
+ contentIndex: proxyEvent.contentIndex,
244
+ content: content.text,
245
+ partial,
246
+ };
247
+ }
248
+ throw new Error("Received text_end for non-text content");
249
+ }
250
+
251
+ case "thinking_start":
252
+ partial.content[proxyEvent.contentIndex] = { type: "thinking", thinking: "" };
253
+ return { type: "thinking_start", contentIndex: proxyEvent.contentIndex, partial };
254
+
255
+ case "thinking_delta": {
256
+ const content = partial.content[proxyEvent.contentIndex];
257
+ if (content?.type === "thinking") {
258
+ content.thinking += proxyEvent.delta;
259
+ return {
260
+ type: "thinking_delta",
261
+ contentIndex: proxyEvent.contentIndex,
262
+ delta: proxyEvent.delta,
263
+ partial,
264
+ };
265
+ }
266
+ throw new Error("Received thinking_delta for non-thinking content");
267
+ }
268
+
269
+ case "thinking_end": {
270
+ const content = partial.content[proxyEvent.contentIndex];
271
+ if (content?.type === "thinking") {
272
+ content.thinkingSignature = proxyEvent.contentSignature;
273
+ return {
274
+ type: "thinking_end",
275
+ contentIndex: proxyEvent.contentIndex,
276
+ content: content.thinking,
277
+ partial,
278
+ };
279
+ }
280
+ throw new Error("Received thinking_end for non-thinking content");
281
+ }
282
+
283
+ case "toolcall_start":
284
+ partial.content[proxyEvent.contentIndex] = {
285
+ type: "toolCall",
286
+ id: proxyEvent.id,
287
+ name: proxyEvent.toolName,
288
+ arguments: {},
289
+ partialJson: "",
290
+ } satisfies ToolCall & { partialJson: string } as ToolCall;
291
+ return { type: "toolcall_start", contentIndex: proxyEvent.contentIndex, partial };
292
+
293
+ case "toolcall_delta": {
294
+ const content = partial.content[proxyEvent.contentIndex];
295
+ if (content?.type === "toolCall") {
296
+ (content as any).partialJson += proxyEvent.delta;
297
+ content.arguments = parseStreamingJson((content as any).partialJson) || {};
298
+ partial.content[proxyEvent.contentIndex] = { ...content }; // Trigger reactivity
299
+ return {
300
+ type: "toolcall_delta",
301
+ contentIndex: proxyEvent.contentIndex,
302
+ delta: proxyEvent.delta,
303
+ partial,
304
+ };
305
+ }
306
+ throw new Error("Received toolcall_delta for non-toolCall content");
307
+ }
308
+
309
+ case "toolcall_end": {
310
+ const content = partial.content[proxyEvent.contentIndex];
311
+ if (content?.type === "toolCall") {
312
+ delete (content as any).partialJson;
313
+ return {
314
+ type: "toolcall_end",
315
+ contentIndex: proxyEvent.contentIndex,
316
+ toolCall: content,
317
+ partial,
318
+ };
319
+ }
320
+ return undefined;
321
+ }
322
+
323
+ case "done":
324
+ partial.stopReason = proxyEvent.reason;
325
+ partial.usage = proxyEvent.usage;
326
+ return { type: "done", reason: proxyEvent.reason, message: partial };
327
+
328
+ case "error":
329
+ partial.stopReason = proxyEvent.reason;
330
+ partial.errorMessage = proxyEvent.errorMessage;
331
+ partial.usage = proxyEvent.usage;
332
+ return { type: "error", reason: proxyEvent.reason, error: partial };
333
+
334
+ default: {
335
+ const _exhaustiveCheck: never = proxyEvent;
336
+ console.warn(`Unhandled proxy event type: ${(proxyEvent as any).type}`);
337
+ return undefined;
338
+ }
339
+ }
340
+ }
package/src/types.ts ADDED
@@ -0,0 +1,313 @@
1
+ import type {
2
+ AssistantMessage,
3
+ AssistantMessageEvent,
4
+ ImageContent,
5
+ Message,
6
+ Model,
7
+ SimpleStreamOptions,
8
+ streamSimple,
9
+ TextContent,
10
+ Tool,
11
+ ToolResultMessage,
12
+ } from "@lenylvt/pi-ai";
13
+ import type { Static, TSchema } from "@sinclair/typebox";
14
+
15
+ /**
16
+ * Stream function used by the agent loop.
17
+ *
18
+ * Contract:
19
+ * - Must not throw or return a rejected promise for request/model/runtime failures.
20
+ * - Must return an AssistantMessageEventStream.
21
+ * - Failures must be encoded in the returned stream via protocol events and a
22
+ * final AssistantMessage with stopReason "error" or "aborted" and errorMessage.
23
+ */
24
+ export type StreamFn = (
25
+ ...args: Parameters<typeof streamSimple>
26
+ ) => ReturnType<typeof streamSimple> | Promise<ReturnType<typeof streamSimple>>;
27
+
28
+ /**
29
+ * Configuration for how tool calls from a single assistant message are executed.
30
+ *
31
+ * - "sequential": each tool call is prepared, executed, and finalized before the next one starts.
32
+ * - "parallel": tool calls are prepared sequentially, then allowed tools execute concurrently.
33
+ * Final tool results are still emitted in assistant source order.
34
+ */
35
+ export type ToolExecutionMode = "sequential" | "parallel";
36
+
37
+ /** A single tool call content block emitted by an assistant message. */
38
+ export type AgentToolCall = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
39
+
40
+ /**
41
+ * Result returned from `beforeToolCall`.
42
+ *
43
+ * Returning `{ block: true }` prevents the tool from executing. The loop emits an error tool result instead.
44
+ * `reason` becomes the text shown in that error result. If omitted, a default blocked message is used.
45
+ */
46
+ export interface BeforeToolCallResult {
47
+ block?: boolean;
48
+ reason?: string;
49
+ }
50
+
51
+ /**
52
+ * Partial override returned from `afterToolCall`.
53
+ *
54
+ * Merge semantics are field-by-field:
55
+ * - `content`: if provided, replaces the tool result content array in full
56
+ * - `details`: if provided, replaces the tool result details value in full
57
+ * - `isError`: if provided, replaces the tool result error flag
58
+ *
59
+ * Omitted fields keep the original executed tool result values.
60
+ * There is no deep merge for `content` or `details`.
61
+ */
62
+ export interface AfterToolCallResult {
63
+ content?: (TextContent | ImageContent)[];
64
+ details?: unknown;
65
+ isError?: boolean;
66
+ }
67
+
68
+ /** Context passed to `beforeToolCall`. */
69
+ export interface BeforeToolCallContext {
70
+ /** The assistant message that requested the tool call. */
71
+ assistantMessage: AssistantMessage;
72
+ /** The raw tool call block from `assistantMessage.content`. */
73
+ toolCall: AgentToolCall;
74
+ /** Validated tool arguments for the target tool schema. */
75
+ args: unknown;
76
+ /** Current agent context at the time the tool call is prepared. */
77
+ context: AgentContext;
78
+ }
79
+
80
+ /** Context passed to `afterToolCall`. */
81
+ export interface AfterToolCallContext {
82
+ /** The assistant message that requested the tool call. */
83
+ assistantMessage: AssistantMessage;
84
+ /** The raw tool call block from `assistantMessage.content`. */
85
+ toolCall: AgentToolCall;
86
+ /** Validated tool arguments for the target tool schema. */
87
+ args: unknown;
88
+ /** The executed tool result before any `afterToolCall` overrides are applied. */
89
+ result: AgentToolResult<any>;
90
+ /** Whether the executed tool result is currently treated as an error. */
91
+ isError: boolean;
92
+ /** Current agent context at the time the tool call is finalized. */
93
+ context: AgentContext;
94
+ }
95
+
96
+ export interface AgentLoopConfig extends SimpleStreamOptions {
97
+ model: Model<any>;
98
+
99
+ /**
100
+ * Converts AgentMessage[] to LLM-compatible Message[] before each LLM call.
101
+ *
102
+ * Each AgentMessage must be converted to a UserMessage, AssistantMessage, or ToolResultMessage
103
+ * that the LLM can understand. AgentMessages that cannot be converted (e.g., UI-only notifications,
104
+ * status messages) should be filtered out.
105
+ *
106
+ * Contract: must not throw or reject. Return a safe fallback value instead.
107
+ * Throwing interrupts the low-level agent loop without producing a normal event sequence.
108
+ *
109
+ * @example
110
+ * ```typescript
111
+ * convertToLlm: (messages) => messages.flatMap(m => {
112
+ * if (m.role === "custom") {
113
+ * // Convert custom message to user message
114
+ * return [{ role: "user", content: m.content, timestamp: m.timestamp }];
115
+ * }
116
+ * if (m.role === "notification") {
117
+ * // Filter out UI-only messages
118
+ * return [];
119
+ * }
120
+ * // Pass through standard LLM messages
121
+ * return [m];
122
+ * })
123
+ * ```
124
+ */
125
+ convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
126
+
127
+ /**
128
+ * Optional transform applied to the context before `convertToLlm`.
129
+ *
130
+ * Use this for operations that work at the AgentMessage level:
131
+ * - Context window management (pruning old messages)
132
+ * - Injecting context from external sources
133
+ *
134
+ * Contract: must not throw or reject. Return the original messages or another
135
+ * safe fallback value instead.
136
+ *
137
+ * @example
138
+ * ```typescript
139
+ * transformContext: async (messages) => {
140
+ * if (estimateTokens(messages) > MAX_TOKENS) {
141
+ * return pruneOldMessages(messages);
142
+ * }
143
+ * return messages;
144
+ * }
145
+ * ```
146
+ */
147
+ transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
148
+
149
+ /**
150
+ * Resolves an API key dynamically for each LLM call.
151
+ *
152
+ * Useful for short-lived OAuth tokens (e.g., GitHub Copilot) that may expire
153
+ * during long-running tool execution phases.
154
+ *
155
+ * Contract: must not throw or reject. Return undefined when no key is available.
156
+ */
157
+ getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
158
+
159
+ /**
160
+ * Returns steering messages to inject into the conversation mid-run.
161
+ *
162
+ * Called after the current assistant turn finishes executing its tool calls.
163
+ * If messages are returned, they are added to the context before the next LLM call.
164
+ * Tool calls from the current assistant message are not skipped.
165
+ *
166
+ * Use this for "steering" the agent while it's working.
167
+ *
168
+ * Contract: must not throw or reject. Return [] when no steering messages are available.
169
+ */
170
+ getSteeringMessages?: () => Promise<AgentMessage[]>;
171
+
172
+ /**
173
+ * Returns follow-up messages to process after the agent would otherwise stop.
174
+ *
175
+ * Called when the agent has no more tool calls and no steering messages.
176
+ * If messages are returned, they're added to the context and the agent
177
+ * continues with another turn.
178
+ *
179
+ * Use this for follow-up messages that should wait until the agent finishes.
180
+ *
181
+ * Contract: must not throw or reject. Return [] when no follow-up messages are available.
182
+ */
183
+ getFollowUpMessages?: () => Promise<AgentMessage[]>;
184
+
185
+ /**
186
+ * Tool execution mode.
187
+ * - "sequential": execute tool calls one by one
188
+ * - "parallel": preflight tool calls sequentially, then execute allowed tools concurrently
189
+ *
190
+ * Default: "parallel"
191
+ */
192
+ toolExecution?: ToolExecutionMode;
193
+
194
+ /**
195
+ * Called before a tool is executed, after arguments have been validated.
196
+ *
197
+ * Return `{ block: true }` to prevent execution. The loop emits an error tool result instead.
198
+ * The hook receives the agent abort signal and is responsible for honoring it.
199
+ */
200
+ beforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise<BeforeToolCallResult | undefined>;
201
+
202
+ /**
203
+ * Called after a tool finishes executing, before final tool events are emitted.
204
+ *
205
+ * Return an `AfterToolCallResult` to override parts of the executed tool result:
206
+ * - `content` replaces the full content array
207
+ * - `details` replaces the full details payload
208
+ * - `isError` replaces the error flag
209
+ *
210
+ * Any omitted fields keep their original values. No deep merge is performed.
211
+ * The hook receives the agent abort signal and is responsible for honoring it.
212
+ */
213
+ afterToolCall?: (context: AfterToolCallContext, signal?: AbortSignal) => Promise<AfterToolCallResult | undefined>;
214
+ }
215
+
216
+ /**
217
+ * Thinking/reasoning level for models that support it.
218
+ * Note: "xhigh" is only supported by OpenAI gpt-5.1-codex-max, gpt-5.2, gpt-5.2-codex, gpt-5.3, and gpt-5.3-codex models.
219
+ */
220
+ export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
221
+
222
+ /**
223
+ * Extensible interface for custom app messages.
224
+ * Apps can extend via declaration merging:
225
+ *
226
+ * @example
227
+ * ```typescript
228
+ * declare module "@lenylvt/pi-agent-core" {
229
+ * interface CustomAgentMessages {
230
+ * artifact: ArtifactMessage;
231
+ * notification: NotificationMessage;
232
+ * }
233
+ * }
234
+ * ```
235
+ */
236
+ export interface CustomAgentMessages {
237
+ // Empty by default - apps extend via declaration merging
238
+ }
239
+
240
+ /**
241
+ * AgentMessage: Union of LLM messages + custom messages.
242
+ * This abstraction allows apps to add custom message types while maintaining
243
+ * type safety and compatibility with the base LLM messages.
244
+ */
245
+ export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages];
246
+
247
+ /**
248
+ * Agent state containing all configuration and conversation data.
249
+ */
250
+ export interface AgentState {
251
+ systemPrompt: string;
252
+ model: Model<any>;
253
+ thinkingLevel: ThinkingLevel;
254
+ tools: AgentTool<any>[];
255
+ messages: AgentMessage[]; // Can include attachments + custom message types
256
+ isStreaming: boolean;
257
+ streamMessage: AgentMessage | null;
258
+ pendingToolCalls: Set<string>;
259
+ error?: string;
260
+ }
261
+
262
+ export interface AgentToolResult<T> {
263
+ // Content blocks supporting text and images
264
+ content: (TextContent | ImageContent)[];
265
+ // Details to be displayed in a UI or logged
266
+ details: T;
267
+ }
268
+
269
+ // Callback for streaming tool execution updates
270
+ export type AgentToolUpdateCallback<T = any> = (partialResult: AgentToolResult<T>) => void;
271
+
272
+ // AgentTool extends Tool but adds argument preparation and execution hooks
273
+ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any> extends Tool<TParameters> {
274
+ // A human-readable label for the tool to be displayed in UI
275
+ label: string;
276
+ // Optional compatibility shim to prepare raw tool call arguments before schema validation.
277
+ // Must return an object conforming to TParameters.
278
+ prepareArguments?: (args: unknown) => Static<TParameters>;
279
+ execute: (
280
+ toolCallId: string,
281
+ params: Static<TParameters>,
282
+ signal?: AbortSignal,
283
+ onUpdate?: AgentToolUpdateCallback<TDetails>,
284
+ ) => Promise<AgentToolResult<TDetails>>;
285
+ }
286
+
287
+ // AgentContext is like Context but uses AgentTool
288
+ export interface AgentContext {
289
+ systemPrompt: string;
290
+ messages: AgentMessage[];
291
+ tools?: AgentTool<any>[];
292
+ }
293
+
294
+ /**
295
+ * Events emitted by the Agent for UI updates.
296
+ * These events provide fine-grained lifecycle information for messages, turns, and tool executions.
297
+ */
298
+ export type AgentEvent =
299
+ // Agent lifecycle
300
+ | { type: "agent_start" }
301
+ | { type: "agent_end"; messages: AgentMessage[] }
302
+ // Turn lifecycle - a turn is one assistant response + any tool calls/results
303
+ | { type: "turn_start" }
304
+ | { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] }
305
+ // Message lifecycle - emitted for user, assistant, and toolResult messages
306
+ | { type: "message_start"; message: AgentMessage }
307
+ // Only emitted for assistant messages during streaming
308
+ | { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent }
309
+ | { type: "message_end"; message: AgentMessage }
310
+ // Tool execution lifecycle
311
+ | { type: "tool_execution_start"; toolCallId: string; toolName: string; args: any }
312
+ | { type: "tool_execution_update"; toolCallId: string; toolName: string; args: any; partialResult: any }
313
+ | { type: "tool_execution_end"; toolCallId: string; toolName: string; result: any; isError: boolean };