@aws-blocks/bb-agent 0.3.4 → 0.4.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 (57) hide show
  1. package/DESIGN.md +65 -16
  2. package/README.md +73 -6
  3. package/dist/agent.aws.d.ts +15 -1
  4. package/dist/agent.aws.d.ts.map +1 -1
  5. package/dist/agent.aws.js +49 -0
  6. package/dist/agent.d.ts +82 -14
  7. package/dist/agent.d.ts.map +1 -1
  8. package/dist/agent.js +238 -55
  9. package/dist/agentcore-bundle.d.ts +12 -0
  10. package/dist/agentcore-bundle.d.ts.map +1 -0
  11. package/dist/agentcore-bundle.js +150 -0
  12. package/dist/agentcore-bundle.test.d.ts +2 -0
  13. package/dist/agentcore-bundle.test.d.ts.map +1 -0
  14. package/dist/agentcore-bundle.test.js +46 -0
  15. package/dist/agentcore-entry.d.ts +21 -0
  16. package/dist/agentcore-entry.d.ts.map +1 -0
  17. package/dist/agentcore-entry.js +120 -0
  18. package/dist/agentcore-runtime.cdk.d.ts +27 -0
  19. package/dist/agentcore-runtime.cdk.d.ts.map +1 -0
  20. package/dist/agentcore-runtime.cdk.js +168 -0
  21. package/dist/index.aws.d.ts +1 -0
  22. package/dist/index.aws.d.ts.map +1 -1
  23. package/dist/index.cdk.d.ts +10 -4
  24. package/dist/index.cdk.d.ts.map +1 -1
  25. package/dist/index.cdk.js +27 -26
  26. package/dist/index.cdk.test.d.ts +2 -0
  27. package/dist/index.cdk.test.d.ts.map +1 -0
  28. package/dist/index.cdk.test.js +143 -0
  29. package/dist/index.mock.d.ts +1 -0
  30. package/dist/index.mock.d.ts.map +1 -1
  31. package/dist/index.test.js +404 -1
  32. package/dist/model-factory.d.ts +2 -2
  33. package/dist/model-factory.d.ts.map +1 -1
  34. package/dist/model-factory.js +2 -2
  35. package/dist/providers/canned.d.ts +8 -1
  36. package/dist/providers/canned.d.ts.map +1 -1
  37. package/dist/providers/canned.js +127 -42
  38. package/dist/types.d.ts +63 -1
  39. package/dist/types.d.ts.map +1 -1
  40. package/dist/version.d.ts +1 -1
  41. package/dist/version.js +1 -1
  42. package/package.json +16 -9
  43. package/src/agent.aws.ts +58 -1
  44. package/src/agent.ts +269 -54
  45. package/src/agentcore-bundle.test.ts +52 -0
  46. package/src/agentcore-bundle.ts +162 -0
  47. package/src/agentcore-entry.ts +134 -0
  48. package/src/agentcore-runtime.cdk.ts +203 -0
  49. package/src/index.aws.ts +3 -0
  50. package/src/index.cdk.test.ts +167 -0
  51. package/src/index.cdk.ts +29 -29
  52. package/src/index.mock.ts +3 -0
  53. package/src/index.test.ts +449 -1
  54. package/src/model-factory.ts +3 -3
  55. package/src/providers/canned.ts +131 -36
  56. package/src/types.ts +64 -1
  57. package/src/version.ts +1 -1
package/dist/agent.js CHANGED
@@ -3,28 +3,47 @@
3
3
  import { Scope, registerSdkIdentifiers, getSdkIdentifiers } from '@aws-blocks/core';
4
4
  import { DistributedTable } from '@aws-blocks/bb-distributed-table';
5
5
  import { Realtime } from '@aws-blocks/bb-realtime';
6
- import { AsyncJob } from '@aws-blocks/bb-async-job';
7
6
  import { FileBucket } from '@aws-blocks/bb-file-bucket';
8
7
  import { Logger } from '@aws-blocks/bb-logger';
9
- import { z } from 'zod';
10
8
  import { createStrandsModel, checkModelHealth } from './model-factory.js';
11
9
  import { messageSchema, conversationSchema, agentStreamChunkSchema } from './schemas.js';
12
10
  import { AgentErrors, blocksAgentError, InterruptError } from './errors.js';
13
11
  import { BB_NAME, BB_VERSION } from './version.js';
14
12
  import { ulid } from 'ulid';
15
- /** Payload submitted to the internal AsyncJob BB. */
16
- const jobPayloadSchema = z.object({
17
- message: z.string(),
18
- conversationId: z.string().optional(),
19
- channelId: z.string(),
20
- userId: z.string(),
21
- resume: z.boolean().optional(), // Resume fields (for HITL interrupt responses)
22
- interruptResponses: z.array(z.object({ interruptId: z.string(), response: z.string() })).optional(),
23
- /** Per-call tool context, forwarded to tool invocations. JSON-serializable. */
24
- context: z.any().optional(),
25
- });
26
13
  /** Key under which the per-call tool context is threaded through Strands `invocationState`. */
27
14
  const TOOL_CONTEXT_KEY = '__bbAgentToolContext';
