Phase 0: CDK stack + Lambdas + AgentCore Runtime 1 scaffold

- CDK TypeScript stack (AgentClawStack):
  - S3 workspace bucket with BucketDeployment seed
  - DynamoDB session-store (actor_id → session_id, TTL)
  - SQS FIFO message queue (serialized per actor)
  - Lambda: tg-ingest (webhook validation, typing action, SQS enqueue)
  - Lambda: agent-runner (SQS → InvokeAgentRuntime, session management)
  - API Gateway HTTP: POST /telegram → tg-ingest
  - AgentCore Runtime 1 IAM execution role
  - CDK outputs: WebhookUrl, WorkspaceBucketName, Runtime1RoleArn

- Runtime 1 (Python + Strands + BedrockAgentCoreApp):
  - main.py: entrypoint, Strands agent, tool wiring
  - channels/: ChannelAdapter Protocol + TelegramAdapter (decoupled)
  - tools/: web_search (Brave), web_fetch, read/write_workspace_file, send_message
  - prompt_builder.py: loads SOUL.md/AGENTS.md/USER.md from S3 (cached)

- Lambdas:
  - tg-ingest: validate X-Telegram-Bot-Api-Secret-Token, send typing, enqueue FIFO
  - agent-runner: session lookup/create in DDB, bundle batched messages, InvokeAgentRuntime

- workspace/: seed files (SOUL.md, AGENTS.md, USER.md, IDENTITY.md, HEARTBEAT.md)

NOTE: AgentCore Runtime 1 creation via CfnResource deferred — deploy CDK first,
create runtime manually with the output Role ARN, then redeploy with runtime1Arn context param.
This commit is contained in:
daniel
2026-05-04 09:00:23 -05:00
parent 6ee2890831
commit 38905bb1e9
24 changed files with 1429 additions and 0 deletions

203
cdk/lib/agent-claw-stack.ts Normal file
View File

