Select your cookie preferences

We use essential cookies and similar tools that are necessary to provide our site and services. We use performance cookies to collect anonymous statistics, so we can understand how customers use our site and make improvements. Essential cookies cannot be deactivated, but you can choose “Customize” or “Decline” to decline performance cookies.

If you agree, AWS and approved third parties will also use cookies to provide useful site features, remember your preferences, and display relevant content, including relevant advertising. To accept or decline all non-essential cookies, choose “Accept” or “Decline.” To make more detailed choices, choose “Customize.”

AWS Logo
Menu
Agentic Code Interpreter with AWS CDK

Agentic Code Interpreter with AWS CDK

In this article, we'll walk you through building a skeleton CDK L2 construct for Amazon Bedrock Agent for its code interpreter support.

Pahud Hsieh
Amazon Employee
Published Sep 4, 2024
Last Modified Sep 11, 2024
One of the most exciting news last month was this tweet from Adam Seligman:
And this inspiring video from Mike Chambers:
It was really great! But unfortunately it could not be deployed with AWS CDK and that really caught my interest. Why not build a CDK construct for the whole amazing stuff?

What customers want

Customers want something that when they do this, it just works! Well, probably with very minimal required properties but yes, the most important user experience is it just works!
1
new Agent();
This means:
When I cdk deploy I know it just works! Hassle-free!
If the Agent needs any IAM role and policies, I don't really care about it, just generate a new role with very scoped policies with least privileges.
Set a default instruction text for the Agent unless I provide specific instructions.
If the Agent needs to enable some "public preview" or new features which is not available in CloudFormation yet, well, I don't care about it. Just enable it with SDK call from a custom resource. I don't care about how to write a custom resource. It should just work. I don't care too much details behind it.
If it requires a Vpc, it should create a well-architected one for me unless I specify an existing one.
If it requires subnet IDs, it should select the private subnets across multi-AZ for me with each AZ has at least one subnet.
It should expose everything I need such as agentId , agentArn or even agentStatus.
I can new Agent() as many as I can and they won't conflict.
It should come with some helpful static methods like addXxx() or grantXxx(). They are not mandatory but would be very helpful.
When I cdk destroy, all created Agents and everything from there should be just eliminated. Nothing would incur on my AWS billing after that.
I can contribute it to the public AWS CDK library or Construct Hub as a public library.
Image not found
Now, after watching this video, I figured out the general process to enable Amazon Bedrock Agent with CodeInterpreter support in this order as below:
  1. Create an Agent - no problem, we have bedrock.CfnAgent L1 construct.
  2. Create an IAM role with scoped policies and the least required privileges. This would take a while because I need to read the document. But hopefully I should just have to do it once.
  3. Create an ActionGroup that supports CodeInterpreter capabilities, which is currently in public preview. This would be a little challenging because CFN has not supported it yet. The ParentActionGroupSignature only supports AMAZON.UserInput at this moment. But that's fine, we have AwsCustomResource to the rescue.
  4. We need to wait for the Agent until it enters the NOT_PREPARED state before we can continue the next step as described in the video here. Well, this is a little bit challenging but let's see what's happening then.
  5. We have to "prepare" the Agent. Obviously, CloudFormation does not support that at this moment. The only option is AwsCustomResource. No big deal.
  6. Last but not least, we need to create an Alias for that Agent and we can retrieve the agentAliasId from there. The good news is that we have bedrock.CfnAgent L1 construct.
OK. So here is my framework off the top of my head. I literally told Amazon Q Developer my intention and it just generated the framework for me:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// We need an interface for our Agent L2 construct.
export interface IAgent extends IResource {}

// props for the Agent
export interface AgentProps {}

// the L2 construct
export class Agent extends cdk.Resource implements IAgent {
constructor(scope: Construct, id: string, props: AgentProps) {
super(scope, id);

// create the CfnAgent here
const resource = new bedrock.CfnAgent(...)

}
private createServiceRole(): iam.Role {...}
private createCustomResourceRole(): iam.Role {...}
private loadInstruction(): string {...}
private createCodeInterpreterActionGroup(): cr.AwsCustomResource {...}
private prepareAgent(agentId: string): cr.AwsCustomResource {...}
public addAlias(aliasName?: string) {...}

}
  • IAgent - This represents the "Agent". It could be a new one created with new Agent(); or an imported one by Agent.fromAgentAttributes(). We need to define what attributes or properties it should have. I checked the return values of AWS::Bedrock::Agent from Cloudformation document. I believe agentId, agentArn and agentVersion are mandatory and I decided to add agentStatus as optional.
