@aws-blocks/bb-agent 0.3.5 → 0.4.1

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 (62) hide show
  1. package/DESIGN.md +65 -17
  2. package/README.md +156 -8
  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 -57
  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 +12 -6
  24. package/dist/index.cdk.d.ts.map +1 -1
  25. package/dist/index.cdk.js +31 -31
  26. package/dist/index.cdk.test.js +128 -51
  27. package/dist/index.hooks.d.ts +2 -2
  28. package/dist/index.hooks.d.ts.map +1 -1
  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 +426 -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 +24 -9
  43. package/src/agent.aws.ts +58 -1
  44. package/src/agent.ts +269 -56
  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 +145 -53
  51. package/src/index.cdk.ts +33 -34
  52. package/src/index.hooks.ts +2 -2
  53. package/src/index.mock.ts +3 -0
  54. package/src/index.test.ts +473 -1
  55. package/src/model-factory.ts +3 -3
  56. package/src/providers/canned.ts +131 -36
  57. package/src/types.ts +64 -1
  58. package/src/version.ts +1 -1
  59. package/dist/job-event-source.d.ts +0 -19
  60. package/dist/job-event-source.d.ts.map +0 -1
  61. package/dist/job-event-source.js +0 -20
  62. package/src/job-event-source.ts +0 -21
package/src/agent.ts CHANGED
@@ -5,37 +5,80 @@ import { Scope, registerSdkIdentifiers, getSdkIdentifiers } from '@aws-blocks/co
5
5
  import type { ScopeParent } from '@aws-blocks/core';
6
6
  import { DistributedTable } from '@aws-blocks/bb-distributed-table';
7
7
  import { Realtime } from '@aws-blocks/bb-realtime';
8
- import { AsyncJob } from '@aws-blocks/bb-async-job';
9
8
  import { FileBucket } from '@aws-blocks/bb-file-bucket';
10
9
  import { Logger } from '@aws-blocks/bb-logger';
11
10
  import type { ChildLogger } from '@aws-blocks/bb-logger';
12
11
  // Runtime values from `@strands-agents/sdk` are deferred to loadStrands(); only types
13
12
  // are imported here (erased at compile time). See loadStrands() / issue #153.
14
13
  import type { Agent as StrandsAgent, SnapshotStorage } from '@strands-agents/sdk';
15
- import { z } from 'zod';
14
+ import type { z } from 'zod';
16
15
  import { createStrandsModel, checkModelHealth } from './model-factory.js';
17
16
  import { messageSchema, conversationSchema, agentStreamChunkSchema } from './schemas.js';
18
- import type { AgentConfig, AgentStreamChunk, AgentStreamResult, StreamOptions, Message, Conversation, TokenUsage, ConversationManagerConfig, ModelConfig, JSONValue, InterruptResponse, DefaultToolContext, AgentTool, ToolDefinition } from './types.js';
17
+ import type { AgentConfig, AgentStreamChunk, AgentStreamResult, StreamOptions, Message, Conversation, TokenUsage, ConversationManagerConfig, ModelConfig, JSONValue, InterruptResponse, DefaultToolContext, AgentTool, ToolDefinition, CannedToolHints } from './types.js';
19
18
  import { AgentErrors, blocksAgentError, InterruptError } from './errors.js';
20
- import { INTERACTIVE_JOB_EVENT_SOURCE } from './job-event-source.js';
21
19
  import { BB_NAME, BB_VERSION } from './version.js';
22
20
  import { ulid } from 'ulid';
23
21
 
24
- /** Payload submitted to the internal AsyncJob BB. */
25
- const jobPayloadSchema = z.object({
26
- message: z.string(),
27
- conversationId: z.string().optional(),
28
- channelId: z.string(),
29
- userId: z.string(),
30
- resume: z.boolean().optional(), // Resume fields (for HITL interrupt responses)
31
- interruptResponses: z.array(z.object({ interruptId: z.string(), response: z.string() })).optional(),
22
+ /**
23
+ * A single agent turn to dispatch (initial message or HITL resume). Passed to
24
+ * {@link AgentBase.dispatchTurn} — run in-process locally, or shipped to the AgentCore Runtime
25
+ * on AWS as the `InvokeAgentRuntime` payload.
26
+ *
27
+ * @internal Internal turn-dispatch seam — not part of the public API. Customers use
28
+ * `stream()` / `resume()`; this is the shape those hand to the compute layer.
29
+ */
30
+ export interface AgentTurnPayload<TContext = DefaultToolContext> {
31
+ /** User prompt. Empty on resume (interruptResponses drive the turn instead). */
32
+ message: string;
33
+ /** Conversation to persist to / restore the session from (undefined for inferenceOnly). */
34
+ conversationId?: string;
35
+ /** Realtime channel the client subscribes to for this turn's chunks. */
36
+ channelId: string;
37
+ /** Conversation owner. */
38
+ userId: string;
39
+ /** HITL resume: approval responses to apply instead of a new prompt. */
40
+ interruptResponses?: Array<{ interruptId: string; response: string }>;
32
41
  /** Per-call tool context, forwarded to tool invocations. JSON-serializable. */
33
- context: z.any().optional(),
34
- });
42
+ context?: TContext;
43
+ }
35
44
 