15
+ /**
16
+ * Default runaway-protection caps applied per turn when `AgentConfig` does not
17
+ * override them. See `AgentConfig.maxLlmCalls` / `maxToolIterations`.
18
+ *
19
+ * 20 is a backstop, not a tuned budget: healthy interactive turns in the agent
20
+ * samples and in Strands' own examples settle at a handful of model/tool calls
21
+ * (a plan step, one or two tool rounds, a final answer), so 20 leaves roughly an
22
+ * order of magnitude of headroom before it engages, while still cutting a
23
+ * reason->act loop off long before it becomes a noticeable Bedrock bill. Agents
24
+ * that legitimately take more steps are expected to raise the value (or set it
25
+ * to `false`) deliberately.
26
+ */
27
+ const DEFAULT_MAX_LLM_CALLS = 20;
28
+ const DEFAULT_MAX_TOOL_ITERATIONS = 20;
29
+ /** `appState` keys holding the per-turn cap counters, so they survive `resume()`. */
30
+ const MODEL_CALL_COUNT_KEY = '__bbAgentModelCallCount';
31
+ const TOOL_CALL_COUNT_KEY = '__bbAgentToolCallCount';
32
+ const COUNTED_TOOL_USE_IDS_KEY = '__bbAgentCountedToolUseIds';
33
+ /** Id of the turn the counters belong to, so a new turn can zero them lazily. */
34
+ const TURN_ID_KEY = '__bbAgentCapTurnId';
35
+ /**
36
+ * Validate a runaway-protection cap: a positive integer, `false` to disable, or
37
+ * `undefined` for the default. Rejects `0`/negatives/non-integers/`NaN`, which
38
+ * would otherwise either kill every turn or silently disable the cap.
39
+ */
40
+ function validateCap(name, value) {
41
+ if (value === false || value === undefined)
42
+ return;
43
+ if (!Number.isInteger(value) || value < 1) {
44
+ throw blocksAgentError(AgentErrors.InvalidModelConfig, `'${name}' must be a positive integer or false to disable the cap, got ${String(value)}.`);
45
+ }
46
+ }
28
47
  /**
29
48
  * Lazily import the Strands SDK runtime, caching the module after the first load.
30
49
  *
@@ -44,6 +63,23 @@ function loadStrands() {
44
63
  });
45
64
  return strandsModulePromise;
46
65
  }
66
+ // ── Agent instance registry ──────────────────────────────────────────────────
67
+ // A module-singleton map of fullId → live Agent instance. The AgentCore Runtime
68
+ // entrypoint (agentcore-entry.ts) imports the app backend (which constructs the
69
+ // Agent, registering it here) and then looks it up by the BB_AGENT_ID the CDK
70
+ // Runtime construct injected, to drive its loop. Because it's a module singleton,
71
+ // the entrypoint and the backend MUST be co-bundled into one module graph (see
72
+ // agentcore-bundle.ts) — otherwise the Agent registers in one copy's map and the
73
+ // lookup reads another's.
74
+ const agentRegistry = new Map();
75
+ /** @internal Register a live Agent instance so the AgentCore entrypoint can find it by fullId. */
76
+ export function registerAgentInstance(fullId, agent) {
77
+ agentRegistry.set(fullId, agent);
78
+ }
79
+ /** @internal Look up a registered Agent instance by fullId (used by the AgentCore entrypoint). */
80
+ export function getAgentInstance(fullId) {
81
+ return agentRegistry.get(fullId);
82
+ }
47
83
  /**
48
84
  * The per-call tool factory handed to the `tools` callback. At runtime it's an identity
49
85
  * function whose only job is to give TypeScript a single call site per tool where it can
@@ -86,12 +122,14 @@ async function createConversationManager(config) {
86
122
  /**
87
123
  * Base class for the Agent BB. Extended by agent.mock.ts (model.local) and agent.aws.ts (model.deployed).
88
124
  *
89
- * Creates up to 4 internal BBs depending on mode:
125
+ * Creates internal BBs depending on mode:
90
126
  * - FileBucket: session snapshot storage for Strands SessionManager (always)
91
127
  * - DistributedTable: frontend message history (when inferenceOnly = false)
92
- * - Realtime: streaming chunks to browser + AsyncJob result delivery (always)
93
- * - AsyncJob: runs Strands agent asynchronously (always)
94
- * - TODO logging
128
+ * - Realtime: streaming chunks to browser (always)
129
+ *
130
+ * The agent loop runs via {@link dispatchTurn}: in-process locally (mock), and on the
131
+ * AgentCore Runtime on AWS (the deployed subclass overrides dispatchTurn to invoke it). The
132
+ * AgentCore Runtime itself is provisioned by the CDK layer (index.cdk.ts → AgentCoreRuntime).
95
133
  */