1
2
3
4
5
6
export interface IAgent extends IResource {
readonly agentId: string;
readonly agentArn: string;
readonly agentStatus?: string;
readonly agentVersion: string;
}
  • AgentProps - This is the props when you new Agent(scope, id, props); We just need to figure out what are props are required from CFN's doc. Looking at all the properties from here, the good news is only AgentName is required. But we still can make it optional in CDK and generate a name when undefined so users can literally pass no props when they new Agent();. This would be amazing! I ended up with this interface that has an optional role only. If you pass an existing iam.IRole in as the ServiceRole, I would just pick up from there; otherwise, generate a scoped one for you.
1
2
3
export interface AgentProps {
role?: iam.IRole;
}
  • Agent - Now we need to implement the constructor of this class. My preferred pattern is like - if you pass some prop, I just use it, otherwise I generate one for you with a private construct method. This makes the main logic very clean. It looks like:
1
2
3
const resource = new bedrock.CfnAgent(this, 'Resource', {
agentResourceRoleArn: props?.role?.roleArn ?? this.createServiceRole().roleArn,
})
If you need something to pass to L1, check if that's available from the props and use it if defined otherwise use the private construct method to create a default one for you. This pattern makes your constructor very clean and easy to read.
After you create the resource using the L1 construct, you need to assign the attributes that are defined in IAgent interface so we can reference the attributes like Arn or resource ID from there. In CDK , we use getResourceArnAttribute() to get the resource ARN. I ended up with all the assignments as below:
1
2
3
4
5
6
7
8
this.agentId = resource.attrAgentId;
this.agentArn = this.getResourceArnAttribute(resource.attrAgentArn, {
service: 'bedrock',
resource: 'agent',
resourceName: this.physicalName,
}),
this.agentStatus = resource.attrAgentStatus;
this.agentVersion = resource.attrAgentVersion;
  • this.createServiceRole() - This should be simply new iam.Role() with bedrock as the service principal and simply addToPolicy() with relevant actions and resources. I ended up with this:
1
2
3
4
5
6
7
8
9
10
11
private createServiceRole(): iam.Role {
const role = new iam.Role(this, 'ServiceRole', {
assumedBy: new iam.ServicePrincipal('bedrock.amazonaws.com'),
});
role.addToPolicy(new iam.PolicyStatement({
actions: ['bedrock:InvokeModel'],
resources: [`arn:aws:bedrock:${Stack.of(this).region}::foundation-model/${this.model.modelId}`],
}));

return role;
}
  • this.createCustomResourceRole() - This role is for AwsCustomResource that would create the action group and prepare agent behind the scene. This is not perfect and well-scoped because we use * in the resources. We could optimize it later.
1
2
3
4
5
6
7
8
9
10
private createCustomResourceRole(): iam.Role {
const role = new iam.Role(this, 'CustomResourceRole', {
assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
});
role.addToPolicy(new iam.PolicyStatement({
actions: ['bedrock:CreateAgentActionGroup', 'bedrock:PrepareAgent'],
resources: ['*'],
}));
return role;
}
  • this.loadInstruction() - We need a method to read in the instructions from an external file like instructions.txt. This method allows us to pass it to the L1 with instruction: this.loadInstruction() Amazon Q literally generated this function for me. Looks awesome!
