@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.
- package/DESIGN.md +65 -17
- package/README.md +156 -8
- 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 +12 -6
- package/dist/index.cdk.d.ts.map +1 -1
- package/dist/index.cdk.js +31 -31
- package/dist/index.cdk.test.js +128 -51
- package/dist/index.hooks.d.ts +2 -2
- package/dist/index.hooks.d.ts.map +1 -1
- package/dist/index.mock.d.ts +1 -0
- package/dist/index.mock.d.ts.map +1 -1
- package/dist/index.test.js +426 -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 +24 -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 +33 -34
- package/src/index.hooks.ts +2 -2
- package/src/index.mock.ts +3 -0
- package/src/index.test.ts +473 -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/dist/index.test.js
CHANGED
|
@@ -277,6 +277,19 @@ describe('CannedProvider', () => {
|
|
|
277
277
|
const text = chunks.join('');
|
|
278
278
|
assert.ok(text.includes('22°C'), 'should contain weather data');
|
|
279
279
|
});
|
|
280
|
+
// Keyword text matching must respect word boundaries for the same reason tool matching
|
|
281
|
+
// does, or "reorder" returns the order response and "helper" returns the help response.
|
|
282
|
+
test('does not return a keyword response when the keyword is only a substring', async () => {
|
|
283
|
+
const provider = new CannedProvider();
|
|
284
|
+
const chunks = [];
|
|
285
|
+
for await (const event of provider.stream([{ role: 'user', content: [{ text: 'please reorder the list alphabetically' }] }])) {
|
|
286
|
+
if (event.type === 'modelContentBlockDeltaEvent' && event.delta.type === 'textDelta')
|
|
287
|
+
chunks.push(event.delta.text);
|
|
288
|
+
}
|
|
289
|
+
const text = chunks.join('');
|
|
290
|
+
assert.ok(!text.includes('#12345'), `"reorder" must not return the order response, got: ${text}`);
|
|
291
|
+
assert.ok(text.includes('No real model was called'), 'should fall through to the default response');
|
|
292
|
+
});
|
|
280
293
|
test('triggers tool call when prompt matches tool name', async () => {
|
|
281
294
|
const provider = new CannedProvider();
|
|
282
295
|
let toolName;
|
|
@@ -321,6 +334,139 @@ describe('CannedProvider', () => {
|
|
|
321
334
|
}
|
|
322
335
|
assert.deepStrictEqual(started, ['getOrder']);
|
|
323
336
|
});
|
|
337
|
+
// Collect the parsed tool input from the first tool call in a stream.
|
|
338
|
+
const collectToolInput = async (provider, prompt, toolSpecs) => {
|
|
339
|
+
let input;
|
|
340
|
+
for await (const event of provider.stream([{ role: 'user', content: [{ text: prompt }] }], { toolSpecs })) {
|
|
341
|
+
if (event.type === 'modelContentBlockDeltaEvent' && event.delta.type === 'toolUseInputDelta') {
|
|
342
|
+
input = JSON.parse(event.delta.input);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return input;
|
|
346
|
+
};
|
|
347
|
+
// Collect the names of every tool call started in a stream.
|
|
348
|
+
const collectToolStarts = async (provider, prompt, toolSpecs) => {
|
|
349
|
+
const started = [];
|
|
350
|
+
for await (const event of provider.stream([{ role: 'user', content: [{ text: prompt }] }], { toolSpecs })) {
|
|
351
|
+
if (event.type === 'modelContentBlockStartEvent' && event.start?.type === 'toolUseStart')
|
|
352
|
+
started.push(event.start.name);
|
|
353
|
+
}
|
|
354
|
+
return started;
|
|
355
|
+
};
|
|
356
|
+
test('cannedExamples are shallow-merged over generated placeholder input', async () => {
|
|
357
|
+
const hints = new Map([['searchDocs', { examples: { query: 'how do I get started' } }]]);
|
|
358
|
+
const provider = new CannedProvider({ hints });
|
|
359
|
+
const toolSpecs = [{ name: 'searchDocs', description: '', inputSchema: { type: 'object', properties: { query: { type: 'string' }, limit: { type: 'integer' } } } }];
|
|
360
|
+
const input = await collectToolInput(provider, 'searchDocs please', toolSpecs);
|
|
361
|
+
// Example query wins; unspecified `limit` falls back to the generic integer placeholder.
|
|
362
|
+
assert.deepStrictEqual(input, { query: 'how do I get started', limit: 1 });
|
|
363
|
+
});
|
|
364
|
+
test('respects schema default values over generic placeholders', async () => {
|
|
365
|
+
const provider = new CannedProvider();
|
|
366
|
+
const toolSpecs = [{ name: 'listItems', description: '', inputSchema: { type: 'object', properties: { limit: { type: 'integer', default: 10 } } } }];
|
|
367
|
+
const input = await collectToolInput(provider, 'listItems now', toolSpecs);
|
|
368
|
+
assert.deepStrictEqual(input, { limit: 10 });
|
|
369
|
+
});
|
|
370
|
+
test('mixes schema defaults with generic placeholders per field', async () => {
|
|
371
|
+
const provider = new CannedProvider();
|
|
372
|
+
const toolSpecs = [{ name: 'listItems', description: '', inputSchema: { type: 'object', properties: { limit: { type: 'integer', default: 10 }, query: { type: 'string' } } } }];
|
|
373
|
+
const input = await collectToolInput(provider, 'listItems now', toolSpecs);
|
|
374
|
+
assert.deepStrictEqual(input, { limit: 10, query: 'sample' });
|
|
375
|
+
});
|
|
376
|
+
test('cannedExamples win over a schema default on the same field', async () => {
|
|
377
|
+
const hints = new Map([['listItems', { examples: { limit: 5 } }]]);
|
|
378
|
+
const provider = new CannedProvider({ hints });
|
|
379
|
+
const toolSpecs = [{ name: 'listItems', description: '', inputSchema: { type: 'object', properties: { limit: { type: 'integer', default: 10 } } } }];
|
|
380
|
+
const input = await collectToolInput(provider, 'listItems now', toolSpecs);
|
|
381
|
+
// Full precedence chain is cannedExamples > schema default > generic placeholder;
|
|
382
|
+
// this pins the top link, where a field carries both an example and a default.
|
|
383
|
+
assert.deepStrictEqual(input, { limit: 5 }, 'the cannedExamples value must beat the schema default');
|
|
384
|
+
});
|
|
385
|
+
test('warns on a cannedExamples field missing from the tool schema, but still sends it', async () => {
|
|
386
|
+
const hints = new Map([['fetchPage', { examples: { rul: 'https://example.com' } }]]);
|
|
387
|
+
const provider = new CannedProvider({ hints });
|
|
388
|
+
const toolSpecs = [{ name: 'fetchPage', description: '', inputSchema: { type: 'object', properties: { url: { type: 'string' } } } }];
|
|
389
|
+
const originalWarn = console.warn;
|
|
390
|
+
const warnings = [];
|
|
391
|
+
console.warn = (msg) => { warnings.push(String(msg)); };
|
|
392
|
+
let input;
|
|
393
|
+
try {
|
|
394
|
+
input = await collectToolInput(provider, 'fetchPage now', toolSpecs);
|
|
395
|
+
}
|
|
396
|
+
finally {
|
|
397
|
+
console.warn = originalWarn;
|
|
398
|
+
}
|
|
399
|
+
assert.ok(warnings.some(w => w.includes('fetchPage') && w.includes('"rul"')), `the typo'd field should be reported, got ${JSON.stringify(warnings)}`);
|
|
400
|
+
// A bad hint is surfaced, never enforced: nothing throws and the value still ships.
|
|
401
|
+
assert.deepStrictEqual(input, { url: 'sample', rul: 'https://example.com' });
|
|
402
|
+
});
|
|
403
|
+
test('cannedTriggers fire a tool for a single-word keyword beyond its name', async () => {
|
|
404
|
+
const hints = new Map([['searchDocs', { triggers: ['find'] }]]);
|
|
405
|
+
const provider = new CannedProvider({ hints });
|
|
406
|
+
const toolSpecs = [{ name: 'searchDocs', description: '', inputSchema: {} }];
|
|
407
|
+
const started = await collectToolStarts(provider, 'help me find the answer', toolSpecs);
|
|
408
|
+
assert.deepStrictEqual(started, ['searchDocs']);
|
|
409
|
+
});
|
|
410
|
+
test('cannedTriggers fire a tool for a multi-word phrase', async () => {
|
|
411
|
+
const hints = new Map([['searchDocs', { triggers: ['look up'] }]]);
|
|
412
|
+
const provider = new CannedProvider({ hints });
|
|
413
|
+
const toolSpecs = [{ name: 'searchDocs', description: '', inputSchema: {} }];
|
|
414
|
+
const started = await collectToolStarts(provider, 'can you look up the manual', toolSpecs);
|
|
415
|
+
assert.deepStrictEqual(started, ['searchDocs']);
|
|
416
|
+
});
|
|
417
|
+
test('single-word cannedTriggers respect word boundaries', async () => {
|
|
418
|
+
const hints = new Map([['searchDocs', { triggers: ['cat'] }]]);
|
|
419
|
+
const provider = new CannedProvider({ hints });
|
|
420
|
+
const toolSpecs = [{ name: 'searchDocs', description: '', inputSchema: {} }];
|
|
421
|
+
const started = await collectToolStarts(provider, 'what category is this', toolSpecs);
|
|
422
|
+
assert.deepStrictEqual(started, [], 'trigger "cat" must not fire on "category"');
|
|
423
|
+
});
|
|
424
|
+
test('multi-word cannedTriggers respect word boundaries', async () => {
|
|
425
|
+
const hints = new Map([['searchDocs', { triggers: ['log in'] }]]);
|
|
426
|
+
const provider = new CannedProvider({ hints });
|
|
427
|
+
const toolSpecs = [{ name: 'searchDocs', description: '', inputSchema: {} }];
|
|
428
|
+
const started = await collectToolStarts(provider, 'check the backlog in the queue', toolSpecs);
|
|
429
|
+
assert.deepStrictEqual(started, [], 'trigger "log in" must not fire on "backlog in"');
|
|
430
|
+
});
|
|
431
|
+
test('multi-word cannedTriggers tolerate flexible internal whitespace', async () => {
|
|
432
|
+
const hints = new Map([['searchDocs', { triggers: ['look up'] }]]);
|
|
433
|
+
const provider = new CannedProvider({ hints });
|
|
434
|
+
const toolSpecs = [{ name: 'searchDocs', description: '', inputSchema: {} }];
|
|
435
|
+
const started = await collectToolStarts(provider, 'can you look up the manual', toolSpecs);
|
|
436
|
+
assert.deepStrictEqual(started, ['searchDocs'], 'multiple spaces between words should still match');
|
|
437
|
+
});
|
|
438
|
+
test('generates generic placeholder when no example or default is given', async () => {
|
|
439
|
+
const provider = new CannedProvider();
|
|
440
|
+
const toolSpecs = [{ name: 'searchDocs', description: '', inputSchema: { type: 'object', properties: { query: { type: 'string' } } } }];
|
|
441
|
+
const input = await collectToolInput(provider, 'searchDocs please', toolSpecs);
|
|
442
|
+
assert.deepStrictEqual(input, { query: 'sample' });
|
|
443
|
+
});
|
|
444
|
+
test('resolves a union (anyOf) field from its first usable variant', async () => {
|
|
445
|
+
const provider = new CannedProvider();
|
|
446
|
+
const toolSpecs = [{ name: 'listItems', description: '', inputSchema: { type: 'object', properties: { limit: { anyOf: [{ type: 'integer' }, { type: 'string' }] } } } }];
|
|
447
|
+
const input = await collectToolInput(provider, 'listItems now', toolSpecs);
|
|
448
|
+
assert.deepStrictEqual(input, { limit: 1 });
|
|
449
|
+
});
|
|
450
|
+
test('resolves a const field to its fixed value', async () => {
|
|
451
|
+
const provider = new CannedProvider();
|
|
452
|
+
const toolSpecs = [{ name: 'listItems', description: '', inputSchema: { type: 'object', properties: { kind: { const: 'archive' } } } }];
|
|
453
|
+
const input = await collectToolInput(provider, 'listItems now', toolSpecs);
|
|
454
|
+
assert.deepStrictEqual(input, { kind: 'archive' });
|
|
455
|
+
});
|
|
456
|
+
// A required field of an unrecognized shape used to be dropped entirely, so the emitted
|
|
457
|
+
// call failed schema validation before the tool ever ran.
|
|
458
|
+
test('fills a required field whose shape yields no placeholder', async () => {
|
|
459
|
+
const provider = new CannedProvider();
|
|
460
|
+
const toolSpecs = [{ name: 'listItems', description: '', inputSchema: { type: 'object', properties: { mystery: {} }, required: ['mystery'] } }];
|
|
461
|
+
const input = await collectToolInput(provider, 'listItems now', toolSpecs);
|
|
462
|
+
assert.deepStrictEqual(input, { mystery: 'sample' });
|
|
463
|
+
});
|
|
464
|
+
test('leaves an optional field whose shape yields no placeholder omitted', async () => {
|
|
465
|
+
const provider = new CannedProvider();
|
|
466
|
+
const toolSpecs = [{ name: 'listItems', description: '', inputSchema: { type: 'object', properties: { mystery: {} } } }];
|
|
467
|
+
const input = await collectToolInput(provider, 'listItems now', toolSpecs);
|
|
468
|
+
assert.deepStrictEqual(input, {}, 'absence is valid for an optional field; do not invent a wrong-typed value');
|
|
469
|
+
});
|
|
324
470
|
test('responds to tool result with acknowledgment', async () => {
|
|
325
471
|
const provider = new CannedProvider();
|
|
326
472
|
const chunks = [];
|
|
@@ -351,6 +497,34 @@ describe('CannedProvider', () => {
|
|
|
351
497
|
assert.strictEqual(done.type, 'done');
|
|
352
498
|
assert.ok(done.text && done.text.length > 0, 'should have response text');
|
|
353
499
|
});
|
|
500
|
+
// End-to-end: exercises the full createStrandsAgent -> createStrandsModel -> CannedProvider
|
|
501
|
+
// plumbing. A prompt that only matches a `cannedTriggers` keyword must fire the tool, and the
|
|
502
|
+
// emitted call must carry the `cannedExamples` input.
|
|
503
|
+
test('canned hints plumb through the Agent end-to-end', async () => {
|
|
504
|
+
const scope = new Scope('test-canned-hints');
|
|
505
|
+
const agent = new Agent(scope, 'hints', {
|
|
506
|
+
inferenceOnly: false,
|
|
507
|
+
systemPrompt: 'test',
|
|
508
|
+
model: { deployed: { provider: 'canned' }, local: { provider: 'canned' } },
|
|
509
|
+
tools: (tool) => ({
|
|
510
|
+
searchDocs: tool({
|
|
511
|
+
description: 'Search documentation',
|
|
512
|
+
parameters: z.object({ query: z.string() }),
|
|
513
|
+
handler: async ({ input }) => ({ echoed: input.query }),
|
|
514
|
+
cannedExamples: { query: 'how do I get started' },
|
|
515
|
+
cannedTriggers: ['find'],
|
|
516
|
+
}),
|
|
517
|
+
}),
|
|
518
|
+
});
|
|
519
|
+
const convId = await agent.createConversationId('test-user');
|
|
520
|
+
const result = await agent.stream('help me find the answer', { conversationId: convId, userId: 'test-user' });
|
|
521
|
+
await result.complete();
|
|
522
|
+
const messages = await agent.getConversation(convId);
|
|
523
|
+
const toolCall = messages.find(m => m.role === 'tool-call');
|
|
524
|
+
assert.ok(toolCall, 'trigger keyword should have fired a tool call');
|
|
525
|
+
assert.strictEqual(toolCall.metadata.toolName, 'searchDocs');
|
|
526
|
+
assert.deepStrictEqual(toolCall.metadata.toolInput, { query: 'how do I get started' });
|
|
527
|
+
});
|
|
354
528
|
test("getConversation with limit returns most recent messages", async () => {
|
|
355
529
|
const scope = new Scope('test-limit');
|
|
356
530
|
const agent = new Agent(scope, 'lim', { systemPrompt: 'test', model: { deployed: { provider: 'canned' }, local: { provider: 'canned' } } });
|
|
@@ -419,7 +593,7 @@ describe('CannedProvider', () => {
|
|
|
419
593
|
const result = await agent.stream('hello', { userId: 'test-user' });
|
|
420
594
|
const ch = await result.channel;
|
|
421
595
|
ch.subscribe((chunk) => { chunks.push(chunk); });
|
|
422
|
-
// Wait for
|
|
596
|
+
// Wait for the in-process turn to publish its error chunk
|
|
423
597
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
424
598
|
const textChunks = chunks.filter((c) => c.type === 'text-delta');
|
|
425
599
|
const errorChunk = chunks.find((c) => c.type === 'error');
|
|
@@ -468,6 +642,213 @@ describe('CannedProvider', () => {
|
|
|
468
642
|
});
|
|
469
643
|
});
|
|
470
644
|
});
|
|
645
|
+
// ── runaway protection caps ──────────────────────────────────────────────────
|
|
646
|
+
/** Run one turn, auto-approving every interrupt, until a terminal chunk arrives. */
|
|
647
|
+
async function runAutoApproving(agent, message, conversationId, userId, maxResumes = 5) {
|
|
648
|
+
const chunks = [];
|
|
649
|
+
const result = await agent.stream(message, { conversationId, userId });
|
|
650
|
+
const channel = await result.channel;
|
|
651
|
+
const sub = channel.subscribe((chunk) => { chunks.push(chunk); });
|
|
652
|
+
const waitFor = async (predicate) => {
|
|
653
|
+
for (let i = 0; i < 200; i++) {
|
|
654
|
+
const hit = predicate();
|
|
655
|
+
if (hit)
|
|
656
|
+
return hit;
|
|
657
|
+
await new Promise(r => setTimeout(r, 25));
|
|
658
|
+
}
|
|
659
|
+
return undefined;
|
|
660
|
+
};
|
|
661
|
+
let resumes = 0;
|
|
662
|
+
let terminal = await waitFor(() => chunks.find(c => c.type === 'done' || c.type === 'error' || c.type === 'interrupt'));
|
|
663
|
+
while (terminal?.type === 'interrupt' && resumes < maxResumes) {
|
|
664
|
+
resumes++;
|
|
665
|
+
const seen = chunks.length;
|
|
666
|
+
await agent.resume(result.channelId, terminal.interrupts.map((i) => ({ interruptId: i.id, approved: true })), { conversationId, userId });
|
|
667
|
+
terminal = await waitFor(() => chunks.slice(seen).find(c => c.type === 'done' || c.type === 'error' || c.type === 'interrupt'));
|
|
668
|
+
}
|
|
669
|
+
sub.unsubscribe();
|
|
670
|
+
return { chunks, terminal, resumes };
|
|
671
|
+
}
|
|
672
|
+
describe('runaway protection caps', () => {
|
|
673
|
+
test('maxLlmCalls stops a turn that keeps calling the model', async () => {
|
|
674
|
+
// A tool prompt drives two model calls (initial call → tool → follow-up call).
|
|
675
|
+
// With maxLlmCalls: 1 the second BeforeModelCallEvent trips the cap and cancels
|
|
676
|
+
// the turn, surfacing an error chunk (so complete() rejects).
|
|
677
|
+
const scope = new Scope('test-cap-llm');
|
|
678
|
+
const agent = new Agent(scope, 'capllm', {
|
|
679
|
+
systemPrompt: 'test',
|
|
680
|
+
maxLlmCalls: 1,
|
|
681
|
+
model: { deployed: { provider: 'canned' }, local: { provider: 'canned' } },
|
|
682
|
+
tools: (tool) => ({ getStatus: tool({ description: 'status', parameters: z.object({}), handler: async () => ({ ok: true }) }) }),
|
|
683
|
+
});
|
|
684
|
+
const result = await agent.stream('run getStatus', { userId: 'test-user' });
|
|
685
|
+
await assert.rejects(() => result.complete(), (err) => {
|
|
686
|
+
assert.match(err.message, /maxLlmCalls/);
|
|
687
|
+
return true;
|
|
688
|
+
});
|
|
689
|
+
});
|
|
690
|
+
test('maxToolIterations stops a turn that fans out too many tools', async () => {
|
|
691
|
+
// Naming both tools makes the canned provider fire two tool calls in one turn;
|
|
692
|
+
// with maxToolIterations: 1 the second BeforeToolCallEvent trips the cap.
|
|
693
|
+
const scope = new Scope('test-cap-tools');
|
|
694
|
+
const agent = new Agent(scope, 'captools', {
|
|
695
|
+
systemPrompt: 'test',
|
|
696
|
+
maxToolIterations: 1,
|
|
697
|
+
model: { deployed: { provider: 'canned' }, local: { provider: 'canned' } },
|
|
698
|
+
tools: (tool) => ({
|
|
699
|
+
alpha: tool({ description: 'a', parameters: z.object({}), handler: async () => ({ ok: true }) }),
|
|
700
|
+
bravo: tool({ description: 'b', parameters: z.object({}), handler: async () => ({ ok: true }) }),
|
|
701
|
+
}),
|
|
702
|
+
});
|
|
703
|
+
const result = await agent.stream('run alpha and bravo', { userId: 'test-user' });
|
|
704
|
+
await assert.rejects(() => result.complete(), (err) => {
|
|
705
|
+
assert.match(err.message, /maxToolIterations/);
|
|
706
|
+
return true;
|
|
707
|
+
});
|
|
708
|
+
});
|
|
709
|
+
test('a normal single-tool turn under the default caps completes', async () => {
|
|
710
|
+
// Two model calls + one tool call are both well under the default caps (20),
|
|
711
|
+
// so the turn completes normally rather than tripping either guard.
|
|
712
|
+
const scope = new Scope('test-cap-default');
|
|
713
|
+
const agent = new Agent(scope, 'capdef', {
|
|
714
|
+
systemPrompt: 'test',
|
|
715
|
+
model: { deployed: { provider: 'canned' }, local: { provider: 'canned' } },
|
|
716
|
+
tools: (tool) => ({ getStatus: tool({ description: 'status', parameters: z.object({}), handler: async () => ({ ok: true }) }) }),
|
|
717
|
+
});
|
|
718
|
+
const result = await agent.stream('run getStatus', { userId: 'test-user' });
|
|
719
|
+
const chunk = await result.complete();
|
|
720
|
+
assert.strictEqual(chunk.type, 'done', 'a normal turn under the default caps should complete');
|
|
721
|
+
});
|
|
722
|
+
test('a cap set to false is disabled', async () => {
|
|
723
|
+
// The same two-tool fan-out that trips at maxToolIterations: 1 must complete
|
|
724
|
+
// when the cap is disabled with `false`.
|
|
725
|
+
const scope = new Scope('test-cap-disabled');
|
|
726
|
+
const agent = new Agent(scope, 'capoff', {
|
|
727
|
+
systemPrompt: 'test',
|
|
728
|
+
maxToolIterations: false,
|
|
729
|
+
model: { deployed: { provider: 'canned' }, local: { provider: 'canned' } },
|
|
730
|
+
tools: (tool) => ({
|
|
731
|
+
alpha: tool({ description: 'a', parameters: z.object({}), handler: async () => ({ ok: true }) }),
|
|
732
|
+
bravo: tool({ description: 'b', parameters: z.object({}), handler: async () => ({ ok: true }) }),
|
|
733
|
+
}),
|
|
734
|
+
});
|
|
735
|
+
const result = await agent.stream('run alpha and bravo', { userId: 'test-user' });
|
|
736
|
+
const chunk = await result.complete();
|
|
737
|
+
assert.strictEqual(chunk.type, 'done', 'disabling the cap should let the turn complete');
|
|
738
|
+
});
|
|
739
|
+
test('an invalid cap value is rejected at construction', async () => {
|
|
740
|
+
const scope = new Scope('test-cap-invalid');
|
|
741
|
+
const model = { deployed: { provider: 'canned' }, local: { provider: 'canned' } };
|
|
742
|
+
let n = 0;
|
|
743
|
+
for (const value of [0, -1, 1.5, Number.NaN]) {
|
|
744
|
+
assert.throws(() => new Agent(scope, `capbad${n++}`, { systemPrompt: 'test', model, maxLlmCalls: value }), (err) => err.name === AgentErrors.InvalidModelConfig && /positive integer or false/.test(err.message), `maxLlmCalls: ${value} should be rejected`);
|
|
745
|
+
assert.throws(() => new Agent(scope, `capbad${n++}`, { systemPrompt: 'test', model, maxToolIterations: value }), (err) => err.name === AgentErrors.InvalidModelConfig && /positive integer or false/.test(err.message), `maxToolIterations: ${value} should be rejected`);
|
|
746
|
+
}
|
|
747
|
+
// Valid values must still construct.
|
|
748
|
+
new Agent(scope, 'capok1', { systemPrompt: 'test', model, maxLlmCalls: 1, maxToolIterations: 99 });
|
|
749
|
+
new Agent(scope, 'capok2', { systemPrompt: 'test', model, maxLlmCalls: false, maxToolIterations: false });
|
|
750
|
+
});
|
|
751
|
+
test('a cap trip leaves paired, explained history', async () => {
|
|
752
|
+
// A tool call cancelled by the cap still gets an AfterToolCallEvent from Strands
|
|
753
|
+
// (the cancellation is the result), so every persisted 'tool-call' keeps its
|
|
754
|
+
// 'tool-result' partner — no dangling tool_use to break the next turn. runAgent
|
|
755
|
+
// additionally records why the turn stopped.
|
|
756
|
+
const scope = new Scope('test-cap-history');
|
|
757
|
+
const agent = new Agent(scope, 'caphist', {
|
|
758
|
+
systemPrompt: 'test',
|
|
759
|
+
maxToolIterations: 1,
|
|
760
|
+
model: { deployed: { provider: 'canned' }, local: { provider: 'canned' } },
|
|
761
|
+
tools: (tool) => ({
|
|
762
|
+
alpha: tool({ description: 'a', parameters: z.object({}), handler: async () => ({ ok: true }) }),
|
|
763
|
+
bravo: tool({ description: 'b', parameters: z.object({}), handler: async () => ({ ok: true }) }),
|
|
764
|
+
}),
|
|
765
|
+
});
|
|
766
|
+
const convId = await agent.createConversationId('test-user');
|
|
767
|
+
const result = await agent.stream('run alpha and bravo', { conversationId: convId, userId: 'test-user' });
|
|
768
|
+
await assert.rejects(() => result.complete());
|
|
769
|
+
const history = await agent.getConversation(convId);
|
|
770
|
+
const toolCalls = history.filter(m => m.role === 'tool-call');
|
|
771
|
+
const toolResults = history.filter(m => m.role === 'tool-result');
|
|
772
|
+
assert.ok(toolCalls.length > 0, 'sanity: a tool call was persisted before the cap fired');
|
|
773
|
+
assert.strictEqual(toolResults.length, toolCalls.length, 'every persisted tool-call needs a matching tool-result');
|
|
774
|
+
const stopRecord = history.find(m => m.role === 'assistant' && /maxToolIterations/.test(JSON.stringify(m.metadata ?? '')));
|
|
775
|
+
assert.ok(stopRecord, 'history should record why the turn stopped');
|
|
776
|
+
});
|
|
777
|
+
test('maxLlmCalls keeps counting after resume() — the pre-resume call still counts', async () => {
|
|
778
|
+
// Segment 1 spends one model call (it emits the toolUse, then needsApproval
|
|
779
|
+
// interrupts). The resumed segment spends one more (the post-tool follow-up), so
|
|
780
|
+
// the turn total is 2. With maxLlmCalls: 1 a per-turn count must trip on that
|
|
781
|
+
// follow-up; with per-segment counters the resumed segment restarts at 0, the
|
|
782
|
+
// follow-up is call #1, and the turn completes — so this discriminates the two.
|
|
783
|
+
const scope = new Scope('test-cap-resume-llm');
|
|
784
|
+
const agent = new Agent(scope, 'caprl', {
|
|
785
|
+
systemPrompt: 'test',
|
|
786
|
+
maxLlmCalls: 1,
|
|
787
|
+
model: { deployed: { provider: 'canned' }, local: { provider: 'canned' } },
|
|
788
|
+
tools: (tool) => ({ getWeather: tool({ description: 'weather', parameters: z.object({ city: z.string() }), needsApproval: true, handler: async () => ({ temp: 22 }) }) }),
|
|
789
|
+
});
|
|
790
|
+
const convId = await agent.createConversationId('test-user');
|
|
791
|
+
const { terminal, resumes, chunks } = await runAutoApproving(agent, 'what is the weather?', convId, 'test-user');
|
|
792
|
+
assert.strictEqual(resumes, 1, 'the tool should have interrupted once for approval');
|
|
793
|
+
assert.ok(terminal, 'the turn should reach a terminal chunk');
|
|
794
|
+
assert.strictEqual(terminal.type, 'error', `expected the cap to trip after resume, got ${terminal.type}`);
|
|
795
|
+
assert.match(terminal.error, /maxLlmCalls/, 'the error should name the cap that tripped');
|
|
796
|
+
// History must stay paired and explain itself.
|
|
797
|
+
const history = await agent.getConversation(convId);
|
|
798
|
+
const resumeToolCalls = history.filter(m => m.role === 'tool-call');
|
|
799
|
+
const resumeToolResults = history.filter(m => m.role === 'tool-result');
|
|
800
|
+
assert.strictEqual(resumeToolResults.length, resumeToolCalls.length, 'every tool-call needs a matching tool-result');
|
|
801
|
+
assert.ok(history.some(m => m.role === 'assistant' && /maxLlmCalls/.test(JSON.stringify(m.metadata ?? ''))), 'history should record why the turn stopped');
|
|
802
|
+
assert.ok(chunks.some((c) => c.type === 'tool-call'), 'sanity: a tool call happened');
|
|
803
|
+
});
|
|
804
|
+
test('an approved tool call is charged once, not twice, across resume()', async () => {
|
|
805
|
+
// The same toolUseId re-emits BeforeToolCallEvent when the turn resumes. With
|
|
806
|
+
// maxToolIterations: 1 a double charge would trip the cap on a single approved
|
|
807
|
+
// call; deduping by toolUseId must let the turn finish.
|
|
808
|
+
const scope = new Scope('test-cap-resume-dedupe');
|
|
809
|
+
const agent = new Agent(scope, 'caprd', {
|
|
810
|
+
systemPrompt: 'test',
|
|
811
|
+
maxToolIterations: 1,
|
|
812
|
+
model: { deployed: { provider: 'canned' }, local: { provider: 'canned' } },
|
|
813
|
+
tools: (tool) => ({ getWeather: tool({ description: 'weather', parameters: z.object({ city: z.string() }), needsApproval: true, handler: async () => ({ temp: 22 }) }) }),
|
|
814
|
+
});
|
|
815
|
+
const convId = await agent.createConversationId('test-user');
|
|
816
|
+
const { terminal, resumes } = await runAutoApproving(agent, 'what is the weather?', convId, 'test-user');
|
|
817
|
+
assert.strictEqual(resumes, 1, 'the tool should have interrupted once for approval');
|
|
818
|
+
assert.ok(terminal, 'the turn should reach a terminal chunk');
|
|
819
|
+
assert.strictEqual(terminal.type, 'done', `a single approved tool call must not trip maxToolIterations: 1, got ${terminal.type}: ${terminal.error ?? ''}`);
|
|
820
|
+
});
|
|
821
|
+
test('a second message on the same conversation gets a fresh budget', async () => {
|
|
822
|
+
// The counters live in the session-persisted appState, so a new message must
|
|
823
|
+
// start a fresh budget. Each tool turn spends 2 model calls, so with
|
|
824
|
+
// maxLlmCalls: 2 both turns must complete on their own budget; if the counts
|
|
825
|
+
// leak across turns (the session snapshot restoring the previous turn's count
|
|
826
|
+
// over an eager reset) turn 2 trips immediately.
|
|
827
|
+
const scope = new Scope('test-cap-turns');
|
|
828
|
+
const agent = new Agent(scope, 'capturns', {
|
|
829
|
+
systemPrompt: 'test',
|
|
830
|
+
maxLlmCalls: 2,
|
|
831
|
+
model: { deployed: { provider: 'canned' }, local: { provider: 'canned' } },
|
|
832
|
+
tools: (tool) => ({ getWeather: tool({ description: 'weather', parameters: z.object({ city: z.string() }), handler: async () => ({ temp: 22 }) }) }),
|
|
833
|
+
});
|
|
834
|
+
const convId = await agent.createConversationId('test-user');
|
|
835
|
+
const first = await (await agent.stream('what is the weather?', { conversationId: convId, userId: 'test-user' })).complete();
|
|
836
|
+
assert.strictEqual(first.type, 'done', 'turn 1 should complete');
|
|
837
|
+
const second = await agent.stream('what is the weather?', { conversationId: convId, userId: 'test-user' });
|
|
838
|
+
const chunks = [];
|
|
839
|
+
const ch = await second.channel;
|
|
840
|
+
const sub = ch.subscribe((c) => { chunks.push(c); });
|
|
841
|
+
let terminal;
|
|
842
|
+
for (let i = 0; i < 200 && !terminal; i++) {
|
|
843
|
+
terminal = chunks.find(c => c.type === 'done' || c.type === 'error' || c.type === 'interrupt');
|
|
844
|
+
if (!terminal)
|
|
845
|
+
await new Promise(r => setTimeout(r, 25));
|
|
846
|
+
}
|
|
847
|
+
sub.unsubscribe();
|
|
848
|
+
assert.ok(terminal, 'turn 2 should reach a terminal chunk');
|
|
849
|
+
assert.strictEqual(terminal.type, 'done', `turn 2 must get a fresh budget, got ${terminal.type}: ${terminal.error ?? ''}`);
|
|
850
|
+
});
|
|
851
|
+
});
|
|
471
852
|
// ── tool context ─────────────────────────────────────────────────────────────
|
|
472
853
|
describe('tool context', () => {
|
|
473
854
|
test('context passed via stream reaches the tool handler', async () => {
|
|
@@ -485,6 +866,28 @@ describe('tool context', () => {
|
|
|
485
866
|
await result.complete();
|
|
486
867
|
assert.deepStrictEqual(seenContext, { userId: 'u-1' }, 'handler should receive the per-call context');
|
|
487
868
|
});
|
|
869
|
+
test('mock reproduces the AWS wire boundary: context is JSON round-tripped before reaching a tool', async () => {
|
|
870
|
+
// On AWS the loop runs in a container and context arrives via JSON (Date→string, Set→{}, undefined
|
|
871
|
+
// dropped). The mock must reproduce that so a serialization bug fails locally, not only after deploy.
|
|
872
|
+
const scope = new Scope('test-ctx-wire');
|
|
873
|
+
let seenContext;
|
|
874
|
+
const agent = new Agent(scope, 'cw', {
|
|
875
|
+
systemPrompt: 'test',
|
|
876
|
+
model: { deployed: { provider: 'canned' }, local: { provider: 'canned' } },
|
|
877
|
+
tools: (tool) => ({ capture: tool({ description: 'captures the context',
|
|
878
|
+
parameters: z.object({}),
|
|
879
|
+
needsApproval: false,
|
|
880
|
+
handler: async ({ context }) => { seenContext = context; return { ok: true }; }, }) }),
|
|
881
|
+
});
|
|
882
|
+
const result = await agent.stream('use capture', {
|
|
883
|
+
userId: 'u',
|
|
884
|
+
context: { when: new Date('2020-01-01T00:00:00.000Z'), tags: new Set(['a']), note: undefined },
|
|
885
|
+
});
|
|
886
|
+
await result.complete();
|
|
887
|
+
assert.strictEqual(seenContext.when, '2020-01-01T00:00:00.000Z', 'Date arrives as an ISO string, like on AWS');
|
|
888
|
+
assert.deepStrictEqual(seenContext.tags, {}, 'Set serializes to {} (silent data loss), like on AWS');
|
|
889
|
+
assert.ok(!('note' in seenContext), 'undefined field is dropped, like on AWS');
|
|
890
|
+
});
|
|
488
891
|
test('toolContextSchema validates context and throws on mismatch', async () => {
|
|
489
892
|
const scope = new Scope('test-ctx-schema');
|
|
490
893
|
const agent = new Agent(scope, 'ctxs', {
|
|
@@ -626,6 +1029,28 @@ describe('model-factory', () => {
|
|
|
626
1029
|
// ── useChat ──────────────────────────────────────────────────────────────────
|
|
627
1030
|
import { useChat } from './index.hooks.js';
|
|
628
1031
|
describe('useChat', () => {
|
|
1032
|
+
// Type-only regression guard for the api return-type contract (PR that widened
|
|
1033
|
+
// sendMessage/resume from Promise<void> to Promise<unknown>). This is compiled by
|
|
1034
|
+
// `tsc --build` before the runtime tests execute, so narrowing either member back
|
|
1035
|
+
// to Promise<void> fails CI here — the durable proof the manual PR check could not
|
|
1036
|
+
// commit. `unknown` must accept BOTH a natural object-returning backend and a
|
|
1037
|
+
// void-returning one; both assignments below must type-check.
|
|
1038
|
+
test('api sendMessage/resume accept object- and void-returning backends (type-only)', () => {
|
|
1039
|
+
const objectBackend = {
|
|
1040
|
+
sendMessage: async () => ({ channelId: 'c' }),
|
|
1041
|
+
createConversation: async () => ({ conversationId: 'c' }),
|
|
1042
|
+
getConversation: async () => ({ messages: [] }),
|
|
1043
|
+
resume: async () => ({ ok: true }),
|
|
1044
|
+
};
|
|
1045
|
+
const voidBackend = {
|
|
1046
|
+
sendMessage: async () => { },
|
|
1047
|
+
createConversation: async () => ({ conversationId: 'c' }),
|
|
1048
|
+
getConversation: async () => ({ messages: [] }),
|
|
1049
|
+
resume: async () => { },
|
|
1050
|
+
};
|
|
1051
|
+
assert.ok(objectBackend.sendMessage);
|
|
1052
|
+
assert.ok(voidBackend.sendMessage);
|
|
1053
|
+
});
|
|
629
1054
|
test('onError is called when error chunk arrives', async () => {
|
|
630
1055
|
let chunkHandler;
|
|
631
1056
|
let errorReceived;
|
package/dist/model-factory.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Model, BaseModelConfig } from '@strands-agents/sdk';
|
|
2
2
|
import type { ChildLogger } from '@aws-blocks/bb-logger';
|
|
3
|
-
import type { ModelConfig } from './types.js';
|
|
3
|
+
import type { CannedToolHints, ModelConfig } from './types.js';
|
|
4
4
|
/**
|
|
5
5
|
* Checks if a model endpoint is available and the specified model exists.
|
|
6
6
|
* Verifies endpoint/model availability only. Does not guarantee EULA acceptance or feature support (e.g. tool calling).
|
|
@@ -21,5 +21,5 @@ export declare function checkModelHealth(config: ModelConfig, log: ChildLogger,
|
|
|
21
21
|
*
|
|
22
22
|
* @see https://strandsagents.com/docs/user-guide/concepts/model-providers/
|
|
23
23
|
*/
|
|
24
|
-
export declare function createStrandsModel(config?: ModelConfig, log?: ChildLogger): Promise<Model<BaseModelConfig>>;
|
|
24
|
+
export declare function createStrandsModel(config?: ModelConfig, log?: ChildLogger, cannedHints?: Map<string, CannedToolHints>): Promise<Model<BaseModelConfig>>;
|
|
25
25
|
//# sourceMappingURL=model-factory.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"model-factory.d.ts","sourceRoot":"","sources":["../src/model-factory.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,KAAK,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAClE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAIzD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"model-factory.d.ts","sourceRoot":"","sources":["../src/model-factory.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,KAAK,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAClE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAIzD,OAAO,KAAK,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAK/D;;;;;;;GAOG;AACH,yDAAyD;AACzD,MAAM,WAAW,mBAAmB;IACnC,IAAI,CAAC,OAAO,EAAE,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;CACjC;AAED,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,WAAW,EAAE,GAAG,EAAE,WAAW,EAAE,WAAW,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,OAAO,CAAC,CAqHjI;AAED;;;;;;GAMG;AACH,wBAAsB,kBAAkB,CAAC,MAAM,CAAC,EAAE,WAAW,EAAE,GAAG,CAAC,EAAE,WAAW,EAAE,WAAW,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,eAAe,CAAC,GAAG,OAAO,CAAC,KAAK,CAAC,eAAe,CAAC,CAAC,CAwD7J"}
|
package/dist/model-factory.js
CHANGED
|
@@ -117,10 +117,10 @@ export async function checkModelHealth(config, log, _testClient) {
|
|
|
117
117
|
*
|
|
118
118
|
* @see https://strandsagents.com/docs/user-guide/concepts/model-providers/
|
|
119
119
|
*/
|
|
120
|
-
export async function createStrandsModel(config, log) {
|
|
120
|
+
export async function createStrandsModel(config, log, cannedHints) {
|
|
121
121
|
if (!config || config.provider === 'canned') {
|
|
122
122
|
const { CannedProvider } = await import('./providers/canned.js');
|
|
123
|
-
return new CannedProvider();
|
|
123
|
+
return new CannedProvider({ hints: cannedHints });
|
|
124
124
|
}
|
|
125
125
|
// Test-only provider — throws mid-stream to verify error handling
|
|
126
126
|
if (config.provider === 'throwing') {
|
|
@@ -12,12 +12,19 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { Model } from '@strands-agents/sdk';
|
|
14
14
|
import type { Message, ModelStreamEvent, StreamOptions } from '@strands-agents/sdk';
|
|
15
|
+
import type { CannedToolHints } from '../types.js';
|
|
15
16
|
interface CannedConfig {
|
|
16
17
|
modelId: string;
|
|
17
18
|
}
|
|
19
|
+
interface CannedProviderOptions {
|
|
20
|
+
modelId?: string;
|
|
21
|
+
/** Per-tool hints (examples, triggers) keyed by tool name. */
|
|
22
|
+
hints?: Map<string, CannedToolHints>;
|
|
23
|
+
}
|
|
18
24
|
export declare class CannedProvider extends Model<CannedConfig> {
|
|
19
25
|
private config;
|
|
20
|
-
|
|
26
|
+
private hints;
|
|
27
|
+
constructor(options?: CannedProviderOptions);
|
|
21
28
|
updateConfig(config: Partial<CannedConfig>): void;
|
|
22
29
|
getConfig(): CannedConfig;
|
|
23
30
|
stream(messages: Message[], options?: StreamOptions): AsyncIterable<ModelStreamEvent>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"canned.d.ts","sourceRoot":"","sources":["../../src/providers/canned.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AAC5C,OAAO,KAAK,EAAE,OAAO,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"canned.d.ts","sourceRoot":"","sources":["../../src/providers/canned.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AAC5C,OAAO,KAAK,EAAE,OAAO,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEpF,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAEnD,UAAU,YAAY;IACrB,OAAO,EAAE,MAAM,CAAC;CAChB;AAED,UAAU,qBAAqB;IAC9B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,8DAA8D;IAC9D,KAAK,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;CACrC;AA4LD,qBAAa,cAAe,SAAQ,KAAK,CAAC,YAAY,CAAC;IACtD,OAAO,CAAC,MAAM,CAAe;IAC7B,OAAO,CAAC,KAAK,CAA+B;gBAEhC,OAAO,CAAC,EAAE,qBAAqB;IAM3C,YAAY,CAAC,MAAM,EAAE,OAAO,CAAC,YAAY,CAAC,GAAG,IAAI;IAIjD,SAAS,IAAI,YAAY;IAIlB,MAAM,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,OAAO,CAAC,EAAE,aAAa,GAAG,aAAa,CAAC,gBAAgB,CAAC;IA6B5F,iDAAiD;YAClC,QAAQ;IAWvB,oEAAoE;YACrD,qBAAqB;IAYpC,iHAAiH;YAClG,YAAY;CAS3B"}
|