@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.
- package/DESIGN.md +65 -16
- 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 -55
- 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 -26
- package/dist/index.cdk.test.d.ts +2 -0
- package/dist/index.cdk.test.d.ts.map +1 -0
- package/dist/index.cdk.test.js +143 -0
- 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 -54
- 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 +167 -0
- package/src/index.cdk.ts +29 -29
- 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/src/index.test.ts
CHANGED
|
@@ -334,6 +334,19 @@ describe('CannedProvider', () => {
|
|
|
334
334
|
assert.ok(text.includes('22°C'), 'should contain weather data');
|
|
335
335
|
});
|
|
336
336
|
|
|
337
|
+
// Keyword text matching must respect word boundaries for the same reason tool matching
|
|
338
|
+
// does, or "reorder" returns the order response and "helper" returns the help response.
|
|
339
|
+
test('does not return a keyword response when the keyword is only a substring', async () => {
|
|
340
|
+
const provider = new CannedProvider();
|
|
341
|
+
const chunks: string[] = [];
|
|
342
|
+
for await (const event of provider.stream([{ role: 'user', content: [{ text: 'please reorder the list alphabetically' }] }] as any)) {
|
|
343
|
+
if (event.type === 'modelContentBlockDeltaEvent' && event.delta.type === 'textDelta') chunks.push(event.delta.text);
|
|
344
|
+
}
|
|
345
|
+
const text = chunks.join('');
|
|
346
|
+
assert.ok(!text.includes('#12345'), `"reorder" must not return the order response, got: ${text}`);
|
|
347
|
+
assert.ok(text.includes('No real model was called'), 'should fall through to the default response');
|
|
348
|
+
});
|
|
349
|
+
|
|
337
350
|
test('triggers tool call when prompt matches tool name', async () => {
|
|
338
351
|
const provider = new CannedProvider();
|
|
339
352
|
let toolName: string | undefined;
|
|
@@ -382,6 +395,157 @@ describe('CannedProvider', () => {
|
|
|
382
395
|
assert.deepStrictEqual(started, ['getOrder']);
|
|
383
396
|
});
|
|
384
397
|
|
|
398
|
+
// Collect the parsed tool input from the first tool call in a stream.
|
|
399
|
+
const collectToolInput = async (provider: CannedProvider, prompt: string, toolSpecs: any[]): Promise<any> => {
|
|
400
|
+
let input: any;
|
|
401
|
+
for await (const event of provider.stream([{ role: 'user', content: [{ text: prompt }] }] as any, { toolSpecs } as any)) {
|
|
402
|
+
if (event.type === 'modelContentBlockDeltaEvent' && event.delta.type === 'toolUseInputDelta') {
|
|
403
|
+
input = JSON.parse(event.delta.input);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return input;
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
// Collect the names of every tool call started in a stream.
|
|
410
|
+
const collectToolStarts = async (provider: CannedProvider, prompt: string, toolSpecs: any[]): Promise<string[]> => {
|
|
411
|
+
const started: string[] = [];
|
|
412
|
+
for await (const event of provider.stream([{ role: 'user', content: [{ text: prompt }] }] as any, { toolSpecs } as any)) {
|
|
413
|
+
if (event.type === 'modelContentBlockStartEvent' && event.start?.type === 'toolUseStart') started.push(event.start.name);
|
|
414
|
+
}
|
|
415
|
+
return started;
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
test('cannedExamples are shallow-merged over generated placeholder input', async () => {
|
|
419
|
+
const hints = new Map([['searchDocs', { examples: { query: 'how do I get started' } }]]);
|
|
420
|
+
const provider = new CannedProvider({ hints });
|
|
421
|
+
const toolSpecs = [{ name: 'searchDocs', description: '', inputSchema: { type: 'object', properties: { query: { type: 'string' }, limit: { type: 'integer' } } } }];
|
|
422
|
+
const input = await collectToolInput(provider, 'searchDocs please', toolSpecs);
|
|
423
|
+
// Example query wins; unspecified `limit` falls back to the generic integer placeholder.
|
|
424
|
+
assert.deepStrictEqual(input, { query: 'how do I get started', limit: 1 });
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test('respects schema default values over generic placeholders', async () => {
|
|
428
|
+
const provider = new CannedProvider();
|
|
429
|
+
const toolSpecs = [{ name: 'listItems', description: '', inputSchema: { type: 'object', properties: { limit: { type: 'integer', default: 10 } } } }];
|
|
430
|
+
const input = await collectToolInput(provider, 'listItems now', toolSpecs);
|
|
431
|
+
assert.deepStrictEqual(input, { limit: 10 });
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test('mixes schema defaults with generic placeholders per field', async () => {
|
|
435
|
+
const provider = new CannedProvider();
|
|
436
|
+
const toolSpecs = [{ name: 'listItems', description: '', inputSchema: { type: 'object', properties: { limit: { type: 'integer', default: 10 }, query: { type: 'string' } } } }];
|
|
437
|
+
const input = await collectToolInput(provider, 'listItems now', toolSpecs);
|
|
438
|
+
assert.deepStrictEqual(input, { limit: 10, query: 'sample' });
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test('cannedExamples win over a schema default on the same field', async () => {
|
|
442
|
+
const hints = new Map([['listItems', { examples: { limit: 5 } }]]);
|
|
443
|
+
const provider = new CannedProvider({ hints });
|
|
444
|
+
const toolSpecs = [{ name: 'listItems', description: '', inputSchema: { type: 'object', properties: { limit: { type: 'integer', default: 10 } } } }];
|
|
445
|
+
const input = await collectToolInput(provider, 'listItems now', toolSpecs);
|
|
446
|
+
// Full precedence chain is cannedExamples > schema default > generic placeholder;
|
|
447
|
+
// this pins the top link, where a field carries both an example and a default.
|
|
448
|
+
assert.deepStrictEqual(input, { limit: 5 }, 'the cannedExamples value must beat the schema default');
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test('warns on a cannedExamples field missing from the tool schema, but still sends it', async () => {
|
|
452
|
+
const hints = new Map([['fetchPage', { examples: { rul: 'https://example.com' } }]]);
|
|
453
|
+
const provider = new CannedProvider({ hints });
|
|
454
|
+
const toolSpecs = [{ name: 'fetchPage', description: '', inputSchema: { type: 'object', properties: { url: { type: 'string' } } } }];
|
|
455
|
+
const originalWarn = console.warn;
|
|
456
|
+
const warnings: string[] = [];
|
|
457
|
+
console.warn = (msg: unknown) => { warnings.push(String(msg)); };
|
|
458
|
+
let input: any;
|
|
459
|
+
try {
|
|
460
|
+
input = await collectToolInput(provider, 'fetchPage now', toolSpecs);
|
|
461
|
+
} finally {
|
|
462
|
+
console.warn = originalWarn;
|
|
463
|
+
}
|
|
464
|
+
assert.ok(
|
|
465
|
+
warnings.some(w => w.includes('fetchPage') && w.includes('"rul"')),
|
|
466
|
+
`the typo'd field should be reported, got ${JSON.stringify(warnings)}`,
|
|
467
|
+
);
|
|
468
|
+
// A bad hint is surfaced, never enforced: nothing throws and the value still ships.
|
|
469
|
+
assert.deepStrictEqual(input, { url: 'sample', rul: 'https://example.com' });
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test('cannedTriggers fire a tool for a single-word keyword beyond its name', async () => {
|
|
473
|
+
const hints = new Map([['searchDocs', { triggers: ['find'] }]]);
|
|
474
|
+
const provider = new CannedProvider({ hints });
|
|
475
|
+
const toolSpecs = [{ name: 'searchDocs', description: '', inputSchema: {} }];
|
|
476
|
+
const started = await collectToolStarts(provider, 'help me find the answer', toolSpecs);
|
|
477
|
+
assert.deepStrictEqual(started, ['searchDocs']);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test('cannedTriggers fire a tool for a multi-word phrase', async () => {
|
|
481
|
+
const hints = new Map([['searchDocs', { triggers: ['look up'] }]]);
|
|
482
|
+
const provider = new CannedProvider({ hints });
|
|
483
|
+
const toolSpecs = [{ name: 'searchDocs', description: '', inputSchema: {} }];
|
|
484
|
+
const started = await collectToolStarts(provider, 'can you look up the manual', toolSpecs);
|
|
485
|
+
assert.deepStrictEqual(started, ['searchDocs']);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test('single-word cannedTriggers respect word boundaries', async () => {
|
|
489
|
+
const hints = new Map([['searchDocs', { triggers: ['cat'] }]]);
|
|
490
|
+
const provider = new CannedProvider({ hints });
|
|
491
|
+
const toolSpecs = [{ name: 'searchDocs', description: '', inputSchema: {} }];
|
|
492
|
+
const started = await collectToolStarts(provider, 'what category is this', toolSpecs);
|
|
493
|
+
assert.deepStrictEqual(started, [], 'trigger "cat" must not fire on "category"');
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test('multi-word cannedTriggers respect word boundaries', async () => {
|
|
497
|
+
const hints = new Map([['searchDocs', { triggers: ['log in'] }]]);
|
|
498
|
+
const provider = new CannedProvider({ hints });
|
|
499
|
+
const toolSpecs = [{ name: 'searchDocs', description: '', inputSchema: {} }];
|
|
500
|
+
const started = await collectToolStarts(provider, 'check the backlog in the queue', toolSpecs);
|
|
501
|
+
assert.deepStrictEqual(started, [], 'trigger "log in" must not fire on "backlog in"');
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test('multi-word cannedTriggers tolerate flexible internal whitespace', async () => {
|
|
505
|
+
const hints = new Map([['searchDocs', { triggers: ['look up'] }]]);
|
|
506
|
+
const provider = new CannedProvider({ hints });
|
|
507
|
+
const toolSpecs = [{ name: 'searchDocs', description: '', inputSchema: {} }];
|
|
508
|
+
const started = await collectToolStarts(provider, 'can you look up the manual', toolSpecs);
|
|
509
|
+
assert.deepStrictEqual(started, ['searchDocs'], 'multiple spaces between words should still match');
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test('generates generic placeholder when no example or default is given', async () => {
|
|
513
|
+
const provider = new CannedProvider();
|
|
514
|
+
const toolSpecs = [{ name: 'searchDocs', description: '', inputSchema: { type: 'object', properties: { query: { type: 'string' } } } }];
|
|
515
|
+
const input = await collectToolInput(provider, 'searchDocs please', toolSpecs);
|
|
516
|
+
assert.deepStrictEqual(input, { query: 'sample' });
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test('resolves a union (anyOf) field from its first usable variant', async () => {
|
|
520
|
+
const provider = new CannedProvider();
|
|
521
|
+
const toolSpecs = [{ name: 'listItems', description: '', inputSchema: { type: 'object', properties: { limit: { anyOf: [{ type: 'integer' }, { type: 'string' }] } } } }];
|
|
522
|
+
const input = await collectToolInput(provider, 'listItems now', toolSpecs);
|
|
523
|
+
assert.deepStrictEqual(input, { limit: 1 });
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test('resolves a const field to its fixed value', async () => {
|
|
527
|
+
const provider = new CannedProvider();
|
|
528
|
+
const toolSpecs = [{ name: 'listItems', description: '', inputSchema: { type: 'object', properties: { kind: { const: 'archive' } } } }];
|
|
529
|
+
const input = await collectToolInput(provider, 'listItems now', toolSpecs);
|
|
530
|
+
assert.deepStrictEqual(input, { kind: 'archive' });
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
// A required field of an unrecognized shape used to be dropped entirely, so the emitted
|
|
534
|
+
// call failed schema validation before the tool ever ran.
|
|
535
|
+
test('fills a required field whose shape yields no placeholder', async () => {
|
|
536
|
+
const provider = new CannedProvider();
|
|
537
|
+
const toolSpecs = [{ name: 'listItems', description: '', inputSchema: { type: 'object', properties: { mystery: {} }, required: ['mystery'] } }];
|
|
538
|
+
const input = await collectToolInput(provider, 'listItems now', toolSpecs);
|
|
539
|
+
assert.deepStrictEqual(input, { mystery: 'sample' });
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test('leaves an optional field whose shape yields no placeholder omitted', async () => {
|
|
543
|
+
const provider = new CannedProvider();
|
|
544
|
+
const toolSpecs = [{ name: 'listItems', description: '', inputSchema: { type: 'object', properties: { mystery: {} } } }];
|
|
545
|
+
const input = await collectToolInput(provider, 'listItems now', toolSpecs);
|
|
546
|
+
assert.deepStrictEqual(input, {}, 'absence is valid for an optional field; do not invent a wrong-typed value');
|
|
547
|
+
});
|
|
548
|
+
|
|
385
549
|
test('responds to tool result with acknowledgment', async () => {
|
|
386
550
|
const provider = new CannedProvider();
|
|
387
551
|
const chunks: string[] = [];
|
|
@@ -416,6 +580,35 @@ describe('CannedProvider', () => {
|
|
|
416
580
|
assert.ok(done.text && done.text.length > 0, 'should have response text');
|
|
417
581
|
});
|
|
418
582
|
|
|
583
|
+
// End-to-end: exercises the full createStrandsAgent -> createStrandsModel -> CannedProvider
|
|
584
|
+
// plumbing. A prompt that only matches a `cannedTriggers` keyword must fire the tool, and the
|
|
585
|
+
// emitted call must carry the `cannedExamples` input.
|
|
586
|
+
test('canned hints plumb through the Agent end-to-end', async () => {
|
|
587
|
+
const scope = new Scope('test-canned-hints');
|
|
588
|
+
const agent = new Agent(scope, 'hints', {
|
|
589
|
+
inferenceOnly: false,
|
|
590
|
+
systemPrompt: 'test',
|
|
591
|
+
model: { deployed: { provider: 'canned' }, local: { provider: 'canned' } },
|
|
592
|
+
tools: (tool) => ({
|
|
593
|
+
searchDocs: tool({
|
|
594
|
+
description: 'Search documentation',
|
|
595
|
+
parameters: z.object({ query: z.string() }),
|
|
596
|
+
handler: async ({ input }) => ({ echoed: input.query }),
|
|
597
|
+
cannedExamples: { query: 'how do I get started' },
|
|
598
|
+
cannedTriggers: ['find'],
|
|
599
|
+
}),
|
|
600
|
+
}),
|
|
601
|
+
});
|
|
602
|
+
const convId = await agent.createConversationId('test-user');
|
|
603
|
+
const result = await agent.stream('help me find the answer', { conversationId: convId, userId: 'test-user' });
|
|
604
|
+
await result.complete();
|
|
605
|
+
const messages = await agent.getConversation(convId);
|
|
606
|
+
const toolCall = messages.find(m => m.role === 'tool-call');
|
|
607
|
+
assert.ok(toolCall, 'trigger keyword should have fired a tool call');
|
|
608
|
+
assert.strictEqual(toolCall.metadata.toolName, 'searchDocs');
|
|
609
|
+
assert.deepStrictEqual(toolCall.metadata.toolInput, { query: 'how do I get started' });
|
|
610
|
+
});
|
|
611
|
+
|
|
419
612
|
test("getConversation with limit returns most recent messages", async () => {
|
|
420
613
|
const scope = new Scope('test-limit');
|
|
421
614
|
const agent = new Agent(scope, 'lim', { systemPrompt: 'test', model: { deployed: { provider: 'canned' }, local: { provider: 'canned' } } });
|
|
@@ -488,7 +681,7 @@ describe('CannedProvider', () => {
|
|
|
488
681
|
const result = await agent.stream('hello', { userId: 'test-user' });
|
|
489
682
|
const ch = await result.channel;
|
|
490
683
|
ch.subscribe((chunk: any) => { chunks.push(chunk); });
|
|
491
|
-
// Wait for
|
|
684
|
+
// Wait for the in-process turn to publish its error chunk
|
|
492
685
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
493
686
|
const textChunks = chunks.filter((c: any) => c.type === 'text-delta');
|
|
494
687
|
const errorChunk = chunks.find((c: any) => c.type === 'error');
|
|
@@ -541,6 +734,238 @@ describe('CannedProvider', () => {
|
|
|
541
734
|
});
|
|
542
735
|
});
|
|
543
736
|
|
|
737
|
+
// ── runaway protection caps ──────────────────────────────────────────────────
|
|
738
|
+
|
|
739
|
+
/** Run one turn, auto-approving every interrupt, until a terminal chunk arrives. */
|
|
740
|
+
async function runAutoApproving(agent: any, message: string, conversationId: string, userId: string, maxResumes = 5) {
|
|
741
|
+
const chunks: any[] = [];
|
|
742
|
+
const result = await agent.stream(message, { conversationId, userId });
|
|
743
|
+
const channel = await result.channel;
|
|
744
|
+
const sub = channel.subscribe((chunk: any) => { chunks.push(chunk); });
|
|
745
|
+
|
|
746
|
+
const waitFor = async (predicate: () => any) => {
|
|
747
|
+
for (let i = 0; i < 200; i++) {
|
|
748
|
+
const hit = predicate();
|
|
749
|
+
if (hit) return hit;
|
|
750
|
+
await new Promise(r => setTimeout(r, 25));
|
|
751
|
+
}
|
|
752
|
+
return undefined;
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
let resumes = 0;
|
|
756
|
+
let terminal = await waitFor(() => chunks.find(c => c.type === 'done' || c.type === 'error' || c.type === 'interrupt'));
|
|
757
|
+
while (terminal?.type === 'interrupt' && resumes < maxResumes) {
|
|
758
|
+
resumes++;
|
|
759
|
+
const seen = chunks.length;
|
|
760
|
+
await agent.resume(result.channelId, terminal.interrupts.map((i: any) => ({ interruptId: i.id, approved: true })), { conversationId, userId });
|
|
761
|
+
terminal = await waitFor(() => chunks.slice(seen).find(c => c.type === 'done' || c.type === 'error' || c.type === 'interrupt'));
|
|
762
|
+
}
|
|
763
|
+
sub.unsubscribe();
|
|
764
|
+
return { chunks, terminal, resumes };
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
describe('runaway protection caps', () => {
|
|
768
|
+
test('maxLlmCalls stops a turn that keeps calling the model', async () => {
|
|
769
|
+
// A tool prompt drives two model calls (initial call → tool → follow-up call).
|
|
770
|
+
// With maxLlmCalls: 1 the second BeforeModelCallEvent trips the cap and cancels
|
|
771
|
+
// the turn, surfacing an error chunk (so complete() rejects).
|
|
772
|
+
const scope = new Scope('test-cap-llm');
|
|
773
|
+
const agent = new Agent(scope, 'capllm', {
|
|
774
|
+
systemPrompt: 'test',
|
|
775
|
+
maxLlmCalls: 1,
|
|
776
|
+
model: { deployed: { provider: 'canned' }, local: { provider: 'canned' } },
|
|
777
|
+
tools: (tool) => ({ getStatus: tool({ description: 'status', parameters: z.object({}), handler: async () => ({ ok: true }) }) }),
|
|
778
|
+
});
|
|
779
|
+
const result = await agent.stream('run getStatus', { userId: 'test-user' });
|
|
780
|
+
await assert.rejects(() => result.complete(), (err: any) => {
|
|
781
|
+
assert.match(err.message, /maxLlmCalls/);
|
|
782
|
+
return true;
|
|
783
|
+
});
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
test('maxToolIterations stops a turn that fans out too many tools', async () => {
|
|
787
|
+
// Naming both tools makes the canned provider fire two tool calls in one turn;
|
|
788
|
+
// with maxToolIterations: 1 the second BeforeToolCallEvent trips the cap.
|
|
789
|
+
const scope = new Scope('test-cap-tools');
|
|
790
|
+
const agent = new Agent(scope, 'captools', {
|
|
791
|
+
systemPrompt: 'test',
|
|
792
|
+
maxToolIterations: 1,
|
|
793
|
+
model: { deployed: { provider: 'canned' }, local: { provider: 'canned' } },
|
|
794
|
+
tools: (tool) => ({
|
|
795
|
+
alpha: tool({ description: 'a', parameters: z.object({}), handler: async () => ({ ok: true }) }),
|
|
796
|
+
bravo: tool({ description: 'b', parameters: z.object({}), handler: async () => ({ ok: true }) }),
|
|
797
|
+
}),
|
|
798
|
+
});
|
|
799
|
+
const result = await agent.stream('run alpha and bravo', { userId: 'test-user' });
|
|
800
|
+
await assert.rejects(() => result.complete(), (err: any) => {
|
|
801
|
+
assert.match(err.message, /maxToolIterations/);
|
|
802
|
+
return true;
|
|
803
|
+
});
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
test('a normal single-tool turn under the default caps completes', async () => {
|
|
807
|
+
// Two model calls + one tool call are both well under the default caps (20),
|
|
808
|
+
// so the turn completes normally rather than tripping either guard.
|
|
809
|
+
const scope = new Scope('test-cap-default');
|
|
810
|
+
const agent = new Agent(scope, 'capdef', {
|
|
811
|
+
systemPrompt: 'test',
|
|
812
|
+
model: { deployed: { provider: 'canned' }, local: { provider: 'canned' } },
|
|
813
|
+
tools: (tool) => ({ getStatus: tool({ description: 'status', parameters: z.object({}), handler: async () => ({ ok: true }) }) }),
|
|
814
|
+
});
|
|
815
|
+
const result = await agent.stream('run getStatus', { userId: 'test-user' });
|
|
816
|
+
const chunk = await result.complete();
|
|
817
|
+
assert.strictEqual(chunk.type, 'done', 'a normal turn under the default caps should complete');
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
test('a cap set to false is disabled', async () => {
|
|
821
|
+
// The same two-tool fan-out that trips at maxToolIterations: 1 must complete
|
|
822
|
+
// when the cap is disabled with `false`.
|
|
823
|
+
const scope = new Scope('test-cap-disabled');
|
|
824
|
+
const agent = new Agent(scope, 'capoff', {
|
|
825
|
+
systemPrompt: 'test',
|
|
826
|
+
maxToolIterations: false,
|
|
827
|
+
model: { deployed: { provider: 'canned' }, local: { provider: 'canned' } },
|
|
828
|
+
tools: (tool) => ({
|
|
829
|
+
alpha: tool({ description: 'a', parameters: z.object({}), handler: async () => ({ ok: true }) }),
|
|
830
|
+
bravo: tool({ description: 'b', parameters: z.object({}), handler: async () => ({ ok: true }) }),
|
|
831
|
+
}),
|
|
832
|
+
});
|
|
833
|
+
const result = await agent.stream('run alpha and bravo', { userId: 'test-user' });
|
|
834
|
+
const chunk = await result.complete();
|
|
835
|
+
assert.strictEqual(chunk.type, 'done', 'disabling the cap should let the turn complete');
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
test('an invalid cap value is rejected at construction', async () => {
|
|
839
|
+
const scope = new Scope('test-cap-invalid');
|
|
840
|
+
const model = { deployed: { provider: 'canned' as const }, local: { provider: 'canned' as const } };
|
|
841
|
+
let n = 0;
|
|
842
|
+
for (const value of [0, -1, 1.5, Number.NaN]) {
|
|
843
|
+
assert.throws(
|
|
844
|
+
() => new Agent(scope, `capbad${n++}`, { systemPrompt: 'test', model, maxLlmCalls: value }),
|
|
845
|
+
(err: any) => err.name === AgentErrors.InvalidModelConfig && /positive integer or false/.test(err.message),
|
|
846
|
+
`maxLlmCalls: ${value} should be rejected`,
|
|
847
|
+
);
|
|
848
|
+
assert.throws(
|
|
849
|
+
() => new Agent(scope, `capbad${n++}`, { systemPrompt: 'test', model, maxToolIterations: value }),
|
|
850
|
+
(err: any) => err.name === AgentErrors.InvalidModelConfig && /positive integer or false/.test(err.message),
|
|
851
|
+
`maxToolIterations: ${value} should be rejected`,
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
// Valid values must still construct.
|
|
855
|
+
new Agent(scope, 'capok1', { systemPrompt: 'test', model, maxLlmCalls: 1, maxToolIterations: 99 });
|
|
856
|
+
new Agent(scope, 'capok2', { systemPrompt: 'test', model, maxLlmCalls: false, maxToolIterations: false });
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
test('a cap trip leaves paired, explained history', async () => {
|
|
860
|
+
// A tool call cancelled by the cap still gets an AfterToolCallEvent from Strands
|
|
861
|
+
// (the cancellation is the result), so every persisted 'tool-call' keeps its
|
|
862
|
+
// 'tool-result' partner — no dangling tool_use to break the next turn. runAgent
|
|
863
|
+
// additionally records why the turn stopped.
|
|
864
|
+
const scope = new Scope('test-cap-history');
|
|
865
|
+
const agent = new Agent(scope, 'caphist', {
|
|
866
|
+
systemPrompt: 'test',
|
|
867
|
+
maxToolIterations: 1,
|
|
868
|
+
model: { deployed: { provider: 'canned' }, local: { provider: 'canned' } },
|
|
869
|
+
tools: (tool) => ({
|
|
870
|
+
alpha: tool({ description: 'a', parameters: z.object({}), handler: async () => ({ ok: true }) }),
|
|
871
|
+
bravo: tool({ description: 'b', parameters: z.object({}), handler: async () => ({ ok: true }) }),
|
|
872
|
+
}),
|
|
873
|
+
});
|
|
874
|
+
const convId = await agent.createConversationId('test-user');
|
|
875
|
+
const result = await agent.stream('run alpha and bravo', { conversationId: convId, userId: 'test-user' });
|
|
876
|
+
await assert.rejects(() => result.complete());
|
|
877
|
+
|
|
878
|
+
const history = await agent.getConversation(convId);
|
|
879
|
+
const toolCalls = history.filter(m => m.role === 'tool-call');
|
|
880
|
+
const toolResults = history.filter(m => m.role === 'tool-result');
|
|
881
|
+
assert.ok(toolCalls.length > 0, 'sanity: a tool call was persisted before the cap fired');
|
|
882
|
+
assert.strictEqual(toolResults.length, toolCalls.length, 'every persisted tool-call needs a matching tool-result');
|
|
883
|
+
const stopRecord = history.find(m => m.role === 'assistant' && /maxToolIterations/.test(JSON.stringify(m.metadata ?? '')));
|
|
884
|
+
assert.ok(stopRecord, 'history should record why the turn stopped');
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
test('maxLlmCalls keeps counting after resume() — the pre-resume call still counts', async () => {
|
|
888
|
+
// Segment 1 spends one model call (it emits the toolUse, then needsApproval
|
|
889
|
+
// interrupts). The resumed segment spends one more (the post-tool follow-up), so
|
|
890
|
+
// the turn total is 2. With maxLlmCalls: 1 a per-turn count must trip on that
|
|
891
|
+
// follow-up; with per-segment counters the resumed segment restarts at 0, the
|
|
892
|
+
// follow-up is call #1, and the turn completes — so this discriminates the two.
|
|
893
|
+
const scope = new Scope('test-cap-resume-llm');
|
|
894
|
+
const agent = new Agent(scope, 'caprl', {
|
|
895
|
+
systemPrompt: 'test',
|
|
896
|
+
maxLlmCalls: 1,
|
|
897
|
+
model: { deployed: { provider: 'canned' }, local: { provider: 'canned' } },
|
|
898
|
+
tools: (tool) => ({ getWeather: tool({ description: 'weather', parameters: z.object({ city: z.string() }), needsApproval: true, handler: async () => ({ temp: 22 }) }) }),
|
|
899
|
+
});
|
|
900
|
+
const convId = await agent.createConversationId('test-user');
|
|
901
|
+
const { terminal, resumes, chunks } = await runAutoApproving(agent, 'what is the weather?', convId, 'test-user');
|
|
902
|
+
|
|
903
|
+
assert.strictEqual(resumes, 1, 'the tool should have interrupted once for approval');
|
|
904
|
+
assert.ok(terminal, 'the turn should reach a terminal chunk');
|
|
905
|
+
assert.strictEqual(terminal.type, 'error', `expected the cap to trip after resume, got ${terminal.type}`);
|
|
906
|
+
assert.match(terminal.error, /maxLlmCalls/, 'the error should name the cap that tripped');
|
|
907
|
+
|
|
908
|
+
// History must stay paired and explain itself.
|
|
909
|
+
const history = await agent.getConversation(convId);
|
|
910
|
+
const resumeToolCalls = history.filter(m => m.role === 'tool-call');
|
|
911
|
+
const resumeToolResults = history.filter(m => m.role === 'tool-result');
|
|
912
|
+
assert.strictEqual(resumeToolResults.length, resumeToolCalls.length, 'every tool-call needs a matching tool-result');
|
|
913
|
+
assert.ok(history.some(m => m.role === 'assistant' && /maxLlmCalls/.test(JSON.stringify(m.metadata ?? ''))), 'history should record why the turn stopped');
|
|
914
|
+
assert.ok(chunks.some((c: any) => c.type === 'tool-call'), 'sanity: a tool call happened');
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
test('an approved tool call is charged once, not twice, across resume()', async () => {
|
|
918
|
+
// The same toolUseId re-emits BeforeToolCallEvent when the turn resumes. With
|
|
919
|
+
// maxToolIterations: 1 a double charge would trip the cap on a single approved
|
|
920
|
+
// call; deduping by toolUseId must let the turn finish.
|
|
921
|
+
const scope = new Scope('test-cap-resume-dedupe');
|
|
922
|
+
const agent = new Agent(scope, 'caprd', {
|
|
923
|
+
systemPrompt: 'test',
|
|
924
|
+
maxToolIterations: 1,
|
|
925
|
+
model: { deployed: { provider: 'canned' }, local: { provider: 'canned' } },
|
|
926
|
+
tools: (tool) => ({ getWeather: tool({ description: 'weather', parameters: z.object({ city: z.string() }), needsApproval: true, handler: async () => ({ temp: 22 }) }) }),
|
|
927
|
+
});
|
|
928
|
+
const convId = await agent.createConversationId('test-user');
|
|
929
|
+
const { terminal, resumes } = await runAutoApproving(agent, 'what is the weather?', convId, 'test-user');
|
|
930
|
+
|
|
931
|
+
assert.strictEqual(resumes, 1, 'the tool should have interrupted once for approval');
|
|
932
|
+
assert.ok(terminal, 'the turn should reach a terminal chunk');
|
|
933
|
+
assert.strictEqual(terminal.type, 'done', `a single approved tool call must not trip maxToolIterations: 1, got ${terminal.type}: ${terminal.error ?? ''}`);
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
test('a second message on the same conversation gets a fresh budget', async () => {
|
|
937
|
+
// The counters live in the session-persisted appState, so a new message must
|
|
938
|
+
// start a fresh budget. Each tool turn spends 2 model calls, so with
|
|
939
|
+
// maxLlmCalls: 2 both turns must complete on their own budget; if the counts
|
|
940
|
+
// leak across turns (the session snapshot restoring the previous turn's count
|
|
941
|
+
// over an eager reset) turn 2 trips immediately.
|
|
942
|
+
const scope = new Scope('test-cap-turns');
|
|
943
|
+
const agent = new Agent(scope, 'capturns', {
|
|
944
|
+
systemPrompt: 'test',
|
|
945
|
+
maxLlmCalls: 2,
|
|
946
|
+
model: { deployed: { provider: 'canned' }, local: { provider: 'canned' } },
|
|
947
|
+
tools: (tool) => ({ getWeather: tool({ description: 'weather', parameters: z.object({ city: z.string() }), handler: async () => ({ temp: 22 }) }) }),
|
|
948
|
+
});
|
|
949
|
+
const convId = await agent.createConversationId('test-user');
|
|
950
|
+
|
|
951
|
+
const first = await (await agent.stream('what is the weather?', { conversationId: convId, userId: 'test-user' })).complete();
|
|
952
|
+
assert.strictEqual(first.type, 'done', 'turn 1 should complete');
|
|
953
|
+
|
|
954
|
+
const second = await agent.stream('what is the weather?', { conversationId: convId, userId: 'test-user' });
|
|
955
|
+
const chunks: any[] = [];
|
|
956
|
+
const ch = await second.channel;
|
|
957
|
+
const sub = ch.subscribe((c: any) => { chunks.push(c); });
|
|
958
|
+
let terminal: any;
|
|
959
|
+
for (let i = 0; i < 200 && !terminal; i++) {
|
|
960
|
+
terminal = chunks.find(c => c.type === 'done' || c.type === 'error' || c.type === 'interrupt');
|
|
961
|
+
if (!terminal) await new Promise(r => setTimeout(r, 25));
|
|
962
|
+
}
|
|
963
|
+
sub.unsubscribe();
|
|
964
|
+
assert.ok(terminal, 'turn 2 should reach a terminal chunk');
|
|
965
|
+
assert.strictEqual(terminal.type, 'done', `turn 2 must get a fresh budget, got ${terminal.type}: ${terminal.error ?? ''}`);
|
|
966
|
+
});
|
|
967
|
+
});
|
|
968
|
+
|
|
544
969
|
// ── tool context ─────────────────────────────────────────────────────────────
|
|
545
970
|
|
|
546
971
|
describe('tool context', () => {
|
|
@@ -560,6 +985,29 @@ describe('tool context', () => {
|
|
|
560
985
|
assert.deepStrictEqual(seenContext, { userId: 'u-1' }, 'handler should receive the per-call context');
|
|
561
986
|
});
|
|
562
987
|
|
|
988
|
+
test('mock reproduces the AWS wire boundary: context is JSON round-tripped before reaching a tool', async () => {
|
|
989
|
+
// On AWS the loop runs in a container and context arrives via JSON (Date→string, Set→{}, undefined
|
|
990
|
+
// dropped). The mock must reproduce that so a serialization bug fails locally, not only after deploy.
|
|
991
|
+
const scope = new Scope('test-ctx-wire');
|
|
992
|
+
let seenContext: any;
|
|
993
|
+
const agent = new Agent(scope, 'cw', {
|
|
994
|
+
systemPrompt: 'test',
|
|
995
|
+
model: { deployed: { provider: 'canned' }, local: { provider: 'canned' } },
|
|
996
|
+
tools: (tool) => ({ capture: tool({ description: 'captures the context',
|
|
997
|
+
parameters: z.object({}),
|
|
998
|
+
needsApproval: false,
|
|
999
|
+
handler: async ({ context }) => { seenContext = context; return { ok: true }; }, }) }),
|
|
1000
|
+
});
|
|
1001
|
+
const result = await agent.stream('use capture', {
|
|
1002
|
+
userId: 'u',
|
|
1003
|
+
context: { when: new Date('2020-01-01T00:00:00.000Z'), tags: new Set(['a']), note: undefined } as any,
|
|
1004
|
+
});
|
|
1005
|
+
await result.complete();
|
|
1006
|
+
assert.strictEqual(seenContext.when, '2020-01-01T00:00:00.000Z', 'Date arrives as an ISO string, like on AWS');
|
|
1007
|
+
assert.deepStrictEqual(seenContext.tags, {}, 'Set serializes to {} (silent data loss), like on AWS');
|
|
1008
|
+
assert.ok(!('note' in seenContext), 'undefined field is dropped, like on AWS');
|
|
1009
|
+
});
|
|
1010
|
+
|
|
563
1011
|
test('toolContextSchema validates context and throws on mismatch', async () => {
|
|
564
1012
|
const scope = new Scope('test-ctx-schema');
|
|
565
1013
|
const agent = new Agent(scope, 'ctxs', {
|
package/src/model-factory.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { ChildLogger } from '@aws-blocks/bb-logger';
|
|
|
8
8
|
// `./providers/canned` and `./providers/throwing` extend Strands' `Model`, so importing
|
|
9
9
|
// them would evaluate `@strands-agents/sdk` at load time — hence they're also loaded
|
|
10
10
|
// lazily inside createStrandsModel(). See issue #153.
|
|
11
|
-
import type { ModelConfig } from './types.js';
|
|
11
|
+
import type { CannedToolHints, ModelConfig } from './types.js';
|
|
12
12
|
import { AgentErrors, blocksAgentError } from './errors.js';
|
|
13
13
|
|
|
14
14
|
// TODO: validate model-specific inference config (e.g., some models don't support topP with temperature)
|
|
@@ -152,10 +152,10 @@ export async function checkModelHealth(config: ModelConfig, log: ChildLogger, _t
|
|
|
152
152
|
*
|
|
153
153
|
* @see https://strandsagents.com/docs/user-guide/concepts/model-providers/
|
|
154
154
|
*/
|
|
155
|
-
export async function createStrandsModel(config?: ModelConfig, log?: ChildLogger): Promise<Model<BaseModelConfig>> {
|
|
155
|
+
export async function createStrandsModel(config?: ModelConfig, log?: ChildLogger, cannedHints?: Map<string, CannedToolHints>): Promise<Model<BaseModelConfig>> {
|
|
156
156
|
if (!config || config.provider === 'canned') {
|
|
157
157
|
const { CannedProvider } = await import('./providers/canned.js');
|
|
158
|
-
return new CannedProvider();
|
|
158
|
+
return new CannedProvider({ hints: cannedHints });
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
// Test-only provider — throws mid-stream to verify error handling
|