96
134
  export class AgentBase extends Scope {
97
135
  /** Developer-facing agent configuration. */
@@ -104,8 +142,6 @@ export class AgentBase extends Scope {
104
142
  messages;
105
143
  /** Realtime pub/sub — streams chunks to browser. */
106
144
  rt;
107
- /** Internal async job — runs the Strands agent in a separate execution context. */
108
- job;
109
145
  /** Which model provider to use. */
110
146
  modelConfig;
111
147
  /** Where to persist Strands agent state (snapshots). */
@@ -125,6 +161,8 @@ export class AgentBase extends Scope {
125
161
  super(id, { parent: scope, bbName: BB_NAME, bbVersion: BB_VERSION });
126
162
  this.log = config?.logger ?? new Logger(this, 'logger', { level: 'error' });
127
163
  this.config = config;
164
+ validateCap('maxLlmCalls', config.maxLlmCalls);
165
+ validateCap('maxToolIterations', config.maxToolIterations);
128
166
  this.toolMap = resolveTools(config.tools);
129
167
  this.modelConfig = modelConfig;
130
168
  // IDs shortened to keep S3 bucket names within the 63-char limit
@@ -145,29 +183,6 @@ export class AgentBase extends Scope {
145
183
  chunks: Realtime.namespace(agentStreamChunkSchema),
146
184
  },
147
185
  });
148
- this.job = new AsyncJob(this, 'job', {
149
- schema: jobPayloadSchema,
150
- handler: async (payload) => {
151
- try {
152
- await this.runAgent(payload.message, payload.conversationId, payload.channelId, payload.userId, payload.interruptResponses, payload.context);
153
- }
154
- catch (err) {
155
- const errorMessage = err instanceof Error ? err.message : String(err);
156
- this.log.error('runAgent error', { error: errorMessage });
157
- // Best-effort: persist error to conversation history (don't let DB failure block error chunk)
158
- try {
159
- if (payload.conversationId && this.messages) {
160
- 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 }) });
161
- }
162
- }
163
- catch (persistErr) {
164
- this.log.error('Failed to persist error to history', { error: persistErr });
165
- }
166
- // Publish error chunk so the client doesn't hang. Don't re-throw — AsyncJob would retry a non-idempotent operation.
167
- await this.rt.publish('chunks', payload.channelId, { type: 'error', error: errorMessage });
168
- }
169
- },
170
- });
171
186
  const identifiers = {};
172
187
  if (this.conversations) {
173
188
  identifiers.conversationsTableName = getSdkIdentifiers(this.conversations).tableName;
@@ -178,22 +193,147 @@ export class AgentBase extends Scope {
178
193
  identifiers.sessionBucketName = getSdkIdentifiers(this.sessionBucket).bucketName;
179
194
  identifiers.realtimeWsUrl = getSdkIdentifiers(this.rt).wsUrl;
180
195
  identifiers.realtimeCallbackUrl = getSdkIdentifiers(this.rt).callbackUrl;
181
- identifiers.jobQueueUrl = getSdkIdentifiers(this.job).queueUrl;
182
196
  registerSdkIdentifiers(this.fullId, identifiers);
197
+ // Register this instance so the AgentCore Runtime entrypoint (agentcore-entry.ts)
198
+ // can find it by fullId and drive its loop. Harmless in the Lambda/mock paths.
199
+ registerAgentInstance(this.fullId, this);
200
+ }
201
+ /**
202
+ * Run one agent turn (initial message or HITL resume) and publish its chunks to Realtime.
203
+ *
204
+ * This is the single execution entry the compute layer invokes: the AgentCore Runtime
205
+ * entrypoint (agentcore-entry.ts) calls it as a background async task on AWS, and locally
206
+ * {@link dispatchTurn} calls it in-process. Errors are caught and published as an `error`
207
+ * chunk (not re-thrown) so the client never hangs and a non-idempotent turn isn't retried.
208
+ *
209
+ * @param payload - the turn to run (see {@link AgentTurnPayload}).
210
+ * @internal Invoked by the compute layer (agentcore-entry / dispatchTurn), not customer API.
211
+ */
212
+ async invokeTurn(payload) {
213
+ try {
214
+ await this.runAgent(payload.message, payload.conversationId, payload.channelId, payload.userId, payload.interruptResponses, payload.context);
215
+ }
216
+ catch (err) {
217
+ const errorMessage = err instanceof Error ? err.message : String(err);
218
+ this.log.error('runAgent error', { error: errorMessage });
219
+ // Best-effort: persist error to conversation history (don't let DB failure block error chunk)
220
+ try {
221
+ if (payload.conversationId && this.messages) {
222
+ 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 }) });
223
+ }
224
+ }
225
+ catch (persistErr) {
226
+ this.log.error('Failed to persist error to history', { error: persistErr });
227
+ }
228
+ // Publish error chunk so the client doesn't hang. Don't re-throw — a non-idempotent turn shouldn't be retried.
229
+ await this.rt.publish('chunks', payload.channelId, { type: 'error', error: errorMessage });
230
+ }
231
+ }
232
+ /**
233
+ * Dispatch a turn to wherever the agent loop runs, returning promptly so `stream()`/`resume()`
234
+ * hand the client a `channelId` without waiting for the turn to finish.
235
+ *
236
+ * Base (local/mock): run the loop IN-PROCESS, fire-and-forget — chunks flow to the mock
237
+ * Realtime as the turn progresses. The deployed (AWS) subclass overrides this to invoke the
238
+ * AgentCore Runtime, which runs the loop as a background task and publishes to Realtime.
239
+ *
240
+ * @internal Internal compute seam (overridden by the AWS subclass); not customer API.
241
+ */
242
+ async dispatchTurn(payload) {
243
+ // Reproduce the AWS wire boundary locally. On AWS, dispatchTurn JSON-serializes `context` into the
244
+ // InvokeAgentRuntime payload and the container re-parses it (z.unknown passthrough), so a tool
245
+ // receives a JSON-mangled context (Date→string, Set→{}, undefined dropped) — NOT the live object
246
+ // it would get here in-process. Round-trip it so a serialization bug fails locally instead of only
247
+ // after deploy. (A non-JSON-serializable context throws here, mirroring the AWS JSON.stringify.)
248
+ const context = payload.context === undefined ? undefined : JSON.parse(JSON.stringify(payload.context));
249
+ // invokeTurn catches and publishes its own errors, so the floating promise can't reject.
250
+ void this.invokeTurn({ ...payload, context });
183
251
  }
