@aws-blocks/bb-agent 0.3.5 → 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.
- package/DESIGN.md +65 -17
- package/README.md +73 -6
- package/dist/agent.aws.d.ts +15 -1
- package/dist/agent.aws.d.ts.map +1 -1
- package/dist/agent.aws.js +49 -0
- package/dist/agent.d.ts +82 -14
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +238 -57
- package/dist/agentcore-bundle.d.ts +12 -0
- package/dist/agentcore-bundle.d.ts.map +1 -0
- package/dist/agentcore-bundle.js +150 -0
- package/dist/agentcore-bundle.test.d.ts +2 -0
- package/dist/agentcore-bundle.test.d.ts.map +1 -0
- package/dist/agentcore-bundle.test.js +46 -0
- package/dist/agentcore-entry.d.ts +21 -0
- package/dist/agentcore-entry.d.ts.map +1 -0
- package/dist/agentcore-entry.js +120 -0
- package/dist/agentcore-runtime.cdk.d.ts +27 -0
- package/dist/agentcore-runtime.cdk.d.ts.map +1 -0
- package/dist/agentcore-runtime.cdk.js +168 -0
- package/dist/index.aws.d.ts +1 -0
- package/dist/index.aws.d.ts.map +1 -1
- package/dist/index.cdk.d.ts +10 -4
- package/dist/index.cdk.d.ts.map +1 -1
- package/dist/index.cdk.js +27 -28
- package/dist/index.cdk.test.js +128 -51
- package/dist/index.mock.d.ts +1 -0
- package/dist/index.mock.d.ts.map +1 -1
- package/dist/index.test.js +404 -1
- package/dist/model-factory.d.ts +2 -2
- package/dist/model-factory.d.ts.map +1 -1
- package/dist/model-factory.js +2 -2
- package/dist/providers/canned.d.ts +8 -1
- package/dist/providers/canned.d.ts.map +1 -1
- package/dist/providers/canned.js +127 -42
- package/dist/types.d.ts +63 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +16 -9
- package/src/agent.aws.ts +58 -1
- package/src/agent.ts +269 -56
- package/src/agentcore-bundle.test.ts +52 -0
- package/src/agentcore-bundle.ts +162 -0
- package/src/agentcore-entry.ts +134 -0
- package/src/agentcore-runtime.cdk.ts +203 -0
- package/src/index.aws.ts +3 -0
- package/src/index.cdk.test.ts +145 -53
- package/src/index.cdk.ts +29 -31
- package/src/index.mock.ts +3 -0
- package/src/index.test.ts +449 -1
- package/src/model-factory.ts +3 -3
- package/src/providers/canned.ts +131 -36
- package/src/types.ts +64 -1
- package/src/version.ts +1 -1
- package/dist/job-event-source.d.ts +0 -19
- package/dist/job-event-source.d.ts.map +0 -1
- package/dist/job-event-source.js +0 -20
- 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
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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
|
|
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
|
|
109
|
-
*
|
|
110
|
-
* -
|
|
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
|
|
210
|
-
*
|
|
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:
|
|
213
|
-
*
|
|
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() →
|
|
422
|
-
*
|
|
423
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
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.
|
|
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
|
+
});
|