@aws-blocks/bb-agent 0.1.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 (75) hide show
  1. package/LICENSE +174 -0
  2. package/README.md +801 -0
  3. package/dist/agent.aws.d.ts +7 -0
  4. package/dist/agent.aws.d.ts.map +1 -0
  5. package/dist/agent.aws.js +9 -0
  6. package/dist/agent.d.ts +121 -0
  7. package/dist/agent.d.ts.map +1 -0
  8. package/dist/agent.js +588 -0
  9. package/dist/agent.mock.d.ts +7 -0
  10. package/dist/agent.mock.d.ts.map +1 -0
  11. package/dist/agent.mock.js +12 -0
  12. package/dist/errors.d.ts +39 -0
  13. package/dist/errors.d.ts.map +1 -0
  14. package/dist/errors.js +40 -0
  15. package/dist/file-bucket-snapshot-storage.d.ts +49 -0
  16. package/dist/file-bucket-snapshot-storage.d.ts.map +1 -0
  17. package/dist/file-bucket-snapshot-storage.js +84 -0
  18. package/dist/index.aws.d.ts +5 -0
  19. package/dist/index.aws.d.ts.map +1 -0
  20. package/dist/index.aws.js +5 -0
  21. package/dist/index.browser.d.ts +4 -0
  22. package/dist/index.browser.d.ts.map +1 -0
  23. package/dist/index.browser.js +8 -0
  24. package/dist/index.cdk.d.ts +15 -0
  25. package/dist/index.cdk.d.ts.map +1 -0
  26. package/dist/index.cdk.js +60 -0
  27. package/dist/index.hooks.d.ts +122 -0
  28. package/dist/index.hooks.d.ts.map +1 -0
  29. package/dist/index.hooks.js +179 -0
  30. package/dist/index.mock.d.ts +5 -0
  31. package/dist/index.mock.d.ts.map +1 -0
  32. package/dist/index.mock.js +5 -0
  33. package/dist/index.test.d.ts +2 -0
  34. package/dist/index.test.d.ts.map +1 -0
  35. package/dist/index.test.js +864 -0
  36. package/dist/model-factory.d.ts +26 -0
  37. package/dist/model-factory.d.ts.map +1 -0
  38. package/dist/model-factory.js +197 -0
  39. package/dist/models.d.ts +83 -0
  40. package/dist/models.d.ts.map +1 -0
  41. package/dist/models.js +84 -0
  42. package/dist/providers/canned.d.ts +32 -0
  43. package/dist/providers/canned.d.ts.map +1 -0
  44. package/dist/providers/canned.js +187 -0
  45. package/dist/providers/throwing.d.ts +10 -0
  46. package/dist/providers/throwing.d.ts.map +1 -0
  47. package/dist/providers/throwing.js +16 -0
  48. package/dist/schemas.d.ts +59 -0
  49. package/dist/schemas.d.ts.map +1 -0
  50. package/dist/schemas.js +36 -0
  51. package/dist/types.d.ts +295 -0
  52. package/dist/types.d.ts.map +1 -0
  53. package/dist/types.js +3 -0
  54. package/dist/version.d.ts +3 -0
  55. package/dist/version.d.ts.map +1 -0
  56. package/dist/version.js +3 -0
  57. package/package.json +59 -0
  58. package/src/agent.aws.ts +13 -0
  59. package/src/agent.mock.ts +16 -0
  60. package/src/agent.ts +604 -0
  61. package/src/errors.ts +44 -0
  62. package/src/file-bucket-snapshot-storage.ts +85 -0
  63. package/src/index.aws.ts +7 -0
  64. package/src/index.browser.ts +10 -0
  65. package/src/index.cdk.ts +70 -0
  66. package/src/index.hooks.ts +256 -0
  67. package/src/index.mock.ts +7 -0
  68. package/src/index.test.ts +1010 -0
  69. package/src/model-factory.ts +228 -0
  70. package/src/models.ts +88 -0
  71. package/src/providers/canned.ts +205 -0
  72. package/src/providers/throwing.ts +19 -0
  73. package/src/schemas.ts +40 -0
  74. package/src/types.ts +311 -0
  75. package/src/version.ts +3 -0