184
252
  /**
185
253
  * Executes the Strands agent, publishes chunks to Realtime, persists messages to DynamoDB.
186
254
  *
187
- * Called by: AsyncJob consumer.
188
- * NOT called directly stream() submits to AsyncJob, which invokes this.
255
+ * Called by {@link invokeTurn} (wherever the loop runs — locally in-process, or inside the
256
+ * AgentCore Runtime container on AWS). Publishes each event to the Realtime `chunks` channel.
257
+ *
258
+ * Flow: invokeTurn() → runAgent() → Strands agent.stream() → publishes chunks to Realtime BB
189
259
  *
190
- * Flow: AsyncJob handler runAgent() Strands agent.stream() publishes chunks to Realtime BB
191
- * TODO add comments for args
260
+ * @param message - the user message that starts the turn (ignored on the resume path)
261
+ * @param conversationId - conversation to load/persist history for; undefined means no persistence
262
+ * @param channelId - Realtime channel the stream chunks are published to
263
+ * @param userId - owner of the conversation, stored on every persisted message
264
+ * @param interruptResponses - approval responses when resuming a turn paused on a HITL interrupt
265
+ * @param context - per-call tool context, threaded to tool handlers via Strands invocationState
192
266
  */
193
267
  async runAgent(message, conversationId, channelId, userId, interruptResponses, context) {
194
- const { InterruptResponseContent, ModelStreamUpdateEvent, BeforeToolCallEvent, AfterToolCallEvent, AgentResultEvent } = await loadStrands();
268
+ const { InterruptResponseContent, ModelStreamUpdateEvent, BeforeModelCallEvent, BeforeToolCallEvent, AfterToolCallEvent, AgentResultEvent } = await loadStrands();
195
269
  const strandsAgent = await this.createStrandsAgent(conversationId, context);
196
270
  const startTime = Date.now();
271
+ // Runaway-protection caps (see AgentConfig.maxLlmCalls / maxToolIterations).
272
+ // Enforced by counting Strands' hooks and cancelling the agent once a cap is
273
+ // exceeded — cancellation surfaces below as stopReason 'cancelled'.
274
+ // Registered here (not in createStrandsAgent) so the `capExceeded` reason stays
275
+ // in scope for the result handling; Strands runs multiple hooks per event, so
276
+ // this coexists with the HITL hook.
277
+ // The counters live in `appState`, which the SessionManager persists, so they
278
+ // keep counting across a HITL interrupt + resume() — the cap bounds a whole
279
+ // logical turn, not just one execution segment.
280
+ // The reset is LAZY (inside the hooks), not done here: the SessionManager
281
+ // restores the snapshot's appState during `stream()`, i.e. after this point, so
282
+ // a reset written here would be overwritten by the previous turn's counts and
283
+ // the budget would leak from turn to turn. Instead a fresh turn gets a new turn
284
+ // id and the first hook to run notices the stored id is stale and zeroes the
285
+ // counters; a resume passes no id, so it continues on the persisted counts.
286
+ let capExceeded;
287
+ const turnId = interruptResponses ? undefined : ulid();
288
+ const startTurnIfNew = () => {
289
+ if (!turnId || strandsAgent.appState.get(TURN_ID_KEY) === turnId)
290
+ return;
291
+ strandsAgent.appState.set(TURN_ID_KEY, turnId);
292
+ strandsAgent.appState.set(MODEL_CALL_COUNT_KEY, 0);
293
+ strandsAgent.appState.set(TOOL_CALL_COUNT_KEY, 0);
294
+ strandsAgent.appState.set(COUNTED_TOOL_USE_IDS_KEY, []);
295
+ };
296
+ const bumpCount = (key) => {
297
+ startTurnIfNew();
298
+ const next = (Number(strandsAgent.appState.get(key)) || 0) + 1;
299
+ strandsAgent.appState.set(key, next);
300
+ return next;
301
+ };
302
+ // A tool call paused on a HITL interrupt re-emits BeforeToolCallEvent when the
303
+ // turn resumes, so count each toolUseId at most once — otherwise an approved
304
+ // call would be charged twice against the cap.
305
+ const countToolCall = (toolUseId) => {
306
+ startTurnIfNew();
307
+ const seen = strandsAgent.appState.get(COUNTED_TOOL_USE_IDS_KEY) ?? [];
308
+ if (seen.includes(toolUseId))
309
+ return Number(strandsAgent.appState.get(TOOL_CALL_COUNT_KEY)) || 0;
310
+ strandsAgent.appState.set(COUNTED_TOOL_USE_IDS_KEY, [...seen, toolUseId]);
311
+ return bumpCount(TOOL_CALL_COUNT_KEY);
312
+ };
313
+ // `false` disables a cap (→ Infinity, never trips); undefined uses the default.
314
+ // Values are validated in the constructor (positive integer or false).
315
+ const maxLlmConfig = this.config.maxLlmCalls ?? DEFAULT_MAX_LLM_CALLS;
316
+ const maxToolsConfig = this.config.maxToolIterations ?? DEFAULT_MAX_TOOL_ITERATIONS;
317
+ const maxLlm = maxLlmConfig === false ? Number.POSITIVE_INFINITY : maxLlmConfig;
318
+ const maxTools = maxToolsConfig === false ? Number.POSITIVE_INFINITY : maxToolsConfig;
319
+ // Both cancels pull weight: `event.cancel` skips this specific over-cap call (so
320
+ // it never reaches the provider — same mechanism as the deny path in
321
+ // createStrandsAgent, which uses it alone and lets the turn continue), while
322
+ // `strandsAgent.cancel()` additionally unwinds the loop so nothing after it runs.
323
+ strandsAgent.addHook(BeforeModelCallEvent, (event) => {
324
+ if (bumpCount(MODEL_CALL_COUNT_KEY) > maxLlm) {
325
+ capExceeded ??= `exceeded maxLlmCalls (${maxLlm})`;
326
+ event.cancel = 'maxLlmCalls exceeded';
327
+ strandsAgent.cancel();
328
+ }
329
+ });
330
+ strandsAgent.addHook(BeforeToolCallEvent, (event) => {
331
+ if (countToolCall(event.toolUse.toolUseId) > maxTools) {
332
+ capExceeded ??= `exceeded maxToolIterations (${maxTools})`;
333
+ event.cancel = 'maxToolIterations exceeded';
334
+ strandsAgent.cancel();
335
+ }
336
+ });
197
337
  // Only persist user message on initial path (not resume)
198
338
  if (!interruptResponses && conversationId && this.messages) {
199
339
  await this.messages.put({ conversationId, messageId: ulid(), role: 'user', content: message, contentType: 'text', userId, createdAt: Date.now(), metadata: '{}' });
@@ -280,6 +420,29 @@ export class AgentBase extends Scope {
280
420
  }
281
421
  const latencyMs = Date.now() - startTime;
282
422
  this.log.info('runAgent done', { textLength: fullText.length, latencyMs, interrupted });
423
+ // A runaway-protection cap (maxLlmCalls / maxToolIterations) cancelled the turn.
424
+ // Cancellation ends the stream normally (no throw, stopReason 'cancelled'), and
425
+ // `capExceeded` is only ever set by the cap hooks above — so surface it as an
426
+ // error chunk and skip the final persist + 'done' below.
427
+ if (capExceeded) {
428
+ const stoppedError = `Agent stopped: ${capExceeded}.`;
429
+ this.log.warn('runAgent stopped by cap', {
430
+ reason: capExceeded,
431
+ modelCallCount: Number(strandsAgent.appState.get(MODEL_CALL_COUNT_KEY)) || 0,
432
+ toolCallCount: Number(strandsAgent.appState.get(TOOL_CALL_COUNT_KEY)) || 0,
433
+ });
434
+ if (conversationId && this.messages) {
435
+ // Record why the turn stopped, mirroring the AsyncJob error handler — a
436
+ // reloaded conversation would otherwise just end without explanation.
437
+ // The cancelled tool call itself stays paired: Strands still emits
438
+ // AfterToolCallEvent for a call cancelled via `event.cancel` (carrying the
439
+ // cancellation as the result), which the loop above persists as
440
+ // 'tool-result', so no dangling tool_use is left behind for the next turn.
441
+ await this.messages.put({ conversationId, messageId: ulid(), role: 'assistant', content: fullText, contentType: 'text', userId, createdAt: Date.now(), metadata: JSON.stringify({ error: stoppedError, latencyMs }) });
442
+ }
443
+ await this.rt.publish('chunks', channelId, { type: 'error', error: stoppedError });
444
+ return;
445
+ }
283
446
  // If interrupted, don't persist final message or publish done — agent is paused
284
447
  if (interrupted)
285
448
  return;
@@ -318,6 +481,16 @@ export class AgentBase extends Scope {
318
481
  interrupt: (params) => context.interrupt(params),
319
482
  }),
320
483
  }));