1
2
3
4
5
6
7
8
9
10
private loadInstruction(): string {
try {
const filePath = path.join(__dirname, 'instruction.txt');
const content = fs.readFileSync(filePath, 'utf-8');
return content.trim();
} catch (error) {
console.error('Error loading instruction:', error);
return 'You are a helpful assistant.'; // Default instruction
}
}
  • this.createCodeInterpreterActionGroup() - As mentioned, cloudformation does not support codeinterpreter at this moment. This method essentially invokes a SDK call to create an ActionGroup wtih AMAZON.CodeInterpreter parentActionGroupSignature for us. Easy peasy!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private createCodeInterpreterActionGroup(id: string): cr.AwsCustomResource {
const resource = new cr.AwsCustomResource(this, id, {
resourceType: 'Custom::BedrockAgentActionGroup',
onCreate: {
service: '@aws-sdk/client-bedrock-agent',
action: 'CreateAgentActionGroupCommand',
parameters: {
actionGroupName: id,
agentId: this.agentId,
actionGroupState: 'ENABLED',
agentVersion: 'DRAFT',
parentActionGroupSignature: 'AMAZON.CodeInterpreter',
},
physicalResourceId: cr.PhysicalResourceId.of(id),
},
role: this.customResourceRole,
});
// custom resource must be created until the default policy of the role is ready
resource.node.addDependency(this.customResourceRole.node.tryFindChild('DefaultPolicy') as iam.CfnPolicy)
return resource;
}
Please note the addDependency() line, this custom resource could be provisioned before the default policy of the role is created and that could cause an error. The addDependency() ensures the custom resource would only be created after the default policy of the role is ready. This trick is very important.
  • this.prepareAgent() - Prepare the agent. Yet another custom resource. No fancy.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private prepareAgent(agentId: string): cr.AwsCustomResource {
const resource = new cr.AwsCustomResource(this, 'prepare', {
resourceType: 'Custom::BedrockAgentActionGroup',
onCreate: {
service: '@aws-sdk/client-bedrock-agent',
action: 'PrepareAgentCommand',
parameters: {
agentId,
},
physicalResourceId: cr.PhysicalResourceId.of('prepare'),
},
role: this.customResourceRole,
});
// custom resource must be created until the default policy of the role is ready
resource.node.addDependency(this.customResourceRole.node.tryFindChild('DefaultPolicy') as iam.CfnPolicy)
return resource;
}
  • this.addAlias() - Last but not least, I decided to define a public addAlias method for it so the expected user experience would be like:
1
2
3
4
5
// create an Agent
const agent = new Agent();

// add an Alias for this agent
agent.addAlias('my-alias-name');
The method looks like:
1
2
3
4
5
6
7
8
public addAlias(aliasName?: string) {
const cfnalias = new bedrock.CfnAgentAlias(this, `${this.id}alias`, {
agentId: this.agentId,
agentAliasName: aliasName ?? `${this.id}alias`,
})
cfnalias.node.addDependency(...this.dependable)
return cfnalias;
}
That's it! Now my final code looks like this. Please note I have also modeled the ActionGroup with its interface IActionGroup but turned out not necessary in this use case. You can just ignore them for now.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
export interface ActionGroupExecutor {
readonly customControl?: string;
readonly lambda?: lambda.IFunction;
}

export interface AgentApiSchema {
readonly payload?: string;
readonly bucket?: s3.IBucket;
readonly key?: string;
}

export interface FunctionParameters {
readonly description?: string;
readonly required?: boolean;
readonly type: string;
}

export interface AgentFunction {
readonly description?: string;
readonly functioName: string;
readonly parameters?: FunctionParameters;
}

export interface FunctionSchema {
readonly function: AgentFunction[];
}
export interface IActionGroup {
readonly executor?: ActionGroupExecutor;
readonly groupName: string;
readonly groupState?: string;
readonly apiSchema?: AgentApiSchema;
readonly description?: string;
readonly functionSchema?: FunctionSchema;
readonly parentActionGroupSignature?: string;
readonly skipResourceInUseCheckOnDelete?: boolean
}

export interface ActionGroupProps {
readonly executor?: ActionGroupExecutor;
readonly groupName: string;
readonly groupState?: string;
readonly apiSchema?: AgentApiSchema;
readonly description?: string;
readonly functionSchema?: FunctionSchema;
readonly parentActionGroupSignature?: string;
readonly skipResourceInUseCheckOnDelete?: boolean
}

export class ActionGroup implements IActionGroup {
readonly executor?: ActionGroupExecutor;
readonly groupName: string;
readonly groupState?: string;
readonly apiSchema?: AgentApiSchema;
readonly description?: string;
readonly functionSchema?: FunctionSchema;
readonly parentActionGroupSignature?: string;
readonly skipResourceInUseCheckOnDelete?: boolean

constructor(scope: Construct, props: ActionGroupProps) {
this.executor = props.executor;
this.groupName = props.groupName;
this.groupState = props.groupState;
this.apiSchema = props.apiSchema;
this.description = props.description;
this.functionSchema = props.functionSchema;
this.parentActionGroupSignature = props.parentActionGroupSignature;
this.skipResourceInUseCheckOnDelete = props.skipResourceInUseCheckOnDelete;
}
}

export interface IAgent extends IResource {
readonly agentId: string;
readonly agentArn: string;
readonly agentStatus?: string;
readonly agentVersion: string;
}

export interface AgentProps {
role?: iam.IRole;
actionGroups?: IActionGroup[];
}

