@aws-blocks/bb-agent 0.3.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/DESIGN.md +65 -17
  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 -57
  9. package/dist/agentcore-bundle.d.ts +12 -0
  10. package/dist/agentcore-bundle.d.ts.map +1 -0
  11. package/dist/agentcore-bundle.js +150 -0
  12. package/dist/agentcore-bundle.test.d.ts +2 -0
  13. package/dist/agentcore-bundle.test.d.ts.map +1 -0
  14. package/dist/agentcore-bundle.test.js +46 -0
  15. package/dist/agentcore-entry.d.ts +21 -0
  16. package/dist/agentcore-entry.d.ts.map +1 -0
  17. package/dist/agentcore-entry.js +120 -0
  18. package/dist/agentcore-runtime.cdk.d.ts +27 -0
  19. package/dist/agentcore-runtime.cdk.d.ts.map +1 -0
  20. package/dist/agentcore-runtime.cdk.js +168 -0
  21. package/dist/index.aws.d.ts +1 -0
  22. package/dist/index.aws.d.ts.map +1 -1
  23. package/dist/index.cdk.d.ts +10 -4
  24. package/dist/index.cdk.d.ts.map +1 -1
  25. package/dist/index.cdk.js +27 -28
  26. package/dist/index.cdk.test.js +128 -51
  27. package/dist/index.mock.d.ts +1 -0
  28. package/dist/index.mock.d.ts.map +1 -1
  29. package/dist/index.test.js +404 -1
  30. package/dist/model-factory.d.ts +2 -2
  31. package/dist/model-factory.d.ts.map +1 -1
  32. package/dist/model-factory.js +2 -2
  33. package/dist/providers/canned.d.ts +8 -1
  34. package/dist/providers/canned.d.ts.map +1 -1
  35. package/dist/providers/canned.js +127 -42
  36. package/dist/types.d.ts +63 -1
  37. package/dist/types.d.ts.map +1 -1
  38. package/dist/version.d.ts +1 -1
  39. package/dist/version.js +1 -1
  40. package/package.json +16 -9
  41. package/src/agent.aws.ts +58 -1
  42. package/src/agent.ts +269 -56
  43. package/src/agentcore-bundle.test.ts +52 -0
  44. package/src/agentcore-bundle.ts +162 -0
  45. package/src/agentcore-entry.ts +134 -0
  46. package/src/agentcore-runtime.cdk.ts +203 -0
  47. package/src/index.aws.ts +3 -0
  48. package/src/index.cdk.test.ts +145 -53
  49. package/src/index.cdk.ts +29 -31
  50. package/src/index.mock.ts +3 -0
  51. package/src/index.test.ts +449 -1
  52. package/src/model-factory.ts +3 -3
  53. package/src/providers/canned.ts +131 -36
  54. package/src/types.ts +64 -1
  55. package/src/version.ts +1 -1
  56. package/dist/job-event-source.d.ts +0 -19
  57. package/dist/job-event-source.d.ts.map +0 -1
  58. package/dist/job-event-source.js +0 -20
  59. package/src/job-event-source.ts +0 -21
@@ -2,74 +2,166 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  /**
5
- * CDK-side regression tests for the Agent's internal AsyncJob event source.
5
+ * CDK-side tests for the Agent BB.
6
6
  *
7
- * `stream()` submits one job per interactive turn (and a second on HITL resume),
8
- * so the caller is blocked on that job starting. AsyncJob's defaults
9
- * (batchSize 10 / maxBatchingWindowSeconds 5) would add up to 5s of SQS
10
- * batching delay to that human-blocking path, so the Agent opts out at both
11
- * construction sites. These tests pin the opt-out to the synthesized template:
12
- * if the defaults are ever inherited again, the latency regression fails here
13
- * instead of surfacing as a slow agent in production.
14
- *
15
- * Must run under `--conditions=cdk`; otherwise the internal BBs resolve to
16
- * their mock implementations and no CloudFormation resources are produced.
7
+ * Pin that the Agent provisions an AgentCore Runtime for the streaming loop that runs AS the shared
8
+ * Blocks execution role (the same principal as the handler) not a bespoke per-runtime role. That
9
+ * shared role already carries everything the loop touches (Realtime publish via the handler wiring,
10
+ * Bedrock, the conversation/message tables, the session bucket); the Agent adds only the
11
+ * AgentCore-specific bits the scoped `bedrock-agentcore` assume-role trust, Bedrock model access,
12
+ * and the shared handler's permission to INVOKE the runtime and injects the config location so the
13
+ * container loads the same app config as the handler.
17
14
  */
