@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
@@ -17,11 +17,18 @@
17
17
  import { Model } from '@strands-agents/sdk';
18
18
  import type { Message, ModelStreamEvent, StreamOptions } from '@strands-agents/sdk';
19
19
  import { ToolResultBlock } from '@strands-agents/sdk';
20
+ import type { CannedToolHints } from '../types.js';
20
21
 
21
22
  interface CannedConfig {
22
23
  modelId: string;
23
24
  }
24
25
 
26
+ interface CannedProviderOptions {
27
+ modelId?: string;
28
+ /** Per-tool hints (examples, triggers) keyed by tool name. */
29
+ hints?: Map<string, CannedToolHints>;
30
+ }
31
+
25
32
  const CANNED_RESPONSES: Record<string, string> = {
26
33
  weather: 'The weather is 22°C and sunny. [canned response]',
27
34
  order: 'Order #12345 has been shipped and is on its way. [canned response]',
@@ -30,28 +37,48 @@ const CANNED_RESPONSES: Record<string, string> = {
30
37
 
31
38
  const DEFAULT_RESPONSE = 'This is a canned mock response. No real model was called. [canned]';
32
39
 
40
+ /**
41
+ * Pick a canned text response by keyword, matched on word boundaries for the same reason
42
+ * tool matching is: substring matching fired `order` inside "reorder" and `help` inside
43
+ * "helper", the same false-positive class the tool matcher avoids.
44
+ */
33
45
  function matchResponse(prompt: string): string {
34
46
  const lower = prompt.toLowerCase();
35
47
  for (const [keyword, response] of Object.entries(CANNED_RESPONSES)) {
36
- if (lower.includes(keyword)) return response;
48
+ if (promptMentionsWord(lower, keyword)) return response;
37
49
  }
38
50
  return DEFAULT_RESPONSE;
39
51
  }
40
52
 
53
+ const wordPatternCache = new Map<string, RegExp>();
54
+
41
55
  /**
42
- * Match a single word against the prompt on word boundaries (case-insensitive).
56
+ * Compile a word-boundary matcher for a word or phrase, cached by phrase.
43
57
  * Uses `\b...\b` rather than substring `includes()` so a tool word like "cat"
44
58
  * (from `getCat`) is NOT triggered by an unrelated word like "category", and
45
- * "pass" (from `getPass`) is not triggered by "password". The word is regex-
46
- * escaped so punctuation in tool names can't break the pattern.
59
+ * "pass" (from `getPass`) is not triggered by "password". Regex metacharacters are
60
+ * escaped so punctuation in tool names can't break the pattern, and internal
61
+ * whitespace becomes `\s+` so a multi-word phrase tolerates irregular spacing.
62
+ * Compiled patterns are cached because matching re-runs for every tool on every
63
+ * `stream()` call, and the key space is bounded by the agent's tool and trigger set.
47
64
  */
65
+ function wordBoundaryPattern(phrase: string): RegExp {
66
+ let pattern = wordPatternCache.get(phrase);
67
+ if (!pattern) {
68
+ const escaped = phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+');
69
+ pattern = new RegExp(`\\b${escaped}\\b`);
70
+ wordPatternCache.set(phrase, pattern);
71
+ }
72
+ return pattern;
73
+ }
74
+
75
+ /** Match a word against an already-lowercased prompt on word boundaries. */
48
76
  function promptMentionsWord(lowerPrompt: string, word: string): boolean {
49
- const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
50
- return new RegExp(`\\b${escaped}\\b`).test(lowerPrompt);
77
+ return wordBoundaryPattern(word).test(lowerPrompt);
51
78
  }
52
79
 
53
80
  /** Find ALL tools mentioned in the prompt (for parallel tool calls). */
54
- function findAllToolMatches(prompt: string, toolSpecs?: { name: string }[]): string[] {
81
+ function findAllToolMatches(prompt: string, toolSpecs?: { name: string }[], hints?: Map<string, CannedToolHints>): string[] {
55
82
  if (!toolSpecs?.length) return [];
56
83
  const lower = prompt.toLowerCase();
57
84
  return toolSpecs.filter(t => {
@@ -60,7 +87,15 @@ function findAllToolMatches(prompt: string, toolSpecs?: { name: string }[]): str
60
87
  // Split camelCase into words (getWeather -> "get weather") and match each
61
88
  // on word boundaries. Skip short words (<=2 chars) to avoid noise.
62
89
  const words = t.name.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase().split(' ');
63
- return words.some(w => w.length > 2 && promptMentionsWord(lower, w));
90
+ if (words.some(w => w.length > 2 && promptMentionsWord(lower, w))) return true;
91
+ // Extra trigger keywords declared via `cannedTriggers`. Single and multi-word triggers
92
+ // both match on word boundaries (consistent with tool-name matching), so "log in" is not
93
+ // triggered by "backlog in" and internal whitespace is flexible (matches one-or-more spaces).
94
+ const triggers = hints?.get(t.name)?.triggers;
95
+ return triggers?.some(tr => {
96
+ const low = tr.trim().toLowerCase();
97
+ return low ? wordBoundaryPattern(low).test(lower) : false;
98
+ }) ?? false;
64
99
  }).map(t => t.name);
65
100
  }
66
101
 
@@ -87,45 +122,107 @@ function getToolResultText(messages: Message[]): string {
87
122
  return results.join(' | ');
88
123
  }
89
124
 
125
+ /** Sentinel for a property whose shape carries no usable signal (distinct from a legitimate `null`/`0`/`false`). */
126
+ const NO_PLACEHOLDER = Symbol('no-placeholder');
127
+
128
+ /**
129
+ * Resolve one property's placeholder value, or `NO_PLACEHOLDER` if its shape gives no signal.
130
+ * Order matters: an authored `default` (from Zod `.default()`) is the most realistic value, then
131
+ * a fixed `const`/`enum` member, then a union variant, then a per-type placeholder. `!== undefined`
132
+ * rather than truthiness so a `default` of `0`, `false`, or `''` is honored.
133
+ */
134
+ function placeholderForProperty(prop: any): any {
135
+ if (prop?.default !== undefined) return prop.default;
136
+ if (prop?.const !== undefined) return prop.const;
137
+ if (prop?.enum?.length) return prop.enum[0];
138
+ // Zod unions (`z.union`, `z.discriminatedUnion`) surface as anyOf/oneOf; any one
139
+ // satisfying variant is enough for a mock, so take the first that resolves.
140
+ const variants = prop?.anyOf ?? prop?.oneOf;
141
+ if (Array.isArray(variants)) {
142
+ for (const variant of variants) {
143
+ const resolved = placeholderForProperty(variant);
144
+ if (resolved !== NO_PLACEHOLDER) return resolved;
145
+ }
146
+ }
147
+ switch (prop?.type) {
148
+ case 'string': return 'sample';
149
+ case 'number':
150
+ case 'integer': return 1;
151
+ case 'boolean': return true;
152
+ case 'array': return [];
153
+ case 'object': return generatePlaceholderInput(prop);
154
+ default: return NO_PLACEHOLDER;
155
+ }
156
+ }
157
+
90
158
  /** Generate placeholder input from a JSON Schema. Produces values that pass validation. */
91
159
  function generatePlaceholderInput(schema: any): any {
92
160
  if (!schema || typeof schema !== 'object') return {};
93
- if (schema.type === 'object' && schema.properties) {
94
- const result: Record<string, any> = {};
95
- for (const [key, prop] of Object.entries(schema.properties) as [string, any][]) {
96
- if (prop.type === 'string') {
97
- if (prop.enum?.length) result[key] = prop.enum[0];
98
- else result[key] = 'sample';
99
- } else if (prop.type === 'number' || prop.type === 'integer') {
100
- result[key] = 1;
101
- } else if (prop.type === 'boolean') {
102
- result[key] = true;
103
- } else if (prop.type === 'array') {
104
- result[key] = [];
105
- } else if (prop.type === 'object') {
106
- result[key] = generatePlaceholderInput(prop);
107
- }
161
+ if (schema.type !== 'object' || !schema.properties) return {};
162
+ const required: unknown[] = Array.isArray(schema.required) ? schema.required : [];
163
+ const result: Record<string, any> = {};
164
+ for (const [key, prop] of Object.entries(schema.properties) as [string, any][]) {
165
+ const value = placeholderForProperty(prop);
166
+ if (value !== NO_PLACEHOLDER) {
167
+ result[key] = value;
168
+ } else if (required.includes(key)) {
169
+ // An unrecognized shape (untyped, or a union of only unrecognized variants) yields no
170
+ // placeholder. Omitting a *required* field makes the emitted call fail validation before
171
+ // the tool ever runs, so fall back to a string. Optional fields stay omitted: absence is
172
+ // valid there, and inventing a wrong-typed value would break calls that used to work.
173
+ result[key] = 'sample';
108
174
  }
109
- return result;
110
175
  }
111
- return {};
176
+ return result;
112
177
  }
113
178
 
114
- /** Look up a tool's inputSchema from toolSpecs and generate placeholder input. */
115
- function getToolInput(toolName: string, toolSpecs?: { name: string; inputSchema?: any }[]): string {
179
+ const warnedExampleKeys = new Set<string>();
180
+
181
+ /**
182
+ * Warn (once per tool+field) when a `cannedExamples` key isn't a field of the tool's
183
+ * inputSchema — almost always a typo in the hint. Only ever reached through the canned
184
+ * provider, which is local-dev-only, so this never warns in a deployed agent. The value
185
+ * is still merged through and nothing throws: a bad hint must not break local dev.
186
+ * Skipped when the schema exposes no `properties`, where unknown keys are unknowable.
187
+ */
188
+ function warnUnknownExampleKeys(toolName: string, examples: Record<string, unknown>, inputSchema?: any): void {
189
+ const properties = inputSchema?.properties;
190
+ if (!properties) return;
191
+ for (const key of Object.keys(examples)) {
192
+ if (key in properties) continue;
193
+ const seen = `${toolName}.${key}`;
194
+ if (warnedExampleKeys.has(seen)) continue;
195
+ warnedExampleKeys.add(seen);
196
+ console.warn(
197
+ `[canned] cannedExamples for tool "${toolName}" sets "${key}", which is not a field of its ` +
198
+ `parameters schema (${Object.keys(properties).join(', ') || 'none'}). Check for a typo — the value is still sent.`,
199
+ );
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Look up a tool's inputSchema from toolSpecs and generate placeholder input, shallow-merging
205
+ * any `cannedExamples` (from hints) on top so realistic values win over generated placeholders.
206
+ */
207
+ function getToolInput(toolName: string, toolSpecs?: { name: string; inputSchema?: any }[], hints?: Map<string, CannedToolHints>): string {
116
208
  const spec = toolSpecs?.find(t => t.name === toolName);
117
- if (!spec?.inputSchema) return '{}';
118
- return JSON.stringify(generatePlaceholderInput(spec.inputSchema));
209
+ const base = spec?.inputSchema ? generatePlaceholderInput(spec.inputSchema) : {};
210
+ const examples = hints?.get(toolName)?.examples;
211
+ if (!examples) return JSON.stringify(base);
212
+ warnUnknownExampleKeys(toolName, examples, spec?.inputSchema);
213
+ return JSON.stringify({ ...base, ...examples });
119
214
  }
120
215
 
121
216
  let toolCallCounter = 0;
122
217
 
123
218
  export class CannedProvider extends Model<CannedConfig> {
124
219
  private config: CannedConfig;
220
+ private hints: Map<string, CannedToolHints>;
125
221
 
126
- constructor(config?: Partial<CannedConfig>) {
222
+ constructor(options?: CannedProviderOptions) {
127
223
  super();
128
- this.config = { modelId: config?.modelId ?? 'canned-mock' };
224
+ this.config = { modelId: options?.modelId ?? 'canned-mock' };
225
+ this.hints = options?.hints ?? new Map();
129
226
  }
130
227
 
131
228
  updateConfig(config: Partial<CannedConfig>): void {
@@ -150,7 +247,7 @@ export class CannedProvider extends Model<CannedConfig> {
150
247
  }
151
248
 
152
249
  // Check if prompt mentions tool names — trigger tool call(s)
153
- const toolMatches = findAllToolMatches(prompt, options?.toolSpecs);
250
+ const toolMatches = findAllToolMatches(prompt, options?.toolSpecs, this.hints);
154
251
  if (toolMatches.length > 1) {
155
252
  yield* this.emitParallelToolCalls(toolMatches, options?.toolSpecs);
156
253
  return;
@@ -161,8 +258,6 @@ export class CannedProvider extends Model<CannedConfig> {
161
258
  return;
162
259
  }
163
260
 
164
- // Default: keyword-based text response
165
-
166
261
  // Default: keyword-based text response
167
262
  yield* this.emitText(matchResponse(prompt));
168
263
  }
@@ -185,7 +280,7 @@ export class CannedProvider extends Model<CannedConfig> {
185
280
  for (const toolName of toolNames) {
186
281
  const toolUseId = `canned-tool-${++toolCallCounter}`;
187
282
  yield { type: 'modelContentBlockStartEvent', start: { type: 'toolUseStart', name: toolName, toolUseId } };
188
- yield { type: 'modelContentBlockDeltaEvent', delta: { type: 'toolUseInputDelta', input: getToolInput(toolName, toolSpecs) } };
283
+ yield { type: 'modelContentBlockDeltaEvent', delta: { type: 'toolUseInputDelta', input: getToolInput(toolName, toolSpecs, this.hints) } };
189
284
  yield { type: 'modelContentBlockStopEvent' };
190
285
  }
191
286
  yield { type: 'modelMessageStopEvent', stopReason: 'toolUse' };
@@ -197,7 +292,7 @@ export class CannedProvider extends Model<CannedConfig> {
197
292
  const toolUseId = `canned-tool-${++toolCallCounter}`;
198
293
  yield { type: 'modelMessageStartEvent', role: 'assistant' };
199
294
  yield { type: 'modelContentBlockStartEvent', start: { type: 'toolUseStart', name: toolName, toolUseId } };
200
- yield { type: 'modelContentBlockDeltaEvent', delta: { type: 'toolUseInputDelta', input: getToolInput(toolName, toolSpecs) } };
295
+ yield { type: 'modelContentBlockDeltaEvent', delta: { type: 'toolUseInputDelta', input: getToolInput(toolName, toolSpecs, this.hints) } };
201
296
  yield { type: 'modelContentBlockStopEvent' };
202
297
  yield { type: 'modelMessageStopEvent', stopReason: 'toolUse' };
203
298
  yield { type: 'modelMetadataEvent', usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, metrics: { latencyMs: 0 } };
package/src/types.ts CHANGED
@@ -85,7 +85,39 @@ export interface AgentConfig<TContext = DefaultToolContext> {
85
85
  */
86
86
  toolContextSchema?: z.ZodType<TContext>;
87
87
  conversation?: ConversationManagerConfig;
88
- structuredOutput?: z.ZodType;
88
+ /**
89
+ * Safety cap on the number of **model (Bedrock) invocations per turn**.
90
+ *
91
+ * The agent runs a reason→act loop where each iteration is one model call,
92
+ * optionally followed by tool calls; a model call that requests no tools ends
93
+ * the turn. Model calls are the unit Bedrock bills for, so this is the most
94
+ * direct guard against a runaway agent that loops indefinitely — and because
95
+ * every tool round needs a model call, it transitively bounds tool loops too.
96
+ *
97
+ * When the cap is hit the turn is stopped and the client receives an `error`
98
+ * chunk instead of `done`. Raise it for agents that legitimately reason over
99
+ * many steps, or set it to `false` to disable the cap entirely. This bounds
100
+ * call *count*, not tokens or wall-clock — pair it with a billing/CloudWatch
101
+ * alarm for defense in depth.
102
+ *
103
+ * Scope: the count covers the whole turn, including across a human-in-the-loop
104
+ * interrupt — it is kept in the agent's session state, so `resume()` continues
105
+ * on the same budget rather than starting a fresh one. Must be a positive
106
+ * integer or `false`; anything else throws `InvalidModelConfigException`.
107
+ */
108
+ maxLlmCalls?: number | false;
109
+ /**
110
+ * Safety cap on the number of **tool calls per turn**.
111
+ *
112
+ * Bounds tool-loop runaways specifically (a turn that keeps invoking tools).
113
+ * Parallel tool batches count each individual call. Raise it for agents that
114
+ * legitimately chain many tools in a single turn, or set it to `false` to
115
+ * disable the cap entirely. When the cap is hit the turn is stopped and the
116
+ * client receives an `error` chunk instead of `done`. Like `maxLlmCalls`, the
117
+ * count covers the whole turn (it survives `resume()` after an interrupt) and
118
+ * must be a positive integer or `false`.
119
+ */
120
+ maxToolIterations?: number | false;
89
121
  /** Controls how text chunks are published to the client via Realtime.
90
122
  * - `'token'`: publish every text delta immediately
91
123
  * - `'block'` (default): buffer text and publish when a full content block completes
@@ -106,6 +138,12 @@ export interface AgentConfig<TContext = DefaultToolContext> {
106
138
  * Ignored by the mock and browser runtimes.
107
139
  */
108
140
  removalPolicy?: 'destroy' | 'retain';
141
+ /**
142
+ * @internal Pre-built AgentCore code-asset directory to use instead of co-bundling the app
143
+ * backend at synth. Set by unit tests and apps that pre-bundle; normal apps leave this unset
144
+ * and the backend is co-bundled automatically. Ignored by the mock/browser runtimes.
145
+ */
146
+ agentcoreAssetPath?: string;
109
147
  /** Optional logger for internal operations. When omitted, a default Logger at error level is created. */
110
148
  logger?: ChildLogger;
111
149
  }
@@ -175,6 +213,31 @@ export interface ToolDefinition<TContext = DefaultToolContext, TParams extends z
175
213
  * - `interrupt` — pause the agent for human input
176
214
  */
177
215
  handler: (args: ToolHandlerArgs<z.infer<TParams>, TContext>) => Promise<JSONValue>;
216
+ /**
217
+ * Local-dev only. Realistic tool input for the `canned` mock provider. Shallow-merged
218
+ * over the generated placeholder input (your fields win; unspecified fields fall back to
219
+ * schema defaults / generic placeholders). The merge is one level deep — a nested-object
220
+ * example replaces that whole generated sub-object rather than deep-merging into it.
221
+ * Ignored by the bedrock/openai providers.
222
+ */
223
+ cannedExamples?: Record<string, JSONValue>;
224
+ /**
225
+ * Local-dev only. Extra keyword phrases that make the `canned` mock provider select this
226
+ * tool, in addition to the tool name and its camelCase words. Single and multi-word phrases
227
+ * match on word boundaries (so "log in" is not triggered by "backlog in"). Ignored by the
228
+ * bedrock/openai providers.
229
+ */
230
+ cannedTriggers?: string[];
231
+ }
232
+
233
+ /**
234
+ * Local-dev hints for the `canned` mock provider, keyed by tool name and threaded from
235
+ * `ToolDefinition.cannedExamples`/`cannedTriggers` into the provider (Strands strips these
236
+ * fields when converting tools, so they're plumbed explicitly). Ignored by real providers.
237
+ */
238
+ export interface CannedToolHints {
239
+ examples?: Record<string, JSONValue>;
240
+ triggers?: string[];
178
241
  }
179
242
 
180
243
  /**
package/src/version.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  // Auto-generated by scripts/generate-version.mjs — do not edit manually
2
2
  export const BB_NAME = 'Agent';
3
- export const BB_VERSION = '0.3.4';
3
+ export const BB_VERSION = '0.4.0';