import * as cdk from 'aws-cdk-lib'; import { Construct } from 'constructs'; import * as iam from 'aws-cdk-lib/aws-iam'; import * as lambda from 'aws-cdk-lib/aws-lambda'; import * as lambdaNodejs from 'aws-cdk-lib/aws-lambda-nodejs'; import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; import * as bedrock from 'aws-cdk-lib/aws-bedrock'; import * as path from 'path'; export interface BedrockAgentProps { accountId: string; region: string; knowledgeBaseId: string; knowledgeBaseArn: string; } export class BedrockAgentConstruct extends Construct { public readonly agent: bedrock.CfnAgent; public readonly agentAlias: bedrock.CfnAgentAlias; public readonly qboLambda: lambdaNodejs.NodejsFunction; public readonly mapsLambda: lambdaNodejs.NodejsFunction; // Foundation model used for orchestration private static readonly MODEL_ID = 'anthropic.claude-3-5-sonnet-20241022-v2:0'; constructor(scope: Construct, id: string, props: BedrockAgentProps) { super(scope, id); // Reference secrets created manually in Secrets Manager (see README for structure) const qboSecret = secretsmanager.Secret.fromSecretNameV2( this, 'QBOSecret', 'seahaven/qbo/oauth', ); const mapsSecret = secretsmanager.Secret.fromSecretNameV2( this, 'MapsSecret', 'seahaven/google/maps-api-key', ); const bundling: lambdaNodejs.BundlingOptions = { externalModules: ['@aws-sdk/*'], minify: true, sourceMap: false, }; // ── QBO vendor lookup action group Lambda ───────────────────────────────── this.qboLambda = new lambdaNodejs.NodejsFunction(this, 'QBOLookupFn', { functionName: 'seahaven-qbo-lookup', entry: path.join(__dirname, '../../lambda/qbo-lookup/index.ts'), handler: 'handler', runtime: lambda.Runtime.NODEJS_22_X, timeout: cdk.Duration.seconds(30), memorySize: 256, environment: { QBO_SECRET_ARN: qboSecret.secretArn }, bundling, }); qboSecret.grantRead(this.qboLambda); // ── Google Maps lookup action group Lambda ──────────────────────────────── this.mapsLambda = new lambdaNodejs.NodejsFunction(this, 'MapsLookupFn', { functionName: 'seahaven-maps-lookup', entry: path.join(__dirname, '../../lambda/maps-lookup/index.ts'), handler: 'handler', runtime: lambda.Runtime.NODEJS_22_X, timeout: cdk.Duration.seconds(30), memorySize: 256, environment: { MAPS_SECRET_ARN: mapsSecret.secretArn }, bundling, }); mapsSecret.grantRead(this.mapsLambda); // ── Bedrock Agent execution role ────────────────────────────────────────── const agentRole = new iam.Role(this, 'AgentRole', { roleName: 'AmazonBedrockExecutionRoleForAgents_seahaven', assumedBy: new iam.ServicePrincipal('bedrock.amazonaws.com', { conditions: { StringEquals: { 'aws:SourceAccount': props.accountId }, ArnLike: { 'aws:SourceArn': `arn:aws:bedrock:${props.region}:${props.accountId}:agent/*`, }, }, }), }); agentRole.addToPolicy(new iam.PolicyStatement({ actions: ['bedrock:InvokeModel'], resources: [ `arn:aws:bedrock:${props.region}::foundation-model/${BedrockAgentConstruct.MODEL_ID}`, ], })); agentRole.addToPolicy(new iam.PolicyStatement({ actions: ['bedrock:Retrieve'], resources: [props.knowledgeBaseArn], })); // Allow Bedrock to invoke the action group Lambdas this.qboLambda.addPermission('BedrockInvokeQBO', { principal: new iam.ServicePrincipal('bedrock.amazonaws.com'), sourceAccount: props.accountId, }); this.mapsLambda.addPermission('BedrockInvokeMaps', { principal: new iam.ServicePrincipal('bedrock.amazonaws.com'), sourceAccount: props.accountId, }); // ── Agent instruction (system prompt) ──────────────────────────────────── const instruction = `You are the Sea Haven Industries internal assistant, accessible to employees via Slack direct message. Sea Haven is a facility services company. You help employees with: 1. Finding vendors and contractors for facility work 2. Company policies, SOPs, and SA8000 social accountability compliance questions 3. Employee handbook questions ## Vendor Query Rules — STRICTLY follow this priority order: Step 1: ALWAYS call QBO_Lookup.search_vendors first. This searches our QuickBooks Online account for existing, vetted vendors we already have a relationship with. Step 2: If QBO_Lookup returns no suitable match, search the knowledge base for approved vendor documentation or lists. Step 3: ONLY if both QBO and the knowledge base return nothing suitable should you call Google_Maps_Lookup.search_nearby_vendors to find new options. When presenting vendor results: - QBO vendors: include name, trade/specialty, phone, email, and last updated date - Knowledge base vendors: cite the source document - Google Maps vendors: clearly label these as NEW (not yet vetted), include name, address, phone, and rating ## General Questions: Use the knowledge base for policy, SOP, SA8000 compliance, and handbook questions. Cite the specific document or section when possible. If the information is not in the knowledge base, say so clearly — do not guess. Keep responses concise, professional, and actionable.`; // ── CfnAgent ────────────────────────────────────────────────────────────── this.agent = new bedrock.CfnAgent(this, 'Agent', { agentName: 'seahaven-assistant', description: 'Sea Haven Industries internal Slack assistant', agentResourceRoleArn: agentRole.roleArn, foundationModel: BedrockAgentConstruct.MODEL_ID, instruction, idleSessionTtlInSeconds: 1800, // 30 min — matches the processor's session window knowledgeBases: [ { knowledgeBaseId: props.knowledgeBaseId, description: 'Sea Haven internal documents: SOPs, SA8000 compliance docs, employee handbook, approved vendor lists', knowledgeBaseState: 'ENABLED', }, ], actionGroups: [ { actionGroupName: 'QBO_Lookup', description: 'Search QuickBooks Online for existing Sea Haven vendors by trade or name', actionGroupState: 'ENABLED', actionGroupExecutor: { lambda: this.qboLambda.functionArn }, functionSchema: { functions: [ { name: 'search_vendors', description: 'Search QuickBooks Online for existing vendors by trade category or company name. Returns contact info and outstanding balance.', parameters: { trade: { type: 'string', description: 'Trade or service type to search for (e.g., "plumbing", "electrical", "HVAC", "janitorial", "landscaping")', required: false, }, name: { type: 'string', description: 'Vendor company name or partial name to search for', required: false, }, }, }, ], }, }, { actionGroupName: 'Google_Maps_Lookup', description: 'Search Google Maps Places for vendors near a location. Use ONLY when QBO and the knowledge base have no suitable vendor.', actionGroupState: 'ENABLED', actionGroupExecutor: { lambda: this.mapsLambda.functionArn }, functionSchema: { functions: [ { name: 'search_nearby_vendors', description: 'Search Google Maps Places for local vendors and contractors by trade type and location.', parameters: { trade: { type: 'string', description: 'Trade or service type to search for (e.g., "plumbing contractor", "electrician")', required: true, }, location: { type: 'string', description: 'City, address, or area to search near (e.g., "Seattle WA", "Chicago IL")', required: true, }, }, }, ], }, }, ], }); // ── Agent alias (stable ARN for invocations) ────────────────────────────── // Creating the alias also triggers agent preparation in CloudFormation. this.agentAlias = new bedrock.CfnAgentAlias(this, 'AgentAlias', { agentId: this.agent.attrAgentId, agentAliasName: 'live', description: 'Production alias — seahaven-assistant', }); } }