18
- import { test, afterEach } from 'node:test';
15
+ import { test, before, after } from 'node:test';
19
16
  import assert from 'node:assert';
17
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
18
+ import { dirname, join } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
20
  import * as cdk from 'aws-cdk-lib';
21
- import type { Construct } from 'constructs';
22
21
  import { Template } from 'aws-cdk-lib/assertions';
23
- import { Scope, DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk';
22
+ import { BlocksStack, BlocksPresets } from '@aws-blocks/core/cdk';
23
+ import type { DefaultComputeFactory } from '@aws-blocks/core/cdk/internal';
24
+ import { LambdaCompute } from '@aws-blocks/bb-lambda-compute/cdk';
24
25
  import { Agent } from './index.cdk.js';
25
26
 
26
- class StubBlocksStack extends cdk.Stack {
27
- public readonly handler: cdk.aws_lambda.Function;
28
- public readonly executionRole: cdk.aws_iam.IRole;
29
- public readonly id: string;
30
- constructor(scope: Construct, id: string) {
31
- super(scope, id);
32
- this.id = id;
33
- (globalThis as any).CURRENT_BLOCKS_STACK = this;
34
- this.executionRole = new cdk.aws_iam.Role(this, 'BlocksRole', {
35
- assumedBy: new cdk.aws_iam.ServicePrincipal('lambda.amazonaws.com'),
36
- });
37
- this.handler = new cdk.aws_lambda.Function(this, 'StubHandler', {
38
- runtime: DEFAULT_NODE_RUNTIME,
39
- handler: 'index.handler',
40
- code: cdk.aws_lambda.Code.fromInline('exports.handler = async () => {};'),
41
- role: this.executionRole,
42
- });
43
- }
44
- }
27
+ /**
28
+ * Inject LambdaCompute as the stack's default compute, the same way
29
+ * `@aws-blocks/blocks` does for real apps. `main` (PR #459) made the Agent
30
+ * resolve the stack's `defaultCompute` at construction (the Realtime/AgentCore
31
+ * wiring reads it), so a bare handler-only stub stack (no default compute) can
32
+ * no longer synthesize it — `BlocksStack.create` initializes the default
33
+ * compute the getter resolves to. (`root as never` is core's existing plumbing
34
+ * pattern for the factory signature.)
35
+ */
36
+ const lambdaFactory: DefaultComputeFactory = (root) => new LambdaCompute(root as never, 'DefaultCompute');
45
37
 
46
- // synthAgent() installs its own stack as the ambient CURRENT_BLOCKS_STACK; clear it
47
- // afterwards so no test observes a stack left behind by the previous one (node
48
- // --test isolates files by default, so this is hygiene against future sharing).
49
- afterEach(() => {
50
- delete (globalThis as any).CURRENT_BLOCKS_STACK;
38
+ /** A real directory to hand fromCodeAsset (any existing dir works for a synth-only test). */
39
+ const ASSET_DIR = dirname(fileURLToPath(import.meta.url));
40
+
41
+ let tmpDir: string;
42
+ let handlerPath: string;
43
+ let backendPath: string;
44
+
45
+ before(() => {
46
+ // Building Blocks resolve to their mock entry points unless `--conditions=cdk`
47
+ // is active; keep it set so `BlocksStack.create` produces real CloudFormation.
48
+ process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --conditions=cdk`;
49
+ // `BlocksStack.create` needs a real backend handler + backend module on disk
50
+ // (it imports the backend module and points the handler compute at the file).
51
+ tmpDir = mkdtempSync(join(ASSET_DIR, 'tmp-agent-cdk-'));
52
+ handlerPath = join(tmpDir, 'handler.mjs');
53
+ writeFileSync(handlerPath, "export const handler = async () => ({ statusCode: 200, body: '{}' });\n");
54
+ backendPath = join(tmpDir, 'backend.mjs');
55
+ writeFileSync(backendPath, 'export default () => {};\n');
51
56
  });
52
57
 
53
- function synthAgent(): any {
54
- const app = new cdk.App();
55
- const stack = new StubBlocksStack(app, 'teststack');
56
- const parent = new Scope('app');
57
- new Agent(parent, 'agent', { inferenceOnly: true });
58
+ after(() => {
59
+ rmSync(tmpDir, { recursive: true, force: true });
60
+ });
58
61
 
59
- const mappings = Template.fromStack(stack).findResources('AWS::Lambda::EventSourceMapping');
60
- const keys = Object.keys(mappings);
61
- assert.strictEqual(keys.length, 1, 'exactly one event source mapping expected for the agent job');
62
- return mappings[keys[0]].Properties;
62
+ async function synth(): Promise<Template> {
63
+ const app = new cdk.App();
64
+ // Real BlocksStack (with the shared executionRole/BlocksRole + a LambdaCompute default compute)
65
+ // so the Agent can resolve its default compute. `id` === 'teststack' so BLOCKS_STACK_NAME (derived
66
+ // from the owning root id) resolves to 'teststack', which the injection assertion below matches.
67
+ const stack = await BlocksStack.create(app, 'teststack', {
68
+ backendHandlerPath: handlerPath,
69
+ backendCDKPath: backendPath,
70
+ defaults: BlocksPresets.production,
71
+ defaultComputeFactory: lambdaFactory,
72
+ });
73
+ // agentcoreAssetPath bypasses the synth-time co-bundle (which needs a BlocksStack backend path).
74
+ new Agent(stack, 'agent', { systemPrompt: 'You are a test agent.', agentcoreAssetPath: ASSET_DIR });
75
+ return Template.fromStack(stack);
63
76
  }
64
77
 
65
- test('CDK: the Agent job takes one message per invocation (no batching on the interactive path)', () => {
66
- assert.strictEqual(synthAgent().BatchSize, 1);
78
+ test('CDK: Agent provisions an AgentCore Runtime for the loop', async () => {
79
+ const template = await synth();
80
+ assert.ok(
81
+ Object.keys(template.findResources('AWS::BedrockAgentCore::Runtime')).length >= 1,
82
+ 'expected an AWS::BedrockAgentCore::Runtime resource',
83
+ );
84
+ });
85
+
86
+ test('CDK: the loop runs as the shared execution role, which carries everything it touches', async () => {
87
+ const template = await synth();
88
+ const json = JSON.stringify(template.toJSON());
89
+ // Realtime publish (both halves) — load-bearing for streaming from the container. Granted to the
90
+ // shared role by the Realtime BB's handler wiring (not by the Agent), and inherited because the
91
+ // loop runs AS that role.
92
+ assert.ok(json.includes('execute-api:ManageConnections'), 'Realtime postToConnection');
93
+ assert.ok(json.includes('dynamodb:Query'), 'connections-table + history query');
94
+ // Model + storage the loop uses (Bedrock granted here; S3/DynamoDB via the child BBs).
95
+ assert.ok(json.includes('bedrock:InvokeModel'), 'Bedrock invoke');
96
+ assert.ok(json.includes('s3:GetObject'), 'session-bucket access (via FileBucket)');
97
+ // The RPC handler (also the shared role) can start the loop.
98
+ assert.ok(json.includes('bedrock-agentcore:InvokeAgentRuntime'), 'InvokeAgentRuntime');
99
+ // The runtime runs AS the shared BlocksRole — not a bespoke per-runtime role. Its RoleArn
100
+ // should reference BlocksRole, and there should be no `RuntimeRole` construct anywhere.
101
+ const runtime = Object.values(template.findResources('AWS::BedrockAgentCore::Runtime'))[0] as { Properties?: Record<string, unknown> };
102
+ assert.ok(JSON.stringify(runtime.Properties ?? {}).includes('BlocksRole'), 'runtime executionRole should be the shared BlocksRole');
103
+ assert.ok(!json.includes('RuntimeRole'), 'no bespoke per-runtime role should be created');
104
+ // The container is given the config location so loadConfigToProcessEnv() loads the same full app
105
+ // config as the handler (delivers BLOCKS_RT_CALLBACK_URL + any config-backed BB values a tool needs).
106
+ const runtimeJson = JSON.stringify(runtime.Properties ?? {});
107
+ assert.ok(runtimeJson.includes('BLOCKS_CONFIG_BUCKET'), 'runtime must be injected BLOCKS_CONFIG_BUCKET');
108
+ assert.ok(runtimeJson.includes('BLOCKS_CONFIG_KEY'), 'runtime must be injected BLOCKS_CONFIG_KEY');
109
+ // BLOCKS_STACK_NAME must be the owning root id (`backendStackName`) — the SAME value the handler
110
+ // uses to derive resource names — so the container's namespace matches what CDK provisioned. Here
111
+ // the owning BlocksStack's id is 'teststack', so backendStackName resolves to 'teststack'.
112
+ assert.ok(runtimeJson.includes('BLOCKS_STACK_NAME'), 'runtime must be injected BLOCKS_STACK_NAME');
113
+ assert.ok(runtimeJson.includes('teststack'), 'BLOCKS_STACK_NAME resolves to the owning root id');
67
114
  });
68
115
 
69
- test('CDK: the Agent job has no SQS batching window (no added latency for the caller)', () => {
70
- assert.strictEqual(synthAgent().MaximumBatchingWindowInSeconds, 0);
116
+ test('CDK: the Agent adds the bedrock-agentcore assume-role trust to the shared role (scoped)', async () => {
117
+ // Core is BB-agnostic — it does not trust bedrock-agentcore. The Agent adds that trust here, so
118
+ // the AgentCore Runtime can assume the shared role it runs AS. It must be scoped by
119
+ // aws:SourceAccount (AWS's recommended AgentCore trust policy), and lambda trust must remain.
120
+ const template = await synth();
121
+ const roles = template.findResources('AWS::IAM::Role');
122
+ const blocksRoleId = Object.keys(roles).find(k => k.includes('BlocksRole'));
123
+ assert.ok(blocksRoleId, 'expected the shared BlocksRole');
124
+
125
+ const statements = roles[blocksRoleId].Properties.AssumeRolePolicyDocument.Statement as Array<{
126
+ Principal?: { Service?: string | string[] };
127
+ Condition?: Record<string, Record<string, unknown>>;
128
+ }>;
129
+ const servicesOf = (s: (typeof statements)[number]) => {
130
+ const svc = s.Principal?.Service;
131
+ return Array.isArray(svc) ? svc : svc ? [svc] : [];
132
+ };
133
+ const services = statements.flatMap(servicesOf);
134
+ assert.ok(services.includes('lambda.amazonaws.com'), 'shared role must stay Lambda-assumable');
135
+ assert.ok(services.includes('bedrock-agentcore.amazonaws.com'), 'shared role must be assumable by the AgentCore Runtime');
136
+
137
+ const agentCoreStmt = statements.find(s => servicesOf(s).includes('bedrock-agentcore.amazonaws.com'));
138
+ assert.ok(agentCoreStmt?.Condition, 'AgentCore trust statement must carry a scoping Condition');
139
+ assert.ok(
140
+ JSON.stringify(agentCoreStmt.Condition).includes('aws:SourceAccount'),
141
+ 'AgentCore trust must be scoped by aws:SourceAccount',
142
+ );
71
143
  });
72
144
 
73
- test('CDK: the Agent job still reports partial batch failures', () => {
74
- assert.deepStrictEqual(synthAgent().FunctionResponseTypes, ['ReportBatchItemFailures']);
145
+ test('CDK: multiple agents add the identical shared-role grants ONCE, not per-agent', async () => {
146
+ // Every agent would otherwise add the SAME bedrock-agentcore trust / Bedrock / InvokeAgentRuntime
147
+ // statements to the one shared role. They're identical (stack-scoped), so they must be added
148
+ // exactly once regardless of agent count — keeping the shared role's policy from bloating with
149
+ // duplicates. (Overflow into managed policies is a normal CDK mechanism and not asserted against.)
150
+ const app = new cdk.App();
151
+ const stack = await BlocksStack.create(app, 'teststack', {
152
+ backendHandlerPath: handlerPath,
153
+ backendCDKPath: backendPath,
154
+ defaults: BlocksPresets.production,
155
+ defaultComputeFactory: lambdaFactory,
156
+ });
157
+ for (const id of ['agent1', 'agent2', 'agent3']) {
158
+ new Agent(stack, id, { systemPrompt: 'You are a test agent.', agentcoreAssetPath: ASSET_DIR });
159
+ }
160
+ const template = Template.fromStack(stack);
161
+ const json = JSON.stringify(template.toJSON());
162
+
163
+ // Three runtimes (per-agent), but the shared-role grant statements appear once (deduped).
164
+ assert.strictEqual(Object.keys(template.findResources('AWS::BedrockAgentCore::Runtime')).length, 3, 'three runtimes');
165
+ const invokeGrants = json.split('bedrock-agentcore:InvokeAgentRuntime').length - 1;
166
+ assert.strictEqual(invokeGrants, 1, `InvokeAgentRuntime should be granted once, found ${invokeGrants}`);
75
167
  });
package/src/index.cdk.ts CHANGED
@@ -1,51 +1,44 @@
1
1
  // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- import { PolicyStatement } from 'aws-cdk-lib/aws-iam';
5
4
  import { Scope } from '@aws-blocks/core/cdk';
6
5
  import type { ScopeParent } from '@aws-blocks/core';
7
6
  import { DistributedTable } from '@aws-blocks/bb-distributed-table';
8
7
  import { Realtime } from '@aws-blocks/bb-realtime';
9
- import { AsyncJob } from '@aws-blocks/bb-async-job';
10
8
  import { FileBucket } from '@aws-blocks/bb-file-bucket';
9
+ import { AgentCoreRuntime } from './agentcore-runtime.cdk.js';
11
10
  import { messageSchema, conversationSchema, agentStreamChunkSchema } from './schemas.js';
12
- import { INTERACTIVE_JOB_EVENT_SOURCE } from './job-event-source.js';
13
- import { z } from 'zod';
11
+ import type { AgentConfig } from './types.js';
14
12
 
15
13
  export { AgentErrors } from './errors.js';
16
14
  export { BedrockModels, OllamaModels } from './models.js';
17
15
 
18
- const jobPayloadSchema = z.object({
19
- message: z.string(),
20
- conversationId: z.string().optional(),
21
- });
22
-
23
16
  export class Agent extends Scope {
24
17
  /**
25
18
  * CDK layer for the Agent BB.
26
- * Mirrors the runtime's BB creation so CDK discovers and provisions all resources.
27
19
  *
28
- * TODO: scope Bedrock IAM grant to specific modelId from config
29
- * TODO: guardrails CDK provisioning
20
+ * Provisions the session FileBucket, the conversation + message DistributedTables, the
21
+ * Realtime BB used to stream chunks to the browser, and the AgentCore Runtime that hosts
22
+ * the streaming agent loop. All AgentCore-specific provisioning (co-bundle, runtime role
23
+ * and its grants, container env, and the handler's invoke permission) lives in the
24
+ * self-contained {@link AgentCoreRuntime} so it can later fold into a per-BB compute abstraction.
25
+ *
26
+ * The loop runs inside the AgentCore Runtime (not the shared handler Lambda), so the shared
27
+ * handler no longer needs Bedrock access — the runtime's own role gets it (see AgentCoreRuntime).
30
28
  */
31
- constructor(scope: ScopeParent, id: string, config?: any) {
29
+ constructor(scope: ScopeParent, id: string, config?: AgentConfig) {
32
30
  super(id, { parent: scope });
33
31
 
34
- this.executionRole.addToPrincipalPolicy(new PolicyStatement({
35
- actions: ['bedrock:InvokeModel', 'bedrock:InvokeModelWithResponseStream', 'bedrock:GetFoundationModel', 'bedrock:ListFoundationModels', 'bedrock:GetInferenceProfile'],
36
- resources: [
37
- 'arn:aws:bedrock:*::foundation-model/*',
38
- 'arn:aws:bedrock:*:*:inference-profile/*',
39
- ],
40
- }));
41
-
42
- // Propagate `removalPolicy` to the sessions bucket so customers can
43
- // opt sandbox stacks into clean teardown. Without it, CDK's RETAIN
44
- // default applies (production-safe) and `cdk destroy` will fail on
45
- // a non-empty bucket — same pattern as FileBucket / KnowledgeBase.
46
- // ID shortened to keep S3 bucket names within the 63-char limit
32
+ // Session-snapshot bucket. Provisioned here (and granted to the shared execution role that the
33
+ // AgentCore Runtime runs as); the deployed loop re-derives its name from this bucket's `fullId`
34
+ // in-process — the same `'sn'` id → same fullId → same physical bucket — so no name needs to be
35
+ // injected into the container. Propagate `removalPolicy` so customers can opt sandbox stacks into
36
+ // clean teardown (without it, CDK's RETAIN default applies). ID shortened to keep the S3 bucket
37
+ // name within the 63-char limit.
47
38
  new FileBucket(this, 'sn', { removalPolicy: config?.removalPolicy });
48
39
 
40
+ // Conversation metadata + message history. These grant read/write to the shared execution
41
+ // role, which the AgentCore Runtime then runs as — so the loop can persist history.
49
42
  if (!config?.inferenceOnly) {
50
43
  new DistributedTable(this, 'convos', {
51
44
  schema: conversationSchema,
@@ -59,14 +52,19 @@ export class Agent extends Scope {
59
52
 
60
53
  new Realtime(this, 'rt', {
61
54
  namespaces: {
62
- chunks: { schema: agentStreamChunkSchema },
55
+ chunks: Realtime.namespace(agentStreamChunkSchema),
63
56
  },
64
57
  });
65
58
 
66
- new AsyncJob(this, 'job', {
67
- schema: jobPayloadSchema,
68
- ...INTERACTIVE_JOB_EVENT_SOURCE,
69
- handler: async () => {},
59
+ // The agent loop runs on the AgentCore Runtime (as the shared Blocks execution role) and
60
+ // streams to the browser over Realtime. AgentCoreRuntime co-bundles the app backend,
61
+ // provisions the runtime, adds the bedrock-agentcore trust + Bedrock to the shared role, and
62
+ // injects the config location so the container loads the same app config as the handler
63
+ // (the session bucket/tables and Realtime publish are already granted to that role). Kept
64
+ // self-contained so it can later fold into a per-BB compute abstraction.
65
+ new AgentCoreRuntime(this, 'runtime', {
66
+ agentFullId: this.fullId,
67
+ agentcoreAssetPath: config?.agentcoreAssetPath,
70
68
  });
71
69
  }
72
70
  }
package/src/index.mock.ts CHANGED
@@ -2,6 +2,9 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  export { Agent } from './agent.mock.js';
5
+ // Exported so api-extractor can resolve the (protected, @internal) dispatchTurn signature; the type
6
+ // itself is @internal — not part of the public API (customers use stream()/resume()).
7
+ export type { AgentTurnPayload } from './agent.js';
5
8
  export { AgentErrors, InterruptError } from './errors.js';
6
9
  export { BedrockModels, OllamaModels } from './models.js';
7
10
  export type { AgentConfig, AgentResult, AgentStreamChunk, AgentStreamResult, ToolDefinition, AgentTool, ToolFactory, ToolsConfig, ToolHandlerArgs, DefaultToolContext, InterruptResponse, ToolCallRecord, ModelConfig, StreamOptions, Message, Conversation, JSONValue, TokenUsage } from './types.js';