package/README.md ADDED
@@ -0,0 +1,801 @@
1
+ # @aws-blocks/bb-agent
2
+
3
+ AI agent with streaming, tool calling, and conversation persistence. Powered by [Strands Agents SDK](https://strandsagents.com/).
4
+
5
+ **When to use:** Conversational AI experiences — chatbots, copilots, data extraction, or any LLM-powered feature. Supports multi-turn conversations, tool calling with Zod schemas, and multiple model providers.
6
+
7
+ **Requires:** `zod` ^4.0.0 as a peer dependency. Tool parameters use Zod schemas for validation. If you see `ZodType missing properties` errors, check your zod version.
8
+
9
+ ## Quick Start
10
+
11
+ ```typescript
12
+ import { Scope } from '@aws-blocks/core';
13
+ import { Agent, BedrockModels } from '@aws-blocks/bb-agent';
14
+
15
+ const scope = new Scope('my-app');
16
+
17
+ const agent = new Agent(scope, 'support-agent', {
18
+ model: { deployed: BedrockModels.DEFAULT },
19
+ systemPrompt: 'You are a helpful support agent.',
20
+ });
21
+
22
+ // Create a conversation and stream a response
23
+ const conversationId = await agent.createConversationId('user-123');
24
+ const channel = await agent.getChannel(conversationId);
25
+ const sub = channel.subscribe((chunk) => { /* handle chunk */ });
26
+ await sub.established;
27
+ const result = await agent.stream('Until when are you open tomorrow?', { conversationId, userId: 'user-123' });
28
+ const done = await result.complete();
29
+ console.log(done.text); // "We're open until 6pm tomorrow."
30
+ ```
31
+ See [Tools](#tools) for adding capabilities, [Model Configuration](#model-configuration) for provider setup, and [Local Development](#local-development) for running without AWS Bedrock.
32
+
33
+ ## API
34
+
35
+ ```typescript
36
+ const agent = new Agent(scope, id, config)
37
+ ```
38
+
39
+ | Method | Returns | Description |
40
+ |--------|---------|-------------|
41
+ | `stream(message, options?)` | `Promise<AgentStreamResult>` | Submit a message. Returns immediately with `{ channelId, channel, complete }`. |
42
+ | `resume(channelId, responses, options?)` | `Promise<void>` | Resume an interrupted agent with user responses. Chunks publish to the same channel. |
43
+ | `createConversationId(userId)` | `Promise<string>` | Generate a new conversation ID (UUID). |
44
+ | `getConversation(id, options?)` | `Promise<Message[]>` | Get messages in a conversation. Pass `{ limit }` for most recent N. |
45
+ | `listConversations(userId)` | `Promise<Conversation[]>` | List all conversations for a user. |
46
+ | `deleteConversation(id, userId)` | `Promise<void>` | Delete a conversation and its session data. |
47
+ | `getPendingInterrupts(conversationId)` | `Promise<Array<...>>` | Get unanswered interrupts (for reload support). |
48
+ | `getChannel(channelId)` | `Promise<RealtimeChannel>` | Get a Realtime channel for subscribing to chunks. |
49
+
50
+ `stream()` submits the message to AsyncJob and returns immediately — no API Gateway timeout risk. The agent runs asynchronously and publishes chunks to Realtime.
51
+
52
+ **Important: Subscribe before sending.** The agent starts emitting chunks immediately after `stream()` is called. If you subscribe to the channel after calling `stream()`, early chunks may be dropped. Always subscribe first, await `established`, then send:
53
+
54
+ ```typescript
55
+ // Correct: subscribe first, await established, then send
56
+ const channel = await agent.getChannel(conversationId);
57
+ const sub = channel.subscribe((chunk) => { /* handle chunk */ });
58
+ await sub.established;
59
+ await agent.stream(message, { conversationId, userId });
60
+
61
+ // Wrong: send first, subscribe after — early chunks lost
62
+ await agent.stream(message, { conversationId, userId });
63
+ const channel = await agent.getChannel(conversationId); // too late!
64
+ ```
65
+
66
+ The `useChat` hook (see [Client Hook](#client-hook--usechat)) handles this ordering automatically. Use it instead of hand-rolling stream logic.
67
+
68
+ ### Authorization (caller responsibility)
69
+
70
+ The Agent BB scopes data by `conversationId`, which is an unguessable UUID, but it does **not** authorize the caller against a conversation on read paths. `getConversation(id)` and `getPendingInterrupts(conversationId)` take only an id, so any caller that supplies a valid conversation ID gets the messages back.
71
+
72
+ Your API handler owns authorization: derive `userId` from the authenticated session and verify the conversation belongs to that user before reading it. `listConversations(userId)` returns only the conversations a user owns, so it's the safe way to resolve which conversation IDs a caller may access:
73
+
74
+ ```typescript
75
+ export const api = new ApiNamespace(scope, 'api', (context) => ({
76
+ async getMessages(conversationId: string) {
77
+ const user = await auth.getCurrentUser(context);
78
+ const owned = await agent.listConversations(user.userId);
79
+ if (!owned.some(c => c.conversationId === conversationId)) {
80
+ throw new Error('Not found');
81
+ }
82
+ return agent.getConversation(conversationId);
83
+ },
84
+ }));
85
+ ```
86
+
87
+ `deleteConversation(id, userId)` is owner-scoped internally — it verifies the conversation belongs to `userId` before deleting anything, so a non-owner call is a no-op.
88
+
89
+ ### AgentStreamResult
90
+
91
+ Returned by `stream()`. Provides the Realtime channel and convenience methods:
92
+
93
+ | Property/Method | Type | Description |
94
+ |--------|------|-------------|
95
+ | `channelId` | `string` | Realtime channel where chunks are published. |
96
+ | `channel` | `Promise<RealtimeChannel>` | Realtime channel handle — `await` it, then call `.subscribe(handler)`. |
97
+ | `complete()` | `Promise<AgentStreamChunk>` | Wait for the done chunk (full text + token usage). |
98
+
99
+ ### AgentConfig
100
+
101
+ | Option | Type | Description |
102
+ |--------|------|----------------------------------------------------------------------|
103
+ | `model` | `{ deployed, local? }` | Model configuration (see below). |
104
+ | `systemPrompt` | `string` | System prompt for the agent. |
105
+ | `tools` | `(tool) => Record<string, AgentTool>` | Tools the agent can call during reasoning. |
106
+ | `toolContextSchema` | `z.ZodType` | Optional schema for per-call tool context. When set, `context` is required and typed. |
107
+ | `inferenceOnly` | `boolean` | Skip persistence infra. Default: `false`. |
108
+ | `conversation` | `ConversationManagerConfig` | How the agent trims message history (sliding-window or summarizing). |
109
+ | `streamingMode` | `'token' \| 'block'` | How text chunks are published to the client. Default: `'block'`. |
110
+
111
+ ### Model Configuration
112
+
113
+ Only `deployed` is required. Local development works out of the box — the canned provider (keyword-based mock) is used automatically when no local model is specified.
114
+
115
+ | Option | Type | Description |
116
+ |--------|------|-------------|
117
+ | `provider` | `'bedrock' \| 'openai-api' \| 'canned'` | Model provider. |
118
+ | `modelId` | `string` | Model ID. Required for bedrock and openai-api. |
119
+ | `endpoint` | `string` | API endpoint. For openai-api (defaults to api.openai.com). |
120
+ | `apiKey` | `string \| () => Promise<string>` | API key for openai-api. Accepts a string or async resolver. Falls back to `OPENAI_API_KEY` env var. |
121
+ | `inferenceConfig` | `{ temperature?, topP?, maxTokens?, stopSequences? }` | Optional inference parameters. |
122
+
123
+ ```typescript
124
+ import { Agent } from '@aws-blocks/bb-agent';
125
+
126
+ // Minimal — just deployed model, canned provider used locally automatically
127
+ const agent = new Agent(scope, 'agent', {
128
+ model: {
129
+ deployed: { provider: 'bedrock', modelId: '...' },
130
+ },
131
+ systemPrompt: '...',
132
+ });
133
+ ```
134
+
135
+ Specify a model for local development to use instead of the canned provider:
136
+
137
+ ```typescript
138
+ const agent = new Agent(scope, 'agent', {
139
+ model: {
140
+ deployed: { provider: 'bedrock', modelId: '...' },
141
+ local: { provider: 'openai-api', modelId: 'llama3.1:8b', endpoint: 'http://localhost:11434/v1', apiKey: 'ollama' },
142
+ },
143
+ systemPrompt: '...',
144
+ });
145
+ ```
146
+
147
+ For fallback support, provide an array of candidates. They are tried in order — the first available model wins. Health checks verify each candidate before selecting it (see [Health Checks](#health-checks)):
148
+
149
+ ```typescript
150
+ model: {
151
+ deployed: [
152
+ { provider: 'bedrock', modelId: '...' },
153
+ { provider: 'bedrock', modelId: '...' },
154
+ { provider: 'canned' }, // canned can be used in deployed as a last resort
155
+ ],
156
+ local: [
157
+ { provider: 'openai-api', modelId: 'llama3.2:3b', endpoint: 'http://localhost:11434/v1' },
158
+ // canned is always appended implicitly as last fallback for local
159
+ ],
160
+ }
161
+ ```
162
+
163
+ #### Bedrock Presets
164
+
165
+ Pre-configured model presets for quick setup. Names are capability-based so the underlying model can be upgraded without breaking your code. These use cross-region inference profiles — work across all AWS regions:
166
+
167
+ ```typescript
168
+ import { Agent, BedrockModels} from '@aws-blocks/bb-agent';
169
+
170
+ const agent = new Agent(scope, 'agent', {
171
+ model: {
172
+ deployed: BedrockModels.DEFAULT,
173
+ },
174
+ systemPrompt: '...',
175
+ });
176
+ ```
177
+
178
+ | Preset | Current Model | Notes |
179
+ |--------|---------------|-------|
180
+ | `BedrockModels.DEFAULT` | `us.anthropic.claude-opus-4-8-20250610-v1:0` | Highest capability. Recommended default. |
181
+ | `BedrockModels.BALANCED` | `us.anthropic.claude-sonnet-4-20250514-v1:0` | Strong quality/cost balance. |
182
+ | `BedrockModels.FAST` | `us.anthropic.claude-haiku-4-5-20251001-v1:0` | Fastest, lowest latency. |
183
+ | `BedrockModels.BUDGET` | `us.amazon.nova-pro-v1:0` | Low cost per token with acceptable quality. |
184
+ | `BedrockModels.MICRO` | `us.amazon.nova-lite-v1:0` | Ultra-cheap for simple tasks. |
185
+
186
+ Override inference settings with spread:
187
+ ```typescript
188
+ model: { deployed: { ...BedrockModels.DEFAULT, inferenceConfig: { temperature: 0.9, maxTokens: 8192 } } }
189
+ ```
190
+
191
+ #### Ollama Presets
192
+
193
+ Convenience shortcuts for local development using [Ollama](https://ollama.com/). Requires Ollama installed and running (`ollama serve`), model pulled (`ollama pull <model-id>`). Uses the default endpoint `http://localhost:11434/v1`.
194
+
195
+ ```typescript
196
+ import { Agent, BedrockModels, OllamaModels} from '@aws-blocks/bb-agent';
197
+
198
+ const agent = new Agent(scope, 'agent', {
199
+ model: {
200
+ deployed: BedrockModels.DEFAULT,
201
+ local: OllamaModels.SMALL,
202
+ },
203
+ systemPrompt: '...',
204
+ });
205
+ ```
206
+
207
+ | Preset | Current Model | Size | Recommended VRAM |
208
+ |--------|---------------|------|------------------|
209
+ | `OllamaModels.XSMALL` | `llama3.2:3b` | 2 GB | 4 GB |
210
+ | `OllamaModels.SMALL` | `llama3.1:8b` | 4.7 GB | 8 GB |
211
+ | `OllamaModels.MEDIUM` | `deepseek-r1:14b` | 9 GB | 16 GB |
212
+ | `OllamaModels.LARGE` | `llama3.3:70b` | 43 GB | 48 GB+ |
213
+ | `OllamaModels.XLARGE` | `llama4:16x17b` | 67 GB | 80 GB+ |
214
+
215
+ Custom endpoint or specific model? Use `openai-api` directly:
216
+ ```typescript
217
+ model: { local: { provider: 'openai-api', modelId: 'llama3.1:8b', endpoint: 'http://custom-host:11434/v1', apiKey: 'ollama' } }
218
+ ```
219
+ See [Ollama Presets](#ollama-presets) and [Local Development](#local-development) for more options.
220
+
221
+ #### Health Checks
222
+
223
+ Before selecting a model, the agent verifies its availability:
224
+
225
+ - **Bedrock:** Verifies model availability via `@aws-sdk/client-bedrock` (free, no inference cost).
226
+ - **OpenAI-compatible:** Pings `GET /v1/models` and checks if the specified model ID is in the response.
227
+ - **Canned:** Always available (no external dependency).
228
+
229
+ Health checks verify the model *exists* but cannot guarantee invoke access (e.g., EULA not accepted, quota limits). If all candidates fail, the agent throws `AgentErrors.ModelUnavailable`. Check logs for details.
230
+
231
+ To see detailed health check logs, pass a logger with `info` level:
232
+
233
+ ```typescript
234
+ import { Logger } from '@aws-blocks/bb-logger';
235
+
236
+ const agent = new Agent(scope, 'agent', {
237
+ model: { deployed: BedrockModels.DEFAULT },
238
+ systemPrompt: '...',
239
+ logger: new Logger(scope, 'agent-log', { level: 'info' }),
240
+ });
241
+ ```
242
+
243
+ #### API Key Management
244
+
245
+ ```typescript
246
+ // Recommended: AppSetting with secret (encrypted via SSM SecureString)
247
+ const openaiKey = new AppSetting(scope, 'openai-key', {
248
+ name: '/myapp/openai-api-key',
249
+ secret: true,
250
+ });
251
+
252
+ const agent = new Agent(scope, 'agent', {
253
+ model: {
254
+ deployed: {
255
+ provider: 'openai-api',
256
+ modelId: 'gpt-4',
257
+ apiKey: () => openaiKey.get(),
258
+ },
259
+ },
260
+ });
261
+
262
+ // Alternative: environment variable (local dev)
263
+ // Set OPENAI_API_KEY — no apiKey needed in config
264
+
265
+ // Alternative: plain string (discouraged — leaks in source control)
266
+ // apiKey: 'sk-...'
267
+ ```
268
+
269
+ #### AWS Credentials (Bedrock)
270
+
271
+ The `bedrock` provider uses your configured AWS credentials. See [Strands quickstart](https://strandsagents.com/docs/user-guide/quickstart/typescript/#configuring-credentials) for setup instructions.
272
+
273
+ #### Bedrock via Mantle
274
+
275
+ Amazon Bedrock exposes an OpenAI-compatible endpoint via [Bedrock Mantle](https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-mantle.html). Use it with `provider: 'openai-api'` and set the endpoint to `https://bedrock-mantle.<region>.api.aws/v1`.
276
+
277
+ ### Error Handling
278
+
279
+ ```typescript
280
+ import { isBlocksError } from '@aws-blocks/core';
281
+ import { AgentErrors } from '@aws-blocks/bb-agent';
282
+
283
+ try {
284
+ await agent.getConversation(id);
285
+ } catch (e: unknown) {
286
+ if (isBlocksError(e, AgentErrors.PersistenceRequired)) {
287
+ // agent is in inferenceOnly mode
288
+ }
289
+ }
290
+ ```
291
+
292
+ | Error | When |
293
+ |-------|------|
294
+ | `AgentErrors.PersistenceRequired` | Conversation CRUD called on an inferenceOnly agent. |
295
+ | `AgentErrors.InvalidModelConfig` | Missing modelId, apiKey, unknown provider, or `needsApproval` + `interrupt` both specified. |
296
+ | `AgentErrors.ModelUnavailable` | All model candidates failed health checks. Check logs for details. |
297
+ | `AgentErrors.StreamFailed` | Agent encountered an error during execution. |
298
+ | `AgentErrors.InterruptRequired` | Agent paused for approval. Use `InterruptError` for typed access to pending interrupts. |
299
+ | `AgentErrors.BrowserNotSupported` | Agent instantiated in the browser (server-side only). |
300
+
301
+ ### Streaming Mode
302
+
303
+ Controls how text is published to the client:
304
+
305
+ - **`'block'` (default)** — buffers text and publishes when a full content block completes.
306
+ - **`'token'`** — publishes every text delta immediately as it arrives. Use for typewriter-style UIs.
307
+
308
+ ```typescript
309
+ const agent = new Agent(scope, 'support', {
310
+ streamingMode: 'token',
311
+ ...
312
+ });
313
+ ```
314
+
315
+ ### Conversation Management
316
+
317
+ Controls how the agent trims message history when the context window fills up:
318
+
319
+ ```typescript
320
+ // Sliding window — keep last 20 messages
321
+ const agent = new Agent(scope, 'support', {
322
+ conversation: { strategy: 'sliding-window', windowSize: 20 },
323
+ ...
324
+ });
325
+
326
+ // Summarizing — summarizes older messages, preserves 5 most recent
327
+ const agent = new Agent(scope, 'support', {
328
+ conversation: { strategy: 'summarizing', preserveRecentMessages: 5 },
329
+ ...
330
+ });
331
+ ```
332
+
333
+ ## Tools
334
+
335
+ Tools let the agent take actions during its reasoning — query a database, call an API, send an email. The model decides *when* to call a tool based on the user's message and the tool's description. You define the tool's schema and handler; the framework handles the rest.
336
+
337
+ ### Adding Tools
338
+
339
+ Add tools to let the agent take actions. Each tool has a description, Zod schema for parameters, and a handler. The handler receives `{ input, context, interrupt }`:
340
+
341
+ ```typescript
342
+ import { z } from 'zod';
343
+
344
+ const agent = new Agent(scope, 'support', {
345
+ model: { deployed: { provider: 'bedrock', modelId: '...' } },
346
+ systemPrompt: 'You are a customer support agent. Look up orders when asked.',
347
+ tools: (tool) => ({
348
+ getOrderStatus: tool({
349
+ description: 'Get the status of a customer order by ID',
350
+ parameters: z.object({ orderId: z.string() }),
351
+ handler: async ({ input }) => {
352
+ const order = await db.getOrder(input.orderId);
353
+ return { orderId: input.orderId, status: order.status, total: order.total };
354
+ },
355
+ }),
356
+ }),
357
+ });
358
+ ```
359
+
360
+ ### Declaring tools (the `tools` callback)
361
+
362
+ `tools` is a callback that receives a `tool()` factory and returns a Record keyed by tool name:
363
+
364
+ ```typescript
365
+ tools: (tool) => ({
366
+ getOrderStatus: tool({ /* ... */ }),
367
+ })
368
+ ```
369
+
370
+ The callback form lets TypeScript infer each tool's `input` from its `parameters`. The Record key is the tool's name.
371
+
372
+ ### Tool Context — Scoping Tools to the Caller
373
+
374
+ Tools often need request-scoped information (e.g. the authenticated `userId`). Pass a `context` object on each `stream()`/`resume()` call; it's forwarded to every tool invocation:
375
+
376
+ ```typescript
377
+ const agent = new Agent(scope, 'support', {
378
+ model: { deployed: { provider: 'bedrock', modelId: '...' } },
379
+ systemPrompt: 'You are a support agent.',
380
+ tools: (tool) => ({
381
+ listMyOrders: tool({
382
+ description: "List the current user's orders",
383
+ parameters: z.object({}),
384
+ handler: async ({ context }) => {
385
+ return db.listOrders({ userId: context.userId });
386
+ },
387
+ }),
388
+ }),
389
+ });
390
+
391
+ const user = await auth.getCurrentUser(requestContext);
392
+ await agent.stream(message, { conversationId, userId: user.userId, context: { userId: user.userId } });
393
+ ```
394
+
395
+ To make context required and type-safe, declare a `toolContextSchema`:
396
+
397
+ ```typescript
398
+ const agent = new Agent(scope, 'support', {
399
+ model: { deployed: { provider: 'bedrock', modelId: '...' } },
400
+ systemPrompt: '...',
401
+ toolContextSchema: z.object({ userId: z.string(), tenantId: z.string() }),
402
+ tools: (tool) => ({
403
+ listMyOrders: tool({
404
+ description: "List the current user's orders",
405
+ parameters: z.object({}),
406
+ handler: async ({ context }) => {
407
+ // context.userId and context.tenantId are typed as string
408
+ return db.listOrders({ userId: context.userId, tenantId: context.tenantId });
409
+ },
410
+ }),
411
+ }),
412
+ });
413
+
414
+ // context is now required and validated — omitting it throws InvalidModelConfig
415
+ await agent.stream(message, { conversationId, userId, context: { userId, tenantId } });
416
+ ```
417
+
418
+ ### Using KnowledgeBase with the Agent
419
+
420
+ The `KnowledgeBase` BB can be used as an agent tool, giving the agent the ability to search documents on demand:
421
+
422
+ ```typescript
423
+ import { Agent } from '@aws-blocks/bb-agent';
424
+ import { KnowledgeBase } from '@aws-blocks/bb-knowledge-base';
425
+ import { z } from 'zod';
426
+
427
+ const kb = new KnowledgeBase(scope, 'docs', {
428
+ source: './knowledge',
429
+ description: 'Product documentation and FAQs',
430
+ });
431
+
432
+ const agent = new Agent(scope, 'assistant', {
433
+ model: { deployed: { provider: 'bedrock', modelId: '...' } },
434
+ systemPrompt: 'You are a helpful assistant. Search the knowledge base when the user asks about our product.',
435
+ tools: (tool) => ({
436
+ searchDocs: tool({
437
+ description: 'Search product documentation for relevant information',
438
+ parameters: z.object({
439
+ query: z.string().describe('The search query'),
440
+ maxResults: z.number().optional().describe('Max results to return (default: 5)'),
441
+ }),
442
+ handler: async ({ input }) => kb.retrieve(input.query, { maxResults: input.maxResults ?? 5 }),
443
+ }),
444
+ }),
445
+ });
446
+ ```
447
+
448
+ ### Tool Approval (Human-in-the-Loop)
449
+
450
+ By default, tools run autonomously. Set `needsApproval: true` on tools that should pause for user approval — the agent publishes an interrupt chunk, the client shows a confirmation UI, the user responds, and the agent resumes.
451
+
452
+ | Configuration | Behavior |
453
+ |---------------|----------|
454
+ | `needsApproval: false` (default) | Tool runs autonomously |
455
+ | `needsApproval: true` | Pauses for approval every time — user sees Yes / No |
456
+ | `needsApproval: true, trustable: true` | Pauses for approval — user sees Yes / No / Trust. "Trust" auto-approves for the rest of the conversation |
457
+
458
+ Tools that modify state should require user approval. Set `needsApproval: true`:
459
+
460
+ ```typescript
461
+ tools: (tool) => ({
462
+ getOrderStatus: tool({
463
+ description: 'Look up an order',
464
+ parameters: z.object({ orderId: z.string() }),
465
+ needsApproval: false, // read-only — safe to run
466
+ handler: async ({ input }) => db.getOrder(input.orderId),
467
+ }),
468
+ cancelOrder: tool({
469
+ description: 'Cancel a customer order',
470
+ parameters: z.object({ orderId: z.string(), reason: z.string() }),
471
+ needsApproval: true, // destructive — ask first
472
+ trustable: true, // user can say "trust" to stop being asked
473
+ handler: async ({ input }) => db.cancelOrder(input.orderId, input.reason),
474
+ }),
475
+ })
476
+ ```
477
+ When a tool is interrupted, the client receives an `interrupt` chunk. Resume with `agent.resume()`:
478
+
479
+ ```typescript
480
+ // Client receives: { type: 'interrupt', interrupts: [{ id, name, reason }] }
481
+ // User approves → resume the agent:
482
+ await agent.resume(channelId, [{ interruptId: interrupt.id, approved: true }], { conversationId, userId });
483
+ ```
484
+
485
+ **Interrupt chunk format:** `name` is `approve:${toolName}:${toolUseId}` and `reason` contains `{ tool: string, input: any, trustable: boolean }`. Use `reason.tool` for display and `reason.trustable` to decide whether to show a Trust button.
486
+
487
+ ### Custom Interrupts
488
+
489
+ For tools that need input-level approval decisions or runtime-conditional pausing, use the `interrupt` field or call `interrupt()` inside the handler:
490
+
491
+ ```typescript
492
+ tools: (tool) => ({
493
+ transferMoney: tool({
494
+ description: 'Transfer money between accounts',
495
+ parameters: z.object({ from: z.string(), to: z.string(), amount: z.number() }),
496
+ interrupt: ({ input, interrupt }) => {
497
+ if (input.amount > 100) {
498
+ interrupt({ name: 'confirm-transfer', reason: { message: `Transfer $${input.amount}?` } });
499
+ }
500
+ },
501
+ handler: async ({ input }) => ({ status: 'completed', amount: input.amount }),
502
+ }),
503
+ })
504
+ ```
505
+
506
+ ## Headless Usage (No UI)
507
+
508
+ The Agent BB works without a frontend — for scripts, background jobs, or server-to-server flows. Use `complete()` to wait for the full response. For UI-based flows, see [Client Hook — useChat](#client-hook--usechat).
509
+
510
+ ### Without tool approval
511
+
512
+ ```typescript
513
+ import { Agent, BedrockModels } from '@aws-blocks/bb-agent';
514
+
515
+ const agent = new Agent(scope, 'summarizer', {
516
+ model: { deployed: BedrockModels.DEFAULT },
517
+ systemPrompt: 'Summarize the input concisely.',
518
+ });
519
+
520
+ const conversationId = await agent.createConversationId('system');
521
+ const result = await agent.stream('Summarize this quarter earnings report...', { conversationId, userId: 'system' });
522
+ const done = await result.complete();
523
+ console.log(done.text);
524
+ ```
525
+
526
+ ### With tool approval
527
+
528
+ When tools have `needsApproval: true`, `complete()` throws an `InterruptError`. Handle it programmatically:
529
+
530
+ ```typescript
531
+ import { Agent, BedrockModels, InterruptError } from '@aws-blocks/bb-agent';
532
+ import { z } from 'zod';
533
+
534
+ const refundBot = new Agent(scope, 'refunds', {
535
+ model: { deployed: BedrockModels.DEFAULT },
536
+ systemPrompt: 'You process customer refund requests.',
537
+ tools: (tool) => ({
538
+ issueRefund: tool({
539
+ description: 'Issue a refund to a customer',
540
+ parameters: z.object({ orderId: z.string(), amount: z.number() }),
541
+ needsApproval: true,
542
+ handler: async ({ input }) => {
543
+ await payments.refund(input.orderId, input.amount);
544
+ return { refunded: true, amount: input.amount };
545
+ },
546
+ }),
547
+ }),
548
+ });
549
+
550
+ const conversationId = await refundBot.createConversationId('system');
551
+ const result = await refundBot.stream('Refund order #456, item was damaged. Total was $75.', { conversationId, userId: 'system' });
552
+
553
+ while (true) {
554
+ try {
555
+ const done = await result.complete();
556
+ console.log(done.text);
557
+ break;
558
+ } catch (err) {
559
+ if (!(err instanceof InterruptError)) throw err;
560
+ // Auto-approve refunds under $100, reject larger ones
561
+ const responses = err.interrupts.map(i => ({
562
+ interruptId: i.id,
563
+ approved: i.reason?.input?.amount < 100,
564
+ }));
565
+ await refundBot.resume(result.channelId, responses, { conversationId, userId: 'system' });
566
+ }
567
+ }
568
+ ```
569
+
570
+ ## Inference-Only (No Persistence)
571
+
572
+ Set `inferenceOnly: true` for stateless tasks that don't need conversation history — classification, extraction, summarization. No DynamoDB tables or session storage are created.
573
+
574
+ ```typescript
575
+ const classifier = new Agent(scope, 'classifier', {
576
+ inferenceOnly: true,
577
+ model: { deployed: { provider: 'bedrock', modelId: '...' } },
578
+ systemPrompt: 'Classify the sentiment of the input as positive, negative, or neutral.',
579
+ });
580
+
581
+ const result = await classifier.stream('I love this product!');
582
+ const done = await result.complete();
583
+ console.log(done.text); // "positive"
584
+ ```
585
+ ## Local Development
586
+
587
+ The Agent BB works locally without any external dependencies. No AWS credentials, no API keys, no running services — just `npm run dev`.
588
+ By default the agent uses the **CannedProvider** — a keyword-based mock that responds instantly without calling any real model. For real LLM calls locally, set `model.local` to an `openai-api` config (Ollama, vLLM, etc.) or use the Ollama presets. See [Model Configuration](#model-configuration) for details.
589
+
590
+ ### Use Local LLM
591
+
592
+ For real model responses during development, use a fallback chain with your company's shared vLLM server and a local Ollama instance. The agent tries each in order — on the company network it uses the shared server, at home it falls through to your local Ollama:
593
+
594
+ ```typescript
595
+ const agent = new Agent(scope, 'support', {
596
+ model: {
597
+ deployed: { provider: 'bedrock', modelId: '...' },
598
+ local: [
599
+ { provider: 'openai-api', modelId: 'llama3.1:70b', endpoint: 'http://vllm.internal.company.com/v1' },
600
+ { provider: 'openai-api', modelId: 'llama3.1:8b', endpoint: 'http://localhost:11434/v1', apiKey: 'ollama' },
601
+ // canned is appended implicitly — if nothing is available, agent still works
602
+ ],
603
+ },
604
+ systemPrompt: '...',
605
+ });
606
+ ```
607
+
608
+ ### Canned Provider
609
+
610
+ The CannedProvider is a custom Strands model provider that requires no network or API keys:
611
+
612
+ - Returns simple mock responses
613
+ - Triggers tool calls when the prompt mentions a tool name (e.g., "get order" triggers `getOrderStatus`)
614
+ - Generates valid tool inputs from Zod schemas using type-based placeholders
615
+ - Streams responses word by word, matching the same protocol as real providers
616
+
617
+
618
+ ## Client Hook — `useChat`
619
+
620
+ Import from `@aws-blocks/bb-agent/client`. Manages conversation state, streaming subscriptions, and interrupt handling. Handles the subscribe-before-send ordering automatically.
621
+
622
+ ```typescript
623
+ import { useChat } from '@aws-blocks/bb-agent/client';
624
+
625
+ const chat = useChat({
626
+ api: {
627
+ sendMessage: (convId, msg, chId) => api.sendMessage(convId, msg, chId),
628
+ createConversation: () => api.createConversation(userId),
629
+ getConversation: (id) => api.getConversation(id),
630
+ resume: (chId, responses, convId) => api.resume(chId, responses, convId),
631
+ },
632
+ subscribe: async (channelId, handler) => {
633
+ const { channel } = await api.getChannel(channelId);
634
+ return channel.subscribe(handler);
635
+ },
636
+ onMessagesChange: (msgs) => renderMessages(msgs),
637
+ onLoadingChange: (loading) => updateSpinner(loading),
638
+ onInterrupt: (interrupts) => showApprovalUI(interrupts),
639
+ });
640
+
641
+ await chat.sendMessage('Hello!');
642
+ await chat.respondToInterrupt([{ interruptId: 'x', approved: true }]);
643
+ ```
644
+
645
+ **Note:** `useChat` is a factory function, not a React hook. Call it **once** (e.g., outside a component or in a ref) — not on every render. It returns a mutable singleton. Message history only includes `user`, `assistant`, and `approval` messages — tool-call/tool-result internals are filtered for UI clarity. Use `getConversation()` directly if you need the full history.
646
+
647
+ ## Full Examples
648
+
649
+ ### 1. End-to-End: Backend + Frontend with `useChat`
650
+
651
+ Complete wiring showing the backend API and frontend `useChat` connected together.
652
+
653
+ **Backend** (`aws-blocks/index.ts`):
654
+
655
+ ```typescript
656
+ import { Scope, ApiNamespace } from '@aws-blocks/core';
657
+ import { Agent, BedrockModels } from '@aws-blocks/bb-agent';
658
+
659
+ const scope = new Scope('my-app');
660
+
661
+ const agent = new Agent(scope, 'chat', {
662
+ model: { deployed: BedrockModels.DEFAULT },
663
+ systemPrompt: 'You are a helpful assistant.',
664
+ });
665
+
666
+ export const api = new ApiNamespace(scope, 'api', (context) => ({
667
+ async createConversation(userId: string) {
668
+ return { conversationId: await agent.createConversationId(userId) };
669
+ },
670
+ async sendMessage(conversationId: string, message: string, channelId: string, userId: string) {
671
+ await agent.stream(message, { conversationId, channelId, userId });
672
+ },
673
+ async getConversation(conversationId: string) {
674
+ const messages = await agent.getConversation(conversationId);
675
+ return { messages };
676
+ },
677
+ async getChannel(channelId: string) {
678
+ return agent.getChannel(channelId);
679
+ },
680
+ }));
681
+ ```
682
+
683
+ **Frontend** (`app.ts`):
684
+
685
+ ```typescript
686
+ import { useChat } from '@aws-blocks/bb-agent/client';
687
+
688
+ const userId = getCurrentUserId();
689
+
690
+ const chat = useChat({
691
+ api: {
692
+ sendMessage: (convId, msg, chId) => api.sendMessage(convId, msg, chId, userId),
693
+ createConversation: () => api.createConversation(userId),
694
+ getConversation: (id) => api.getConversation(id),
695
+ },
696
+ subscribe: async (channelId, handler) => {
697
+ const channel = await api.getChannel(channelId);
698
+ return channel.subscribe(handler);
699
+ },
700
+ onMessagesChange: (msgs) => renderMessages(msgs),
701
+ onLoadingChange: (loading) => updateSpinner(loading),
702
+ });
703
+
704
+ // Send a message — useChat handles subscribe-before-send automatically
705
+ await chat.sendMessage('Hello!');
706
+
707
+ // Load an existing conversation (subscribes first, then backfills history)
708
+ await chat.loadConversation('conv-123');
709
+ ```
710
+
711
+ ### 2. Support Agent with Tools
712
+
713
+ Agent with tools that can look up orders and search documentation. Uses tool context to scope queries to the authenticated user.
714
+
715
+ ```typescript
716
+ import { Scope, ApiNamespace } from '@aws-blocks/core';
717
+ import { Agent, BedrockModels } from '@aws-blocks/bb-agent';
718
+ import { KnowledgeBase } from '@aws-blocks/bb-knowledge-base';
719
+ import { z } from 'zod';
720
+
721
+ const scope = new Scope('my-app');
722
+
723
+ const kb = new KnowledgeBase(scope, 'docs', { source: './knowledge' });
724
+
725
+ const agent = new Agent(scope, 'support', {
726
+ model: { deployed: BedrockModels.DEFAULT },
727
+ systemPrompt: 'You are a customer support agent. Look up orders and search documentation to help the user.',
728
+ toolContextSchema: z.object({ userId: z.string() }),
729
+ tools: (tool) => ({
730
+ getOrder: tool({
731
+ description: 'Get order details by ID',
732
+ parameters: z.object({ orderId: z.string() }),
733
+ handler: async ({ input, context }) => {
734
+ return db.getOrder(input.orderId, { userId: context.userId });
735
+ },
736
+ }),
737
+ searchDocs: tool({
738
+ description: 'Search product documentation',
739
+ parameters: z.object({ query: z.string() }),
740
+ handler: async ({ input }) => kb.retrieve(input.query, { maxResults: 5 }),
741
+ }),
742
+ }),
743
+ });
744
+
745
+ export const api = new ApiNamespace(scope, 'api', (context) => ({
746
+ async chat(message: string, conversationId: string) {
747
+ const user = await auth.getCurrentUser(context);
748
+ return await agent.stream(message, {
749
+ conversationId,
750
+ userId: user.userId,
751
+ context: { userId: user.userId },
752
+ });
753
+ },
754
+ }));
755
+ ```
756
+
757
+
758
+ ## Best Practices
759
+
760
+ - Keep system prompts focused — one agent per task, not one agent for everything
761
+ - Define tools with descriptive names and descriptions — the model uses these to decide when to call them
762
+ - Set `model.local` to an array of fallback candidates for flexible local dev
763
+ - Set logging to `info` during development to surface health check and model resolution details
764
+
765
+ ## What It Provisions
766
+
767
+ The Agent BB composes several internal Building Blocks automatically:
768
+
769
+ | BB | AWS Resource | Purpose |
770
+ |----|-------------|---------|
771
+ | `FileBucket` | S3 | Session snapshot storage (Strands agent state between turns) |
772
+ | `DistributedTable` × 2 | DynamoDB | Conversations table + messages table |
773
+ | `Realtime` | API Gateway WebSocket | Streaming chunks to connected clients |
774
+ | `AsyncJob` | SQS + Lambda | Runs the agent asynchronously (no API Gateway timeout) |
775
+
776
+ When `inferenceOnly: true`, the two DistributedTables are skipped (no conversation persistence).
777
+
778
+ ## Scaling & Cost (AWS)
779
+
780
+ - **Model:** Bedrock pay-per-token pricing. See [Bedrock pricing](https://aws.amazon.com/bedrock/pricing/).
781
+ - **Persistence:** DynamoDB (DistributedTable) — PAY_PER_REQUEST, single-digit ms latency.
782
+ - **Session storage:** S3 (FileBucket) — ~$0.023 per GB/month.
783
+ - **Async execution:** SQS (AsyncJob) — $0.40 per million messages.
784
+ - **Streaming:** AppSync Events (Realtime) — $1.00 per million connection minutes.
785
+ - **No timeout limit:** Agent runs in AsyncJob consumer Lambda (up to 15 min), not behind API Gateway.
786
+
787
+ ## Troubleshooting
788
+
789
+ **"Access denied / Legacy model"** — Some older model IDs may be marked as legacy. Switch to a cross-region inference profile.
790
+
791
+ **"ValidationException"** — Model ID not recognized. Use `aws bedrock list-foundation-models --query "modelSummaries[].modelId"` to see available models.
792
+
793
+ **Health check passes but invocation fails** — The health check verifies the model exists but cannot check EULA acceptance or account-level access.
794
+
795
+ ## See Also
796
+
797
+ - [Strands Agents SDK](https://strandsagents.com/)
798
+ - [Bedrock supported models](https://docs.aws.amazon.com/bedrock/latest/userguide/models-supported.html)
799
+ - [Cross-region inference profiles](https://docs.aws.amazon.com/bedrock/latest/userguide/cross-region-inference.html)
800
+ - [Bedrock pricing](https://aws.amazon.com/bedrock/pricing/)
801
+ - [Ollama model library](https://ollama.com/library)