@@ -0,0 +1,203 @@
import * as cdk from 'aws-cdk-lib';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as sqs from 'aws-cdk-lib/aws-sqs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigatewayv2 from 'aws-cdk-lib/aws-apigatewayv2';
import * as apigatewayv2integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
import { SqsEventSource } from 'aws-cdk-lib/aws-lambda-event-sources';
import { Construct } from 'constructs';
import * as path from 'path';
export class AgentClawStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// ── Context parameters ─────────────────────────────────────────────────
const telegramBotTokenSecretArn = this.node.tryGetContext('telegramBotTokenSecretArn') as string | undefined;
const braveApiKeySecretArn = this.node.tryGetContext('braveApiKeySecretArn') as string | undefined;
const existingWorkspaceBucketName = this.node.tryGetContext('workspaceBucketName') as string | undefined;
const runtime1Arn = this.node.tryGetContext('runtime1Arn') as string | undefined;
if (!telegramBotTokenSecretArn) {
throw new Error('Context param required: telegramBotTokenSecretArn');
}
if (!braveApiKeySecretArn) {
throw new Error('Context param required: braveApiKeySecretArn');
}
// ── Secrets (reference existing) ───────────────────────────────────────
const botTokenSecret = secretsmanager.Secret.fromSecretCompleteArn(
this, 'TelegramBotToken', telegramBotTokenSecretArn
);
const braveApiKeySecret = secretsmanager.Secret.fromSecretCompleteArn(
this, 'BraveApiKey', braveApiKeySecretArn
);
// ── S3 workspace bucket ────────────────────────────────────────────────
const workspaceBucket = existingWorkspaceBucketName
? s3.Bucket.fromBucketName(this, 'WorkspaceBucket', existingWorkspaceBucketName)
: new s3.Bucket(this, 'WorkspaceBucket', {
bucketName: `agent-claw-workspace-${this.account}`,
removalPolicy: cdk.RemovalPolicy.RETAIN,
versioned: false,
encryption: s3.BucketEncryption.S3_MANAGED,
});
// Seed workspace files on deploy (only if bucket was created by us)
if (!existingWorkspaceBucketName) {
new s3deploy.BucketDeployment(this, 'WorkspaceFiles', {
sources: [s3deploy.Source.asset(path.join(__dirname, '../../workspace'))],
destinationBucket: workspaceBucket as s3.Bucket,
});
}
// ── DynamoDB session store ─────────────────────────────────────────────
const sessionTable = new dynamodb.Table(this, 'SessionStore', {
tableName: 'agent-claw-sessions',
partitionKey: { name: 'actor_id', type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
timeToLiveAttribute: 'ttl',
removalPolicy: cdk.RemovalPolicy.RETAIN,
});
// ── SQS FIFO message queue ─────────────────────────────────────────────
const messageQueue = new sqs.Queue(this, 'MessageQueue', {
queueName: 'agent-claw-messages.fifo',
fifo: true,
contentBasedDeduplication: false,
visibilityTimeout: cdk.Duration.seconds(900),
receiveMessageWaitTime: cdk.Duration.seconds(20),
});
// ── Lambda: tg-ingest ─────────────────────────────────────────────────
const tgIngestFn = new lambda.Function(this, 'TgIngest', {
functionName: 'agent-claw-tg-ingest',
runtime: lambda.Runtime.PYTHON_3_12,
handler: 'handler.handler',
code: lambda.Code.fromAsset(path.join(__dirname, '../../src/lambdas/tg-ingest')),
timeout: cdk.Duration.seconds(10),
memorySize: 128,
environment: {
MESSAGE_QUEUE_URL: messageQueue.queueUrl,
TELEGRAM_BOT_TOKEN_SECRET_ARN: telegramBotTokenSecretArn,
TELEGRAM_WEBHOOK_SECRET: '', // set via SSM or direct env after deploy
},
});
messageQueue.grantSendMessages(tgIngestFn);
botTokenSecret.grantRead(tgIngestFn);
// ── Lambda: agent-runner ───────────────────────────────────────────────
const agentRunnerFn = new lambda.Function(this, 'AgentRunner', {
functionName: 'agent-claw-agent-runner',
runtime: lambda.Runtime.PYTHON_3_12,
handler: 'handler.handler',
code: lambda.Code.fromAsset(path.join(__dirname, '../../src/lambdas/agent-runner')),
timeout: cdk.Duration.seconds(900),
memorySize: 256,
environment: {
SESSION_TABLE_NAME: sessionTable.tableName,
WORKSPACE_BUCKET_NAME: workspaceBucket.bucketName,
TELEGRAM_BOT_TOKEN_SECRET_ARN: telegramBotTokenSecretArn,
BRAVE_API_KEY_SECRET_ARN: braveApiKeySecretArn,
RUNTIME_1_ARN: runtime1Arn ?? 'PLACEHOLDER_SET_AFTER_RUNTIME_DEPLOY',
AWS_REGION_NAME: 'us-east-1',
},
});
sessionTable.grantReadWriteData(agentRunnerFn);
workspaceBucket.grantRead(agentRunnerFn);
botTokenSecret.grantRead(agentRunnerFn);
braveApiKeySecret.grantRead(agentRunnerFn);
messageQueue.grantConsumeMessages(agentRunnerFn);
// AgentCore invoke permission
agentRunnerFn.addToRolePolicy(new iam.PolicyStatement({
actions: ['bedrock-agentcore:InvokeAgentRuntime'],
resources: ['*'],
}));
// SQS event source
agentRunnerFn.addEventSource(new SqsEventSource(messageQueue, {
batchSize: 10,
enabled: true,
}));
// ── API Gateway HTTP ───────────────────────────────────────────────────
const httpApi = new apigatewayv2.HttpApi(this, 'WebhookApi', {
apiName: 'agent-claw-webhook',
});
httpApi.addRoutes({
path: '/telegram',
methods: [apigatewayv2.HttpMethod.POST],
integration: new apigatewayv2integrations.HttpLambdaIntegration(
'TgIngestIntegration', tgIngestFn
),
});
// ── AgentCore Runtime 1 ────────────────────────────────────────────────
// NOTE: AgentCore CDK L2 constructs are in preview. Using CfnResource.
// The runtime1Arn output below needs to be fed back as context param
// on subsequent deploys so agent-runner can invoke it.
// IAM execution role for Runtime 1
const runtime1Role = new iam.Role(this, 'Runtime1Role', {
assumedBy: new iam.ServicePrincipal('bedrock-agentcore.amazonaws.com'),
description: 'Execution role for agent-claw Runtime 1 (main assistant)',
});
runtime1Role.addToPolicy(new iam.PolicyStatement({
actions: [
'bedrock:InvokeModel',
'bedrock:InvokeModelWithResponseStream',
],
resources: ['*'],
}));
workspaceBucket.grantRead(runtime1Role);
botTokenSecret.grantRead(runtime1Role);
braveApiKeySecret.grantRead(runtime1Role);
runtime1Role.addToPolicy(new iam.PolicyStatement({
actions: [
'bedrock-agentcore:CreateEvent',
'bedrock-agentcore:ListEvents',
'bedrock-agentcore:RetrieveMemoryRecords',
],
resources: ['*'],
}));
// AgentCore Runtime 1 resource (CfnResource — L2 in preview)
// CodeZip: packages src/runtime-1/ as a zip and uploads to S3
// TODO: Replace with L2 construct when aws-cdk-lib includes it stable
// For now, the runtime ARN must be created manually or via CLI after first synth
// and fed back as context param runtime1Arn.
// ── Outputs ────────────────────────────────────────────────────────────
new cdk.CfnOutput(this, 'WebhookUrl', {
value: `${httpApi.url}telegram`,
description: 'Register this URL with Telegram BotFather as webhook endpoint',
});
new cdk.CfnOutput(this, 'WorkspaceBucketName', {
value: workspaceBucket.bucketName,
description: 'S3 bucket containing agent workspace files',
});
new cdk.CfnOutput(this, 'SessionTableName', {
value: sessionTable.tableName,
description: 'DynamoDB table for session mapping',
});
new cdk.CfnOutput(this, 'MessageQueueUrl', {
value: messageQueue.queueUrl,
description: 'SQS FIFO queue for incoming messages',
});
new cdk.CfnOutput(this, 'Runtime1RoleArn', {
value: runtime1Role.roleArn,
description: 'IAM execution role ARN for AgentCore Runtime 1',
});
}
}