@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/dist/agent.js ADDED
@@ -0,0 +1,588 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { Scope, registerSdkIdentifiers, getSdkIdentifiers } from '@aws-blocks/core';
4
+ import { DistributedTable } from '@aws-blocks/bb-distributed-table';
5
+ import { Realtime } from '@aws-blocks/bb-realtime';
6
+ import { AsyncJob } from '@aws-blocks/bb-async-job';
7
+ import { FileBucket } from '@aws-blocks/bb-file-bucket';
8
+ import { Logger } from '@aws-blocks/bb-logger';
9
+ import { Agent as StrandsAgent, tool, SessionManager, ModelStreamUpdateEvent, AfterToolCallEvent, BeforeToolCallEvent, AgentResultEvent } from '@strands-agents/sdk';
10
+ import { InterruptResponseContent } from '@strands-agents/sdk';
11
+ import { z } from 'zod';
12
+ import { createStrandsModel, checkModelHealth } from './model-factory.js';
13
+ import { messageSchema, conversationSchema, agentStreamChunkSchema } from './schemas.js';
14
+ import { AgentErrors, blocksAgentError, InterruptError } from './errors.js';
15
+ import { SlidingWindowConversationManager, SummarizingConversationManager } from '@strands-agents/sdk';
16
+ import { BB_NAME, BB_VERSION } from './version.js';
17
+ import { ulid } from 'ulid';
18
+ /** Payload submitted to the internal AsyncJob BB. */
19
+ const jobPayloadSchema = z.object({
20
+ message: z.string(),
21
+ conversationId: z.string().optional(),
22
+ channelId: z.string(),
23
+ userId: z.string(),
24
+ resume: z.boolean().optional(), // Resume fields (for HITL interrupt responses)
25
+ interruptResponses: z.array(z.object({ interruptId: z.string(), response: z.string() })).optional(),
26
+ /** Per-call tool context, forwarded to tool invocations. JSON-serializable. */
27
+ context: z.any().optional(),
28
+ });
29
+ /** Key under which the per-call tool context is threaded through Strands `invocationState`. */
30
+ const TOOL_CONTEXT_KEY = '__bbAgentToolContext';
31
+ /**
32
+ * The per-call tool factory handed to the `tools` callback. At runtime it's an identity
33
+ * function whose only job is to give TypeScript a single call site per tool where it can
34
+ * infer `TParams` (hence `input`) and apply the unforgeable brand. See `ToolFactory`.
35
+ */
36
+ const makeTool = (tool) => tool;
37
+ /**
38
+ * Resolve the developer's `tools` callback into a name→tool map.
39
+ * The Record key is the canonical tool name (overrides any `name` on the definition).
40
+ */
41
+ function resolveTools(toolsConfig) {
42
+ const map = new Map();
43
+ if (!toolsConfig)
44
+ return map;
45
+ const record = toolsConfig(makeTool);
46
+ for (const [name, def] of Object.entries(record)) {
47
+ // Record key is the source of truth for the tool name.
48
+ map.set(name, { ...def, name });
49
+ }
50
+ return map;
51
+ }
52
+ /**
53
+ * Maps ConversationManagerConfig to Strands' ConversationManager.
54
+ * Controls how message history is trimmed in-memory before sending to the model. Does not handle persistence.
55
+ * @see https://strandsagents.com/docs/user-guide/concepts/agents/conversation-management/
56
+ */
57
+ function createConversationManager(config) {
58
+ if (!config || !config.strategy || config.strategy === 'sliding-window') {
59
+ const windowSize = config && 'windowSize' in config ? config.windowSize : undefined;
60
+ return new SlidingWindowConversationManager({ windowSize });
61
+ }
62
+ if (config.strategy === 'summarizing') {
63
+ return new SummarizingConversationManager({
64
+ summaryRatio: config.summaryRatio,
65
+ preserveRecentMessages: config.preserveRecentMessages,
66
+ });
67
+ }
68
+ }
69
+ /**
70
+ * Base class for the Agent BB. Extended by agent.mock.ts (model.local) and agent.aws.ts (model.deployed).
71
+ *
72
+ * Creates up to 4 internal BBs depending on mode:
73
+ * - FileBucket: session snapshot storage for Strands SessionManager (always)
74
+ * - DistributedTable: frontend message history (when inferenceOnly = false)
75
+ * - Realtime: streaming chunks to browser + AsyncJob result delivery (always)
76
+ * - AsyncJob: runs Strands agent asynchronously (always)
77
+ * - TODO logging
78
+ */
79
+ export class AgentBase extends Scope {
80
+ /** Developer-facing agent configuration. */
81
+ config;
82
+ /** Tools resolved from the `tools` callback into a name→tool map (name = Record key). */
83
+ toolMap;
84
+ /** Conversation metadata table. */
85
+ conversations;
86
+ /** Message history table. */
87
+ messages;
88
+ /** Realtime pub/sub — streams chunks to browser. */
89
+ rt;
90
+ /** Internal async job — runs the Strands agent in a separate execution context. */
91
+ job;
92
+ /** Which model provider to use. */
93
+ modelConfig;
94
+ /** Where to persist Strands agent state (snapshots). */
95
+ snapshotStorage;
96
+ /** Internal FileBucket for session storage. */
97
+ sessionBucket;
98
+ /** @internal Logger for internal operations. Defaults to error-level when not provided. */
99
+ log;
100
+ /**
101
+ * @param scope - Blocks scope parent (determines resource naming and CDK discovery)
102
+ * @param id - unique agent ID (used in resource names, keep short for AppSync namespace limits)
103
+ * @param config - developer-facing agent configuration
104
+ * @param modelConfig - which model to use, picked by subclass (model.local or model.deployed)
105
+ * @param createSnapshotStorage - factory that receives the internal FileBucket and returns the appropriate SnapshotStorage
106
+ */
107
+ constructor(scope, id, config, modelConfig, createSnapshotStorage) {
108
+ super(id, { parent: scope, bbName: BB_NAME, bbVersion: BB_VERSION });
109
+ this.log = config?.logger ?? new Logger(this, 'logger', { level: 'error' });
110
+ this.config = config;
111
+ this.toolMap = resolveTools(config.tools);
112
+ this.modelConfig = modelConfig;
113
+ // IDs shortened to keep S3 bucket names within the 63-char limit
114
+ this.sessionBucket = new FileBucket(this, 'sn');
115
+ this.snapshotStorage = createSnapshotStorage(this.sessionBucket);
116
+ if (!config.inferenceOnly) {
117
+ this.conversations = new DistributedTable(this, 'convos', {
118
+ schema: conversationSchema,
119
+ key: { partitionKey: 'userId', sortKey: 'conversationId' },
120
+ });
121
+ this.messages = new DistributedTable(this, 'messages', {
122
+ schema: messageSchema,
123
+ key: { partitionKey: 'conversationId', sortKey: 'messageId' },
124
+ });
125
+ }
126
+ this.rt = new Realtime(this, 'rt', {
127
+ namespaces: {
128
+ chunks: Realtime.namespace(agentStreamChunkSchema),
129
+ },
130
+ });
131
+ this.job = new AsyncJob(this, 'job', {
132
+ schema: jobPayloadSchema,
133
+ handler: async (payload) => {
134
+ try {
135
+ await this.runAgent(payload.message, payload.conversationId, payload.channelId, payload.userId, payload.interruptResponses, payload.context);
136
+ }
137
+ catch (err) {
138
+ const errorMessage = err instanceof Error ? err.message : String(err);
139
+ this.log.error('runAgent error', { error: errorMessage });
140
+ // Best-effort: persist error to conversation history (don't let DB failure block error chunk)
141
+ try {
142
+ if (payload.conversationId && this.messages) {
143
+ await this.messages.put({ conversationId: payload.conversationId, messageId: ulid(), role: 'assistant', content: '', contentType: 'text', userId: payload.userId, createdAt: Date.now(), metadata: JSON.stringify({ error: errorMessage }) });
144
+ }
145
+ }
146
+ catch (persistErr) {
147
+ this.log.error('Failed to persist error to history', { error: persistErr });
148
+ }
149
+ // Publish error chunk so the client doesn't hang. Don't re-throw — AsyncJob would retry a non-idempotent operation.
150
+ await this.rt.publish('chunks', payload.channelId, { type: 'error', error: errorMessage });
151
+ }
152
+ },
153
+ });
154
+ const identifiers = {};
155
+ if (this.conversations) {
156
+ identifiers.conversationsTableName = getSdkIdentifiers(this.conversations).tableName;
157
+ }
158
+ if (this.messages) {
159
+ identifiers.messagesTableName = getSdkIdentifiers(this.messages).tableName;
160
+ }
161
+ identifiers.sessionBucketName = getSdkIdentifiers(this.sessionBucket).bucketName;
162
+ identifiers.realtimeWsUrl = getSdkIdentifiers(this.rt).wsUrl;
163
+ identifiers.realtimeCallbackUrl = getSdkIdentifiers(this.rt).callbackUrl;
164
+ identifiers.jobQueueUrl = getSdkIdentifiers(this.job).queueUrl;
165
+ registerSdkIdentifiers(this.fullId, identifiers);
166
+ }
167
+ /**
168
+ * Executes the Strands agent, publishes chunks to Realtime, persists messages to DynamoDB.
169
+ *
170
+ * Called by: AsyncJob consumer.
171
+ * NOT called directly — stream() submits to AsyncJob, which invokes this.
172
+ *
173
+ * Flow: AsyncJob handler → runAgent() → Strands agent.stream() → publishes chunks to Realtime BB
174
+ * TODO add comments for args
175
+ */
176
+ async runAgent(message, conversationId, channelId, userId, interruptResponses, context) {
177
+ const strandsAgent = await this.createStrandsAgent(conversationId, context);
178
+ const startTime = Date.now();
179
+ // Only persist user message on initial path (not resume)
180
+ if (!interruptResponses && conversationId && this.messages) {
181
+ await this.messages.put({ conversationId, messageId: ulid(), role: 'user', content: message, contentType: 'text', userId, createdAt: Date.now(), metadata: '{}' });
182
+ }
183
+ let fullText = '';
184
+ let usage;
185
+ let blockBuffer = '';
186
+ let interrupted = false;
187
+ const isBlockMode = this.config.streamingMode !== 'token';
188
+ // Determine input: resume with responses or initial message
189
+ const input = interruptResponses
190
+ ? interruptResponses.map(r => new InterruptResponseContent({ interruptId: r.interruptId, response: r.response }))
191
+ : message;
192
+ this.log.info('runAgent started', { resume: !!interruptResponses, conversationId });
193
+ // Thread the per-call context through Strands invocationState so it reaches
194
+ // tool callbacks and the interrupt hook (see createStrandsAgent).
195
+ const invocationState = { [TOOL_CONTEXT_KEY]: context ?? {} };
196
+ try {
197
+ for await (const event of strandsAgent.stream(input, { invocationState })) {
198
+ if (event instanceof ModelStreamUpdateEvent) {
199
+ if (event.event.type === 'modelContentBlockDeltaEvent' && event.event.delta.type === 'textDelta') {
200
+ fullText += event.event.delta.text;
201
+ if (isBlockMode) {
202
+ blockBuffer += event.event.delta.text;
203
+ }
204
+ else {
205
+ await this.rt.publish('chunks', channelId, { type: 'text-delta', text: event.event.delta.text });
206
+ }
207
+ }
208
+ else if (isBlockMode && event.event.type === 'modelContentBlockStopEvent' && blockBuffer) {
209
+ await this.rt.publish('chunks', channelId, { type: 'text-delta', text: blockBuffer });
210
+ blockBuffer = '';
211
+ }
212
+ }
213
+ else if (event instanceof BeforeToolCallEvent) {
214
+ await this.rt.publish('chunks', channelId, { type: 'tool-call', toolName: event.toolUse.name, input: event.toolUse.input });
215
+ if (conversationId && this.messages) {
216
+ await this.messages.put({ conversationId, messageId: ulid(), role: 'tool-call', content: '', contentType: 'text', userId, createdAt: Date.now(), metadata: JSON.stringify({ toolName: event.toolUse.name, toolInput: event.toolUse.input }) });
217
+ }
218
+ }
219
+ else if (event instanceof AfterToolCallEvent) {
220
+ await this.rt.publish('chunks', channelId, { type: 'tool-result', toolName: event.toolUse.name });
221
+ if (conversationId && this.messages) {
222
+ await this.messages.put({ conversationId, messageId: ulid(), role: 'tool-result', content: '', contentType: 'text', userId, createdAt: Date.now(), metadata: JSON.stringify({ toolName: event.toolUse.name, toolOutput: event.result?.content }) });
223
+ }
224
+ }
225
+ else if (event instanceof AgentResultEvent) {
226
+ const u = event.result.metrics?.toJSON()?.accumulatedUsage;
227
+ if (u)
228
+ usage = { inputTokens: u.inputTokens, outputTokens: u.outputTokens, totalTokens: u.totalTokens };
229
+ // Check if agent was interrupted
230
+ if (event.result.stopReason === 'interrupt' && event.result.interrupts?.length) {
231
+ interrupted = true;
232
+ // Flush partial block buffer before publishing interrupt
233
+ if (blockBuffer) {
234
+ await this.rt.publish('chunks', channelId, { type: 'text-delta', text: blockBuffer });
235
+ blockBuffer = '';
236
+ }
237
+ // Publish interrupt chunk with pending approvals
238
+ const pendingInterrupts = event.result.interrupts.map(i => ({ id: i.id, name: i.name, reason: i.reason }));
239
+ await this.rt.publish('chunks', channelId, {
240
+ type: 'interrupt',
241
+ interrupts: pendingInterrupts,
242
+ });
243
+ // Persist interrupt to DynamoDB for reload/audit
244
+ if (conversationId && this.messages) {
245
+ await this.messages.put({ conversationId, messageId: ulid(), role: 'interrupt', content: '', contentType: 'text', userId, createdAt: Date.now(), metadata: JSON.stringify({ interrupts: pendingInterrupts }) });
246
+ }
247
+ }
248
+ }
249
+ }
250
+ }
251
+ catch (err) {
252
+ // Flush partial block buffer so client gets whatever text was generated before the error
253
+ if (blockBuffer) {
254
+ await this.rt.publish('chunks', channelId, { type: 'text-delta', text: blockBuffer });
255
+ blockBuffer = '';
256
+ }
257
+ throw err;
258
+ }
259
+ // Normal flush (stream completed successfully)
260
+ if (blockBuffer) {
261
+ await this.rt.publish('chunks', channelId, { type: 'text-delta', text: blockBuffer });
262
+ }
263
+ const latencyMs = Date.now() - startTime;
264
+ this.log.info('runAgent done', { textLength: fullText.length, latencyMs, interrupted });
265
+ // If interrupted, don't persist final message or publish done — agent is paused
266
+ if (interrupted)
267
+ return;
268
+ if (conversationId && this.messages) {
269
+ await this.messages.put({ conversationId, messageId: ulid(), role: 'assistant', content: fullText, contentType: 'text', userId, createdAt: Date.now(), metadata: JSON.stringify({ usage, latencyMs }) });
270
+ }
271
+ // TODO: use partial update when DistributedTable supports it (DynamoDB UpdateItem)
272
+ if (conversationId && this.conversations) {
273
+ const existing = await this.conversations.get({ userId, conversationId });
274
+ if (existing) {
275
+ await this.conversations.put({ ...existing, updatedAt: Date.now() });
276
+ }
277
+ }
278
+ await this.rt.publish('chunks', channelId, { type: 'done', text: fullText, usage });
279
+ }
280
+ async createStrandsAgent(conversationId, fallbackContext) {
281
+ const toolDefs = [...this.toolMap.values()];
282
+ // Validate mutual exclusivity of needsApproval/trustable and interrupt
283
+ for (const t of toolDefs) {
284
+ if ((t.needsApproval !== undefined || t.trustable !== undefined) && t.interrupt) {
285
+ throw blocksAgentError(AgentErrors.InvalidModelConfig, `Cannot specify 'needsApproval' or 'trustable' alongside 'interrupt' on tool '${t.name}'. Use 'interrupt' for custom logic, or 'needsApproval'/'trustable' for simple approval.`);
286
+ }
287
+ }
288
+ // Reads the per-call context threaded through invocationState, falling back to the
289
+ // context captured at runAgent time (defensive — Strands always provides invocationState).
290
+ const readContext = (strandsCtx) => (strandsCtx?.invocationState?.[TOOL_CONTEXT_KEY] ?? fallbackContext ?? {});
291
+ const strandsTools = toolDefs.map(t => tool({
292
+ // `name` is always set by resolveTools() from the Record key.
293
+ name: t.name,
294
+ description: t.description,
295
+ inputSchema: t.parameters,
296
+ callback: (input, context) => t.handler({
297
+ input,
298
+ context: readContext(context),
299
+ interrupt: (params) => context.interrupt(params),
300
+ }),
301
+ }));
302
+ const configs = Array.isArray(this.modelConfig) ? this.modelConfig : this.modelConfig ? [this.modelConfig] : [];
303
+ let resolvedConfig;
304
+ for (const config of configs) {
305
+ if (await checkModelHealth(config, this.log)) {
306
+ resolvedConfig = config;
307
+ break;
308
+ }
309
+ }
310
+ if (!resolvedConfig && configs.length > 0) {
311
+ const tried = configs.map(c => `${c.provider}${c.modelId ? ` (${c.modelId})` : ''}`).join(', ');
312
+ throw blocksAgentError(AgentErrors.ModelUnavailable, `No model available. Tried: ${tried}. Check logs for details.`);
313
+ }
314
+ const model = await createStrandsModel(resolvedConfig, this.log);
315
+ // SessionManager restores/saves agent state across invocations.
316
+ // undefined when no conversationId (inference-only calls — no state to persist).
317
+ const sessionManager = conversationId
318
+ ? new SessionManager({ sessionId: conversationId, storage: { snapshot: this.snapshotStorage } })
319
+ : undefined;
320
+ const agent = new StrandsAgent({
321
+ model,
322
+ // Forward the optional agent identity from AgentConfig. Strands' Agent
323
+ // supports name/description (e.g. for multi-agent routing and tracing);
324
+ // previously these AgentConfig fields were accepted but never passed
325
+ // through, so they silently had no effect. Spread conditionally so we
326
+ // don't override Strands defaults with `undefined` when unset.
327
+ ...(this.config.name !== undefined && { name: this.config.name }),
328
+ ...(this.config.description !== undefined && { description: this.config.description }),
329
+ systemPrompt: this.config.systemPrompt,
330
+ tools: strandsTools,
331
+ conversationManager: createConversationManager(this.config.conversation),
332
+ sessionManager,
333
+ printer: false, //disable Strands automatic printing
334
+ });
335
+ // Register interrupt hook for HITL — checks needsApproval or custom interrupt function before execution
336
+ const toolConfigs = this.toolMap;
337
+ agent.addHook(BeforeToolCallEvent, (event) => {
338
+ const toolDef = toolConfigs.get(event.toolUse.name);
339
+ if (!toolDef)
340
+ return;
341
+ // Custom interrupt function — developer has full control
342
+ if (toolDef.interrupt) {
343
+ toolDef.interrupt({
344
+ input: event.toolUse.input,
345
+ context: readContext(event),
346
+ interrupt: (params) => event.interrupt(params),
347
+ });
348
+ return;
349
+ }
350
+ // Built-in approval check
351
+ const needsApproval = toolDef.needsApproval ?? false;
352
+ if (!needsApproval)
353
+ return;
354
+ if (toolDef.trustable && event.agent.appState.get(`trusted:${event.toolUse.name}`))
355
+ return;
356
+ this.log.info('Tool approval required, interrupting', { tool: event.toolUse.name, trustable: !!toolDef.trustable });
357
+ const response = event.interrupt({ name: `approve:${event.toolUse.name}:${event.toolUse.toolUseId}`, reason: { tool: event.toolUse.name, input: event.toolUse.input, trustable: !!toolDef.trustable } });
358
+ if (response === 'trust') {
359
+ event.agent.appState.set(`trusted:${event.toolUse.name}`, true);
360
+ this.log.info('Tool trusted for session', { tool: event.toolUse.name });
361
+ }
362
+ else if (response !== 'yes') {
363
+ event.cancel = `User denied permission to run ${event.toolUse.name}`;
364
+ this.log.info('Tool denied by user', { tool: event.toolUse.name });
365
+ }
366
+ });
367
+ return agent;
368
+ }
369
+ /**
370
+ * Submit a message to the agent. Returns immediately with a channelId.
371
+ *
372
+ * Flow: stream() → AsyncJob.submit() → returns { channelId }
373
+ * The AsyncJob consumer calls runAgent() separately.
374
+ * Chunks are published to Realtime on the returned channelId.
375
+ *
376
+ * Subscribe to chunks via result.channel, or await result.complete() for the final response.
377
+ */
378
+ async stream(message, options) {
379
+ const conversationId = options?.conversationId;
380
+ const channelId = options?.channelId ?? conversationId ?? crypto.randomUUID();
381
+ if (!options?.userId && !this.config.inferenceOnly)
382
+ throw blocksAgentError(AgentErrors.PersistenceRequired, 'userId is required when persistence is enabled. Pass it via options.userId.');
383
+ const userId = options?.userId ?? 'anonymous';
384
+ const context = this.resolveContext(options?.context);
385
+ await this.job.submit({ message, conversationId, channelId, userId, context });
386
+ return {
387
+ channelId,
388
+ /** Realtime channel handle — subscribe to streaming chunks or return to client as Transferable. */
389
+ channel: this.rt.getChannel('chunks', channelId),
390
+ /** Wait for the complete response (server-side). Resolves on done, rejects on error. */
391
+ complete: () => new Promise((resolve, reject) => {
392
+ const unsub = this.rt.subscribe('chunks', channelId, (data) => {
393
+ const chunk = data;
394
+ if (chunk.type === 'done') {
395
+ unsub();
396
+ resolve(chunk);
397
+ }
398
+ else if (chunk.type === 'error') {
399
+ unsub();
400
+ reject(blocksAgentError(AgentErrors.StreamFailed, chunk.error ?? 'Agent error'));
401
+ }
402
+ else if (chunk.type === 'interrupt') {
403
+ unsub();
404
+ reject(new InterruptError('Agent requires approval to continue', chunk.interrupts ?? []));
405
+ }
406
+ });
407
+ }),
408
+ };
409
+ }
410
+ /**
411
+ * Resume an interrupted agent with user's responses.
412
+ * Submits a new AsyncJob that loads the session and continues from the interrupt point.
413
+ * Chunks are published to the same channelId — use the existing subscription or call complete() again to wait for the result.
414
+ */
415
+ async resume(channelId, responses, options) {
416
+ if (!responses.length)
417
+ throw blocksAgentError(AgentErrors.InterruptRequired, 'resume() requires at least one interrupt response.');
418
+ this.log.info('Resuming agent', { channelId, responseCount: responses.length, conversationId: options?.conversationId });
419
+ const conversationId = options?.conversationId;
420
+ // Resuming an interrupted agent requires restoring its paused state, which
421
+ // only the SessionManager (keyed by conversationId) holds. Without a
422
+ // conversationId there is no session to restore, so the resume job would run
423
+ // with a fresh agent and the interrupt responses would have nothing to apply
424
+ // to. Fail fast with a clear error instead of silently submitting a job that
425
+ // can't honor the responses. For inferenceOnly agents this is a fundamental
426
+ // limitation (they never persist a session), not a missing parameter — call
427
+ // that out explicitly so developers don't go looking for a workaround.
428
+ if (!conversationId) {
429
+ const reason = this.config.inferenceOnly
430
+ ? 'Agents with inferenceOnly: true cannot be resumed because they have no persistent session to restore.'
431
+ : 'Pass options.conversationId so the interrupted session can be restored.';
432
+ throw blocksAgentError(AgentErrors.InterruptRequired, `resume() requires a conversationId to restore the interrupted session. ${reason}`);
433
+ }
434
+ if (!options?.userId && !this.config.inferenceOnly)
435
+ throw blocksAgentError(AgentErrors.PersistenceRequired, 'userId is required when persistence is enabled. Pass it via options.userId.');
436
+ const userId = options?.userId ?? 'anonymous';
437
+ // Persist each decision to conversation history
438
+ if (conversationId && this.messages) {
439
+ for (const r of responses) {
440
+ const content = r.response != null ? String(r.response) : r.approved ? (r.trust ? 'trust' : 'yes') : 'no';
441
+ await this.messages.put({ conversationId, messageId: ulid(), role: 'approval', content, contentType: 'text', userId, createdAt: Date.now(), metadata: JSON.stringify({ interruptId: r.interruptId, approved: r.approved, trust: r.trust ?? false, response: r.response, toolName: r.toolName, input: r.input }) });
442
+ }
443
+ }
444
+ // Translate to format for Strands — use response directly if provided, otherwise translate from approved/trust
445
+ const translated = responses.map(r => ({
446
+ interruptId: r.interruptId,
447
+ response: r.response != null ? String(r.response) : r.approved ? (r.trust ? 'trust' : 'yes') : 'no',
448
+ }));
449
+ const context = this.resolveContext(options?.context);
450
+ await this.job.submit({ message: '', conversationId, channelId, userId, resume: true, interruptResponses: translated, context });
451
+ }
452
+ /**
453
+ * Validates the per-call tool context against `toolContextSchema` (when set) and returns it.
454
+ * Throws InvalidModelConfig when the schema is declared but the context is missing or invalid.
455
+ */
456
+ resolveContext(context) {
457
+ const schema = this.config.toolContextSchema;
458
+ if (!schema)
459
+ return context;
460
+ const result = schema.safeParse(context);
461
+ if (!result.success) {
462
+ throw blocksAgentError(AgentErrors.InvalidModelConfig, `Invalid tool context: ${result.error.message}. This agent declares a 'toolContextSchema', so a matching 'context' must be passed to stream()/resume().`);
463
+ }
464
+ return result.data;
465
+ }
466
+ /** Generate a new conversation ID and create the conversation record. */
467
+ async createConversationId(userId) {
468
+ if (!this.conversations)
469
+ throw blocksAgentError(AgentErrors.PersistenceRequired, 'createConversationId() requires persistence. Set inferenceOnly: false (default).');
470
+ const conversationId = crypto.randomUUID();
471
+ const now = Date.now();
472
+ await this.conversations.put({ userId, conversationId, name: conversationId, createdAt: now, updatedAt: now });
473
+ return conversationId;
474
+ }
475
+ /** Get a Realtime channel for streaming chunks. Use this to subscribe before calling stream(). */
476
+ getChannel(channelId) {
477
+ return this.rt.getChannel('chunks', channelId);
478
+ }
479
+ /** Check if a conversation has pending (unanswered) interrupts by checking DynamoDB history.
480
+ *
481
+ * ⚠️ Does NOT verify ownership — it reads by conversationId alone. The caller must
482
+ * authorize the request (e.g. confirm the conversation belongs to the authenticated
483
+ * user via listConversations(userId)) before exposing the result. See the
484
+ * "Authorization (caller responsibility)" section in the README.
485
+ *
486
+ * TODO: optimize — query in reverse with limit instead of loading all messages.
487
+ */
488
+ async getPendingInterrupts(conversationId) {
489
+ if (!this.messages)
490
+ return [];
491
+ // Single pass in reverse: collect approval IDs until we hit the interrupt
492
+ const approvedIds = new Set();
493
+ let lastInterrupt = null;
494
+ for await (const msg of this.messages.query({ where: { conversationId: { equals: conversationId } }, order: 'desc' })) {
495
+ if (msg.role === 'approval') {
496
+ approvedIds.add(JSON.parse(msg.metadata).interruptId);
497
+ }
498
+ else if (msg.role === 'interrupt') {
499
+ lastInterrupt = msg;
500
+ break;
501
+ }
502
+ else if (msg.role === 'assistant') {
503
+ return [];
504
+ }
505
+ }
506
+ if (!lastInterrupt)
507
+ return [];
508
+ const interrupts = JSON.parse(lastInterrupt.metadata).interrupts;
509
+ return interrupts.filter(i => !approvedIds.has(i.id));
510
+ }
511
+ /** List all conversations for a user. */
512
+ async listConversations(userId) {
513
+ if (!this.conversations)
514
+ throw blocksAgentError(AgentErrors.PersistenceRequired, 'listConversations() requires persistence. Set inferenceOnly: false (default).');
515
+ const result = [];
516
+ for await (const item of this.conversations.query({ where: { userId: { equals: userId } } })) {
517
+ result.push({ conversationId: item.conversationId, name: item.name, createdAt: item.createdAt, updatedAt: item.updatedAt });
518
+ }
519
+ return result.sort((a, b) => b.updatedAt - a.updatedAt);
520
+ }
521
+ /** Get messages in a conversation (for frontend display).
522
+ * Returns the most recent messages when `limit` is specified.
523
+ *
524
+ * ⚠️ Does NOT verify ownership — it reads by conversationId alone. The caller must
525
+ * authorize the request (e.g. confirm the conversation belongs to the authenticated
526
+ * user via listConversations(userId)) before returning messages. See the
527
+ * "Authorization (caller responsibility)" section in the README.
528
+ *
529
+ * @param options.limit - Maximum number of (most recent) messages to return.
530
+ * A `limit` of `0` returns an empty array, and any negative value is treated
531
+ * the same as `0` (returns no messages). Omit `limit` to return all messages.
532
+ * TODO: support pagination
533
+ */
534
+ async getConversation(id, options) {
535
+ if (!this.messages)
536
+ throw blocksAgentError(AgentErrors.PersistenceRequired, 'getConversation() requires persistence. Set inferenceOnly: false (default).');
537
+ // A limit of 0 means "zero messages", and a negative limit is nonsensical
538
+ // for a message count. The previous `options?.limit && ...` guard treated 0
539
+ // (and never reached the negative case usefully) as falsy, so the cap was
540
+ // ignored and ALL messages were returned. Treat any limit <= 0 as "return no
541
+ // messages" so neither 0 nor a negative value is silently read as "no limit".
542
+ if (options?.limit !== undefined && options.limit <= 0)
543
+ return [];
544
+ const result = [];
545
+ for await (const item of this.messages.query({ where: { conversationId: { equals: id } }, order: 'desc' })) {
546
+ result.push({
547
+ messageId: item.messageId,
548
+ role: item.role,
549
+ content: item.content,
550
+ contentType: item.contentType,
551
+ createdAt: item.createdAt,
552
+ metadata: JSON.parse(item.metadata),
553
+ });
554
+ // Explicit undefined check so a valid positive limit caps results; 0 and
555
+ // negatives are already handled above.
556
+ if (options?.limit !== undefined && result.length >= options.limit)
557
+ break;
558
+ }
559
+ return result.reverse();
560
+ }
561
+ /** Delete a conversation and its agent state. */
562
+ async deleteConversation(id, userId) {
563
+ if (!this.messages || !this.conversations)
564
+ throw blocksAgentError(AgentErrors.PersistenceRequired, 'deleteConversation() requires persistence. Set inferenceOnly: false (default).');
565
+ // Verify ownership before any destructive work. The messages table is
566
+ // partitioned by conversationId (not userId) and snapshot storage is keyed
567
+ // by sessionId alone, so deleting them is NOT user-scoped on its own. The
568
+ // conversation record IS keyed by { userId, conversationId }, so its absence
569
+ // means the caller is not the owner (or the conversation doesn't exist).
570
+ // Without this guard, a non-owner could wipe another user's message history
571
+ // and session state while the owner's conversation record (a no-op keyed
572
+ // delete) survived. Bail out unless the caller owns the conversation.
573
+ const owned = await this.conversations.get({ userId, conversationId: id });
574
+ if (!owned)
575
+ return;
576
+ // Delete conversation record first — if it fails mid-way, orphaned messages are invisible (better than broken conversation with missing messages)
577
+ await this.conversations.delete({ userId, conversationId: id });
578
+ // Delete all messages in batch
579
+ const toDelete = [];
580
+ for await (const item of this.messages.query({ where: { conversationId: { equals: id } } })) {
581
+ toDelete.push({ conversationId: item.conversationId, messageId: item.messageId });
582
+ }
583
+ if (toDelete.length > 0)
584
+ await this.messages.deleteBatch(toDelete);
585
+ // Delete agent session data
586
+ await this.snapshotStorage.deleteSession({ sessionId: id });
587
+ }
588
+ }
@@ -0,0 +1,7 @@
1
+ import type { ScopeParent } from '@aws-blocks/core';
2
+ import { AgentBase } from './agent.js';
3
+ import type { AgentConfig, DefaultToolContext } from './types.js';
4
+ export declare class Agent<TContext = DefaultToolContext> extends AgentBase<TContext> {
5
+ constructor(scope: ScopeParent, id: string, config: AgentConfig<TContext>);
6
+ }
7
+ //# sourceMappingURL=agent.mock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent.mock.d.ts","sourceRoot":"","sources":["../src/agent.mock.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAEvC,OAAO,KAAK,EAAE,WAAW,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAElE,qBAAa,KAAK,CAAC,QAAQ,GAAG,kBAAkB,CAAE,SAAQ,SAAS,CAAC,QAAQ,CAAC;gBAChE,KAAK,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,CAAC,QAAQ,CAAC;CAMzE"}
@@ -0,0 +1,12 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { AgentBase } from './agent.js';
4
+ import { FileBucketSnapshotStorage } from './file-bucket-snapshot-storage.js';
5
+ export class Agent extends AgentBase {
6
+ constructor(scope, id, config) {
7
+ // Canned provider is appended as implicit last fallback for local dev
8
+ const local = config.model.local;
9
+ const candidates = local ? (Array.isArray(local) ? [...local, { provider: 'canned' }] : [local, { provider: 'canned' }]) : [{ provider: 'canned' }];
10
+ super(scope, id, config, candidates, (bucket) => new FileBucketSnapshotStorage(bucket));
11
+ }
12
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Typed error constants for Agent BB. Use with `isBlocksError()` in catch blocks.
3
+ *
4
+ * @example
5
+ * ```typescript
6
+ * import { isBlocksError } from '@aws-blocks/core';
7
+ * import { AgentErrors } from '@aws-blocks/bb-agent';
8
+ *
9
+ * try {
10
+ * await agent.getConversation(id);
11
+ * } catch (e) {
12
+ * if (isBlocksError(e, AgentErrors.PersistenceRequired)) {
13
+ * // agent is in inferenceOnly mode
14
+ * }
15
+ * }
16
+ * ```
17
+ */
18
+ export declare const AgentErrors: {
19
+ readonly PersistenceRequired: "PersistenceRequiredException";
20
+ readonly InvalidModelConfig: "InvalidModelConfigException";
21
+ readonly ModelUnavailable: "ModelUnavailableException";
22
+ readonly BrowserNotSupported: "BrowserNotSupportedException";
23
+ readonly StreamFailed: "StreamFailedException";
24
+ readonly InterruptRequired: "InterruptRequiredException";
25
+ };
26
+ export declare function blocksAgentError(name: string, message: string): Error;
27
+ export declare class InterruptError extends Error {
28
+ readonly interrupts: Array<{
29
+ id: string;
30
+ name: string;
31
+ reason?: any;
32
+ }>;
33
+ constructor(message: string, interrupts: Array<{
34
+ id: string;
35
+ name: string;
36
+ reason?: any;
37
+ }>);
38
+ }
39
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,WAAW;;;;;;;CAOd,CAAC;AAEX,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,KAAK,CAIrE;AAED,qBAAa,cAAe,SAAQ,KAAK;IACxC,QAAQ,CAAC,UAAU,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,GAAG,CAAA;KAAE,CAAC,CAAC;gBAE3D,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,GAAG,CAAA;KAAE,CAAC;CAK1F"}