@genui-a3/create 0.1.7 → 0.1.9

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.
@@ -5,7 +5,7 @@ let _instance: ReturnType<typeof createAnthropicProvider> | null = null
5
5
  export function getAnthropicProvider() {
6
6
  if (!_instance) {
7
7
  _instance = createAnthropicProvider({
8
- models: ['claude-sonnet-4-5-20250929', 'claude-haiku-4-5-20251001'],
8
+ models: ['claude-sonnet-4-6', 'claude-haiku-4-5-20251001'],
9
9
  })
10
10
  }
11
11
  return _instance
@@ -0,0 +1,36 @@
1
+ # Custom Logging
2
+
3
+ By default, A3 logs internally using [tslog](https://tslog.js.org) with no configuration required.
4
+ If you want A3's internal logs to flow through your own logging infrastructure — for example, to include your app's trace IDs, ship logs to a provider like Datadog or OpenTelemetry, or change the output format — you can provide a custom logger.
5
+
6
+ ## How it works
7
+
8
+ A3 uses [LogLayer](https://loglayer.dev) as its logging interface.
9
+ LogLayer is a transport-agnostic logging layer: you configure it with a _transport_ that wraps your preferred logging library, and LogLayer routes A3's log calls through it.
10
+
11
+ You do not need to know the LogLayer API to use your own logger — you only need to wrap it in the appropriate LogLayer transport.
12
+
13
+ ## Default behaviour
14
+
15
+ Out of the box, A3:
16
+
17
+ - Outputs pretty, human-readable logs in development (`NODE_ENV !== 'production'`)
18
+ - Outputs structured JSON in production
19
+ - Defaults to log level `info`, overridden by the `A3_LOG_LEVEL` environment variable
20
+
21
+ ```bash
22
+ # Supported levels: silly | trace | debug | info | warn | error | fatal
23
+ A3_LOG_LEVEL=debug node your-app.js
24
+ ```
25
+
26
+ ## Configuring a custom logger
27
+
28
+ A3 uses the [LogLayer](https://loglayer.dev) interface.
29
+ Call `configureLogger()` with a `LogLayer` instance configured for your preferred logging library.
30
+ See the [LogLayer documentation](https://loglayer.dev) for setup instructions and the full list of available transports.
31
+
32
+ ## Further reading
33
+
34
+ - [LogLayer documentation](https://loglayer.dev) — full API, plugins, multi-transport, context, and more
35
+ - [LogLayer transports](https://loglayer.dev/transports) — all available transports with setup instructions
36
+ - [tslog documentation](https://tslog.js.org) — the default A3 logger
@@ -0,0 +1,642 @@
1
+ # Creating a Custom Provider
2
+
3
+ This guide walks you through building an A3 provider for any LLM that isn't covered by the built-in Bedrock, OpenAI, or Anthropic packages.
4
+ By the end you'll have a working `Provider` implementation with both blocking and streaming support.
5
+
6
+ ## When to Create a Custom Provider
7
+
8
+ Create a custom provider when you need to connect A3 to an LLM that doesn't have a built-in provider package — for example Google Gemini, Cohere, Mistral, or a locally-hosted model.
9
+
10
+ A provider is a thin adapter.
11
+ Its job is to:
12
+
13
+ 1. Convert A3's provider-agnostic request format into your LLM's API format
14
+ 1. Send the request
15
+ 1. Convert the response back into A3's expected format (JSON string for blocking, AG-UI events for streaming)
16
+
17
+ ## The Provider Interface
18
+
19
+ Every provider implements the `Provider` interface from `@genui-a3/core`:
20
+
21
+ ```typescript
22
+ import { ZodType } from 'zod'
23
+
24
+ interface Provider {
25
+ /** Blocking request that returns a structured JSON response */
26
+ sendRequest(request: ProviderRequest): Promise<ProviderResponse>
27
+
28
+ /** Streaming request that yields AG-UI compatible events */
29
+ sendRequestStream<TState extends BaseState = BaseState>(
30
+ request: ProviderRequest,
31
+ ): AsyncIterable<StreamEvent<TState>>
32
+
33
+ /** Human-readable name for logging */
34
+ readonly name: string
35
+ }
36
+ ```
37
+
38
+ | Member | Description |
39
+ |---|---|
40
+ | `sendRequest(request)` | Blocking call. Returns a `Promise<ProviderResponse>` containing the full JSON response. |
41
+ | `sendRequestStream(request)` | Streaming call. Returns an `AsyncIterable` (typically an `AsyncGenerator`) of AG-UI `StreamEvent`s. |
42
+ | `name` | A human-readable string used in log messages (e.g. `'gemini'`, `'mistral'`). |
43
+
44
+ ## What Your Provider Receives
45
+
46
+ Both methods receive a `ProviderRequest`:
47
+
48
+ ```typescript
49
+ interface ProviderRequest {
50
+ /** System prompt including agent instructions */
51
+ systemPrompt: string
52
+ /** Conversation messages */
53
+ messages: ProviderMessage[]
54
+ /** Zod schema for structured response validation */
55
+ responseSchema: ZodType
56
+ }
57
+
58
+ interface ProviderMessage {
59
+ role: 'user' | 'assistant'
60
+ content: string
61
+ }
62
+ ```
63
+
64
+ | Field | Description |
65
+ |---|---|
66
+ | `systemPrompt` | The full system prompt, including the agent's instructions, base prompt, and transition context. Pass this as the system message to your LLM. |
67
+ | `messages` | Conversation history in chronological order. Already converted to simple `{ role, content }` pairs. |
68
+ | `responseSchema` | A Zod schema describing the exact JSON structure the LLM must return. Use this for validation and to generate a JSON schema for the LLM. |
69
+
70
+ ### The Output Schema Structure
71
+
72
+ The `responseSchema` is a Zod object that always includes these base fields, merged with the agent's custom `outputSchema`:
73
+
74
+ | Field | Type | Description |
75
+ |---|---|---|
76
+ | `chatbotMessage` | `string` | The agent's text response to the user (streamed in real-time) |
77
+ | `goalAchieved` | `boolean` | Whether the agent considers its goal complete |
78
+ | `redirectToAgent` | `string \| null` | Next agent ID for LLM-driven transitions, or `null` to stay |
79
+ | `conversationPayload` | `object` | Agent-specific structured data (defined by the agent's `outputSchema`) |
80
+ | `widgets` | `object \| undefined` | Optional widget data for UI rendering |
81
+
82
+ The LLM must return valid JSON matching this schema.
83
+ The `chatbotMessage` field is the text that gets streamed to the user in real-time during streaming mode.
84
+
85
+ ## Implementing `sendRequest` (Blocking)
86
+
87
+ The blocking path is straightforward: call the LLM, get JSON back, validate it.
88
+
89
+ ### Step-by-Step
90
+
91
+ 1. **Convert the Zod schema to JSON Schema** — call `responseSchema.toJSONSchema()` (Zod v4) to get a plain JSON schema object your LLM can understand
92
+ 1. **Format and send the request** — convert messages to your LLM's format and call its API
93
+ 1. **Extract the JSON response** — parse the LLM's output to get a JSON object
94
+ 1. **Return a `ProviderResponse`** — wrap the JSON string with optional usage info
95
+
96
+ ```typescript
97
+ interface ProviderResponse {
98
+ /** JSON string matching the response schema */
99
+ content: string
100
+ /** Optional token usage information */
101
+ usage?: {
102
+ inputTokens?: number
103
+ outputTokens?: number
104
+ totalTokens?: number
105
+ }
106
+ }
107
+ ```
108
+
109
+ ### Example
110
+
111
+ ```typescript
112
+ async sendRequest(request: ProviderRequest): Promise<ProviderResponse> {
113
+ // 1. Convert Zod schema to JSON Schema
114
+ const jsonSchema = request.responseSchema.toJSONSchema()
115
+
116
+ // 2. Call your LLM
117
+ const response = await myLLM.chat({
118
+ system: request.systemPrompt,
119
+ messages: request.messages.map(m => ({
120
+ role: m.role,
121
+ content: m.content,
122
+ })),
123
+ responseFormat: { type: 'json_schema', schema: jsonSchema },
124
+ })
125
+
126
+ // 3. Validate the response (throws ZodError if invalid)
127
+ const validated = request.responseSchema.parse(JSON.parse(response.text))
128
+
129
+ // 4. Return ProviderResponse
130
+ return {
131
+ content: JSON.stringify(validated),
132
+ usage: {
133
+ inputTokens: response.usage?.input,
134
+ outputTokens: response.usage?.output,
135
+ totalTokens: response.usage?.total,
136
+ },
137
+ }
138
+ }
139
+ ```
140
+
141
+ ## Implementing `sendRequestStream` (Streaming)
142
+
143
+ Streaming is where most of the complexity lives.
144
+ Your provider must yield AG-UI events that the A3 framework consumes.
145
+
146
+ ### Events Your Provider Yields
147
+
148
+ Your provider is only responsible for yielding **three** event types:
149
+
150
+ | Event | When | Purpose |
151
+ |---|---|---|
152
+ | `TEXT_MESSAGE_CONTENT` | Each time new text is available | Delivers text deltas to the UI in real-time |
153
+ | `TOOL_CALL_RESULT` | Stream completes successfully | Delivers the full, validated JSON response |
154
+ | `RUN_ERROR` | Any error occurs | Reports the error — **never throw from a stream** |
155
+
156
+ ### Events the Framework Handles
157
+
158
+ The framework (`simpleAgentResponseStream` in `src/core/agent.ts`) wraps your events with lifecycle events automatically.
159
+ Do **not** yield these yourself:
160
+
161
+ - `TEXT_MESSAGE_START` / `TEXT_MESSAGE_END` — opened/closed around your `TEXT_MESSAGE_CONTENT` events
162
+ - `RUN_STARTED` / `RUN_FINISHED` — emitted by `chatFlow.ts` and `AGUIAgent.ts`
163
+
164
+ ### Text Delta Extraction
165
+
166
+ During streaming, the LLM progressively builds a JSON object.
167
+ Your job is to track the `chatbotMessage` field's growth and yield only the **new** characters as deltas.
168
+
169
+ The pattern used by all built-in providers:
170
+
171
+ ```typescript
172
+ function extractDelta(
173
+ partial: Record<string, unknown>,
174
+ prevLength: number,
175
+ ): string | null {
176
+ const chatbotMessage = partial.chatbotMessage
177
+ if (typeof chatbotMessage !== 'string' || chatbotMessage.length <= prevLength) {
178
+ return null
179
+ }
180
+ return chatbotMessage.slice(prevLength)
181
+ }
182
+ ```
183
+
184
+ Use it in your stream loop:
185
+
186
+ ```typescript
187
+ let prevMessageLength = 0
188
+
189
+ for await (const partial of partialObjects) {
190
+ const delta = extractDelta(partial, prevMessageLength)
191
+ if (delta) {
192
+ prevMessageLength += delta.length
193
+ yield {
194
+ type: EventType.TEXT_MESSAGE_CONTENT,
195
+ messageId: '',
196
+ delta,
197
+ agentId,
198
+ } as StreamEvent<TState>
199
+ }
200
+ }
201
+ ```
202
+
203
+ ### Approach 1: Vercel AI SDK (Recommended)
204
+
205
+ If your LLM has a [Vercel AI SDK provider](https://sdk.vercel.ai/providers) (e.g. `@ai-sdk/google`, `@ai-sdk/mistral`), this is the easiest path.
206
+ The AI SDK handles partial JSON parsing, schema conversion, and streaming internally.
207
+
208
+ The pattern (used by the OpenAI and Anthropic providers):
209
+
210
+ ```typescript
211
+ import { streamText, Output, jsonSchema } from 'ai'
212
+ import { createMyLLM } from '@ai-sdk/my-llm' // hypothetical
213
+ import { EventType } from '@ag-ui/client'
214
+ import type { StreamEvent, BaseState } from '@genui-a3/core'
215
+
216
+ async *sendRequestStream<TState extends BaseState>(
217
+ request: ProviderRequest,
218
+ ): AsyncGenerator<StreamEvent<TState>> {
219
+ const myLLM = createMyLLM({ apiKey: '...' })
220
+
221
+ // Start the stream with structured output
222
+ const result = streamText({
223
+ model: myLLM('my-model'),
224
+ system: request.systemPrompt,
225
+ messages: request.messages,
226
+ output: Output.object({ schema: toJsonSchema(request.responseSchema) }),
227
+ })
228
+
229
+ // Iterate over partial objects
230
+ let prevMessageLength = 0
231
+ for await (const partial of result.partialOutputStream) {
232
+ const obj = partial as Record<string, unknown>
233
+ const delta = extractDelta(obj, prevMessageLength)
234
+ if (delta) {
235
+ prevMessageLength += delta.length
236
+ yield {
237
+ type: EventType.TEXT_MESSAGE_CONTENT,
238
+ messageId: '',
239
+ delta,
240
+ agentId: 'my-provider',
241
+ } as StreamEvent<TState>
242
+ }
243
+ }
244
+
245
+ // Validate and yield the final result
246
+ const finalObject = await result.output
247
+ if (finalObject === null) {
248
+ yield {
249
+ type: EventType.RUN_ERROR,
250
+ message: 'Stream completed with null output',
251
+ agentId: 'my-provider',
252
+ } as StreamEvent<TState>
253
+ return
254
+ }
255
+
256
+ const validated = request.responseSchema.parse(finalObject)
257
+ yield {
258
+ type: EventType.TOOL_CALL_RESULT,
259
+ toolCallId: '',
260
+ messageId: '',
261
+ content: JSON.stringify(validated),
262
+ agentId: 'my-provider',
263
+ } as StreamEvent<TState>
264
+ }
265
+ ```
266
+
267
+ ### Approach 2: Raw SDK
268
+
269
+ If no Vercel AI SDK adapter exists for your LLM, work directly with the LLM's native streaming API.
270
+ You'll need to:
271
+
272
+ 1. Accumulate text chunks and yield them as `TEXT_MESSAGE_CONTENT` deltas
273
+ 1. Accumulate tool/JSON chunks into a buffer
274
+ 1. Parse and validate the buffer when the stream ends
275
+ 1. Yield `TOOL_CALL_RESULT` on success or `RUN_ERROR` on failure
276
+
277
+ This is the pattern the Bedrock provider uses:
278
+
279
+ ```typescript
280
+ async *sendRequestStream<TState extends BaseState>(
281
+ request: ProviderRequest,
282
+ ): AsyncGenerator<StreamEvent<TState>> {
283
+ const jsonSchema = request.responseSchema.toJSONSchema()
284
+
285
+ // Start the raw stream
286
+ const rawStream = await myLLM.streamChat({
287
+ system: request.systemPrompt,
288
+ messages: request.messages,
289
+ responseFormat: { type: 'json_schema', schema: jsonSchema },
290
+ })
291
+
292
+ let jsonBuffer = ''
293
+
294
+ try {
295
+ for await (const chunk of rawStream) {
296
+ if (chunk.type === 'text') {
297
+ yield {
298
+ type: EventType.TEXT_MESSAGE_CONTENT,
299
+ messageId: '',
300
+ delta: chunk.text,
301
+ agentId: 'my-provider',
302
+ } as StreamEvent<TState>
303
+ } else if (chunk.type === 'json') {
304
+ jsonBuffer += chunk.data
305
+ }
306
+ }
307
+
308
+ // Validate the accumulated JSON
309
+ const parsed = JSON.parse(jsonBuffer)
310
+ const validated = request.responseSchema.parse(parsed)
311
+
312
+ yield {
313
+ type: EventType.TOOL_CALL_RESULT,
314
+ toolCallId: '',
315
+ messageId: '',
316
+ content: JSON.stringify(validated),
317
+ agentId: 'my-provider',
318
+ } as StreamEvent<TState>
319
+ } catch (err) {
320
+ yield {
321
+ type: EventType.RUN_ERROR,
322
+ message: `Stream error: ${(err as Error).message}`,
323
+ agentId: 'my-provider',
324
+ } as StreamEvent<TState>
325
+ }
326
+ }
327
+ ```
328
+
329
+ ### Error Handling
330
+
331
+ **Never throw from `sendRequestStream`.** The framework expects the stream to yield a terminal event rather than throw.
332
+
333
+ - Wrap the entire stream body in `try/catch`
334
+ - In the `catch` block, yield a `RUN_ERROR` event
335
+ - Always yield exactly one terminal event: either `TOOL_CALL_RESULT` (success) or `RUN_ERROR` (failure)
336
+
337
+ ```typescript
338
+ async *sendRequestStream<TState extends BaseState>(
339
+ request: ProviderRequest,
340
+ ): AsyncGenerator<StreamEvent<TState>> {
341
+ try {
342
+ // ... stream logic ...
343
+ } catch (err) {
344
+ yield {
345
+ type: EventType.RUN_ERROR,
346
+ message: `MyProvider stream error: ${(err as Error).message}`,
347
+ agentId: 'my-provider',
348
+ } as StreamEvent<TState>
349
+ }
350
+ }
351
+ ```
352
+
353
+ ## Model Fallback
354
+
355
+ A3 provides a shared `executeWithFallback` utility in `providers/utils/`.
356
+ It tries each model in order and falls back to the next if one fails.
357
+
358
+ ### Blocking Fallback
359
+
360
+ ```typescript
361
+ import { executeWithFallback } from '../utils/executeWithFallback'
362
+
363
+ async sendRequest(request: ProviderRequest): Promise<ProviderResponse> {
364
+ return executeWithFallback(this.models, (model) =>
365
+ this.sendWithModel(model, request),
366
+ )
367
+ }
368
+ ```
369
+
370
+ ### Streaming Fallback (Eager First-Chunk Pattern)
371
+
372
+ For streaming, you need to detect connection/model errors **before** you start yielding events.
373
+ The pattern: start the stream, consume the first chunk (which forces the API call), and only then yield events.
374
+
375
+ ```typescript
376
+ // Helper: start stream and consume first chunk to trigger the API call
377
+ async function startStream(model: string, request: ProviderRequest) {
378
+ const result = streamText({
379
+ model: myLLM(model),
380
+ system: request.systemPrompt,
381
+ messages: request.messages,
382
+ output: Output.object({ schema: toJsonSchema(request.responseSchema) }),
383
+ })
384
+
385
+ const reader = result.partialOutputStream[Symbol.asyncIterator]()
386
+ const first = await reader.next() // Forces the API call — throws on connection error
387
+
388
+ return { result, reader, first }
389
+ }
390
+
391
+ // In sendRequestStream:
392
+ async *sendRequestStream<TState extends BaseState>(
393
+ request: ProviderRequest,
394
+ ): AsyncGenerator<StreamEvent<TState>> {
395
+ // Fallback happens here — if primary model fails on first chunk, retry with next
396
+ const { result, reader, first } = await executeWithFallback(
397
+ this.models,
398
+ (model) => startStream(model, request),
399
+ )
400
+
401
+ // Process the stream (first chunk already consumed)
402
+ yield* processMyStream<TState>(result, reader, first, 'my-provider', request.responseSchema)
403
+ }
404
+ ```
405
+
406
+ This is the exact pattern used by the OpenAI and Anthropic providers.
407
+
408
+ ## Complete Example
409
+
410
+ A full, copy-pasteable provider factory for a hypothetical LLM:
411
+
412
+ ```typescript
413
+ import { EventType } from '@ag-ui/client'
414
+ import type {
415
+ Provider,
416
+ ProviderRequest,
417
+ ProviderResponse,
418
+ BaseState,
419
+ StreamEvent,
420
+ } from '@genui-a3/core'
421
+ import { executeWithFallback } from '@genui-a3/providers/utils'
422
+
423
+ // --- Hypothetical LLM SDK ---
424
+ import { MyLLMClient } from 'my-llm-sdk'
425
+
426
+ export interface MyProviderConfig {
427
+ apiKey: string
428
+ models: string[]
429
+ }
430
+
431
+ /**
432
+ * Extracts the new portion of chatbotMessage from a partial object.
433
+ */
434
+ function extractDelta(
435
+ partial: Record<string, unknown>,
436
+ prevLength: number,
437
+ ): string | null {
438
+ const chatbotMessage = partial.chatbotMessage
439
+ if (typeof chatbotMessage !== 'string' || chatbotMessage.length <= prevLength) {
440
+ return null
441
+ }
442
+ return chatbotMessage.slice(prevLength)
443
+ }
444
+
445
+ export function createMyProvider(config: MyProviderConfig): Provider {
446
+ const client = new MyLLMClient({ apiKey: config.apiKey })
447
+ const models = config.models
448
+
449
+ return {
450
+ name: 'my-provider',
451
+
452
+ async sendRequest(request: ProviderRequest): Promise<ProviderResponse> {
453
+ const jsonSchema = request.responseSchema.toJSONSchema()
454
+
455
+ return executeWithFallback(models, async (model) => {
456
+ const response = await client.chat({
457
+ model,
458
+ system: request.systemPrompt,
459
+ messages: request.messages.map((m) => ({
460
+ role: m.role,
461
+ content: m.content,
462
+ })),
463
+ responseFormat: { type: 'json_schema', schema: jsonSchema },
464
+ })
465
+
466
+ // Validate before returning
467
+ const validated = request.responseSchema.parse(JSON.parse(response.text))
468
+
469
+ return {
470
+ content: JSON.stringify(validated),
471
+ usage: {
472
+ inputTokens: response.usage?.inputTokens,
473
+ outputTokens: response.usage?.outputTokens,
474
+ totalTokens: (response.usage?.inputTokens ?? 0) + (response.usage?.outputTokens ?? 0),
475
+ },
476
+ }
477
+ })
478
+ },
479
+
480
+ async *sendRequestStream<TState extends BaseState = BaseState>(
481
+ request: ProviderRequest,
482
+ ): AsyncGenerator<StreamEvent<TState>> {
483
+ const jsonSchema = request.responseSchema.toJSONSchema()
484
+ const agentId = 'my-provider'
485
+
486
+ try {
487
+ // Use fallback for the connection phase
488
+ const rawStream = await executeWithFallback(models, (model) =>
489
+ client.streamChat({
490
+ model,
491
+ system: request.systemPrompt,
492
+ messages: request.messages.map((m) => ({
493
+ role: m.role,
494
+ content: m.content,
495
+ })),
496
+ responseFormat: { type: 'json_schema', schema: jsonSchema },
497
+ }),
498
+ )
499
+
500
+ let prevMessageLength = 0
501
+ let fullJson = ''
502
+
503
+ for await (const chunk of rawStream) {
504
+ // Yield text deltas for real-time display
505
+ if (chunk.type === 'text') {
506
+ yield {
507
+ type: EventType.TEXT_MESSAGE_CONTENT,
508
+ messageId: '',
509
+ delta: chunk.text,
510
+ agentId,
511
+ } as StreamEvent<TState>
512
+ }
513
+
514
+ // Accumulate JSON for final validation
515
+ if (chunk.type === 'partial_json') {
516
+ const partial = chunk.data as Record<string, unknown>
517
+ const delta = extractDelta(partial, prevMessageLength)
518
+ if (delta) {
519
+ prevMessageLength += delta.length
520
+ yield {
521
+ type: EventType.TEXT_MESSAGE_CONTENT,
522
+ messageId: '',
523
+ delta,
524
+ agentId,
525
+ } as StreamEvent<TState>
526
+ }
527
+ fullJson = JSON.stringify(partial)
528
+ }
529
+ }
530
+
531
+ // Validate the final object
532
+ const parsed = JSON.parse(fullJson)
533
+ const validated = request.responseSchema.parse(parsed)
534
+
535
+ yield {
536
+ type: EventType.TOOL_CALL_RESULT,
537
+ toolCallId: '',
538
+ messageId: '',
539
+ content: JSON.stringify(validated),
540
+ agentId,
541
+ } as StreamEvent<TState>
542
+ } catch (err) {
543
+ yield {
544
+ type: EventType.RUN_ERROR,
545
+ message: `my-provider stream error: ${(err as Error).message}`,
546
+ agentId,
547
+ } as StreamEvent<TState>
548
+ }
549
+ },
550
+ }
551
+ }
552
+ ```
553
+
554
+ ### Using Your Provider
555
+
556
+ ```typescript
557
+ import { ChatSession, MemorySessionStore } from '@genui-a3/core'
558
+ import { createMyProvider } from './myProvider'
559
+
560
+ const provider = createMyProvider({
561
+ apiKey: process.env.MY_LLM_API_KEY!,
562
+ models: ['my-model-large', 'my-model-small'],
563
+ })
564
+
565
+ const session = new ChatSession({
566
+ sessionId: 'user-123',
567
+ store: new MemorySessionStore(),
568
+ initialAgentId: 'greeting',
569
+ provider,
570
+ })
571
+
572
+ // Blocking
573
+ const response = await session.send({ message: 'Hello!' })
574
+
575
+ // Streaming
576
+ for await (const event of session.send({ message: 'Hello!', stream: true })) {
577
+ if (event.type === 'TEXT_MESSAGE_CONTENT') {
578
+ process.stdout.write(event.delta)
579
+ }
580
+ }
581
+ ```
582
+
583
+ ## Gotchas and Tips
584
+
585
+ ### Message Ordering
586
+
587
+ Some LLMs require messages to start with a user turn or alternate strictly between roles.
588
+ The Bedrock provider handles this by prepending a `"Hi"` user message and merging consecutive same-role messages.
589
+ Check your LLM's requirements and preprocess accordingly.
590
+
591
+ ### `messageId` and `toolCallId` Assignment
592
+
593
+ The framework handles these IDs differently depending on the event type:
594
+
595
+ - **`TEXT_MESSAGE_CONTENT`** — The framework always overwrites `messageId` with its own `crypto.randomUUID()` in `simpleAgentResponseStream`.
596
+ Any value you provide (including `''`) is silently ignored.
597
+ You can safely pass an empty string here.
598
+ - **`TOOL_CALL_RESULT`** — The event is yielded as-is.
599
+ The framework does **not** assign or overwrite IDs, so whatever `messageId` and `toolCallId` you set will pass through to downstream consumers.
600
+ If you need traceability or correlation on the final result event, supply your own meaningful IDs here.
601
+
602
+ Providers can supply their own IDs for any event without issue.
603
+ For `TOOL_CALL_RESULT` it is particularly useful to do so, since those values are preserved end-to-end.
604
+
605
+ ### Schema Enforcement
606
+
607
+ Different LLMs have different levels of JSON schema support:
608
+
609
+ - **OpenAI**: Supports `response_format: { type: 'json_schema' }` with strict mode.
610
+ Requires `additionalProperties: false` and all properties in `required`.
611
+ - **Bedrock**: Uses tool-based extraction (a `structuredResponse` tool with the schema as input).
612
+ - **Others**: If your LLM doesn't support structured output natively, include the JSON schema in the system prompt and parse the response yourself.
613
+
614
+ Always validate with `responseSchema.parse()` regardless of the LLM's schema support — this is your safety net.
615
+
616
+ ### Terminal Events
617
+
618
+ Every stream must yield exactly **one** terminal event:
619
+
620
+ - `TOOL_CALL_RESULT` on success (contains the full validated JSON)
621
+ - `RUN_ERROR` on failure
622
+
623
+ If a stream ends without either, the framework throws `'Stream completed without tool call data'`.
624
+
625
+ ### Testing Your Provider
626
+
627
+ 1. **Unit test `sendRequest`**: Mock your LLM SDK, verify the returned `content` parses as valid JSON matching the schema
628
+ 1. **Unit test `sendRequestStream`**: Collect all yielded events, verify you get `TEXT_MESSAGE_CONTENT` events followed by exactly one `TOOL_CALL_RESULT`
629
+ 1. **Integration test**: Wire your provider into a `ChatSession` and send a real message through the full flow
630
+ 1. **Error paths**: Verify that API errors, invalid JSON, and schema validation failures all produce `RUN_ERROR` events (not thrown exceptions)
631
+
632
+ ## Reference
633
+
634
+ | File | Description |
635
+ |---|---|
636
+ | `src/types/provider.ts` | `Provider`, `ProviderRequest`, `ProviderResponse` interfaces |
637
+ | `src/types/stream.ts` | `StreamEvent` type union (all AG-UI event types) |
638
+ | `src/core/schemas.ts` | `createFullOutputSchema` — how the output schema is built |
639
+ | `src/core/agent.ts` | `simpleAgentResponseStream` — how the framework consumes provider events |
640
+ | `providers/openai/streamProcessor.ts` | AI SDK streaming pattern (cleanest reference) |
641
+ | `providers/bedrock/streamProcessor.ts` | Raw SDK streaming pattern |
642
+ | `providers/utils/executeWithFallback.ts` | Shared model fallback utility |