// create an AIAgent Stack
export class Agent extends Resource implements IAgent {
private readonly model:bedrock.FoundationModelIdentifier
public readonly agentId: string;
public readonly agentArn: string;
public readonly agentStatus?: string;
public readonly agentVersion: string;
public readonly id: string;
public readonly customResourceRole: iam.IRole;
/**
* dependable resources of this Agent
*/

public readonly dependable: IDependable[] = [];
constructor(scope: Construct, id: string, props?: AgentProps) {
super(scope, id);

this.id = id;
this.model = bedrock.FoundationModelIdentifier.ANTHROPIC_CLAUDE_3_SONNET_20240229_V1_0;
const resource = new bedrock.CfnAgent(this, 'Resource', {
agentName: `${id}agent`,
foundationModel: this.model.modelId,
agentResourceRoleArn: props?.role?.roleArn ?? this.createServiceRole().roleArn,
instruction: this.loadInstruction(),
actionGroups: props?.actionGroups?.map((actionGroup) => ({
actionGroupName: actionGroup.groupName,
actionGroupExecutor: {
lambda: actionGroup.executor?.lambda?.functionArn,
customControl: actionGroup.executor?.customControl,
},
apiSchema: actionGroup.apiSchema,
description: actionGroup.description,
functionSchema: actionGroup.functionSchema ? {
functions: actionGroup.functionSchema.function.map((agentFunction) => ({
description: agentFunction.description,
name: agentFunction.functioName,
parameters: {
required: agentFunction.parameters?.required,
type: agentFunction.parameters?.type,
description: agentFunction.parameters?.description,
}
}))
} : undefined,
parentActionGroupSignature: actionGroup.parentActionGroupSignature,
skipResourceInUseCheckOnDelete: actionGroup.skipResourceInUseCheckOnDelete,
})) as bedrock.CfnAgent.AgentActionGroupProperty[],
});

this.agentId = resource.attrAgentId;
this.agentArn = this.getResourceArnAttribute(resource.attrAgentArn, {
service: 'bedrock',
resource: 'agent',
resourceName: this.physicalName,
}),
this.agentStatus = resource.attrAgentStatus;
this.agentVersion = resource.attrAgentVersion;

new CfnOutput(this, 'AgentId', { value: this.agentId });
this.customResourceRole = this.createCustomResourceRole();

// create action group
const ag = this.createCodeInterpreterActionGroup('CodeInterpreterActionGroup');
// prepare the agent
const preparation = this.prepareAgent(this.agentId);
preparation.node.addDependency(ag);
this.dependable.push(preparation)
}
private createCustomResourceRole(): iam.Role {
const role = new iam.Role(this, 'CustomResourceRole', {
assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
});
role.addToPolicy(new iam.PolicyStatement({
actions: ['bedrock:CreateAgentActionGroup', 'bedrock:PrepareAgent'],
resources: ['*'],
}));
return role;
}
private loadInstruction(): string {
try {
const filePath = path.join(__dirname, 'instruction.txt');
const content = fs.readFileSync(filePath, 'utf-8');
return content.trim();
} catch (error) {
console.error('Error loading instruction:', error);
return 'You are a helpful assistant.'; // Default instruction
}
}
private createServiceRole(): iam.Role {
const role = new iam.Role(this, 'ServiceRole', {
assumedBy: new iam.ServicePrincipal('bedrock.amazonaws.com'),
});
role.addToPolicy(new iam.PolicyStatement({
actions: ['bedrock:InvokeModel'],
resources: [`arn:aws:bedrock:${Stack.of(this).region}::foundation-model/${this.model.modelId}`],
}));

return role;
}
private createCodeInterpreterActionGroup(id: string): cr.AwsCustomResource {
const resource = new cr.AwsCustomResource(this, id, {
resourceType: 'Custom::BedrockAgentActionGroup',
onCreate: {
service: '@aws-sdk/client-bedrock-agent',
action: 'CreateAgentActionGroupCommand',
parameters: {
actionGroupName: id,
agentId: this.agentId,
actionGroupState: 'ENABLED',
agentVersion: 'DRAFT',
parentActionGroupSignature: 'AMAZON.CodeInterpreter',
},
physicalResourceId: cr.PhysicalResourceId.of(id),
},
role: this.customResourceRole,
});
// custom resource must be created until the default policy of the role is ready
resource.node.addDependency(this.customResourceRole.node.tryFindChild('DefaultPolicy') as iam.CfnPolicy)
return resource;
}
private prepareAgent(agentId: string): cr.AwsCustomResource {
const resource = new cr.AwsCustomResource(this, 'prepare', {
resourceType: 'Custom::BedrockAgentActionGroup',
onCreate: {
service: '@aws-sdk/client-bedrock-agent',
action: 'PrepareAgentCommand',
parameters: {
agentId,
},
physicalResourceId: cr.PhysicalResourceId.of('prepare'),
},
role: this.customResourceRole,
});
// custom resource must be created until the default policy of the role is ready
resource.node.addDependency(this.customResourceRole.node.tryFindChild('DefaultPolicy') as iam.CfnPolicy)
return resource;
}
public addAlias(aliasName?: string) {
const cfnalias = new bedrock.CfnAgentAlias(this, `${this.id}alias`, {
agentId: this.agentId,
agentAliasName: aliasName ?? `${this.id}alias`,
})
cfnalias.node.addDependency(...this.dependable)
return cfnalias;
}
}
OK. Now it's time to verify the "it-just-works" developer experience.
1
2
3
4
5
6
7
8
9
10
11
12
const app = new cdk.App();