36
45
  /** Key under which the per-call tool context is threaded through Strands `invocationState`. */
37
46
  const TOOL_CONTEXT_KEY = '__bbAgentToolContext';
38
47
 
48
+ /**
49
+ * Default runaway-protection caps applied per turn when `AgentConfig` does not
50
+ * override them. See `AgentConfig.maxLlmCalls` / `maxToolIterations`.
51
+ *
52
+ * 20 is a backstop, not a tuned budget: healthy interactive turns in the agent
53
+ * samples and in Strands' own examples settle at a handful of model/tool calls
54
+ * (a plan step, one or two tool rounds, a final answer), so 20 leaves roughly an
55
+ * order of magnitude of headroom before it engages, while still cutting a
56
+ * reason->act loop off long before it becomes a noticeable Bedrock bill. Agents
57
+ * that legitimately take more steps are expected to raise the value (or set it
58
+ * to `false`) deliberately.
59
+ */
60
+ const DEFAULT_MAX_LLM_CALLS = 20;
61
+ const DEFAULT_MAX_TOOL_ITERATIONS = 20;
62
+
63
+ /** `appState` keys holding the per-turn cap counters, so they survive `resume()`. */
64
+ const MODEL_CALL_COUNT_KEY = '__bbAgentModelCallCount';
65
+ const TOOL_CALL_COUNT_KEY = '__bbAgentToolCallCount';
66
+ const COUNTED_TOOL_USE_IDS_KEY = '__bbAgentCountedToolUseIds';
67
+ /** Id of the turn the counters belong to, so a new turn can zero them lazily. */
68
+ const TURN_ID_KEY = '__bbAgentCapTurnId';
69
+
70
+ /**
71
+ * Validate a runaway-protection cap: a positive integer, `false` to disable, or
72
+ * `undefined` for the default. Rejects `0`/negatives/non-integers/`NaN`, which
73
+ * would otherwise either kill every turn or silently disable the cap.
74
+ */
75
+ function validateCap(name: string, value: number | false | undefined): void {
76
+ if (value === false || value === undefined) return;
77
+ if (!Number.isInteger(value) || value < 1) {
78
+ throw blocksAgentError(AgentErrors.InvalidModelConfig, `'${name}' must be a positive integer or false to disable the cap, got ${String(value)}.`);
79
+ }
80
+ }
81
+
39
82
  /**
40
83
  * Lazily import the Strands SDK runtime, caching the module after the first load.
41
84
  *
@@ -56,6 +99,26 @@ function loadStrands(): Promise<typeof import('@strands-agents/sdk')> {
56
99
  return strandsModulePromise;
57
100
  }
58
101
 
102
+ // ── Agent instance registry ──────────────────────────────────────────────────
103
+ // A module-singleton map of fullId → live Agent instance. The AgentCore Runtime
104
+ // entrypoint (agentcore-entry.ts) imports the app backend (which constructs the
105
+ // Agent, registering it here) and then looks it up by the BB_AGENT_ID the CDK
106
+ // Runtime construct injected, to drive its loop. Because it's a module singleton,
107
+ // the entrypoint and the backend MUST be co-bundled into one module graph (see
108
+ // agentcore-bundle.ts) — otherwise the Agent registers in one copy's map and the
109
+ // lookup reads another's.
110
+ const agentRegistry = new Map<string, AgentBase<any>>();
111
+
112
+ /** @internal Register a live Agent instance so the AgentCore entrypoint can find it by fullId. */
113
+ export function registerAgentInstance(fullId: string, agent: AgentBase<any>): void {
114
+ agentRegistry.set(fullId, agent);
115
+ }
116
+
117
+ /** @internal Look up a registered Agent instance by fullId (used by the AgentCore entrypoint). */
118
+ export function getAgentInstance(fullId: string): AgentBase<any> | undefined {
119
+ return agentRegistry.get(fullId);
120
+ }
121
+
59
122
  /**
60
123
  * The per-call tool factory handed to the `tools` callback. At runtime it's an identity
61
124
  * function whose only job is to give TypeScript a single call site per tool where it can
@@ -102,12 +165,14 @@ async function createConversationManager(config?: ConversationManagerConfig) {
102
165
  /**
103
166
  * Base class for the Agent BB. Extended by agent.mock.ts (model.local) and agent.aws.ts (model.deployed).
104
167
  *
105
- * Creates up to 4 internal BBs depending on mode:
168
+ * Creates internal BBs depending on mode:
106
169
  * - FileBucket: session snapshot storage for Strands SessionManager (always)
107
170
  * - DistributedTable: frontend message history (when inferenceOnly = false)
108
- * - Realtime: streaming chunks to browser + AsyncJob result delivery (always)
109
- * - AsyncJob: runs Strands agent asynchronously (always)
110
- * - TODO logging
171
+ * - Realtime: streaming chunks to browser (always)
172
+ *
173
+ * The agent loop runs via {@link dispatchTurn}: in-process locally (mock), and on the
174
+ * AgentCore Runtime on AWS (the deployed subclass overrides dispatchTurn to invoke it). The
175
+ * AgentCore Runtime itself is provisioned by the CDK layer (index.cdk.ts → AgentCoreRuntime).
111
176
  */