484
+ // Collect local-dev hints for the canned provider (Strands strips these fields from
485
+ // tools, so plumb them explicitly). Built only when a tool declares one — otherwise
486
+ // undefined, so real providers and the common no-hint case carry zero overhead.
487
+ let cannedHints;
488
+ for (const t of toolDefs) {
489
+ if (t.cannedExamples || t.cannedTriggers) {
490
+ cannedHints ??= new Map();
491
+ cannedHints.set(t.name, { examples: t.cannedExamples, triggers: t.cannedTriggers });
492
+ }
493
+ }
321
494
  const configs = Array.isArray(this.modelConfig) ? this.modelConfig : this.modelConfig ? [this.modelConfig] : [];
322
495
  let resolvedConfig;
323
496
  for (const config of configs) {
@@ -330,7 +503,7 @@ export class AgentBase extends Scope {
330
503
  const tried = configs.map(c => `${c.provider}${c.modelId ? ` (${c.modelId})` : ''}`).join(', ');
331
504
  throw blocksAgentError(AgentErrors.ModelUnavailable, `No model available. Tried: ${tried}. Check logs for details.`);
332
505
  }
333
- const model = await createStrandsModel(resolvedConfig, this.log);
506
+ const model = await createStrandsModel(resolvedConfig, this.log, cannedHints);
334
507
  // SessionManager restores/saves agent state across invocations.
335
508
  // undefined when no conversationId (inference-only calls — no state to persist).
336
509
  const sessionManager = conversationId
@@ -388,11 +561,17 @@ export class AgentBase extends Scope {
388
561
  /**
389
562
  * Submit a message to the agent. Returns immediately with a channelId.
390
563
  *
391
- * Flow: stream() → AsyncJob.submit() → returns { channelId }
392
- * The AsyncJob consumer calls runAgent() separately.
393
- * Chunks are published to Realtime on the returned channelId.
564
+ * Flow: stream() → dispatchTurn() → returns { channelId }
565
+ * dispatchTurn runs the loop where the compute lives (in-process locally; on the AgentCore
566
+ * Runtime on AWS) and publishes chunks to Realtime on the returned channelId.
394
567
  *
395
568
  * Subscribe to chunks via result.channel, or await result.complete() for the final response.
569
+ *
570
+ * Errors surface on two paths. Once the turn is dispatched, loop failures arrive as an `error`
571
+ * chunk on the channel (and reject `complete()`). A failure to *dispatch* the turn — e.g. on AWS
572
+ * when the AgentCore Runtime can't be invoked (unresolved runtime ARN, or an `InvokeAgentRuntime`
573
+ * error) — rejects this `stream()` call itself rather than reaching the channel. Always `await`
574
+ * `stream()` so a dispatch failure isn't lost.
396
575
  */
397
576
  async stream(message, options) {
398
577
  const conversationId = options?.conversationId;
@@ -401,7 +580,7 @@ export class AgentBase extends Scope {
401
580
  throw blocksAgentError(AgentErrors.PersistenceRequired, 'userId is required when persistence is enabled. Pass it via options.userId.');
402
581
  const userId = options?.userId ?? 'anonymous';
403
582
  const context = this.resolveContext(options?.context);
404
- await this.job.submit({ message, conversationId, channelId, userId, context });
583
+ await this.dispatchTurn({ message, conversationId, channelId, userId, context });
405
584
  return {
406
585
  channelId,
407
586
  /** Realtime channel handle — subscribe to streaming chunks or return to client as Transferable. */
@@ -429,8 +608,12 @@ export class AgentBase extends Scope {
429
608
  }
430
609
  /**
431
610
  * Resume an interrupted agent with user's responses.
432
- * Submits a new AsyncJob that loads the session and continues from the interrupt point.
611
+ * Dispatches a new turn that loads the session and continues from the interrupt point.
433
612
  * Chunks are published to the same channelId — use the existing subscription or call complete() again to wait for the result.
613
+ *
614
+ * Like `stream()`, errors surface on two paths: loop failures arrive as an `error` chunk on the
615
+ * channel once the turn is dispatched, while a failure to *dispatch* (e.g. on AWS when the AgentCore
616
+ * Runtime can't be invoked) rejects this `resume()` call itself. Always `await` it.
434
617
  */
435
618
  async resume(channelId, responses, options) {
436
619
  if (!responses.length)
@@ -467,7 +650,7 @@ export class AgentBase extends Scope {
467
650
  response: r.response != null ? String(r.response) : r.approved ? (r.trust ? 'trust' : 'yes') : 'no',
468
651
  }));
469
652
  const context = this.resolveContext(options?.context);
470
- await this.job.submit({ message: '', conversationId, channelId, userId, resume: true, interruptResponses: translated, context });
653
+ await this.dispatchTurn({ message: '', conversationId, channelId, userId, interruptResponses: translated, context });
471
654
  }
472
655
  /**
473
656
  * Validates the per-call tool context against `toolContextSchema` (when set) and returns it.
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Co-bundle the app backend + `serve()` into a self-contained AgentCore asset directory.
3
+ *
4
+ * @param backendModulePath - absolute path to the app's backend module (BlocksStack `backendCDKPath`)
5
+ * @param outDir - directory to write the bundle into (a stable, synth-scoped path under cdk.out)
6
+ * @returns `outDir` (contains `main.js`, `_deps/`, `package.json`), ready for `fromCodeAsset`.
7
+ * The bundle is named `main.js` (not `agentcore-entry.js`) because AgentCore's entrypoint
8
+ * validator rejects names it considers to have "multiple dots" / disallowed chars; `main.js`
9
+ * matches the official @aws/agentcore CLI's convention and passes.
10
+ */
11
+ export declare function bundleAgentCoreAsset(backendModulePath: string, outDir: string): string;
12
+ //# sourceMappingURL=agentcore-bundle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agentcore-bundle.d.ts","sourceRoot":"","sources":["../src/agentcore-bundle.ts"],"names":[],"mappings":"AAqGA;;;;;;;;;GASG;AACH,wBAAgB,oBAAoB,CAAC,iBAAiB,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAkDtF"}
@@ -0,0 +1,150 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Synth-time co-bundle for the AgentCore Runtime code asset.
5
+ *
6
+ * The AgentCore process must run BOTH the developer's backend (which constructs the real
7
+ * Agent and its tool closures) AND bb-agent's `serve()` — from a SINGLE module graph, so
8
+ * the Agent instance registry (a module singleton in agent.ts) is shared. If the backend
9
+ * and the entrypoint were bundled separately they'd each get their own bb-agent copy, the
10
+ * Agent would register in one registry and the lookup would read the other, and `serve()`
11
+ * would fail with "No Agent registered".
12
+ *
13
+ * We esbuild-bundle a tiny generated entry that imports both from the same graph. We call
14
+ * `esbuild.buildSync()` DIRECTLY (not CDK's `NodejsFunction`) — that avoids the
15
+ * `PathNotUnderRoot` failure `NodejsFunction` hits for npm-installed packages, since direct
16
+ * esbuild has no projectRoot/lockfile requirement. `buildSync` because CDK synth is sync.
17
+ *
18
+ * Packaging mirrors the official `@aws/agentcore` CLI's Node CodeZip packager
19
+ * (lib/packaging/node.js), because the AgentCore direct-deploy base image provides ONLY the
20
+ * Node runtime — every dependency (including the AWS SDK) must be in the asset. Two wrinkles
21
+ * the CLI solves and we copy verbatim:
22
+ *
23
+ * 1. The `bedrock-agentcore` harness does `createRequire(import.meta.url); require('@fastify/sse')`
24
+ * at module load. esbuild can't statically bundle those dynamic requires. So we emit CJS
25
+ * (`format: 'cjs'`), shim `import.meta.url` to a real value, and prepend a banner that
26
+ * patches `Module._resolveFilename` to fall back to a sibling `_deps/` dir. The dynamic
27
+ * packages are copied into `_deps/` so the fallback finds them at runtime.
28
+ * 2. The `@aws-sdk/*` packages are pure JS and NOT in the base image, so we bundle them
29
+ * (no `external`).
30
+ */
31
+ import { buildSync } from 'esbuild';
32
+ import { cpSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
33
+ import { createRequire } from 'node:module';
34
+ import { dirname, join } from 'node:path';
35
+ import { fileURLToPath } from 'node:url';
36
+ const __dirname = dirname(fileURLToPath(import.meta.url));
37
+ /** bb-agent package root (dist/.. == package dir), used as esbuild's module-resolution base. */
38
+ const PKG_ROOT = join(__dirname, '..');
39
+ /** Sibling dir (next to the bundle) holding packages that are loaded via dynamic require(). */
40
+ const DEPS_DIR = '_deps';
41
+ /**
42
+ * Packages the `bedrock-agentcore` harness (and its Fastify plugins) load via dynamic
43
+ * `require()` at runtime, which esbuild leaves unbundled. Copied into `_deps/` and resolved
44
+ * by the `_resolveFilename` banner below. This list is taken verbatim from the official
45
+ * `@aws/agentcore` CLI so it stays in sync with the harness's runtime require graph.
46
+ */
47
+ const DYNAMIC_REQUIRE_PACKAGES = [
48
+ '@fastify/sse',
49
+ '@fastify/websocket',
50
+ 'duplexify',
51
+ 'end-of-stream',
52
+ 'fastify-plugin',
53
+ 'inherits',
54
+ 'once',
55
+ 'readable-stream',
56
+ 'safe-buffer',
57
+ 'stream-shift',
58
+ 'string_decoder',
59
+ 'util-deprecate',
60
+ 'wrappy',
61
+ 'ws',
62
+ ];
63
+ /**
64
+ * Banner prepended to the CJS bundle. First line gives ESM-style `import.meta.url` a real
65
+ * value (the harness calls `createRequire(import.meta.url)`); the IIFE patches Node's module
66
+ * resolver so a failed `require('X')` retries against `__dirname/_deps/X` (reading that
67
+ * package's `main` from its package.json). Byte-for-byte the CLI's banner.
68
+ */
69
+ const CJS_BANNER = 'const importMetaUrl = require("url").pathToFileURL(__filename).href;' +
70
+ '(function(){var M=require("module"),p=require("path"),f=require("fs"),d=p.join(__dirname,"_deps"),o=M._resolveFilename;' +
71
+ 'M._resolveFilename=function(r,P,i,O){try{return o.call(this,r,P,i,O)}catch(e){' +
72
+ 'var dp=p.join(d,r);if(f.existsSync(dp)){var pk=p.join(dp,"package.json");' +
73
+ 'if(f.existsSync(pk)){var m=JSON.parse(f.readFileSync(pk,"utf8")).main||"index.js";return p.resolve(dp,m)}' +
74
+ 'return p.resolve(dp,"index.js")}throw e}};})();';
75
+ /** Copy each dynamic-require package into `<outDir>/_deps/<pkg>`, resolving via bb-agent's
76
+ * module graph so hoisted (monorepo) and nested installs both work. */
77
+ function copyDynamicDeps(outDir) {
78
+ const require = createRequire(join(PKG_ROOT, 'noop.js'));
79
+ for (const pkg of DYNAMIC_REQUIRE_PACKAGES) {
80
+ let pkgDir;
81
+ try {
82
+ // Resolve the package's own package.json, then take its directory.
83
+ pkgDir = dirname(require.resolve(`${pkg}/package.json`));
84
+ }
85
+ catch {
86
+ // Not installed / not resolvable from here — skip; the runtime fallback only
87
+ // needs the packages actually reached by the harness's require graph.
88
+ continue;
89
+ }
90
+ if (existsSync(pkgDir)) {
91
+ cpSync(pkgDir, join(outDir, DEPS_DIR, pkg), { recursive: true });
92
+ }
93
+ }
94
+ }
95
+ /**
96
+ * Co-bundle the app backend + `serve()` into a self-contained AgentCore asset directory.
97
+ *
98
+ * @param backendModulePath - absolute path to the app's backend module (BlocksStack `backendCDKPath`)
99
+ * @param outDir - directory to write the bundle into (a stable, synth-scoped path under cdk.out)
100
+ * @returns `outDir` (contains `main.js`, `_deps/`, `package.json`), ready for `fromCodeAsset`.
101
+ * The bundle is named `main.js` (not `agentcore-entry.js`) because AgentCore's entrypoint
102
+ * validator rejects names it considers to have "multiple dots" / disallowed chars; `main.js`
103
+ * matches the official @aws/agentcore CLI's convention and passes.
104
+ */
105
+ export function bundleAgentCoreAsset(backendModulePath, outDir) {
106
+ // Generated entry: load config, import the backend (constructs + registers the Agent),
107
+ // then serve. `resolveDir` = bb-agent package root so `@aws-blocks/*` and this package's
108
+ // own `serve` resolve via node_modules; the backend is imported by absolute PATH (esbuild
109
+ // resolves paths, not file:// URLs) so it's bundled into the SAME graph and its
110
+ // `@aws-blocks/*` deps dedupe to one instance.
111
+ // Wrapped in an async IIFE (not top-level await) because the bundle is emitted as CJS,
112
+ // which does not support top-level await.
113
+ const entrySource = [
114
+ "import { loadConfigToProcessEnv } from '@aws-blocks/core';",
115
+ "import { serve } from '@aws-blocks/bb-agent/agentcore';",
116
+ '(async () => {',
117
+ ' await loadConfigToProcessEnv();',
118
+ ` await import(${JSON.stringify(backendModulePath)});`,
119
+ ' serve();',
120
+ '})();',
121
+ ].join('\n');
122
+ mkdirSync(outDir, { recursive: true });
123
+ buildSync({
124
+ stdin: {
125
+ contents: entrySource,
126
+ resolveDir: PKG_ROOT,
127
+ sourcefile: '__agentcore_entry.mjs',
128
+ loader: 'js',
129
+ },
130
+ outfile: join(outDir, 'main.js'),
131
+ bundle: true,
132
+ platform: 'node',
133
+ target: 'node22',
134
+ // CJS (not ESM) so the harness's createRequire + our _resolveFilename patch work.
135
+ format: 'cjs',
136
+ minify: true,
137
+ // Resolve @aws-blocks/* (and the backend's BB constructions) to their AWS-runtime
138
+ // variants, exactly as core bundles the Lambda handler.
139
+ conditions: ['aws-runtime', 'node'],
140
+ banner: { js: CJS_BANNER },
141
+ // Give ESM `import.meta.url` (used by the harness) a real value under CJS output.
142
+ define: { 'import.meta.url': 'importMetaUrl' },
143
+ });
144
+ // `{"type":"commonjs"}` so Node treats the .js bundle as CJS regardless of any ambient
145
+ // "type":"module" in a parent package.json.
146
+ writeFileSync(join(outDir, 'package.json'), '{"type":"commonjs"}');
147
+ // Ship the harness's dynamic-require closure alongside the bundle.
148
+ copyDynamicDeps(outDir);
149
+ return outDir;
150
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=agentcore-bundle.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agentcore-bundle.test.d.ts","sourceRoot":"","sources":["../src/agentcore-bundle.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,46 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Unit coverage for the synth-time AgentCore co-bundle (`bundleAgentCoreAsset`).
5
+ *
6
+ * The CDK tests bypass this path (they pass a pre-built `agentcoreAssetPath`), so without this
7
+ * test the intricate esbuild co-bundle — the CJS banner, the `import.meta.url` shim, the `_deps/`
8
+ * dynamic-require copy, and the `{"type":"commonjs"}` marker — is only ever exercised by a sandbox
9
+ * e2e deploy. This runs the real bundler over a trivial fixture backend and asserts the asset's
10
+ * structure, so regressions in the bundle shape fail in CI rather than silently at deploy time.
11
+ */
12
+ import { test } from 'node:test';
13
+ import assert from 'node:assert';
14
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { bundleAgentCoreAsset } from './agentcore-bundle.js';
18
+ test('bundleAgentCoreAsset co-bundles a backend into a CJS AgentCore asset', () => {
19
+ const workDir = mkdtempSync(join(tmpdir(), 'bb-agent-bundle-'));
20
+ try {
21
+ // A trivial backend module — bundling is static, so it only needs to be a resolvable
22
+ // module the generated entry can `import()` by absolute path.
23
+ const backendPath = join(workDir, 'backend.js');
24
+ writeFileSync(backendPath, 'export const __fixture = true;\n');
25
+ const outDir = join(workDir, 'asset');
26
+ mkdirSync(outDir, { recursive: true });
27
+ const result = bundleAgentCoreAsset(backendPath, outDir);
28
+ assert.strictEqual(result, outDir, 'returns the output directory');
29
+ // main.js — the CJS bundle, with the harness banner (createRequire shim + _resolveFilename patch).
30
+ const mainPath = join(outDir, 'main.js');
31
+ assert.ok(existsSync(mainPath), 'emits main.js');
32
+ const main = readFileSync(mainPath, 'utf-8');
33
+ assert.ok(main.length > 0, 'main.js is non-empty');
34
+ assert.ok(main.includes('_resolveFilename'), 'main.js carries the _deps resolver banner');
35
+ assert.ok(main.includes('importMetaUrl'), 'main.js carries the import.meta.url shim');
36
+ // package.json — forces Node to treat the .js bundle as CommonJS.
37
+ assert.strictEqual(readFileSync(join(outDir, 'package.json'), 'utf-8'), '{"type":"commonjs"}');
38
+ // _deps/ — the harness's dynamic-require closure copied alongside the bundle.
39
+ const depsDir = join(outDir, '_deps');
40
+ assert.ok(existsSync(depsDir), 'emits the _deps/ dynamic-require dir');
41
+ assert.ok(readdirSync(depsDir).length > 0, '_deps/ contains the copied packages');
42
+ }
43
+ finally {
44
+ rmSync(workDir, { recursive: true, force: true });
45
+ }
46
+ });