const stack = new Stack(app, 'dummy-stack');

// create an agent that supports code interpreter
const agent = new Agent(stack, 'my-agent');

// create an alias for the agent
const cfnalias = agent.addAlias();

// output the aliasId
new cdk.CfnOutput(stack, 'AgentAliasId', { value: cfnalias.attrAgentAliasId });
Let's check what would be created using cdk diff
$ npx cdk diff
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Resources
[+] AWS::IAM::Role my-agent/ServiceRole myagentServiceRoleB7D0824C
[+] AWS::IAM::Policy my-agent/ServiceRole/DefaultPolicy myagentServiceRoleDefaultPolicyA6E2267C
[+] AWS::Bedrock::Agent my-agent myagentE3D29236
[+] AWS::IAM::Role my-agent/CustomResourceRole myagentCustomResourceRole2B6858D3
[+] AWS::IAM::Policy my-agent/CustomResourceRole/DefaultPolicy myagentCustomResourceRoleDefaultPolicyE0E266F3
[+] Custom::BedrockAgentActionGroup my-agent/CodeInterpreterActionGroup/Resource myagentCodeInterpreterActionGroupE39538C0
[+] Custom::BedrockAgentActionGroup my-agent/prepare/Resource myagentprepare920C7AFE
[+] AWS::Bedrock::AgentAlias my-agent/my-agentalias myagentmyagentalias2E256E2B
[+] AWS::Lambda::Function AWS679f53fac002430cb0da5b7982bd2287 AWS679f53fac002430cb0da5b7982bd22872D164C4C

Outputs
[+] Output my-agent/AgentId myagentAgentId10E55C5B: {"Value":{"Fn::GetAtt":["myagentE3D29236","AgentId"]}}
[+] Output AgentAliasId AgentAliasId: {"Value":{"Fn::GetAtt":["myagentmyagentalias2E256E2B","AgentAliasId"]}}
This is great we are creating two custom resources but only one Lambda Singeleton Function and a shared IAM role for them. Awesome!
Let's deploy it!
$ npx cdk deploy
1
2
3
4
5
6
7
8

✅ dummy-stack

✨ Deployment time: 74.58s

Outputs:
dummy-stack.AgentAliasId = DXGB8QQBEO
dummy-stack.myagentAgentId10E55C5B = TYT1Y2XXZR
It deployed with all my required info in the Outputs. Awesome!
I will share more about how I validate it using a Dunkin Donuts real-life use case in the next blog post but I'd like to wrap it up with some key takeaways:

Key Take-aways

  1. Build your "It just works" developer experience with a skeleton L2 construct.
  2. A skeleton L2 construct is a MVP(Minimum Viable Product) of a CDK L2 construct that only supports mandatory props with very clear and clean definition that could be extended in future development iteration. It should be built with AI like Amazon Q Developer in your IDE and typically could be drafted within a couple of minutes.
  3. Define your private and public construct helper methods to separate them from your constructor code logic. This makes your code clean and easy to read.
  4. Use the props.foo ?? this.createFoo() pattern.
  5. Don't hesitate to use AwsCustomResource when necessary.
I hope you find this blog post useful and enjoy the journey building your own skeleton L2 constructs to help you onboard any new AWS services or features like this.
Enjoy building GenAI with CDK on AWS!
 

Further Reading:

 

Any opinions in this post are those of the individual author and may not reflect the opinions of AWS.

Comments

Log in to comment