112
177
  export class AgentBase<TContext = DefaultToolContext> extends Scope {
113
178
  /** Developer-facing agent configuration. */
@@ -120,8 +185,6 @@ export class AgentBase<TContext = DefaultToolContext> extends Scope {
120
185
  private messages?: DistributedTable<z.infer<typeof messageSchema>, { partitionKey: 'conversationId'; sortKey: 'messageId' }>;
121
186
  /** Realtime pub/sub — streams chunks to browser. */
122
187
  private rt: InstanceType<typeof Realtime>;
123
- /** Internal async job — runs the Strands agent in a separate execution context. */
124
- private job: AsyncJob<z.infer<typeof jobPayloadSchema>>;
125
188
  /** Which model provider to use. */
126
189
  private modelConfig: ModelConfig | ModelConfig[] | undefined;
127
190
  /** Where to persist Strands agent state (snapshots). */
@@ -142,6 +205,8 @@ export class AgentBase<TContext = DefaultToolContext> extends Scope {
142
205
  super(id, { parent: scope, bbName: BB_NAME, bbVersion: BB_VERSION });
143
206
  this.log = config?.logger ?? new Logger(this, 'logger', { level: 'error' });
144
207
  this.config = config;
208
+ validateCap('maxLlmCalls', config.maxLlmCalls);
209
+ validateCap('maxToolIterations', config.maxToolIterations);
145
210
  this.toolMap = resolveTools<TContext>(config.tools);
146
211
  this.modelConfig = modelConfig;
147
212
 
@@ -166,29 +231,6 @@ export class AgentBase<TContext = DefaultToolContext> extends Scope {
166
231
  },
167
232
  });
168
233
 
169
- this.job = new AsyncJob(this, 'job', {
170
- schema: jobPayloadSchema,
171
- ...INTERACTIVE_JOB_EVENT_SOURCE,
172
- handler: async (payload) => {
173
- try {
174
- await this.runAgent(payload.message, payload.conversationId, payload.channelId, payload.userId, payload.interruptResponses, payload.context);
175
- } catch (err) {
176
- const errorMessage = err instanceof Error ? err.message : String(err);
177
- this.log.error('runAgent error', { error: errorMessage });
178
- // Best-effort: persist error to conversation history (don't let DB failure block error chunk)
179
- try {
180
- if (payload.conversationId && this.messages) {
181
- await this.messages.put({ conversationId: payload.conversationId, messageId: ulid(), role: 'assistant' as const, content: '', contentType: 'text' as const, userId: payload.userId, createdAt: Date.now(), metadata: JSON.stringify({ error: errorMessage }) });
182
- }
183
- } catch (persistErr) {
184
- this.log.error('Failed to persist error to history', { error: persistErr });
185
- }
186
- // Publish error chunk so the client doesn't hang. Don't re-throw — AsyncJob would retry a non-idempotent operation.
187
- await this.rt.publish('chunks', payload.channelId, { type: 'error', error: errorMessage });
188
- }
189
- },
190
- });
191
-
192
234
  const identifiers: Record<string, string> = {};
193
235
  if (this.conversations) {
194
236
  identifiers.conversationsTableName = getSdkIdentifiers(this.conversations).tableName;
@@ -199,24 +241,150 @@ export class AgentBase<TContext = DefaultToolContext> extends Scope {
199
241
  identifiers.sessionBucketName = getSdkIdentifiers(this.sessionBucket).bucketName;
200
242
  identifiers.realtimeWsUrl = getSdkIdentifiers(this.rt).wsUrl;
201
243
  identifiers.realtimeCallbackUrl = getSdkIdentifiers(this.rt).callbackUrl;
202
- identifiers.jobQueueUrl = getSdkIdentifiers(this.job).queueUrl;
203
244
  registerSdkIdentifiers(this.fullId, identifiers);
245
+
246
+ // Register this instance so the AgentCore Runtime entrypoint (agentcore-entry.ts)
247
+ // can find it by fullId and drive its loop. Harmless in the Lambda/mock paths.
248
+ registerAgentInstance(this.fullId, this);
249
+ }
250
+
251
+ /**
252
+ * Run one agent turn (initial message or HITL resume) and publish its chunks to Realtime.
253
+ *
254
+ * This is the single execution entry the compute layer invokes: the AgentCore Runtime
255
+ * entrypoint (agentcore-entry.ts) calls it as a background async task on AWS, and locally
256
+ * {@link dispatchTurn} calls it in-process. Errors are caught and published as an `error`
257
+ * chunk (not re-thrown) so the client never hangs and a non-idempotent turn isn't retried.
258
+ *
259
+ * @param payload - the turn to run (see {@link AgentTurnPayload}).
260
+ * @internal Invoked by the compute layer (agentcore-entry / dispatchTurn), not customer API.
261
+ */
262
+ async invokeTurn(payload: AgentTurnPayload<TContext>): Promise<void> {
263
+ try {
264
+ await this.runAgent(payload.message, payload.conversationId, payload.channelId, payload.userId, payload.interruptResponses, payload.context);
265
+ } catch (err) {
266
+ const errorMessage = err instanceof Error ? err.message : String(err);
267
+ this.log.error('runAgent error', { error: errorMessage });
268
+ // Best-effort: persist error to conversation history (don't let DB failure block error chunk)
269
+ try {
270
+ if (payload.conversationId && this.messages) {
271
+ await this.messages.put({ conversationId: payload.conversationId, messageId: ulid(), role: 'assistant' as const, content: '', contentType: 'text' as const, userId: payload.userId, createdAt: Date.now(), metadata: JSON.stringify({ error: errorMessage }) });
272
+ }
273
+ } catch (persistErr) {
274
+ this.log.error('Failed to persist error to history', { error: persistErr });
275
+ }
276
+ // Publish error chunk so the client doesn't hang. Don't re-throw — a non-idempotent turn shouldn't be retried.
277
+ await this.rt.publish('chunks', payload.channelId, { type: 'error', error: errorMessage });
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Dispatch a turn to wherever the agent loop runs, returning promptly so `stream()`/`resume()`
283
+ * hand the client a `channelId` without waiting for the turn to finish.
284
+ *
285
+ * Base (local/mock): run the loop IN-PROCESS, fire-and-forget — chunks flow to the mock
286
+ * Realtime as the turn progresses. The deployed (AWS) subclass overrides this to invoke the
287
+ * AgentCore Runtime, which runs the loop as a background task and publishes to Realtime.
288
+ *
289
+ * @internal Internal compute seam (overridden by the AWS subclass); not customer API.
290
+ */
291
+ protected async dispatchTurn(payload: AgentTurnPayload<TContext>): Promise<void> {
292
+ // Reproduce the AWS wire boundary locally. On AWS, dispatchTurn JSON-serializes `context` into the
293
+ // InvokeAgentRuntime payload and the container re-parses it (z.unknown passthrough), so a tool
294
+ // receives a JSON-mangled context (Date→string, Set→{}, undefined dropped) — NOT the live object
295
+ // it would get here in-process. Round-trip it so a serialization bug fails locally instead of only
296
+ // after deploy. (A non-JSON-serializable context throws here, mirroring the AWS JSON.stringify.)
297
+ const context =
298
+ payload.context === undefined ? undefined : (JSON.parse(JSON.stringify(payload.context)) as TContext);
299
+ // invokeTurn catches and publishes its own errors, so the floating promise can't reject.
300
+ void this.invokeTurn({ ...payload, context });
204
301
  }
205
302
 
206
303
  /**
207
304
  * Executes the Strands agent, publishes chunks to Realtime, persists messages to DynamoDB.
208
305
  *
209
- * Called by: AsyncJob consumer.
210
- * NOT called directly stream() submits to AsyncJob, which invokes this.
306
+ * Called by {@link invokeTurn} (wherever the loop runs — locally in-process, or inside the
307
+ * AgentCore Runtime container on AWS). Publishes each event to the Realtime `chunks` channel.
211
308
  *
212
- * Flow: AsyncJob handler → runAgent() → Strands agent.stream() → publishes chunks to Realtime BB
213
- * TODO add comments for args
309
+ * Flow: invokeTurn() → runAgent() → Strands agent.stream() → publishes chunks to Realtime BB
310
+ *
311
+ * @param message - the user message that starts the turn (ignored on the resume path)
312
+ * @param conversationId - conversation to load/persist history for; undefined means no persistence
313
+ * @param channelId - Realtime channel the stream chunks are published to
314
+ * @param userId - owner of the conversation, stored on every persisted message
315
+ * @param interruptResponses - approval responses when resuming a turn paused on a HITL interrupt
316
+ * @param context - per-call tool context, threaded to tool handlers via Strands invocationState
214
317
  */
215
318
  private async runAgent(message: string, conversationId: string | undefined, channelId: string, userId: string, interruptResponses?: Array<{ interruptId: string; response: string }>, context?: TContext): Promise<void> {
216
- const { InterruptResponseContent, ModelStreamUpdateEvent, BeforeToolCallEvent, AfterToolCallEvent, AgentResultEvent } = await loadStrands();
319
+ const { InterruptResponseContent, ModelStreamUpdateEvent, BeforeModelCallEvent, BeforeToolCallEvent, AfterToolCallEvent, AgentResultEvent } = await loadStrands();
217
320
  const strandsAgent = await this.createStrandsAgent(conversationId, context);
218
321
  const startTime = Date.now();
219
322
 
323
+ // Runaway-protection caps (see AgentConfig.maxLlmCalls / maxToolIterations).
324
+ // Enforced by counting Strands' hooks and cancelling the agent once a cap is
325
+ // exceeded — cancellation surfaces below as stopReason 'cancelled'.
326
+ // Registered here (not in createStrandsAgent) so the `capExceeded` reason stays
327
+ // in scope for the result handling; Strands runs multiple hooks per event, so
328
+ // this coexists with the HITL hook.
329
+ // The counters live in `appState`, which the SessionManager persists, so they
330
+ // keep counting across a HITL interrupt + resume() — the cap bounds a whole
331
+ // logical turn, not just one execution segment.
332
+ // The reset is LAZY (inside the hooks), not done here: the SessionManager
333
+ // restores the snapshot's appState during `stream()`, i.e. after this point, so
334
+ // a reset written here would be overwritten by the previous turn's counts and
335
+ // the budget would leak from turn to turn. Instead a fresh turn gets a new turn
336
+ // id and the first hook to run notices the stored id is stale and zeroes the
337
+ // counters; a resume passes no id, so it continues on the persisted counts.
338
+ let capExceeded: string | undefined;
339
+ const turnId = interruptResponses ? undefined : ulid();
340
+ const startTurnIfNew = (): void => {
341
+ if (!turnId || strandsAgent.appState.get(TURN_ID_KEY) === turnId) return;
342
+ strandsAgent.appState.set(TURN_ID_KEY, turnId);
343
+ strandsAgent.appState.set(MODEL_CALL_COUNT_KEY, 0);
344
+ strandsAgent.appState.set(TOOL_CALL_COUNT_KEY, 0);
345
+ strandsAgent.appState.set(COUNTED_TOOL_USE_IDS_KEY, []);
346
+ };
347
+ const bumpCount = (key: string): number => {
348
+ startTurnIfNew();
349
+ const next = (Number(strandsAgent.appState.get(key)) || 0) + 1;
350
+ strandsAgent.appState.set(key, next);
351
+ return next;
352
+ };
353
+ // A tool call paused on a HITL interrupt re-emits BeforeToolCallEvent when the
354
+ // turn resumes, so count each toolUseId at most once — otherwise an approved
355
+ // call would be charged twice against the cap.
356
+ const countToolCall = (toolUseId: string): number => {
357
+ startTurnIfNew();
358
+ const seen = (strandsAgent.appState.get(COUNTED_TOOL_USE_IDS_KEY) as string[] | undefined) ?? [];
359
+ if (seen.includes(toolUseId)) return Number(strandsAgent.appState.get(TOOL_CALL_COUNT_KEY)) || 0;
360
+ strandsAgent.appState.set(COUNTED_TOOL_USE_IDS_KEY, [...seen, toolUseId]);
361
+ return bumpCount(TOOL_CALL_COUNT_KEY);
362
+ };
363
+ // `false` disables a cap (→ Infinity, never trips); undefined uses the default.
364
+ // Values are validated in the constructor (positive integer or false).
365
+ const maxLlmConfig = this.config.maxLlmCalls ?? DEFAULT_MAX_LLM_CALLS;
366
+ const maxToolsConfig = this.config.maxToolIterations ?? DEFAULT_MAX_TOOL_ITERATIONS;
367
+ const maxLlm = maxLlmConfig === false ? Number.POSITIVE_INFINITY : maxLlmConfig;
368
+ const maxTools = maxToolsConfig === false ? Number.POSITIVE_INFINITY : maxToolsConfig;
369
+ // Both cancels pull weight: `event.cancel` skips this specific over-cap call (so
370
+ // it never reaches the provider — same mechanism as the deny path in
371
+ // createStrandsAgent, which uses it alone and lets the turn continue), while
372
+ // `strandsAgent.cancel()` additionally unwinds the loop so nothing after it runs.
373
+ strandsAgent.addHook(BeforeModelCallEvent, (event) => {
374
+ if (bumpCount(MODEL_CALL_COUNT_KEY) > maxLlm) {
375
+ capExceeded ??= `exceeded maxLlmCalls (${maxLlm})`;
376
+ event.cancel = 'maxLlmCalls exceeded';
377
+ strandsAgent.cancel();
378
+ }
379
+ });
380
+ strandsAgent.addHook(BeforeToolCallEvent, (event) => {
381
+ if (countToolCall(event.toolUse.toolUseId) > maxTools) {
382
+ capExceeded ??= `exceeded maxToolIterations (${maxTools})`;
383
+ event.cancel = 'maxToolIterations exceeded';
384
+ strandsAgent.cancel();
385
+ }
386
+ });
387
+
220
388
  // Only persist user message on initial path (not resume)
221
389
  if (!interruptResponses && conversationId && this.messages) {
222
390
  await this.messages.put({ conversationId, messageId: ulid(), role: 'user' as const, content: message, contentType: 'text' as const, userId, createdAt: Date.now(), metadata: '{}' });
@@ -301,6 +469,30 @@ export class AgentBase<TContext = DefaultToolContext> extends Scope {
301
469
  const latencyMs = Date.now() - startTime;
302
470
  this.log.info('runAgent done', { textLength: fullText.length, latencyMs, interrupted });
303
471
 
472
+ // A runaway-protection cap (maxLlmCalls / maxToolIterations) cancelled the turn.
473
+ // Cancellation ends the stream normally (no throw, stopReason 'cancelled'), and
474
+ // `capExceeded` is only ever set by the cap hooks above — so surface it as an
475
+ // error chunk and skip the final persist + 'done' below.
476
+ if (capExceeded) {
477
+ const stoppedError = `Agent stopped: ${capExceeded}.`;
478
+ this.log.warn('runAgent stopped by cap', {
479
+ reason: capExceeded,
480
+ modelCallCount: Number(strandsAgent.appState.get(MODEL_CALL_COUNT_KEY)) || 0,
481
+ toolCallCount: Number(strandsAgent.appState.get(TOOL_CALL_COUNT_KEY)) || 0,
482
+ });
483
+ if (conversationId && this.messages) {
484
+ // Record why the turn stopped, mirroring the AsyncJob error handler — a
485
+ // reloaded conversation would otherwise just end without explanation.
486
+ // The cancelled tool call itself stays paired: Strands still emits
487
+ // AfterToolCallEvent for a call cancelled via `event.cancel` (carrying the
488
+ // cancellation as the result), which the loop above persists as
489
+ // 'tool-result', so no dangling tool_use is left behind for the next turn.
490
+ await this.messages.put({ conversationId, messageId: ulid(), role: 'assistant' as const, content: fullText, contentType: 'text' as const, userId, createdAt: Date.now(), metadata: JSON.stringify({ error: stoppedError, latencyMs }) });
491
+ }
492
+ await this.rt.publish('chunks', channelId, { type: 'error', error: stoppedError });
493
+ return;
494
+ }
495
+
304
496
  // If interrupted, don't persist final message or publish done — agent is paused
305
497
  if (interrupted) return;
306
498
 
@@ -346,6 +538,17 @@ export class AgentBase<TContext = DefaultToolContext> extends Scope {
346
538
  }),
347
539
  }));
348
540
 
541
+ // Collect local-dev hints for the canned provider (Strands strips these fields from
542
+ // tools, so plumb them explicitly). Built only when a tool declares one — otherwise
543
+ // undefined, so real providers and the common no-hint case carry zero overhead.
544
+ let cannedHints: Map<string, CannedToolHints> | undefined;
545
+ for (const t of toolDefs) {
546
+ if (t.cannedExamples || t.cannedTriggers) {
547
+ cannedHints ??= new Map();
548
+ cannedHints.set(t.name!, { examples: t.cannedExamples, triggers: t.cannedTriggers });
549
+ }
550
+ }
551
+
349
552
  const configs = Array.isArray(this.modelConfig) ? this.modelConfig : this.modelConfig ? [this.modelConfig] : [];
350
553
  let resolvedConfig: ModelConfig | undefined;
351
554
  for (const config of configs) {
@@ -355,7 +558,7 @@ export class AgentBase<TContext = DefaultToolContext> extends Scope {
355
558
  const tried = configs.map(c => `${c.provider}${c.modelId ? ` (${c.modelId})` : ''}`).join(', ');
356
559
  throw blocksAgentError(AgentErrors.ModelUnavailable, `No model available. Tried: ${tried}. Check logs for details.`);
357
560
  }
358
- const model = await createStrandsModel(resolvedConfig, this.log);
561
+ const model = await createStrandsModel(resolvedConfig, this.log, cannedHints);
359
562
 
360
563
  // SessionManager restores/saves agent state across invocations.
361
564
  // undefined when no conversationId (inference-only calls — no state to persist).
@@ -418,11 +621,17 @@ export class AgentBase<TContext = DefaultToolContext> extends Scope {
418
621
  /**
419
622
  * Submit a message to the agent. Returns immediately with a channelId.
420
623
  *
421
- * Flow: stream() → AsyncJob.submit() → returns { channelId }
422
- * The AsyncJob consumer calls runAgent() separately.
423
- * Chunks are published to Realtime on the returned channelId.
624
+ * Flow: stream() → dispatchTurn() → returns { channelId }
625
+ * dispatchTurn runs the loop where the compute lives (in-process locally; on the AgentCore
626
+ * Runtime on AWS) and publishes chunks to Realtime on the returned channelId.
424
627
  *
425
628
  * Subscribe to chunks via result.channel, or await result.complete() for the final response.
629
+ *
630
+ * Errors surface on two paths. Once the turn is dispatched, loop failures arrive as an `error`
631
+ * chunk on the channel (and reject `complete()`). A failure to *dispatch* the turn — e.g. on AWS
632
+ * when the AgentCore Runtime can't be invoked (unresolved runtime ARN, or an `InvokeAgentRuntime`
633
+ * error) — rejects this `stream()` call itself rather than reaching the channel. Always `await`
634
+ * `stream()` so a dispatch failure isn't lost.
426
635
  */
427
636
  async stream(message: string, options?: StreamOptions<TContext>): Promise<AgentStreamResult> {
428
637
  const conversationId = options?.conversationId;
@@ -430,7 +639,7 @@ export class AgentBase<TContext = DefaultToolContext> extends Scope {
430
639
  if (!options?.userId && !this.config.inferenceOnly) throw blocksAgentError(AgentErrors.PersistenceRequired, 'userId is required when persistence is enabled. Pass it via options.userId.');
431
640
  const userId = options?.userId ?? 'anonymous';
432
641
  const context = this.resolveContext(options?.context);
433
- await this.job.submit({ message, conversationId, channelId, userId, context });
642
+ await this.dispatchTurn({ message, conversationId, channelId, userId, context });
434
643
  return {
435
644
  channelId,
436
645
  /** Realtime channel handle — subscribe to streaming chunks or return to client as Transferable. */
@@ -457,8 +666,12 @@ export class AgentBase<TContext = DefaultToolContext> extends Scope {
457
666
 
458
667
  /**
459
668
  * Resume an interrupted agent with user's responses.
460
- * Submits a new AsyncJob that loads the session and continues from the interrupt point.
669
+ * Dispatches a new turn that loads the session and continues from the interrupt point.
461
670
  * Chunks are published to the same channelId — use the existing subscription or call complete() again to wait for the result.
671
+ *
672
+ * Like `stream()`, errors surface on two paths: loop failures arrive as an `error` chunk on the
673
+ * channel once the turn is dispatched, while a failure to *dispatch* (e.g. on AWS when the AgentCore
674
+ * Runtime can't be invoked) rejects this `resume()` call itself. Always `await` it.
462
675
  */
463
676
  async resume(channelId: string, responses: Array<InterruptResponse>, options?: { conversationId?: string; userId?: string; context?: TContext }): Promise<void> {
464
677
  if (!responses.length) throw blocksAgentError(AgentErrors.InterruptRequired, 'resume() requires at least one interrupt response.');
@@ -493,7 +706,7 @@ export class AgentBase<TContext = DefaultToolContext> extends Scope {
493
706
  response: r.response != null ? String(r.response) : r.approved ? (r.trust ? 'trust' : 'yes') : 'no',
494
707
  }));
495
708
  const context = this.resolveContext(options?.context);
496
- await this.job.submit({ message: '', conversationId, channelId, userId, resume: true, interruptResponses: translated, context });
709
+ await this.dispatchTurn({ message: '', conversationId, channelId, userId, interruptResponses: translated, context });
497
710
  }
498
711
 
499
712
  /**
@@ -0,0 +1,52 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Unit coverage for the synth-time AgentCore co-bundle (`bundleAgentCoreAsset`).
6
+ *
7
+ * The CDK tests bypass this path (they pass a pre-built `agentcoreAssetPath`), so without this
8
+ * test the intricate esbuild co-bundle — the CJS banner, the `import.meta.url` shim, the `_deps/`
9
+ * dynamic-require copy, and the `{"type":"commonjs"}` marker — is only ever exercised by a sandbox
10
+ * e2e deploy. This runs the real bundler over a trivial fixture backend and asserts the asset's
11
+ * structure, so regressions in the bundle shape fail in CI rather than silently at deploy time.
12
+ */
13
+ import { test } from 'node:test';
14
+ import assert from 'node:assert';
15
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+ import { bundleAgentCoreAsset } from './agentcore-bundle.js';
19
+
20
+ test('bundleAgentCoreAsset co-bundles a backend into a CJS AgentCore asset', () => {
21
+ const workDir = mkdtempSync(join(tmpdir(), 'bb-agent-bundle-'));
22
+ try {
23
+ // A trivial backend module — bundling is static, so it only needs to be a resolvable
24
+ // module the generated entry can `import()` by absolute path.
25
+ const backendPath = join(workDir, 'backend.js');
26
+ writeFileSync(backendPath, 'export const __fixture = true;\n');
27
+
28
+ const outDir = join(workDir, 'asset');
29
+ mkdirSync(outDir, { recursive: true });
30
+
31
+ const result = bundleAgentCoreAsset(backendPath, outDir);
32
+ assert.strictEqual(result, outDir, 'returns the output directory');
33
+
34
+ // main.js — the CJS bundle, with the harness banner (createRequire shim + _resolveFilename patch).
35
+ const mainPath = join(outDir, 'main.js');
36
+ assert.ok(existsSync(mainPath), 'emits main.js');
37
+ const main = readFileSync(mainPath, 'utf-8');
38
+ assert.ok(main.length > 0, 'main.js is non-empty');
39
+ assert.ok(main.includes('_resolveFilename'), 'main.js carries the _deps resolver banner');
40
+ assert.ok(main.includes('importMetaUrl'), 'main.js carries the import.meta.url shim');
41
+
42
+ // package.json — forces Node to treat the .js bundle as CommonJS.
43
+ assert.strictEqual(readFileSync(join(outDir, 'package.json'), 'utf-8'), '{"type":"commonjs"}');
44
+
45
+ // _deps/ — the harness's dynamic-require closure copied alongside the bundle.
46
+ const depsDir = join(outDir, '_deps');
47
+ assert.ok(existsSync(depsDir), 'emits the _deps/ dynamic-require dir');
48
+ assert.ok(readdirSync(depsDir).length > 0, '_deps/ contains the copied packages');
49
+ } finally {
50
+ rmSync(workDir, { recursive: true, force: true });
51
+ }
52
+ });