From a770794e6ccddf0f093e76d6e4badd563ec229f9 Mon Sep 17 00:00:00 2001 From: Pete Davis Date: Wed, 11 Mar 2026 12:22:43 +0000 Subject: [PATCH 01/19] feat: add AppSync Events + Lambda + AgentCore real-time chat pattern --- .../.gitignore | 11 + appsync-events-lambda-agnetcore-cdk/README.md | 60 ++++ .../agents/chat/Dockerfile | 21 ++ .../agents/chat/entrypoint.py | 83 ++++++ .../agents/chat/requirements.txt | 3 + appsync-events-lambda-agnetcore-cdk/app.py | 23 ++ appsync-events-lambda-agnetcore-cdk/cdk.json | 103 +++++++ .../cdk/__init__.py | 0 .../cdk/cdk_stack.py | 39 +++ .../cdk/constructs/__init__.py | 1 + .../cdk/constructs/agent_invoker.py | 59 ++++ .../cdk/constructs/appsync_events.py | 52 ++++ .../cdk/constructs/chat_agent.py | 185 ++++++++++++ .../cdk/constructs/standard_lambda.py | 197 +++++++++++++ .../cdk/constructs/stream_relay.py | 53 ++++ .../example-pattern.json | 59 ++++ .../functions/agent_invoker/index.py | 85 ++++++ .../functions/agent_invoker/requirements.txt | 1 + .../functions/stream_relay/index.py | 163 +++++++++++ .../functions/stream_relay/requirements.txt | 1 + appsync-events-lambda-agnetcore-cdk/mise.toml | 57 ++++ .../requirements-dev.txt | 6 + .../requirements.txt | 2 + .../tests/__init__.py | 0 .../tests/integration/__init__.py | 1 + .../tests/integration/conftest.py | 155 ++++++++++ .../tests/integration/test_appsync_events.py | 265 ++++++++++++++++++ .../tests/unit/__init__.py | 0 .../tests/unit/conftest.py | 47 ++++ .../tests/unit/test_agent_invoker.py | 123 ++++++++ .../tests/unit/test_stream_relay.py | 123 ++++++++ 31 files changed, 1978 insertions(+) create mode 100644 appsync-events-lambda-agnetcore-cdk/.gitignore create mode 100644 appsync-events-lambda-agnetcore-cdk/README.md create mode 100644 appsync-events-lambda-agnetcore-cdk/agents/chat/Dockerfile create mode 100644 appsync-events-lambda-agnetcore-cdk/agents/chat/entrypoint.py create mode 100644 appsync-events-lambda-agnetcore-cdk/agents/chat/requirements.txt create mode 100644 appsync-events-lambda-agnetcore-cdk/app.py create mode 100644 appsync-events-lambda-agnetcore-cdk/cdk.json create mode 100644 appsync-events-lambda-agnetcore-cdk/cdk/__init__.py create mode 100644 appsync-events-lambda-agnetcore-cdk/cdk/cdk_stack.py create mode 100644 appsync-events-lambda-agnetcore-cdk/cdk/constructs/__init__.py create mode 100644 appsync-events-lambda-agnetcore-cdk/cdk/constructs/agent_invoker.py create mode 100644 appsync-events-lambda-agnetcore-cdk/cdk/constructs/appsync_events.py create mode 100644 appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_agent.py create mode 100644 appsync-events-lambda-agnetcore-cdk/cdk/constructs/standard_lambda.py create mode 100644 appsync-events-lambda-agnetcore-cdk/cdk/constructs/stream_relay.py create mode 100644 appsync-events-lambda-agnetcore-cdk/example-pattern.json create mode 100644 appsync-events-lambda-agnetcore-cdk/functions/agent_invoker/index.py create mode 100644 appsync-events-lambda-agnetcore-cdk/functions/agent_invoker/requirements.txt create mode 100644 appsync-events-lambda-agnetcore-cdk/functions/stream_relay/index.py create mode 100644 appsync-events-lambda-agnetcore-cdk/functions/stream_relay/requirements.txt create mode 100644 appsync-events-lambda-agnetcore-cdk/mise.toml create mode 100644 appsync-events-lambda-agnetcore-cdk/requirements-dev.txt create mode 100644 appsync-events-lambda-agnetcore-cdk/requirements.txt create mode 100644 appsync-events-lambda-agnetcore-cdk/tests/__init__.py create mode 100644 appsync-events-lambda-agnetcore-cdk/tests/integration/__init__.py create mode 100644 appsync-events-lambda-agnetcore-cdk/tests/integration/conftest.py create mode 100644 appsync-events-lambda-agnetcore-cdk/tests/integration/test_appsync_events.py create mode 100644 appsync-events-lambda-agnetcore-cdk/tests/unit/__init__.py create mode 100644 appsync-events-lambda-agnetcore-cdk/tests/unit/conftest.py create mode 100644 appsync-events-lambda-agnetcore-cdk/tests/unit/test_agent_invoker.py create mode 100644 appsync-events-lambda-agnetcore-cdk/tests/unit/test_stream_relay.py diff --git a/appsync-events-lambda-agnetcore-cdk/.gitignore b/appsync-events-lambda-agnetcore-cdk/.gitignore new file mode 100644 index 0000000000..548815fe42 --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/.gitignore @@ -0,0 +1,11 @@ +*.swp +package-lock.json +__pycache__ +.pytest_cache +.venv +*.egg-info + +# CDK asset staging directory +.cdk.staging +cdk.out +.kiro \ No newline at end of file diff --git a/appsync-events-lambda-agnetcore-cdk/README.md b/appsync-events-lambda-agnetcore-cdk/README.md new file mode 100644 index 0000000000..8e3a35d132 --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/README.md @@ -0,0 +1,60 @@ +# AWS Service 1 to AWS Service 2 + +This pattern << explain usage >> + +Learn more about this pattern at Serverless Land Patterns: << Add the live URL here >> + +Important: this application uses various AWS services and there are costs associated with these services after the Free Tier usage - please see the [AWS Pricing page](https://aws.amazon.com/pricing/) for details. You are responsible for any AWS costs incurred. No warranty is implied in this example. + +## Requirements + +* [Create an AWS account](https://portal.aws.amazon.com/gp/aws/developer/registration/index.html) if you do not already have one and log in. The IAM user that you use must have sufficient permissions to make necessary AWS service calls and manage AWS resources. +* [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) installed and configured +* [Git Installed](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) +* [AWS Serverless Application Model](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html) (AWS SAM) installed + +## Deployment Instructions + +1. Create a new directory, navigate to that directory in a terminal and clone the GitHub repository: + ``` + git clone https://github.com/aws-samples/serverless-patterns + ``` +1. Change directory to the pattern directory: + ``` + cd _patterns-model + ``` +1. From the command line, use AWS SAM to deploy the AWS resources for the pattern as specified in the template.yml file: + ``` + sam deploy --guided + ``` +1. During the prompts: + * Enter a stack name + * Enter the desired AWS Region + * Allow SAM CLI to create IAM roles with the required permissions. + + Once you have run `sam deploy --guided` mode once and saved arguments to a configuration file (samconfig.toml), you can use `sam deploy` in future to use these defaults. + +1. Note the outputs from the SAM deployment process. These contain the resource names and/or ARNs which are used for testing. + +## How it works + +Explain how the service interaction works. + +## Testing + +Provide steps to trigger the integration and show what should be observed if successful. + +## Cleanup + +1. Delete the stack + ```bash + aws cloudformation delete-stack --stack-name STACK_NAME + ``` +1. Confirm the stack has been deleted + ```bash + aws cloudformation list-stacks --query "StackSummaries[?contains(StackName,'STACK_NAME')].StackStatus" + ``` +---- +Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +SPDX-License-Identifier: MIT-0 diff --git a/appsync-events-lambda-agnetcore-cdk/agents/chat/Dockerfile b/appsync-events-lambda-agnetcore-cdk/agents/chat/Dockerfile new file mode 100644 index 0000000000..516c0ccd04 --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/agents/chat/Dockerfile @@ -0,0 +1,21 @@ +FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim + +WORKDIR /app + +ENV UV_SYSTEM_PYTHON=1 UV_COMPILE_BYTECODE=1 + +COPY chat/requirements.txt requirements.txt +RUN uv pip install --system --no-cache -r requirements.txt + +ARG AWS_REGION +ENV AWS_REGION=${AWS_REGION} +ENV DOCKER_CONTAINER=1 + +RUN useradd -m -u 1000 bedrock_agentcore +USER bedrock_agentcore + +EXPOSE 8080 + +COPY chat/ /app + +CMD ["opentelemetry-instrument", "python", "-m", "entrypoint"] diff --git a/appsync-events-lambda-agnetcore-cdk/agents/chat/entrypoint.py b/appsync-events-lambda-agnetcore-cdk/agents/chat/entrypoint.py new file mode 100644 index 0000000000..bbfb39ce78 --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/agents/chat/entrypoint.py @@ -0,0 +1,83 @@ +"""Chat agent entrypoint for AgentCore runtime. + +Pure streaming agent with S3-backed session persistence. +Yields response chunks via SSE. Has no knowledge of delivery +mechanism (AppSync, WebSocket, etc.). +""" + +import os +import logging + +from strands import Agent +from strands.models import BedrockModel +from strands.session.s3_session_manager import S3SessionManager +from bedrock_agentcore.runtime import BedrockAgentCoreApp + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = BedrockAgentCoreApp() + +MODEL_ID = os.environ.get("BEDROCK_MODEL_ID") +if not MODEL_ID: + raise ValueError("BEDROCK_MODEL_ID environment variable is required") + +REGION = os.environ.get("AWS_REGION", "eu-west-1") +SESSION_BUCKET = os.environ.get("SESSION_BUCKET") + +SYSTEM_PROMPT = """\ +You are a helpful chat assistant. Answer questions clearly and concisely. +""" + + +def _create_agent(session_id: str | None = None) -> Agent: + """Create a Strands agent with Bedrock model and optional session.""" + model = BedrockModel(model_id=MODEL_ID, region_name=REGION) + + kwargs = { + "system_prompt": SYSTEM_PROMPT, + "model": model, + } + + if session_id and SESSION_BUCKET: + kwargs["session_manager"] = S3SessionManager( + session_id=session_id, + bucket=SESSION_BUCKET, + region_name=REGION, + ) + + return Agent(**kwargs) + + +@app.entrypoint +async def invoke(payload=None): + """Stream agent response as SSE events.""" + if not payload: + yield {"status": "error", "error": "payload is required"} + return + + query = payload.get("content") or payload.get("prompt") + if not query: + yield {"status": "error", "error": "content or prompt is required"} + return + + session_id = payload.get("sessionId") + logger.info("Processing query: %s (session: %s)", query[:100], session_id) + + agent = _create_agent(session_id) + + async for event in agent.stream_async(query): + if "data" in event: + yield {"data": event["data"]} + elif "result" in event: + result = event["result"] + yield { + "result": { + "stop_reason": str(result.stop_reason), + "message": result.message, + }, + } + + +if __name__ == "__main__": + app.run() diff --git a/appsync-events-lambda-agnetcore-cdk/agents/chat/requirements.txt b/appsync-events-lambda-agnetcore-cdk/agents/chat/requirements.txt new file mode 100644 index 0000000000..26155e5dc8 --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/agents/chat/requirements.txt @@ -0,0 +1,3 @@ +strands-agents>=1.13.0 +bedrock-agentcore>=1.0.3 +aws-opentelemetry-distro diff --git a/appsync-events-lambda-agnetcore-cdk/app.py b/appsync-events-lambda-agnetcore-cdk/app.py new file mode 100644 index 0000000000..3ef497bb5e --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/app.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +import os + +import aws_cdk as cdk + +from cdk.cdk_stack import CdkStack + + +app = cdk.App() + +stack_name = app.node.try_get_context("stack_name") or "AppsyncLambdaAgentcore" + +region = os.environ.get("AWS_REGION") +if not region: + raise EnvironmentError("AWS_REGION environment variable must be set") + +CdkStack( + app, + stack_name, + env=cdk.Environment(region=region), +) + +app.synth() diff --git a/appsync-events-lambda-agnetcore-cdk/cdk.json b/appsync-events-lambda-agnetcore-cdk/cdk.json new file mode 100644 index 0000000000..be99e99033 --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/cdk.json @@ -0,0 +1,103 @@ +{ + "app": "python3 app.py", + "watch": { + "include": [ + "**" + ], + "exclude": [ + "README.md", + "cdk*.json", + "requirements*.txt", + "source.bat", + "**/__init__.py", + "**/__pycache__", + "tests" + ] + }, + "context": { + "stack_name": "AppsyncLambdaAgentcore", + "model_id": "eu.anthropic.claude-sonnet-4-20250514-v1:0", + "@aws-cdk/aws-signer:signingProfileNamePassedToCfn": true, + "@aws-cdk/aws-ecs-patterns:secGroupsDisablesImplicitOpenListener": true, + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:target-partitions": [ + "aws", + "aws-cn" + ], + "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, + "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, + "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, + "@aws-cdk/aws-iam:minimizePolicies": true, + "@aws-cdk/core:validateSnapshotRemovalPolicy": true, + "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, + "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, + "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, + "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, + "@aws-cdk/core:enablePartitionLiterals": true, + "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, + "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, + "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, + "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, + "@aws-cdk/aws-route53-patters:useCertificate": true, + "@aws-cdk/customresources:installLatestAwsSdkDefault": false, + "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, + "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, + "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, + "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, + "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, + "@aws-cdk/aws-redshift:columnId": true, + "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true, + "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, + "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, + "@aws-cdk/aws-kms:aliasNameRef": true, + "@aws-cdk/aws-kms:applyImportedAliasPermissionsToPrincipal": true, + "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, + "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, + "@aws-cdk/aws-efs:denyAnonymousAccess": true, + "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true, + "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true, + "@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true, + "@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true, + "@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true, + "@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true, + "@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true, + "@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": true, + "@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": true, + "@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true, + "@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true, + "@aws-cdk/aws-eks:nodegroupNameAttribute": true, + "@aws-cdk/aws-eks:useNativeOidcProvider": true, + "@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true, + "@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": true, + "@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault": false, + "@aws-cdk/aws-s3:keepNotificationInImportedBucket": false, + "@aws-cdk/core:explicitStackTags": true, + "@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions": true, + "@aws-cdk/aws-dynamodb:resourcePolicyPerReplica": true, + "@aws-cdk/aws-ec2:ec2SumTImeoutEnabled": true, + "@aws-cdk/aws-appsync:appSyncGraphQLAPIScopeLambdaPermission": true, + "@aws-cdk/aws-rds:setCorrectValueForDatabaseInstanceReadReplicaInstanceResourceId": true, + "@aws-cdk/core:cfnIncludeRejectComplexResourceUpdateCreatePolicyIntrinsics": true, + "@aws-cdk/aws-lambda-nodejs:sdkV3ExcludeSmithyPackages": true, + "@aws-cdk/aws-stepfunctions-tasks:fixRunEcsTaskPolicy": true, + "@aws-cdk/aws-ec2:bastionHostUseAmazonLinux2023ByDefault": true, + "@aws-cdk/aws-route53-targets:userPoolDomainNameMethodWithoutCustomResource": true, + "@aws-cdk/aws-elasticloadbalancingV2:albDualstackWithoutPublicIpv4SecurityGroupRulesDefault": true, + "@aws-cdk/aws-iam:oidcRejectUnauthorizedConnections": true, + "@aws-cdk/core:enableAdditionalMetadataCollection": true, + "@aws-cdk/aws-lambda:createNewPoliciesWithAddToRolePolicy": false, + "@aws-cdk/aws-s3:setUniqueReplicationRoleName": true, + "@aws-cdk/aws-events:requireEventBusPolicySid": true, + "@aws-cdk/core:aspectPrioritiesMutating": true, + "@aws-cdk/aws-dynamodb:retainTableReplica": true, + "@aws-cdk/aws-stepfunctions:useDistributedMapResultWriterV2": true, + "@aws-cdk/s3-notifications:addS3TrustKeyPolicyForSnsSubscriptions": true, + "@aws-cdk/aws-ec2:requirePrivateSubnetsForEgressOnlyInternetGateway": true, + "@aws-cdk/aws-s3:publicAccessBlockedByDefault": true, + "@aws-cdk/aws-lambda:useCdkManagedLogGroup": true, + "@aws-cdk/aws-elasticloadbalancingv2:networkLoadBalancerWithSecurityGroupByDefault": true, + "@aws-cdk/aws-ecs-patterns:uniqueTargetGroupId": true, + "@aws-cdk/aws-route53-patterns:useDistribution": true + } +} diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/__init__.py b/appsync-events-lambda-agnetcore-cdk/cdk/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/cdk_stack.py b/appsync-events-lambda-agnetcore-cdk/cdk/cdk_stack.py new file mode 100644 index 0000000000..35ac3dcd1b --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/cdk/cdk_stack.py @@ -0,0 +1,39 @@ +"""Main CDK stack for the AppSync Events + Lambda + AgentCore architecture.""" + +from aws_cdk import Stack +from constructs import Construct + +from cdk.constructs.appsync_events import AppSyncEventsConstruct +from cdk.constructs.agent_invoker import AgentInvokerConstruct +from cdk.constructs.chat_agent import ChatAgentConstruct +from cdk.constructs.stream_relay import StreamRelayConstruct + + +class CdkStack(Stack): + """AppSync Events chat stack with Lambda invoker and AgentCore runtime.""" + + def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: + super().__init__(scope, construct_id, **kwargs) + + self.appsync_events = AppSyncEventsConstruct(self, "AppSyncEvents") + + self.chat_agent = ChatAgentConstruct( + self, + "AgentCoreRuntime", + model_id=self.node.try_get_context("model_id"), + ) + + self.stream_relay = StreamRelayConstruct( + self, + "StreamRelay", + agent_runtime_arn=self.chat_agent.runtime.attr_agent_runtime_arn, + appsync_http_endpoint=self.appsync_events.api.http_dns, + appsync_api_key=self.appsync_events.api.api_keys["Default"].attr_api_key, + ) + + self.agent_invoker = AgentInvokerConstruct( + self, + "AgentInvoker", + event_api=self.appsync_events.api, + stream_relay_function=self.stream_relay.lambda_function.function, + ) diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/__init__.py b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/__init__.py @@ -0,0 +1 @@ + diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/agent_invoker.py b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/agent_invoker.py new file mode 100644 index 0000000000..a1a226c74c --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/agent_invoker.py @@ -0,0 +1,59 @@ +"""CDK Construct for the Agent Invoker Lambda integrated with AppSync Events. + +Thin dispatcher — receives events from AppSync, invokes the stream relay +Lambda asynchronously, and returns immediately. +""" + +from aws_cdk import ( + Duration, + aws_appsync as appsync, + aws_lambda as lambda_, +) +from constructs import Construct + +from cdk.constructs.standard_lambda import StandardLambda + + +class AgentInvokerConstruct(Construct): + """Creates the agent invoker Lambda and wires it to an AppSync Event API.""" + + def __init__( + self, + scope: Construct, + construct_id: str, + event_api: appsync.EventApi, + stream_relay_function: lambda_.IFunction, + **kwargs, + ) -> None: + super().__init__(scope, construct_id, **kwargs) + + self.lambda_function = StandardLambda( + self, + "AgentInvokerLambda", + handler="index.handler", + code_path="functions/agent_invoker", + timeout=Duration.seconds(10), + environment={ + "STREAM_RELAY_ARN": stream_relay_function.function_arn, + }, + ) + + # Grant permission to invoke the stream relay async + stream_relay_function.grant_invoke(self.lambda_function.function) + + # Register Lambda as a data source on the Event API + lambda_ds = event_api.add_lambda_data_source( + "AgentInvokerDS", + self.lambda_function.function, + ) + + # Add the chat namespace with direct Lambda integration + # Validation is handled in the Lambda (direct mode cannot use JS handlers) + event_api.add_channel_namespace( + "chat", + publish_handler_config=appsync.HandlerConfig( + data_source=lambda_ds, + direct=True, + lambda_invoke_type=appsync.LambdaInvokeType.REQUEST_RESPONSE, + ), + ) diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/appsync_events.py b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/appsync_events.py new file mode 100644 index 0000000000..27464e8f97 --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/appsync_events.py @@ -0,0 +1,52 @@ +"""CDK Construct for AppSync Events API used by the chat interface.""" + +from constructs import Construct +from aws_cdk import ( + CfnOutput, + aws_appsync as appsync, + aws_logs as logs, +) + + +class AppSyncEventsConstruct(Construct): + """Creates an AppSync Event API with a chat channel namespace.""" + + def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: + super().__init__(scope, construct_id, **kwargs) + + # Auth provider — using API key for now, swap to Cognito/IAM as needed + api_key_provider = appsync.AppSyncAuthProvider( + authorization_type=appsync.AppSyncAuthorizationType.API_KEY, + ) + + # Event API + self.api = appsync.EventApi( + self, + "ChatEventApi", + api_name="ChatEventApi", + authorization_config=appsync.EventApiAuthConfig( + auth_providers=[api_key_provider], + connection_auth_mode_types=[ + appsync.AppSyncAuthorizationType.API_KEY, + ], + default_publish_auth_mode_types=[ + appsync.AppSyncAuthorizationType.API_KEY, + ], + default_subscribe_auth_mode_types=[ + appsync.AppSyncAuthorizationType.API_KEY, + ], + ), + log_config=appsync.AppSyncLogConfig( + field_log_level=appsync.AppSyncFieldLogLevel.INFO, + retention=logs.RetentionDays.ONE_WEEK, + ), + ) + + # Responses namespace — no handler, used by stream relay to publish + # agent responses without re-triggering the invoker Lambda + self.api.add_channel_namespace("responses") + + # Outputs + CfnOutput(self, "EventApiHttpEndpoint", value=self.api.http_dns) + CfnOutput(self, "EventApiRealtimeEndpoint", value=self.api.realtime_dns) + CfnOutput(self, "EventApiApiKey", value=self.api.api_keys["Default"].attr_api_key) diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_agent.py b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_agent.py new file mode 100644 index 0000000000..dfdbcc8992 --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_agent.py @@ -0,0 +1,185 @@ +"""CDK Construct for the chat agent — AgentCore runtime + S3 session bucket. + +Creates: +- S3 bucket for conversation session storage (Strands S3SessionManager) +- Docker image from agents/chat/ +- IAM role with Bedrock, ECR, CloudWatch, X-Ray, and S3 policies +- CfnRuntime with HTTP protocol and PUBLIC network +""" + +from constructs import Construct +from aws_cdk import ( + CfnOutput, + RemovalPolicy, + Stack, + aws_bedrockagentcore as agentcore, + aws_ecr_assets as ecr_assets, + aws_iam as iam, + aws_s3 as s3, +) + + +class ChatAgentConstruct(Construct): + """Creates the chat agent runtime with session persistence.""" + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + model_id: str, + environment_variables: dict = None, + **kwargs, + ) -> None: + super().__init__(scope, construct_id, **kwargs) + + stack = Stack.of(self) + + # S3 bucket for conversation sessions + self.session_bucket = s3.Bucket( + self, + "SessionBucket", + removal_policy=RemovalPolicy.DESTROY, + auto_delete_objects=True, + encryption=s3.BucketEncryption.S3_MANAGED, + enforce_ssl=True, + ) + + # Build Docker image from agents/chat/ + agent_image = ecr_assets.DockerImageAsset( + self, + "AgentImage", + directory="agents", + file="chat/Dockerfile", + platform=ecr_assets.Platform.LINUX_ARM64, + build_args={"AWS_REGION": stack.region}, + exclude=["**/__pycache__", "**/*.pyc"], + ) + + # IAM role for the runtime + self.runtime_role = iam.Role( + self, + "Role", + assumed_by=iam.ServicePrincipal( + "bedrock-agentcore.amazonaws.com", + ), + inline_policies=self._build_policies( + stack, agent_image, self.session_bucket, + ), + ) + + # Merge environment variables + merged_env = { + "BEDROCK_MODEL_ID": model_id, + "AWS_REGION": stack.region, + "SESSION_BUCKET": self.session_bucket.bucket_name, + **(environment_variables or {}), + } + + # AgentCore Runtime (L1) + runtime_name = f"{stack.stack_name.replace('-', '_')}_chat_agent" + self.runtime = agentcore.CfnRuntime( + self, + "Runtime", + agent_runtime_name=runtime_name, + role_arn=self.runtime_role.role_arn, + agent_runtime_artifact=agentcore.CfnRuntime.AgentRuntimeArtifactProperty( + container_configuration=agentcore.CfnRuntime.ContainerConfigurationProperty( + container_uri=agent_image.image_uri, + ), + ), + network_configuration=agentcore.CfnRuntime.NetworkConfigurationProperty( + network_mode="PUBLIC", + ), + protocol_configuration="HTTP", + environment_variables=merged_env, + description="Chat agent runtime with session persistence", + ) + + CfnOutput(self, "RuntimeArn", value=self.runtime.attr_agent_runtime_arn) + CfnOutput(self, "RuntimeName", value=runtime_name) + CfnOutput( + self, "SessionBucketName", + value=self.session_bucket.bucket_name, + ) + + @staticmethod + def _build_policies(stack, agent_image, session_bucket): + """Build IAM inline policies for the runtime.""" + return { + "Bedrock": iam.PolicyDocument( + statements=[ + iam.PolicyStatement( + actions=[ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream", + ], + resources=[ + "arn:aws:bedrock:*::foundation-model/anthropic.claude-*", + f"arn:aws:bedrock:{stack.region}:{stack.account}:inference-profile/*", + ], + ), + ], + ), + "ECR": iam.PolicyDocument( + statements=[ + iam.PolicyStatement( + actions=["ecr:GetAuthorizationToken"], + resources=["*"], + ), + iam.PolicyStatement( + actions=[ + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchCheckLayerAvailability", + ], + resources=[agent_image.repository.repository_arn], + ), + ], + ), + "S3Sessions": iam.PolicyDocument( + statements=[ + iam.PolicyStatement( + actions=[ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject", + ], + resources=[f"{session_bucket.bucket_arn}/*"], + ), + iam.PolicyStatement( + actions=["s3:ListBucket"], + resources=[session_bucket.bucket_arn], + ), + ], + ), + "CloudWatchLogs": iam.PolicyDocument( + statements=[ + iam.PolicyStatement( + actions=[ + "logs:DescribeLogStreams", + "logs:CreateLogGroup", + "logs:DescribeLogGroups", + "logs:CreateLogStream", + "logs:PutLogEvents", + ], + resources=[ + f"arn:aws:logs:{stack.region}:{stack.account}:log-group:/aws/bedrock-agentcore/runtimes/*", + ], + ), + ], + ), + "XRay": iam.PolicyDocument( + statements=[ + iam.PolicyStatement( + actions=[ + "xray:PutTraceSegments", + "xray:PutTelemetryRecords", + "xray:GetSamplingRules", + "xray:GetSamplingTargets", + ], + resources=["*"], + ), + ], + ), + } diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/standard_lambda.py b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/standard_lambda.py new file mode 100644 index 0000000000..d9c0af2c44 --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/standard_lambda.py @@ -0,0 +1,197 @@ +from pathlib import Path +from aws_cdk import ( + aws_lambda as lambda_, + aws_logs as logs, + BundlingOptions, + Duration, + RemovalPolicy, + Stack, +) +from constructs import Construct + + +class StandardLambda(Construct): + """ + A reusable CDK Construct that provides a standardized blueprint for creating + AWS Lambda functions — similar to the Global section of an AWS SAM template. + + This construct automatically includes: + - A dedicated CloudWatch Log Group with configurable retention and removal policies, + along with IAM permissions for the Lambda function to write logs. + - The AWS Lambda Powertools for Python V3 layer (managed by AWS), pre-configured + with POWERTOOLS_SERVICE_NAME and LOG_LEVEL environment variables. + - X-Ray active tracing enabled by default. + - Container-based dependency bundling when a requirements.txt file is detected. + + All default settings can be overridden per function. Environment variables and + layers are merged (not replaced) so that Powertools config is always preserved + unless explicitly overridden. + + The function and its execution role are exposed as public attributes (self.function + and self.role) so consumers can grant additional IAM permissions after creation. + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + handler: str, + code_path: str, + runtime: lambda_.Runtime = lambda_.Runtime.PYTHON_3_14, + log_retention: logs.RetentionDays = logs.RetentionDays.ONE_WEEK, + log_removal_policy: RemovalPolicy = RemovalPolicy.DESTROY, + **kwargs, + ) -> None: + super().__init__(scope, construct_id) + + stack = Stack.of(self) + + # Automatically detect whether the Lambda code directory contains a + # requirements.txt with real dependencies. If it does, Container bundling + # is used to install them into the deployment package. If not, the code + # directory is packaged as-is. + code = self._build_code(code_path, runtime) + + # Create a dedicated CloudWatch Log Group for this Lambda function. + log_group = logs.LogGroup( + self, + "LogGroup", + retention=log_retention, + removal_policy=log_removal_policy, + ) + + # Resolve the architecture — default to ARM_64 for better price/performance, + # but allow the consumer to override it via kwargs. + architecture = kwargs.pop("architecture", lambda_.Architecture.ARM_64) + + # Determine the correct Powertools layer ARN based on both architecture. + # Compare on .name because CDK Architecture objects are not singletons + # (Architecture.ARM_64 == Architecture.ARM_64 is False). + arch_suffix = "arm64" if architecture.name == "arm64" else "x86_64" + + # Extract the Python version string from the runtime name (e.g. "python3.14"becomes "python314") + runtime_suffix = runtime.name.replace(".", "") + + powertools_layer = lambda_.LayerVersion.from_layer_version_arn( + self, + "PowertoolsLayer", + f"arn:aws:lambda:{stack.region}:017000801446:layer:AWSLambdaPowertoolsPythonV3-{runtime_suffix}-{arch_suffix}:27", + ) + + # Define the default configuration that every Lambda function created + defaults = { + "runtime": runtime, + "architecture": architecture, + "timeout": Duration.seconds(30), + "memory_size": 256, + "layers": [powertools_layer], + "environment": { + "POWERTOOLS_SERVICE_NAME": construct_id, + "LOG_LEVEL": "DEBUG", + }, + "tracing": lambda_.Tracing.ACTIVE, + "log_group": log_group, + } + + # Handle layers and environment variables separately from other kwargs. + # We pop them out before the general merge so we can combine them + # (append/merge) rather than replace the defaults entirely. + user_layers = kwargs.pop("layers", None) + user_environment = kwargs.pop("environment", None) + + # Merge all remaining kwargs with defaults. User-provided values + # take precedence over defaults (e.g. a custom timeout or memory_size). + merged_config = {**defaults, **kwargs} + + + # Merge layers additively — the consumer's layers are appended + # after the Powertools layer so all layers are included. + if user_layers is not None: + merged_config["layers"] = defaults.get("layers", []) + user_layers + + # Merge environment variables additively — the consumer's env vars + # are added alongside the Powertools defaults, not replacing them. + if user_environment is not None: + merged_config["environment"] = { + **defaults.get("environment", {}), + **user_environment, + } + + # Create the Lambda function with the merged configuration. + self.function = lambda_.Function( + self, "Function", handler=handler, code=code, **merged_config + ) + + # Expose convenience attributes so to keep the syntax similar to a standard lambda + self.function_arn = self.function.function_arn + self.function_name = self.function.function_name + + # Grant the Lambda function permission to write logs to its dedicated + # CloudWatch Log Group. + log_group.grant_write(self.function) + + # Expose the function's IAM execution role as a public attribute. + # This allows consumers to attach additional permissions after + # creating the construct, e.g.: + # my_lambda.role.add_managed_policy(...) + # my_table.grant_read_write_data(my_lambda.function) + self.role = self.function.role + + def _build_code(self, code_path: str, runtime: lambda_.Runtime) -> lambda_.Code: + """ + Build the Lambda deployment package with automatic dependency detection. + + Checks for a requirements.txt in the code directory. If one exists and + contains real dependencies (not just comments or blank lines), Container + bundling is used: a container with the matching Lambda runtime image + runs pip install into /asset-output, then copies the function code + alongside the installed packages. This produces a flat deployment zip + where Python can import everything directly. + + If no requirements.txt is found (or it's empty), the code directory + is simply packaged as-is with no Docker overhead. + """ + code_dir = Path(code_path) + requirements_file = code_dir / "requirements.txt" + + if requirements_file.exists() and self._has_dependencies(requirements_file): + # Use Container bundling + return lambda_.Code.from_asset( + code_path, + bundling=BundlingOptions( + image=runtime.bundling_image, + command=[ + "bash", + "-c", + " && ".join( + [ + # Install dependencies + "pip install -r requirements.txt -t /asset-output/", + # Copy the function source code alongside the + # installed dependencies + "cp -r . /asset-output", + ] + ), + ], + ), + ) + + # No dependencies found — package the code directory directly without spinning up a container + return lambda_.Code.from_asset(code_path) + + def _has_dependencies(self, requirements_file: Path) -> bool: + """ + Check if a requirements.txt file contains actual package dependencies. + Returns False if the file is empty or contains only comments and blank lines. + This avoids unnecessary Docker bundling for functions with no external deps. + """ + try: + with open(requirements_file, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + # Skip empty lines and comment-only lines + if line and not line.startswith("#"): + return True + return False + except Exception: + return False diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/stream_relay.py b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/stream_relay.py new file mode 100644 index 0000000000..409d59fcfa --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/stream_relay.py @@ -0,0 +1,53 @@ +"""CDK Construct for the stream relay Lambda. + +Consumes the SSE stream from AgentCore Runtime and publishes +chunks to AppSync Events. Invoked asynchronously by the agent invoker. +""" + +from aws_cdk import ( + Duration, + aws_iam as iam, +) +from constructs import Construct + +from cdk.constructs.standard_lambda import StandardLambda + + +class StreamRelayConstruct(Construct): + """Creates the stream relay Lambda that bridges AgentCore to AppSync Events.""" + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + agent_runtime_arn: str, + appsync_http_endpoint: str, + appsync_api_key: str, + **kwargs, + ) -> None: + super().__init__(scope, construct_id, **kwargs) + + self.lambda_function = StandardLambda( + self, + "StreamRelayLambda", + handler="index.handler", + code_path="functions/stream_relay", + timeout=Duration.minutes(5), + environment={ + "AGENT_RUNTIME_ARN": agent_runtime_arn, + "APPSYNC_HTTP_ENDPOINT": appsync_http_endpoint, + "APPSYNC_API_KEY": appsync_api_key, + }, + ) + + # Grant permission to invoke AgentCore runtime + self.lambda_function.function.add_to_role_policy( + iam.PolicyStatement( + actions=["bedrock-agentcore:InvokeAgentRuntime"], + resources=[ + agent_runtime_arn, + f"{agent_runtime_arn}/*", + ], + ), + ) diff --git a/appsync-events-lambda-agnetcore-cdk/example-pattern.json b/appsync-events-lambda-agnetcore-cdk/example-pattern.json new file mode 100644 index 0000000000..8616bc19b6 --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/example-pattern.json @@ -0,0 +1,59 @@ +{ + "title": "Step Functions to Athena", + "description": "Create a Step Functions workflow to query Amazon Athena.", + "language": "Python", + "level": "200", + "framework": "AWS CDK", + "introBox": { + "headline": "How it works", + "text": [ + "This sample project demonstrates how to use an AWS Step Functions state machine to query Athena and get the results. This pattern is leveraging the native integration between these 2 services which means only JSON-based, structured language is used to define the implementation.", + "With Amazon Athena you can get up to 1000 results per invocation of the GetQueryResults method and this is the reason why the Step Function has a loop to get more results. The results are sent to a Map which can be configured to handle (the DoSomething state) the items in parallel or one by one by modifying the max_concurrency parameter.", + "This pattern deploys one Step Functions, two S3 Buckets, one Glue table and one Glue database." + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/sfn-athena-cdk-python", + "templateURL": "serverless-patterns/sfn-athena-cdk-python", + "projectFolder": "sfn-athena-cdk-python", + "templateFile": "sfn_athena_cdk_python_stack.py" + } + }, + "resources": { + "bullets": [ + { + "text": "Call Athena with Step Functions", + "link": "https://docs.aws.amazon.com/step-functions/latest/dg/connect-athena.html" + }, + { + "text": "Amazon Athena - Serverless Interactive Query Service", + "link": "https://aws.amazon.com/athena/" + } + ] + }, + "deploy": { + "text": [ + "sam deploy" + ] + }, + "testing": { + "text": [ + "See the GitHub repo for detailed testing instructions." + ] + }, + "cleanup": { + "text": [ + "Delete the stack: cdk delete." + ] + }, + "authors": [ + { + "name": "Your name", + "image": "link-to-your-photo.jpg", + "bio": "Your bio.", + "linkedin": "linked-in-ID", + "twitter": "twitter-handle" + } + ] +} diff --git a/appsync-events-lambda-agnetcore-cdk/functions/agent_invoker/index.py b/appsync-events-lambda-agnetcore-cdk/functions/agent_invoker/index.py new file mode 100644 index 0000000000..33c6283b84 --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/functions/agent_invoker/index.py @@ -0,0 +1,85 @@ +"""Agent invoker — thin dispatcher for AppSync Events direct Lambda integration. + +Receives chat events, invokes the stream relay Lambda asynchronously, +and returns immediately so AppSync gets a fast response. + +Requires sessionId in the message payload — used for both session +persistence and channel isolation. +""" + +import json +import os + +import boto3 +from aws_lambda_powertools import Logger, Tracer + +logger = Logger() +tracer = Tracer() + +lambda_client = boto3.client("lambda") + +STREAM_RELAY_ARN = os.environ["STREAM_RELAY_ARN"] + + +@logger.inject_lambda_context +@tracer.capture_lambda_handler +def handler(event: dict, context) -> dict: + """Handle incoming chat messages from AppSync Events.""" + logger.info("Received event", extra={"event": event}) + + incoming_events = event.get("events", []) + channel = event.get("info", {}).get("channel", {}).get("path", "/chat/default") + results = [] + + for e in incoming_events: + payload = e.get("payload", {}) + event_id = e.get("id") + + message = payload.get("message") + if not message or not str(message).strip(): + results.append({ + "id": event_id, + "payload": { + "error": "message is required and cannot be empty", + }, + }) + continue + + session_id = payload.get("sessionId") + if not session_id or not str(session_id).strip(): + results.append({ + "id": event_id, + "payload": { + "error": "sessionId is required and cannot be empty", + }, + }) + continue + + relay_payload = { + "content": payload.get("message", ""), + "channel": f"/responses{channel}", + "eventId": event_id, + "sessionId": session_id, + } + + logger.info( + "Invoking stream relay", + extra={ + "event_id": event_id, + "channel": channel, + "session_id": session_id, + }, + ) + + lambda_client.invoke( + FunctionName=STREAM_RELAY_ARN, + InvocationType="Event", + Payload=json.dumps(relay_payload).encode(), + ) + + results.append({ + "id": event_id, + "payload": {**payload, "sessionId": session_id}, + }) + + return {"events": results} diff --git a/appsync-events-lambda-agnetcore-cdk/functions/agent_invoker/requirements.txt b/appsync-events-lambda-agnetcore-cdk/functions/agent_invoker/requirements.txt new file mode 100644 index 0000000000..61b2360d82 --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/functions/agent_invoker/requirements.txt @@ -0,0 +1 @@ +# Dependencies provided by Lambda runtime (boto3) and Powertools layer diff --git a/appsync-events-lambda-agnetcore-cdk/functions/stream_relay/index.py b/appsync-events-lambda-agnetcore-cdk/functions/stream_relay/index.py new file mode 100644 index 0000000000..08465c45e3 --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/functions/stream_relay/index.py @@ -0,0 +1,163 @@ +"""Stream relay — consumes SSE stream from AgentCore and publishes chunks to AppSync Events. + +Invoked asynchronously by the agent_invoker Lambda. Has up to 15 minutes +to consume the full agent response stream. + +Flow: +1. Receives agent runtime ARN, channel, and event context +2. Calls invoke_agent_runtime and consumes the SSE stream +3. Publishes each chunk to the AppSync Events channel +4. Publishes a completion event when the stream ends +""" + +import json +import os +import urllib.request + +import boto3 +from aws_lambda_powertools import Logger, Tracer + +logger = Logger() +tracer = Tracer() + +agentcore_client = boto3.client("bedrock-agentcore") + +APPSYNC_HTTP_ENDPOINT = os.environ["APPSYNC_HTTP_ENDPOINT"] +APPSYNC_API_KEY = os.environ["APPSYNC_API_KEY"] +AGENT_RUNTIME_ARN = os.environ["AGENT_RUNTIME_ARN"] + + +def _publish_to_channel(channel: str, event: dict): + """Publish an event to an AppSync Events channel via HTTP.""" + url = f"https://{APPSYNC_HTTP_ENDPOINT}/event" + body = json.dumps({ + "channel": channel, + "events": [json.dumps(event)], + }).encode() + + req = urllib.request.Request( + url, + data=body, + method="POST", + headers={ + "Content-Type": "application/json", + "x-api-key": APPSYNC_API_KEY, + }, + ) + try: + with urllib.request.urlopen(req) as resp: + logger.debug("Published to %s: %s", channel, resp.status) + except Exception: + logger.exception("Failed to publish to %s", channel) + + +@logger.inject_lambda_context +@tracer.capture_lambda_handler +def handler(event: dict, context) -> dict: + """Consume agent SSE stream and relay chunks to AppSync Events.""" + channel = event["channel"] + event_id = event["eventId"] + content = event["content"] + session_id = event["sessionId"] + + logger.info( + "Starting stream relay", + extra={ + "channel": channel, + "event_id": event_id, + "session_id": session_id, + }, + ) + + # Invoke AgentCore Runtime + payload = json.dumps({ + "content": content, + "sessionId": session_id, + }).encode() + response = agentcore_client.invoke_agent_runtime( + agentRuntimeArn=AGENT_RUNTIME_ARN, + payload=payload, + ) + + content_type = response.get("contentType", "") + sequence = 0 + full_response = "" + current_chunk = "" + + if "text/event-stream" in content_type: + # Streaming SSE response — consume line by line + for line in response["response"].iter_lines(chunk_size=1024): + if not line: + continue + decoded = line.decode("utf-8") + if not decoded.startswith("data: "): + continue + + data_str = decoded[6:] # strip "data: " prefix + try: + data = json.loads(data_str) + except json.JSONDecodeError: + # Raw text chunk + current_chunk += data_str + full_response += data_str + continue + + # Skip non-dict events (shouldn't happen but be safe) + if not isinstance(data, dict): + logger.debug("Non-dict SSE event", extra={"data": data}) + continue + + # Skip Strands control events (init_event_loop, start, etc.) + if any(k in data for k in ( + "init_event_loop", "start", "start_event_loop", + "force_stop", "complete", + )): + logger.debug("Control event", extra={"data": data}) + continue + + # Extract text from the event — Strands uses "data" key + text = data.get("data", "") + if isinstance(text, str) and text: + current_chunk += text + full_response += text + + if ( + len(current_chunk) >= 50 + or text.endswith((".", "!", "?", "\n")) + ): + _publish_to_channel(channel, { + "type": "chunk", + "sequence": sequence, + "content": current_chunk, + "eventId": event_id, + }) + sequence += 1 + current_chunk = "" + else: + # Non-streaming JSON response + raw = response["response"].read().decode("utf-8") + try: + body = json.loads(raw) + full_response = body.get("message", raw) + except json.JSONDecodeError: + full_response = raw + + # Publish completion event (includes any remaining chunk content) + _publish_to_channel(channel, { + "type": "complete", + "sequence": sequence, + "content": current_chunk, + "response": full_response, + "eventId": event_id, + }) + + logger.info( + "Stream relay complete", + extra={ + "event_id": event_id, + "chunks_sent": sequence + 1, + "response_length": len(full_response), + }, + ) + + return {"status": "success", "chunks_sent": sequence + 1} diff --git a/appsync-events-lambda-agnetcore-cdk/functions/stream_relay/requirements.txt b/appsync-events-lambda-agnetcore-cdk/functions/stream_relay/requirements.txt new file mode 100644 index 0000000000..61b2360d82 --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/functions/stream_relay/requirements.txt @@ -0,0 +1 @@ +# Dependencies provided by Lambda runtime (boto3) and Powertools layer diff --git a/appsync-events-lambda-agnetcore-cdk/mise.toml b/appsync-events-lambda-agnetcore-cdk/mise.toml new file mode 100644 index 0000000000..5d1eb03389 --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/mise.toml @@ -0,0 +1,57 @@ +[env] +_.python.venv = { path = ".venv", create = true } +CDK_DOCKER = "finch" +AWS_REGION = "eu-west-1" + +[tools] +node = "22" +python = "3.14" +uv = "latest" +"npm:aws-cdk" = "2.1110" + +[tasks.init] +description = "Initialise the environment" +run = [ + "uv pip install -r requirements.txt", + "uv pip install -r requirements-dev.txt", + "cdk bootstrap" +] + +[tasks.clean] +description = "Remove build artifacts and dependencies" +run = [ + "rm -rf .venv cdk.out", + "find . -type d \\( -name '__pycache__' -o -name '.pytest_cache' \\) -exec rm -rf {} +" +] + +[tasks."cdk:synth"] +description = "Deploy CDK Stack" +run = "cdk synth" + +[tasks."cdk:sync"] +description = "Sync CDK Stack - Development only" +run = "cdk deploy --watch" + +[tasks."cdk:deploy"] +description = "Deploy CDK Stack" +run = "cdk deploy --require-approval never" + +[tasks."cdk:destroy"] +description = "Destroy CDK Stack" +run = "cdk destroy" + +[tasks.test] +run = [ + "mise run test:unit", + "mise run test:integration" +] + +[tasks."test:unit"] +run = "pytest tests/unit -v" + +[tasks."test:integration"] +run = "pytest tests/integration -v" + +[tasks."test:integration:verbose"] +description = "Run integration tests with WebSocket output visible" +run = "pytest tests/integration -v -s" diff --git a/appsync-events-lambda-agnetcore-cdk/requirements-dev.txt b/appsync-events-lambda-agnetcore-cdk/requirements-dev.txt new file mode 100644 index 0000000000..e8214751a2 --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/requirements-dev.txt @@ -0,0 +1,6 @@ +pytest>=9.0.2 +pytest-asyncio>=1.3.0 +boto3>=1.42.64 +websockets>=16.0 +aws-lambda-powertools>=3.0.0 +aws-xray-sdk>=2.15.0 diff --git a/appsync-events-lambda-agnetcore-cdk/requirements.txt b/appsync-events-lambda-agnetcore-cdk/requirements.txt new file mode 100644 index 0000000000..8b78286e5f --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/requirements.txt @@ -0,0 +1,2 @@ +aws-cdk-lib>=2.241.0,<3.0.0 +constructs>=10.5.0,<11.0.0 diff --git a/appsync-events-lambda-agnetcore-cdk/tests/__init__.py b/appsync-events-lambda-agnetcore-cdk/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/appsync-events-lambda-agnetcore-cdk/tests/integration/__init__.py b/appsync-events-lambda-agnetcore-cdk/tests/integration/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/tests/integration/__init__.py @@ -0,0 +1 @@ + diff --git a/appsync-events-lambda-agnetcore-cdk/tests/integration/conftest.py b/appsync-events-lambda-agnetcore-cdk/tests/integration/conftest.py new file mode 100644 index 0000000000..f1d01cb290 --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/tests/integration/conftest.py @@ -0,0 +1,155 @@ +"""Shared fixtures for integration tests.""" + +import asyncio +import base64 +import json +import os +from contextlib import asynccontextmanager +from pathlib import Path +import urllib.request +import uuid + +import boto3 +import pytest +import websockets + +# --------------------------------------------------------------------------- +# Output key prefixes (CDK appends hash suffixes) +# --------------------------------------------------------------------------- +_PREFIX_HTTP = "AppSyncEventsEventApiHttpEndpoint" +_PREFIX_WS = "AppSyncEventsEventApiRealtimeEndpoint" +_PREFIX_KEY = "AppSyncEventsEventApiApiKey" + +_DEFAULT_STACK_NAME = "AppsyncLambdaAgentcore" + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="session") +def stack_outputs(): + """Fetch all CloudFormation stack outputs once for the test session.""" + cdk_json_path = Path(__file__).resolve().parents[2] / "cdk.json" + try: + with open(cdk_json_path, "r", encoding="utf-8") as f: + cdk_config = json.load(f) + stack_name = cdk_config.get("context", {}).get( + "stack_name", _DEFAULT_STACK_NAME, + ) + except (FileNotFoundError, json.JSONDecodeError): + stack_name = _DEFAULT_STACK_NAME + + region = os.environ.get("AWS_REGION") + if not region: + raise EnvironmentError("AWS_REGION environment variable must be set") + + cfn = boto3.client("cloudformation", region_name=region) + response = cfn.describe_stacks(StackName=stack_name) + raw = response["Stacks"][0].get("Outputs", []) + return {o["OutputKey"]: o["OutputValue"] for o in raw} + + +@pytest.fixture(scope="session") +def get_output(stack_outputs): + """Return a callable that looks up a stack output by key prefix.""" + def _lookup(prefix: str) -> str: + for key, value in stack_outputs.items(): + if key.startswith(prefix): + return value + pytest.skip(f"Stack output starting with '{prefix}' not found") + return "" + return _lookup + + +@pytest.fixture(scope="session") +def api_config(get_output): + """Resolve the three AppSync Events endpoints/key from stack outputs.""" + return { + "http_endpoint": get_output(_PREFIX_HTTP), + "realtime_endpoint": get_output(_PREFIX_WS), + "api_key": get_output(_PREFIX_KEY), + } + + +@pytest.fixture(scope="session") +def auth_subprotocol(api_config): + """Base64-encoded auth subprotocol string for WebSocket connections.""" + header = json.dumps({ + "host": api_config["http_endpoint"], + "x-api-key": api_config["api_key"], + }).encode() + return "header-" + base64.b64encode(header).decode().rstrip("=") + + +@pytest.fixture(scope="session") +def publish(api_config): + """Return a callable that publishes to a channel via HTTP.""" + def _do_publish(channel: str, message: dict) -> dict: + url = f"https://{api_config['http_endpoint']}/event" + payload = json.dumps({ + "channel": f"/{channel}", + "events": [json.dumps(message)], + }).encode() + req = urllib.request.Request( + url, + data=payload, + method="POST", + headers={ + "Content-Type": "application/json", + "x-api-key": api_config["api_key"], + }, + ) + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read().decode()) + return _do_publish + + +@pytest.fixture +def subscribe(api_config, auth_subprotocol): + """Async context manager that connects, inits, and subscribes to a channel. + + Yields (ws, sub_id) — the WebSocket and subscription ID. + + Usage: + async with subscribe("/responses/chat/abc") as (ws, sub_id): + ... + """ + @asynccontextmanager + async def _subscribe(channel: str): + ws_url = ( + f"wss://{api_config['realtime_endpoint']}/event/realtime" + ) + async with websockets.connect( + ws_url, + subprotocols=["aws-appsync-event-ws", auth_subprotocol], + additional_headers={}, + close_timeout=2, + ) as ws: + await ws.send(json.dumps({"type": "connection_init"})) + ack = json.loads(await ws.recv()) + assert ack["type"] == "connection_ack" + + sub_id = str(uuid.uuid4()) + await ws.send(json.dumps({ + "type": "subscribe", + "id": sub_id, + "channel": channel, + "authorization": { + "x-api-key": api_config["api_key"], + "host": api_config["http_endpoint"], + }, + })) + + while True: + msg = json.loads( + await asyncio.wait_for(ws.recv(), timeout=10), + ) + if msg["type"] == "ka": + continue + assert msg["type"] == "subscribe_success" + break + + yield ws, sub_id + + return _subscribe diff --git a/appsync-events-lambda-agnetcore-cdk/tests/integration/test_appsync_events.py b/appsync-events-lambda-agnetcore-cdk/tests/integration/test_appsync_events.py new file mode 100644 index 0000000000..df091e67dd --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/tests/integration/test_appsync_events.py @@ -0,0 +1,265 @@ +"""Integration tests for the deployed AppSync Event API. + +Tests HTTP publish, streaming responses, multi-turn conversations, +and error handling for the full end-to-end flow. +""" + +import asyncio +import json +import uuid + +import pytest + + +async def _collect_response(ws, sub_id, timeout=60): + """Collect streaming events until a completion event arrives.""" + events = [] + complete = None + deadline = asyncio.get_event_loop().time() + timeout + + while asyncio.get_event_loop().time() < deadline: + try: + raw = await asyncio.wait_for(ws.recv(), timeout=10) + except asyncio.TimeoutError: + break + msg = json.loads(raw) + if msg["type"] == "ka": + continue + if msg["type"] == "data" and msg["id"] == sub_id: + event_data = msg["event"] + if isinstance(event_data, str): + event_data = json.loads(event_data) + events.append(event_data) + + payload = event_data.get("payload", event_data) + if payload.get("type") == "complete": + complete = payload + break + + return events, complete + + +def test_publish_returns_success(publish): + """Publish a chat message and verify the API accepts it.""" + body = publish("chat/test", {"message": "ping", "sessionId": str(uuid.uuid4())}) + + assert "successful" in body, f"Expected 'successful' key: {body}" + assert len(body["successful"]) == 1 + assert len(body["failed"]) == 0 + + +@pytest.mark.asyncio +async def test_subscribe_receives_agent_response(subscribe, publish): + """Subscribe via WebSocket, publish via HTTP, and verify + the agent streams chunks back via the channel.""" + conversation_id = str(uuid.uuid4()) + publish_channel = f"chat/{conversation_id}" + + async with subscribe(f"/responses/{publish_channel}") as (ws, sub_id): + publish(publish_channel, { + "message": "Write a short nursery rhyme about goats", + "sessionId": conversation_id, + }) + + received, complete = await _collect_response(ws, sub_id) + + assert len(received) > 0, "Did not receive any data messages" + assert complete is not None, "Did not receive a completion event" + assert complete.get("response"), "Completion should contain a response" + + +@pytest.mark.asyncio +async def test_conversation_with_session(subscribe, publish): + """Test multi-turn conversation using sessionId for continuity. + + Turn 1: Ask for a short nursery rhyme about goats (< 100 words). + Turn 2: Ask to make it longer (250-300 words) — the agent should + remember the first request via session persistence. + """ + session_id = str(uuid.uuid4()) + publish_channel = f"chat/{session_id}" + + async with subscribe(f"/responses/{publish_channel}") as (ws, sub_id): + # Turn 1: short nursery rhyme + publish(publish_channel, { + "message": ( + "Write a short nursery rhyme about goats. " + "Keep it under 100 words." + ), + "sessionId": session_id, + }) + + _, complete_1 = await _collect_response(ws, sub_id) + assert complete_1 is not None, "Turn 1 did not complete" + response_1 = complete_1.get("response", "") + word_count_1 = len(response_1.split()) + print(f"\n[turn 1] {word_count_1} words: {response_1[:200]}...") + assert word_count_1 < 150, f"Turn 1 too long: {word_count_1} words" + + # Turn 2: ask to make it longer — agent should remember the rhyme + publish(publish_channel, { + "message": ( + "That was great! Now make the nursery rhyme longer, " + "between 250 and 300 words." + ), + "sessionId": session_id, + }) + + _, complete_2 = await _collect_response(ws, sub_id) + assert complete_2 is not None, "Turn 2 did not complete" + response_2 = complete_2.get("response", "") + word_count_2 = len(response_2.split()) + print(f"\n[turn 2] {word_count_2} words: {response_2[:200]}...") + + assert word_count_2 > word_count_1, ( + f"Turn 2 ({word_count_2} words) should be longer " + f"than turn 1 ({word_count_1} words)" + ) + assert "goat" in response_2.lower(), ( + "Turn 2 should reference goats from the conversation context" + ) + +@pytest.mark.asyncio +async def test_unsubscribe_stops_receiving_events(subscribe, publish): + """After unsubscribing, the client should stop receiving events + on that channel while the WebSocket remains open.""" + conversation_id = str(uuid.uuid4()) + publish_channel = f"chat/{conversation_id}" + response_channel = f"/responses/{publish_channel}" + + async with subscribe(response_channel) as (ws, sub_id): + # Verify subscription works — publish and collect response + publish(publish_channel, { + "message": "Say hello", + "sessionId": conversation_id, + }) + _, complete = await _collect_response(ws, sub_id, timeout=30) + assert complete is not None, "Should receive response while subscribed" + + # Unsubscribe from the channel + await ws.send(json.dumps({ + "type": "unsubscribe", + "id": sub_id, + })) + + # Wait for unsubscribe ack + deadline = asyncio.get_event_loop().time() + 5 + while asyncio.get_event_loop().time() < deadline: + raw = await asyncio.wait_for(ws.recv(), timeout=5) + msg = json.loads(raw) + if msg["type"] == "ka": + continue + if msg["type"] == "unsubscribe_success": + break + + # Publish again — should NOT receive anything on this sub + new_session = str(uuid.uuid4()) + publish(publish_channel, { + "message": "Say goodbye", + "sessionId": new_session, + }) + + # Wait briefly — we should only see keepalives, no data + got_data = False + deadline = asyncio.get_event_loop().time() + 10 + while asyncio.get_event_loop().time() < deadline: + try: + raw = await asyncio.wait_for(ws.recv(), timeout=5) + except asyncio.TimeoutError: + break + msg = json.loads(raw) + if msg["type"] == "ka": + continue + if msg["type"] == "data" and msg["id"] == sub_id: + got_data = True + break + + assert not got_data, "Should not receive data after unsubscribing" + + +@pytest.mark.asyncio +async def test_channel_isolation(subscribe, publish): + """Messages published to one conversation channel should not + leak to a subscriber on a different conversation channel.""" + id_a = str(uuid.uuid4()) + id_b = str(uuid.uuid4()) + + async with subscribe(f"/responses/chat/{id_a}") as (ws_a, sub_a): + async with subscribe(f"/responses/chat/{id_b}") as (ws_b, sub_b): + # Publish only to channel A + publish(f"chat/{id_a}", { + "message": "Say hello from channel A", + "sessionId": id_a, + }) + + # Channel A should receive the response + _, complete_a = await _collect_response( + ws_a, sub_a, timeout=30, + ) + assert complete_a is not None, "Channel A should receive a response" + + # Channel B should NOT receive anything + got_data_b = False + deadline = asyncio.get_event_loop().time() + 10 + while asyncio.get_event_loop().time() < deadline: + try: + raw = await asyncio.wait_for(ws_b.recv(), timeout=5) + except asyncio.TimeoutError: + break + msg = json.loads(raw) + if msg["type"] == "ka": + continue + if msg["type"] == "data" and msg["id"] == sub_b: + got_data_b = True + break + + assert not got_data_b, ( + "Channel B should not receive events from channel A" + ) + + + +async def _expect_error_event(subscribe, publish, channel_id, payload, expected_text): + """Publish an invalid payload and verify the Lambda returns an error + event to WebSocket subscribers on the chat channel.""" + publish_channel = f"chat/{channel_id}" + + async with subscribe(f"/{publish_channel}") as (ws, sub_id): + publish(publish_channel, payload) + + deadline = asyncio.get_event_loop().time() + 15 + while asyncio.get_event_loop().time() < deadline: + try: + raw = await asyncio.wait_for(ws.recv(), timeout=5) + except asyncio.TimeoutError: + break + msg = json.loads(raw) + if msg["type"] == "ka": + continue + if msg["type"] == "data" and msg["id"] == sub_id: + event_data = msg["event"] + if isinstance(event_data, str): + event_data = json.loads(event_data) + error = event_data.get("payload", event_data).get("error", "") + if error: + assert expected_text in error.lower(), ( + f"Expected '{expected_text}' in error: {error}" + ) + return + + pytest.fail(f"Did not receive error event for payload: {payload}") + + +@pytest.mark.asyncio +async def test_missing_session_id_returns_error(subscribe, publish): + """Publishing without a sessionId should return an error via WebSocket.""" + await _expect_error_event( + subscribe, publish, + channel_id=str(uuid.uuid4()), + payload={"message": "hello"}, + expected_text="sessionid", + ) + + + + diff --git a/appsync-events-lambda-agnetcore-cdk/tests/unit/__init__.py b/appsync-events-lambda-agnetcore-cdk/tests/unit/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/appsync-events-lambda-agnetcore-cdk/tests/unit/conftest.py b/appsync-events-lambda-agnetcore-cdk/tests/unit/conftest.py new file mode 100644 index 0000000000..6f582ea609 --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/tests/unit/conftest.py @@ -0,0 +1,47 @@ +"""Unit test configuration — sets up isolated environment before any imports.""" + +import os +from dataclasses import dataclass + +import pytest + +# Prevent any real AWS service calls — fake credentials and region +os.environ["AWS_ACCESS_KEY_ID"] = "testing" +os.environ["AWS_SECRET_ACCESS_KEY"] = "testing" +os.environ["AWS_SECURITY_TOKEN"] = "testing" +os.environ["AWS_SESSION_TOKEN"] = "testing" +os.environ["AWS_DEFAULT_REGION"] = "eu-west-1" +os.environ["AWS_REGION"] = "eu-west-1" + +# Disable X-Ray tracing so Tracer uses a no-op provider +os.environ["POWERTOOLS_TRACE_DISABLED"] = "true" +os.environ["POWERTOOLS_SERVICE_NAME"] = "test" + +# Agent invoker Lambda env vars +os.environ["STREAM_RELAY_ARN"] = ( + "arn:aws:lambda:eu-west-1:123456789012:function:stream-relay" +) + +# Stream relay Lambda env vars +os.environ["APPSYNC_HTTP_ENDPOINT"] = "test.appsync-api.eu-west-1.amazonaws.com" +os.environ["APPSYNC_API_KEY"] = "test-api-key" +os.environ["AGENT_RUNTIME_ARN"] = ( + "arn:aws:bedrock-agentcore:eu-west-1:123456789012:runtime/test" +) + + +@dataclass +class FakeLambdaContext: + """Minimal Lambda context for Powertools inject_lambda_context.""" + + function_name: str = "test-function" + memory_limit_in_mb: int = 256 + invoked_function_arn: str = ( + "arn:aws:lambda:eu-west-1:123456789012:function:test" + ) + aws_request_id: str = "test-request-id" + + +@pytest.fixture +def lambda_context(): + return FakeLambdaContext() diff --git a/appsync-events-lambda-agnetcore-cdk/tests/unit/test_agent_invoker.py b/appsync-events-lambda-agnetcore-cdk/tests/unit/test_agent_invoker.py new file mode 100644 index 0000000000..ec2b79877d --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/tests/unit/test_agent_invoker.py @@ -0,0 +1,123 @@ +"""Unit tests for the agent invoker Lambda handler.""" + +import json +import os +from unittest.mock import patch + +from functions.agent_invoker.index import handler + + +def _make_event(payload, channel="/chat/test-123"): + """Build a minimal AppSync Events direct Lambda integration event.""" + return { + "events": [{"id": "evt-1", "payload": payload}], + "info": {"channel": {"path": channel}}, + } + + +def _make_multi_event(payloads, channel="/chat/test-123"): + """Build an event with multiple published messages.""" + return { + "events": [ + {"id": f"evt-{i}", "payload": p} + for i, p in enumerate(payloads) + ], + "info": {"channel": {"path": channel}}, + } + + +@patch("functions.agent_invoker.index.lambda_client") +def test_valid_message_invokes_stream_relay(mock_client, lambda_context): + """Valid payload triggers async Lambda invoke with correct relay payload.""" + event = _make_event({"message": "hello", "sessionId": "sess-1"}) + result = handler(event, lambda_context) + + mock_client.invoke.assert_called_once() + call_kwargs = mock_client.invoke.call_args[1] + assert call_kwargs["InvocationType"] == "Event" + assert call_kwargs["FunctionName"] == os.environ["STREAM_RELAY_ARN"] + + relay = json.loads(call_kwargs["Payload"]) + assert relay["content"] == "hello" + assert relay["sessionId"] == "sess-1" + + assert len(result["events"]) == 1 + assert "error" not in result["events"][0]["payload"] + + +@patch("functions.agent_invoker.index.lambda_client") +def test_missing_message_returns_error(mock_client, lambda_context): + """Missing message key returns error without invoking stream relay.""" + event = _make_event({"sessionId": "sess-1"}) + result = handler(event, lambda_context) + + mock_client.invoke.assert_not_called() + assert "message" in result["events"][0]["payload"]["error"].lower() + + +@patch("functions.agent_invoker.index.lambda_client") +def test_empty_message_returns_error(mock_client, lambda_context): + """Whitespace-only message is rejected.""" + event = _make_event({"message": " ", "sessionId": "sess-1"}) + result = handler(event, lambda_context) + + mock_client.invoke.assert_not_called() + assert "message" in result["events"][0]["payload"]["error"].lower() + + +@patch("functions.agent_invoker.index.lambda_client") +def test_missing_session_id_returns_error(mock_client, lambda_context): + """Missing sessionId returns error without invoking stream relay.""" + event = _make_event({"message": "hello"}) + result = handler(event, lambda_context) + + mock_client.invoke.assert_not_called() + assert "sessionid" in result["events"][0]["payload"]["error"].lower() + + +@patch("functions.agent_invoker.index.lambda_client") +def test_empty_session_id_returns_error(mock_client, lambda_context): + """Whitespace-only sessionId is rejected.""" + event = _make_event({"message": "hello", "sessionId": " "}) + result = handler(event, lambda_context) + + mock_client.invoke.assert_not_called() + assert "sessionid" in result["events"][0]["payload"]["error"].lower() + + +@patch("functions.agent_invoker.index.lambda_client") +def test_empty_payload_returns_error(mock_client, lambda_context): + """Empty payload returns message error (first validation hit).""" + event = _make_event({}) + result = handler(event, lambda_context) + + mock_client.invoke.assert_not_called() + assert "message" in result["events"][0]["payload"]["error"].lower() + + +@patch("functions.agent_invoker.index.lambda_client") +def test_response_channel_prefixed_with_responses(mock_client, lambda_context): + """Relay payload channel should be /responses{original_channel}.""" + event = _make_event( + {"message": "hi", "sessionId": "s1"}, + channel="/chat/conv-abc", + ) + handler(event, lambda_context) + + relay = json.loads(mock_client.invoke.call_args[1]["Payload"]) + assert relay["channel"] == "/responses/chat/conv-abc" + + +@patch("functions.agent_invoker.index.lambda_client") +def test_multiple_events_processed_independently(mock_client, lambda_context): + """Batch with one valid and one invalid event returns mixed results.""" + event = _make_multi_event([ + {"message": "hello", "sessionId": "s1"}, + {"message": "world"}, # missing sessionId + ]) + result = handler(event, lambda_context) + + assert mock_client.invoke.call_count == 1 # only valid event invoked + events = result["events"] + assert "error" not in events[0]["payload"] + assert "error" in events[1]["payload"] diff --git a/appsync-events-lambda-agnetcore-cdk/tests/unit/test_stream_relay.py b/appsync-events-lambda-agnetcore-cdk/tests/unit/test_stream_relay.py new file mode 100644 index 0000000000..12dfdf5077 --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/tests/unit/test_stream_relay.py @@ -0,0 +1,123 @@ +"""Unit tests for the stream relay Lambda handler.""" + +import json +from unittest.mock import MagicMock, patch + +from functions.stream_relay.index import handler + + +def _make_event(content="hello", channel="/responses/chat/conv-1", + event_id="evt-1", session_id="sess-1"): + return { + "content": content, + "channel": channel, + "eventId": event_id, + "sessionId": session_id, + } + + +def _mock_sse_response(lines): + """Create a mock AgentCore response with SSE streaming.""" + encoded_lines = [line.encode("utf-8") for line in lines] + stream = MagicMock() + stream.iter_lines.return_value = encoded_lines + return { + "contentType": "text/event-stream", + "response": stream, + } + + +@patch("functions.stream_relay.index._publish_to_channel") +@patch("functions.stream_relay.index.agentcore_client") +def test_streaming_sse_publishes_chunks(mock_ac, mock_publish, lambda_context): + """SSE stream with data events publishes chunks to AppSync channel.""" + mock_ac.invoke_agent_runtime.return_value = _mock_sse_response([ + 'data: {"data": "Hello, "}', + 'data: {"data": "how are you?"}', + ]) + + result = handler(_make_event(), lambda_context) + + assert result["status"] == "success" + assert mock_publish.call_count >= 1 + last_call = mock_publish.call_args_list[-1] + completion = last_call[0][1] + assert completion["type"] == "complete" + assert "Hello, " in completion["response"] + assert "how are you?" in completion["response"] + + +@patch("functions.stream_relay.index._publish_to_channel") +@patch("functions.stream_relay.index.agentcore_client") +def test_completion_event_includes_full_response(mock_ac, mock_publish, lambda_context): + """Final publish contains the assembled full response text.""" + mock_ac.invoke_agent_runtime.return_value = _mock_sse_response([ + 'data: {"data": "Part one. "}', + 'data: {"data": "Part two."}', + ]) + + handler(_make_event(), lambda_context) + + completion = mock_publish.call_args_list[-1][0][1] + assert completion["type"] == "complete" + assert completion["response"] == "Part one. Part two." + assert completion["eventId"] == "evt-1" + + +@patch("functions.stream_relay.index._publish_to_channel") +@patch("functions.stream_relay.index.agentcore_client") +def test_control_events_are_skipped(mock_ac, mock_publish, lambda_context): + """Strands control events should not produce chunk publishes.""" + mock_ac.invoke_agent_runtime.return_value = _mock_sse_response([ + 'data: {"init_event_loop": true}', + 'data: {"start": true}', + 'data: {"data": "actual text."}', + 'data: {"complete": true}', + ]) + + handler(_make_event(), lambda_context) + + completion = mock_publish.call_args_list[-1][0][1] + assert completion["response"] == "actual text." + for c in mock_publish.call_args_list: + payload = c[0][1] + if payload["type"] == "chunk": + assert "init_event_loop" not in payload["content"] + assert "start" not in payload["content"] + + +@patch("functions.stream_relay.index._publish_to_channel") +@patch("functions.stream_relay.index.agentcore_client") +def test_non_streaming_response_handled(mock_ac, mock_publish, lambda_context): + """Non-SSE response is read and published as completion.""" + stream = MagicMock() + stream.read.return_value = json.dumps({"message": "static reply"}).encode() + mock_ac.invoke_agent_runtime.return_value = { + "contentType": "application/json", + "response": stream, + } + + handler(_make_event(), lambda_context) + + completion = mock_publish.call_args_list[-1][0][1] + assert completion["type"] == "complete" + assert completion["response"] == "static reply" + + +@patch("functions.stream_relay.index._publish_to_channel") +@patch("functions.stream_relay.index.agentcore_client") +def test_chunk_batching_on_punctuation(mock_ac, mock_publish, lambda_context): + """Chunks should flush on sentence-ending punctuation.""" + mock_ac.invoke_agent_runtime.return_value = _mock_sse_response([ + 'data: {"data": "Short."}', + 'data: {"data": " More text after."}', + ]) + + handler(_make_event(), lambda_context) + + chunk_publishes = [ + c[0][1] for c in mock_publish.call_args_list + if c[0][1]["type"] == "chunk" + ] + assert len(chunk_publishes) >= 1 + assert chunk_publishes[0]["content"] == "Short." From 0903b3e096b3644b5791f273c5c4298ac5ebf757 Mon Sep 17 00:00:00 2001 From: Pete Davis Date: Wed, 11 Mar 2026 13:01:34 +0000 Subject: [PATCH 02/19] feat: add tools to chat agent and integration tests for each tool - Wire http_request, calculator, and current_time tools into the agent entrypoint with an updated system prompt - Add strands-agents-tools dependency to requirements - Add integration tests: test_http_request_tool, test_calculator_tool, test_current_time_tool - Print streaming chunks in _collect_response for visibility with -s - Rework session test to use web fetch for more realistic multi-turn --- .../agents/chat/entrypoint.py | 13 ++- .../agents/chat/requirements.txt | 3 +- .../tests/integration/test_appsync_events.py | 108 +++++++++++++----- 3 files changed, 94 insertions(+), 30 deletions(-) diff --git a/appsync-events-lambda-agnetcore-cdk/agents/chat/entrypoint.py b/appsync-events-lambda-agnetcore-cdk/agents/chat/entrypoint.py index bbfb39ce78..d54adbe315 100644 --- a/appsync-events-lambda-agnetcore-cdk/agents/chat/entrypoint.py +++ b/appsync-events-lambda-agnetcore-cdk/agents/chat/entrypoint.py @@ -11,6 +11,7 @@ from strands import Agent from strands.models import BedrockModel from strands.session.s3_session_manager import S3SessionManager +from strands_tools import http_request, calculator, current_time from bedrock_agentcore.runtime import BedrockAgentCoreApp logging.basicConfig(level=logging.INFO) @@ -26,7 +27,16 @@ SESSION_BUCKET = os.environ.get("SESSION_BUCKET") SYSTEM_PROMPT = """\ -You are a helpful chat assistant. Answer questions clearly and concisely. +You are a research assistant with access to the web, a calculator, and a clock. + +You can: +- Fetch and summarise content from any public URL using http_request +- Perform mathematical calculations using calculator +- Check the current date and time in any timezone using current_time + +When fetching web content, prefer converting HTML to markdown for readability +by setting convert_to_markdown=true. Always cite the URL you fetched. +Keep responses clear and concise. """ @@ -37,6 +47,7 @@ def _create_agent(session_id: str | None = None) -> Agent: kwargs = { "system_prompt": SYSTEM_PROMPT, "model": model, + "tools": [http_request, calculator, current_time], } if session_id and SESSION_BUCKET: diff --git a/appsync-events-lambda-agnetcore-cdk/agents/chat/requirements.txt b/appsync-events-lambda-agnetcore-cdk/agents/chat/requirements.txt index 26155e5dc8..d00d16803e 100644 --- a/appsync-events-lambda-agnetcore-cdk/agents/chat/requirements.txt +++ b/appsync-events-lambda-agnetcore-cdk/agents/chat/requirements.txt @@ -1,3 +1,4 @@ strands-agents>=1.13.0 +strands-agents-tools>=0.2.22 bedrock-agentcore>=1.0.3 -aws-opentelemetry-distro +aws-opentelemetry-distro \ No newline at end of file diff --git a/appsync-events-lambda-agnetcore-cdk/tests/integration/test_appsync_events.py b/appsync-events-lambda-agnetcore-cdk/tests/integration/test_appsync_events.py index df091e67dd..795f78852b 100644 --- a/appsync-events-lambda-agnetcore-cdk/tests/integration/test_appsync_events.py +++ b/appsync-events-lambda-agnetcore-cdk/tests/integration/test_appsync_events.py @@ -1,12 +1,15 @@ """Integration tests for the deployed AppSync Event API. -Tests HTTP publish, streaming responses, multi-turn conversations, -and error handling for the full end-to-end flow. +Tests HTTP publish, streaming responses, tool use (http_request, +calculator, current_time), multi-turn conversations, and error +handling for the full end-to-end flow. """ import asyncio import json +import re import uuid +from datetime import datetime, timezone import pytest @@ -30,6 +33,7 @@ async def _collect_response(ws, sub_id, timeout=60): if isinstance(event_data, str): event_data = json.loads(event_data) events.append(event_data) + print(f"Chunk {len(events)}: {event_data}") payload = event_data.get("payload", event_data) if payload.get("type") == "complete": @@ -49,15 +53,17 @@ def test_publish_returns_success(publish): @pytest.mark.asyncio -async def test_subscribe_receives_agent_response(subscribe, publish): - """Subscribe via WebSocket, publish via HTTP, and verify - the agent streams chunks back via the channel.""" +async def test_http_request_tool(subscribe, publish): + """Ask the agent to fetch a URL using http_request and verify + it streams a summarised response back via the channel.""" conversation_id = str(uuid.uuid4()) publish_channel = f"chat/{conversation_id}" async with subscribe(f"/responses/{publish_channel}") as (ws, sub_id): publish(publish_channel, { - "message": "Write a short nursery rhyme about goats", + "message": ( + "Fetch the AWS blog homepage at https://aws.amazon.com/blogs/ and show me the latest 5 blog posts titles." + ), "sessionId": conversation_id, }) @@ -72,52 +78,42 @@ async def test_subscribe_receives_agent_response(subscribe, publish): async def test_conversation_with_session(subscribe, publish): """Test multi-turn conversation using sessionId for continuity. - Turn 1: Ask for a short nursery rhyme about goats (< 100 words). - Turn 2: Ask to make it longer (250-300 words) — the agent should - remember the first request via session persistence. + Turn 1: Ask the agent to fetch and summarise an AWS blog post. + Turn 2: Ask a follow-up question — the agent should remember + the blog content from the first turn via session persistence. """ session_id = str(uuid.uuid4()) publish_channel = f"chat/{session_id}" async with subscribe(f"/responses/{publish_channel}") as (ws, sub_id): - # Turn 1: short nursery rhyme + # Turn 1: fetch and summarise an AWS blog post publish(publish_channel, { "message": ( - "Write a short nursery rhyme about goats. " + "Fetch https://aws.amazon.com/blogs/aws/ and give me " + "a short summary of the first blog post you see. " "Keep it under 100 words." ), "sessionId": session_id, }) - _, complete_1 = await _collect_response(ws, sub_id) + events_1, complete_1 = await _collect_response(ws, sub_id) assert complete_1 is not None, "Turn 1 did not complete" response_1 = complete_1.get("response", "") - word_count_1 = len(response_1.split()) - print(f"\n[turn 1] {word_count_1} words: {response_1[:200]}...") - assert word_count_1 < 150, f"Turn 1 too long: {word_count_1} words" - # Turn 2: ask to make it longer — agent should remember the rhyme + # Turn 2: ask a follow-up — agent should remember the blog post publish(publish_channel, { "message": ( - "That was great! Now make the nursery rhyme longer, " - "between 250 and 300 words." + "Based on that blog post, what AWS services were mentioned? " + "List them out." ), "sessionId": session_id, }) - _, complete_2 = await _collect_response(ws, sub_id) + events_2, complete_2 = await _collect_response(ws, sub_id) assert complete_2 is not None, "Turn 2 did not complete" response_2 = complete_2.get("response", "") - word_count_2 = len(response_2.split()) - print(f"\n[turn 2] {word_count_2} words: {response_2[:200]}...") - assert word_count_2 > word_count_1, ( - f"Turn 2 ({word_count_2} words) should be longer " - f"than turn 1 ({word_count_1} words)" - ) - assert "goat" in response_2.lower(), ( - "Turn 2 should reference goats from the conversation context" - ) + assert len(response_2) > 0, "Turn 2 should have a non-empty response" @pytest.mark.asyncio async def test_unsubscribe_stops_receiving_events(subscribe, publish): @@ -250,6 +246,62 @@ async def _expect_error_event(subscribe, publish, channel_id, payload, expected_ pytest.fail(f"Did not receive error event for payload: {payload}") +@pytest.mark.asyncio +async def test_calculator_tool(subscribe, publish): + """Ask the agent to perform a calculation and verify it uses the + calculator tool and returns the correct result.""" + conversation_id = str(uuid.uuid4()) + publish_channel = f"chat/{conversation_id}" + + async with subscribe(f"/responses/{publish_channel}") as (ws, sub_id): + publish(publish_channel, { + "message": "Use the calculator to compute 347 * 29. Reply with only the number.", + "sessionId": conversation_id, + }) + + _, complete = await _collect_response(ws, sub_id) + assert complete is not None, "Did not receive a completion event" + response = complete.get("response", "") + assert "10063" in response, ( + f"Expected calculator result 10063 in response: {response}" + ) + + +@pytest.mark.asyncio +async def test_current_time_tool(subscribe, publish): + """Ask the agent for the current UTC time and verify the response + matches the actual time to the minute (±2 min grace).""" + conversation_id = str(uuid.uuid4()) + publish_channel = f"chat/{conversation_id}" + + async with subscribe(f"/responses/{publish_channel}") as (ws, sub_id): + publish(publish_channel, { + "message": ( + "Get the current time in UTC. " + "Reply with ONLY the time in this exact format: YYYY-MM-DD HH:MM " + "For example: 2026-03-11 14:05" + ), + "sessionId": conversation_id, + }) + + now_utc = datetime.now(timezone.utc) + _, complete = await _collect_response(ws, sub_id) + assert complete is not None, "Did not receive a completion event" + response = complete.get("response", "") + + match = re.search(r"\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}", response) + assert match, f"Could not find YYYY-MM-DD HH:MM in response: {response}" + + reported = datetime.strptime(match.group(), "%Y-%m-%d %H:%M").replace( + tzinfo=timezone.utc, + ) + diff = abs((reported - now_utc).total_seconds()) + assert diff <= 120, ( + f"Reported time {match.group()} differs from actual " + f"{now_utc.strftime('%Y-%m-%d %H:%M')} by {diff:.0f}s (max 120s)" + ) + + @pytest.mark.asyncio async def test_missing_session_id_returns_error(subscribe, publish): """Publishing without a sessionId should return an error via WebSocket.""" From 3d6acf3d2e5f06a471cd28bd0aaa4e51888e2279 Mon Sep 17 00:00:00 2001 From: Pete Davis Date: Wed, 11 Mar 2026 13:02:10 +0000 Subject: [PATCH 03/19] chore: bump minimum dependency versions to latest across all requirements --- .../agents/chat/requirements.txt | 6 +++--- appsync-events-lambda-agnetcore-cdk/requirements-dev.txt | 4 ++-- appsync-events-lambda-agnetcore-cdk/requirements.txt | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/appsync-events-lambda-agnetcore-cdk/agents/chat/requirements.txt b/appsync-events-lambda-agnetcore-cdk/agents/chat/requirements.txt index d00d16803e..f128c93186 100644 --- a/appsync-events-lambda-agnetcore-cdk/agents/chat/requirements.txt +++ b/appsync-events-lambda-agnetcore-cdk/agents/chat/requirements.txt @@ -1,4 +1,4 @@ -strands-agents>=1.13.0 +strands-agents>=1.29.0 strands-agents-tools>=0.2.22 -bedrock-agentcore>=1.0.3 -aws-opentelemetry-distro \ No newline at end of file +bedrock-agentcore>=1.4.4 +aws-opentelemetry-distro>=0.15.0 \ No newline at end of file diff --git a/appsync-events-lambda-agnetcore-cdk/requirements-dev.txt b/appsync-events-lambda-agnetcore-cdk/requirements-dev.txt index e8214751a2..5c9ed205c8 100644 --- a/appsync-events-lambda-agnetcore-cdk/requirements-dev.txt +++ b/appsync-events-lambda-agnetcore-cdk/requirements-dev.txt @@ -1,6 +1,6 @@ pytest>=9.0.2 pytest-asyncio>=1.3.0 -boto3>=1.42.64 +boto3>=1.42.65 websockets>=16.0 -aws-lambda-powertools>=3.0.0 +aws-lambda-powertools>=3.25.0 aws-xray-sdk>=2.15.0 diff --git a/appsync-events-lambda-agnetcore-cdk/requirements.txt b/appsync-events-lambda-agnetcore-cdk/requirements.txt index 8b78286e5f..83cee3900e 100644 --- a/appsync-events-lambda-agnetcore-cdk/requirements.txt +++ b/appsync-events-lambda-agnetcore-cdk/requirements.txt @@ -1,2 +1,2 @@ -aws-cdk-lib>=2.241.0,<3.0.0 -constructs>=10.5.0,<11.0.0 +aws-cdk-lib>=2.242.0,<3.0.0 +constructs>=10.5.1,<11.0.0 From 7e19294d77544beab6ac7df23276022c76242bff Mon Sep 17 00:00:00 2001 From: Pete Davis Date: Wed, 11 Mar 2026 13:46:58 +0000 Subject: [PATCH 04/19] refactor: move stack name from cdk.json context to mise environment variable --- appsync-events-lambda-agnetcore-cdk/cdk.json | 1 - appsync-events-lambda-agnetcore-cdk/mise.toml | 11 ++++++----- .../tests/integration/conftest.py | 11 +---------- 3 files changed, 7 insertions(+), 16 deletions(-) diff --git a/appsync-events-lambda-agnetcore-cdk/cdk.json b/appsync-events-lambda-agnetcore-cdk/cdk.json index be99e99033..08a898325a 100644 --- a/appsync-events-lambda-agnetcore-cdk/cdk.json +++ b/appsync-events-lambda-agnetcore-cdk/cdk.json @@ -15,7 +15,6 @@ ] }, "context": { - "stack_name": "AppsyncLambdaAgentcore", "model_id": "eu.anthropic.claude-sonnet-4-20250514-v1:0", "@aws-cdk/aws-signer:signingProfileNamePassedToCfn": true, "@aws-cdk/aws-ecs-patterns:secGroupsDisablesImplicitOpenListener": true, diff --git a/appsync-events-lambda-agnetcore-cdk/mise.toml b/appsync-events-lambda-agnetcore-cdk/mise.toml index 5d1eb03389..4f146c94ca 100644 --- a/appsync-events-lambda-agnetcore-cdk/mise.toml +++ b/appsync-events-lambda-agnetcore-cdk/mise.toml @@ -2,6 +2,7 @@ _.python.venv = { path = ".venv", create = true } CDK_DOCKER = "finch" AWS_REGION = "eu-west-1" +STACK_NAME = "AppsyncLambdaAgentcore" [tools] node = "22" @@ -25,20 +26,20 @@ run = [ ] [tasks."cdk:synth"] -description = "Deploy CDK Stack" -run = "cdk synth" +description = "Synthesize CDK Stack" +run = "cdk synth -c stack_name=$STACK_NAME" [tasks."cdk:sync"] description = "Sync CDK Stack - Development only" -run = "cdk deploy --watch" +run = "cdk deploy --watch -c stack_name=$STACK_NAME" [tasks."cdk:deploy"] description = "Deploy CDK Stack" -run = "cdk deploy --require-approval never" +run = "cdk deploy --require-approval never -c stack_name=$STACK_NAME" [tasks."cdk:destroy"] description = "Destroy CDK Stack" -run = "cdk destroy" +run = "cdk destroy -c stack_name=$STACK_NAME" [tasks.test] run = [ diff --git a/appsync-events-lambda-agnetcore-cdk/tests/integration/conftest.py b/appsync-events-lambda-agnetcore-cdk/tests/integration/conftest.py index f1d01cb290..a16f2aa22c 100644 --- a/appsync-events-lambda-agnetcore-cdk/tests/integration/conftest.py +++ b/appsync-events-lambda-agnetcore-cdk/tests/integration/conftest.py @@ -5,7 +5,6 @@ import json import os from contextlib import asynccontextmanager -from pathlib import Path import urllib.request import uuid @@ -30,15 +29,7 @@ @pytest.fixture(scope="session") def stack_outputs(): """Fetch all CloudFormation stack outputs once for the test session.""" - cdk_json_path = Path(__file__).resolve().parents[2] / "cdk.json" - try: - with open(cdk_json_path, "r", encoding="utf-8") as f: - cdk_config = json.load(f) - stack_name = cdk_config.get("context", {}).get( - "stack_name", _DEFAULT_STACK_NAME, - ) - except (FileNotFoundError, json.JSONDecodeError): - stack_name = _DEFAULT_STACK_NAME + stack_name = os.environ.get("STACK_NAME") or _DEFAULT_STACK_NAME region = os.environ.get("AWS_REGION") if not region: From 39efbd803eed72fd3e5de20ff554a8cdc95bd528 Mon Sep 17 00:00:00 2001 From: Pete Davis Date: Wed, 11 Mar 2026 16:01:46 +0000 Subject: [PATCH 05/19] docs: enhance README with architecture diagram, console testing guide, and auth section --- appsync-events-lambda-agnetcore-cdk/README.md | 132 ++++++++++++++---- .../images/appsync-pubsub-publish.jpg | Bin 0 -> 70483 bytes .../appsync-pubsub-subscribe-result.jpg | Bin 0 -> 79700 bytes .../images/appsync-pubsub-subscribe.jpg | Bin 0 -> 43046 bytes .../images/architecture.drawio | 67 +++++++++ .../images/architecture.png | Bin 0 -> 68213 bytes 6 files changed, 174 insertions(+), 25 deletions(-) create mode 100644 appsync-events-lambda-agnetcore-cdk/images/appsync-pubsub-publish.jpg create mode 100644 appsync-events-lambda-agnetcore-cdk/images/appsync-pubsub-subscribe-result.jpg create mode 100644 appsync-events-lambda-agnetcore-cdk/images/appsync-pubsub-subscribe.jpg create mode 100644 appsync-events-lambda-agnetcore-cdk/images/architecture.drawio create mode 100644 appsync-events-lambda-agnetcore-cdk/images/architecture.png diff --git a/appsync-events-lambda-agnetcore-cdk/README.md b/appsync-events-lambda-agnetcore-cdk/README.md index 8e3a35d132..04e40c3bca 100644 --- a/appsync-events-lambda-agnetcore-cdk/README.md +++ b/appsync-events-lambda-agnetcore-cdk/README.md @@ -1,59 +1,141 @@ -# AWS Service 1 to AWS Service 2 +# AppSync Events to Lambda to Bedrock AgentCore -This pattern << explain usage >> +This pattern deploys a real-time streaming chat service using AWS AppSync Events with Lambda to invoke a Strands agent running on Amazon Bedrock AgentCore Runtime. -Learn more about this pattern at Serverless Land Patterns: << Add the live URL here >> +Learn more about this pattern at Serverless Land Patterns: https://serverlessland.com/patterns/appsync-events-lambda-agentcore-cdk Important: this application uses various AWS services and there are costs associated with these services after the Free Tier usage - please see the [AWS Pricing page](https://aws.amazon.com/pricing/) for details. You are responsible for any AWS costs incurred. No warranty is implied in this example. ## Requirements * [Create an AWS account](https://portal.aws.amazon.com/gp/aws/developer/registration/index.html) if you do not already have one and log in. The IAM user that you use must have sufficient permissions to make necessary AWS service calls and manage AWS resources. -* [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) installed and configured -* [Git Installed](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) -* [AWS Serverless Application Model](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html) (AWS SAM) installed +* [AWS CLI installed and configured](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) +* [Git installed](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) +* [mise installed](https://mise.jdx.dev/) (installs Python 3.14, Node.js 22, AWS CDK, and uv automatically) +* [Finch](https://runfinch.com/) or [Docker installed](https://docs.docker.com/get-docker/) (used for CDK bundling) ## Deployment Instructions 1. Create a new directory, navigate to that directory in a terminal and clone the GitHub repository: - ``` + ``` git clone https://github.com/aws-samples/serverless-patterns ``` 1. Change directory to the pattern directory: ``` - cd _patterns-model + cd appsync-events-lambda-agentcore-cdk + ``` +1. Review `mise.toml` and update `AWS_REGION` and `STACK_NAME` in the `[env]` section as appropriate for your environment. If you are using Docker instead of Finch, comment out the `CDK_DOCKER = "finch"` line. +1. Trust the mise configuration for this project: + ``` + mise trust + ``` +1. Install tools and dependencies. + ``` + mise install + mise run init ``` -1. From the command line, use AWS SAM to deploy the AWS resources for the pattern as specified in the template.yml file: +1. Deploy the stack: ``` - sam deploy --guided + mise run cdk:deploy ``` -1. During the prompts: - * Enter a stack name - * Enter the desired AWS Region - * Allow SAM CLI to create IAM roles with the required permissions. +1. Note the outputs from the CDK deployment process. These contain the AppSync Events HTTP endpoint, WebSocket endpoint, and API key needed for testing. - Once you have run `sam deploy --guided` mode once and saved arguments to a configuration file (samconfig.toml), you can use `sam deploy` in future to use these defaults. +## How it works -1. Note the outputs from the SAM deployment process. These contain the resource names and/or ARNs which are used for testing. +![Architecture diagram](images/architecture.png) -## How it works +Figure 1 - Architecture + +1. The client publishes a message to the inbound channel (`/chat/{conversationId}`) via HTTP POST to AppSync Events. +2. AppSync Events triggers the agent invoker Lambda via direct Lambda integration. +3. The agent invoker validates the payload, invokes the stream relay Lambda asynchronously, and returns immediately. +4. The stream relay calls `invoke_agent_runtime` on the Bedrock AgentCore Runtime, which hosts a Strands agent container, and consumes the Server-Sent Events (SSE) stream. +5. The stream relay publishes each chunk back to the response channel on AppSync Events (`/responses/chat/{conversationId}`). +6. The client receives agent response tokens in real time via the WebSocket subscription. + +The client subscribes to the response channel before publishing. Separate channel namespaces (`chat` for inbound, `responses` for outbound) ensure the stream relay's publishes do not re-trigger the agent invoker. -Explain how the service interaction works. +The agent is a Strands-based research assistant with access to `http_request`, `calculator`, and `current_time` tools, backed by S3 session persistence for multi-turn conversations. ## Testing -Provide steps to trigger the integration and show what should be observed if successful. +### Automated tests + +```bash +mise run test:unit # unit tests (no deployed stack needed) +mise run test:integration:verbose # integration tests with streaming output +``` + +### Using the AppSync Pub/Sub Editor + +You can test the deployed service directly from the AWS Console using the AppSync Events built-in Pub/Sub Editor. No additional tooling required. + +1. Open the [AWS AppSync console](https://console.aws.amazon.com/appsync/) in the region you deployed to (e.g. `eu-west-1`). +1. Select the Event API created by the stack (look for the API with "EventApi" in the name). +1. Click the **Pub/Sub Editor** tab. +1. Scroll to the bottom of the page. The API key is pre-populated in the authorization token field. Click **Connect** to establish a WebSocket connection. +1. In the **Subscribe** panel, select `responses` from the namespace dropdown, then enter the path: + ``` + /chat/test-conversation-1 + ``` +1. Click **Subscribe**. + + ![AppSync Pub/Sub Editor — Subscribe panel](images/appsync-pubsub-subscribe.jpg) + + Figure 2 - AppSync Pub/Sub Editor - Subscribe panel + +1. Scroll back to the top of the page to the **Publish** panel. Select `chat` from the namespace dropdown, then enter the path: + ``` + /test-conversation-1 + ``` + Enter this JSON as the event payload: + ```json + [ + { + "message": "What is 347 multiplied by 29?", + "sessionId": "test-conversation-1" + } + ] + ``` + Click **Publish**. When prompted, choose **WebSocket** as the publish method. + + ![AppSync Pub/Sub Editor — Publish panel](images/appsync-pubsub-publish.jpg) + + Figure 3 - AppSync Pub/Sub Editor - Publish panel + +1. Scroll back down to the bottom of the page to watch the subscription panel — you should see streaming chunk events arrive in real time, followed by a final completion event containing the full response. + + ![AppSync Pub/Sub Editor — Subscribe results](images/appsync-pubsub-subscribe-result.jpg) + + Figure 4 - AppSync Pub/Sub Editor - Subscribe results + + +A few things to note: + +- The `sessionId` value ties messages to a conversation. Use the same `sessionId` across publishes to test multi-turn conversation with session persistence. +- The subscribe channel must be prefixed with `/responses` — the agent invoker publishes responses to `/responses/chat/{conversationId}` to avoid re-triggering itself. +- You can try different prompts to exercise the agent's tools: ask it to fetch a URL (`http_request`), do arithmetic (`calculator`), or tell you the current time (`current_time`). + +## Authentication + +This example uses an API key for authentication to keep things simple. API keys are suitable for development and testing but are not recommended for production workloads. + +AppSync Events supports several authentication methods that are better suited for production: + +- **Amazon Cognito user pools** — ideal for end-user authentication in web and mobile apps. +- **AWS IAM** — best for server-to-server or backend service communication. +- **OpenID Connect (OIDC)** — use with third-party identity providers. +- **Lambda authorizers** — for custom authorization logic. + +You can configure multiple authorization modes on a single API and apply different modes per channel namespace. See the [AppSync Events authorization and authentication](https://docs.aws.amazon.com/appsync/latest/eventapi/configure-event-api-auth.html) documentation for details. ## Cleanup - + 1. Delete the stack - ```bash - aws cloudformation delete-stack --stack-name STACK_NAME ``` -1. Confirm the stack has been deleted - ```bash - aws cloudformation list-stacks --query "StackSummaries[?contains(StackName,'STACK_NAME')].StackStatus" + mise run cdk:destroy ``` + ---- Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/appsync-events-lambda-agnetcore-cdk/images/appsync-pubsub-publish.jpg b/appsync-events-lambda-agnetcore-cdk/images/appsync-pubsub-publish.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d58091df8a33aa61e5a0772ce92c5725ece5bb67 GIT binary patch literal 70483 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appsync-events-lambda-agnetcore-cdk/images/architecture.png b/appsync-events-lambda-agnetcore-cdk/images/architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..818b12d722d26ea40f9d26b1fbf7231f46f7152e GIT binary patch literal 68213 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 TCPJTICSvXTFZ29TaU1yER4&z5$ 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 Op*pN%tX-&S7y3UWm?q!= literal 0 HcmV?d00001 From ff1b7f1511096cd4d9c9e3f654aad8755589b9af Mon Sep 17 00:00:00 2001 From: Pete Davis Date: Wed, 11 Mar 2026 16:10:11 +0000 Subject: [PATCH 06/19] chore: remove unused code and add .DS_Store to gitignore --- appsync-events-lambda-agnetcore-cdk/.gitignore | 1 + appsync-events-lambda-agnetcore-cdk/agents/chat/Dockerfile | 1 - .../tests/integration/test_appsync_events.py | 5 ++--- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/appsync-events-lambda-agnetcore-cdk/.gitignore b/appsync-events-lambda-agnetcore-cdk/.gitignore index 548815fe42..5d72866e23 100644 --- a/appsync-events-lambda-agnetcore-cdk/.gitignore +++ b/appsync-events-lambda-agnetcore-cdk/.gitignore @@ -4,6 +4,7 @@ __pycache__ .pytest_cache .venv *.egg-info +.DS_Store # CDK asset staging directory .cdk.staging diff --git a/appsync-events-lambda-agnetcore-cdk/agents/chat/Dockerfile b/appsync-events-lambda-agnetcore-cdk/agents/chat/Dockerfile index 516c0ccd04..b447083188 100644 --- a/appsync-events-lambda-agnetcore-cdk/agents/chat/Dockerfile +++ b/appsync-events-lambda-agnetcore-cdk/agents/chat/Dockerfile @@ -9,7 +9,6 @@ RUN uv pip install --system --no-cache -r requirements.txt ARG AWS_REGION ENV AWS_REGION=${AWS_REGION} -ENV DOCKER_CONTAINER=1 RUN useradd -m -u 1000 bedrock_agentcore USER bedrock_agentcore diff --git a/appsync-events-lambda-agnetcore-cdk/tests/integration/test_appsync_events.py b/appsync-events-lambda-agnetcore-cdk/tests/integration/test_appsync_events.py index 795f78852b..a8f5d3540f 100644 --- a/appsync-events-lambda-agnetcore-cdk/tests/integration/test_appsync_events.py +++ b/appsync-events-lambda-agnetcore-cdk/tests/integration/test_appsync_events.py @@ -96,9 +96,8 @@ async def test_conversation_with_session(subscribe, publish): "sessionId": session_id, }) - events_1, complete_1 = await _collect_response(ws, sub_id) + _, complete_1 = await _collect_response(ws, sub_id) assert complete_1 is not None, "Turn 1 did not complete" - response_1 = complete_1.get("response", "") # Turn 2: ask a follow-up — agent should remember the blog post publish(publish_channel, { @@ -109,7 +108,7 @@ async def test_conversation_with_session(subscribe, publish): "sessionId": session_id, }) - events_2, complete_2 = await _collect_response(ws, sub_id) + _, complete_2 = await _collect_response(ws, sub_id) assert complete_2 is not None, "Turn 2 did not complete" response_2 = complete_2.get("response", "") From 235b83b64431c7ca4cde41b1096dda71f5abe39f Mon Sep 17 00:00:00 2001 From: Pete Davis Date: Wed, 11 Mar 2026 16:46:28 +0000 Subject: [PATCH 07/19] chore: add cdk-nag AwsSolutions checks with granular suppressions --- appsync-events-lambda-agnetcore-cdk/app.py | 3 + .../cdk/cdk_stack.py | 86 +++++++++++++++++++ .../cdk/constructs/appsync_events.py | 17 ++++ .../cdk/constructs/chat_agent.py | 45 ++++++++++ .../cdk/constructs/standard_lambda.py | 18 ++++ .../requirements.txt | 1 + 6 files changed, 170 insertions(+) diff --git a/appsync-events-lambda-agnetcore-cdk/app.py b/appsync-events-lambda-agnetcore-cdk/app.py index 3ef497bb5e..dd404660d3 100644 --- a/appsync-events-lambda-agnetcore-cdk/app.py +++ b/appsync-events-lambda-agnetcore-cdk/app.py @@ -2,6 +2,7 @@ import os import aws_cdk as cdk +from cdk_nag import AwsSolutionsChecks from cdk.cdk_stack import CdkStack @@ -20,4 +21,6 @@ env=cdk.Environment(region=region), ) +cdk.Aspects.of(app).add(AwsSolutionsChecks(verbose=True)) + app.synth() diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/cdk_stack.py b/appsync-events-lambda-agnetcore-cdk/cdk/cdk_stack.py index 35ac3dcd1b..01fd348378 100644 --- a/appsync-events-lambda-agnetcore-cdk/cdk/cdk_stack.py +++ b/appsync-events-lambda-agnetcore-cdk/cdk/cdk_stack.py @@ -1,6 +1,7 @@ """Main CDK stack for the AppSync Events + Lambda + AgentCore architecture.""" from aws_cdk import Stack +from cdk_nag import NagSuppressions from constructs import Construct from cdk.constructs.appsync_events import AppSyncEventsConstruct @@ -37,3 +38,88 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: event_api=self.appsync_events.api, stream_relay_function=self.stream_relay.lambda_function.function, ) + + self._add_nag_suppressions() + + def _add_nag_suppressions(self): + """Add cdk-nag suppressions for CDK-managed resources and grant_invoke wildcards.""" + + # CDK's LogRetention custom resource Lambda — managed by CDK, not user code. + # Suppress by finding the LogRetention singleton via node tree traversal + # to avoid hardcoding the hash suffix in the construct path. + for child in self.node.children: + if child.node.id.startswith("LogRetention"): + NagSuppressions.add_resource_suppressions( + child, + [ + { + "id": "AwsSolutions-IAM4", + "reason": "CDK LogRetention custom resource uses AWS managed policy.", + "applies_to": [ + "Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + ], + }, + { + "id": "AwsSolutions-IAM5", + "reason": "CDK LogRetention custom resource requires wildcard to set retention on any log group.", + "applies_to": ["Resource::*"], + }, + ], + apply_to_children=True, + ) + + # grant_invoke adds :* suffix for Lambda version/alias invocations. + # The finding string includes the target Lambda's logical ID. + NagSuppressions.add_resource_suppressions( + self.agent_invoker, + [ + { + "id": "AwsSolutions-IAM5", + "reason": "grant_invoke appends :* to allow invocation of any version/alias of the target Lambda.", + "applies_to": [ + "Resource:::*", + ], + }, + { + "id": "AwsSolutions-IAM5", + "reason": "X-Ray actions do not support resource-level permissions.", + "applies_to": ["Resource::*"], + }, + ], + apply_to_children=True, + ) + + # AppSync data source role for invoking the agent invoker Lambda + NagSuppressions.add_resource_suppressions( + self.appsync_events, + [ + { + "id": "AwsSolutions-IAM5", + "reason": "AppSync data source grant_invoke appends :* for Lambda version/alias invocations.", + "applies_to": [ + "Resource:::*", + ], + }, + ], + apply_to_children=True, + ) + + # Stream relay: AgentCore runtime ARN wildcard and X-Ray permissions + NagSuppressions.add_resource_suppressions( + self.stream_relay, + [ + { + "id": "AwsSolutions-IAM5", + "reason": "AgentCore runtime ARN requires /* suffix for endpoint invocation.", + "applies_to": [ + "Resource::/*", + ], + }, + { + "id": "AwsSolutions-IAM5", + "reason": "X-Ray actions do not support resource-level permissions.", + "applies_to": ["Resource::*"], + }, + ], + apply_to_children=True, + ) diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/appsync_events.py b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/appsync_events.py index 27464e8f97..a4e5b3b201 100644 --- a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/appsync_events.py +++ b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/appsync_events.py @@ -6,6 +6,7 @@ aws_appsync as appsync, aws_logs as logs, ) +from cdk_nag import NagSuppressions class AppSyncEventsConstruct(Construct): @@ -50,3 +51,19 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: CfnOutput(self, "EventApiHttpEndpoint", value=self.api.http_dns) CfnOutput(self, "EventApiRealtimeEndpoint", value=self.api.realtime_dns) CfnOutput(self, "EventApiApiKey", value=self.api.api_keys["Default"].attr_api_key) + + # Suppress cdk-nag for the AppSync managed logging policy. + # CDK creates this role automatically when log_config is set. + NagSuppressions.add_resource_suppressions( + self.api, + [ + { + "id": "AwsSolutions-IAM4", + "reason": "AWSAppSyncPushToCloudWatchLogs is the standard managed policy for AppSync API logging.", + "applies_to": [ + "Policy::arn::iam::aws:policy/service-role/AWSAppSyncPushToCloudWatchLogs", + ], + }, + ], + apply_to_children=True, + ) diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_agent.py b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_agent.py index dfdbcc8992..4ade6cd852 100644 --- a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_agent.py +++ b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_agent.py @@ -17,6 +17,7 @@ aws_iam as iam, aws_s3 as s3, ) +from cdk_nag import NagSuppressions class ChatAgentConstruct(Construct): @@ -103,6 +104,50 @@ def __init__( value=self.session_bucket.bucket_name, ) + # --- cdk-nag suppressions --- + + NagSuppressions.add_resource_suppressions( + self.session_bucket, + [{"id": "AwsSolutions-S1", "reason": "Access logs not required for sample code."}], + ) + + NagSuppressions.add_resource_suppressions( + self.runtime_role, + [ + { + "id": "AwsSolutions-IAM5", + "reason": "Bedrock foundation model ARNs require wildcards for model version flexibility.", + "applies_to": [ + "Resource::arn:aws:bedrock:*::foundation-model/anthropic.claude-*", + f"Resource::arn:aws:bedrock:{stack.region}::inference-profile/*", + ], + }, + { + "id": "AwsSolutions-IAM5", + "reason": ( + "ecr:GetAuthorizationToken requires Resource::* (account-wide token, cannot be scoped). " + "X-Ray actions (PutTraceSegments, PutTelemetryRecords, GetSamplingRules, GetSamplingTargets) " + "do not support resource-level permissions per AWS documentation." + ), + "applies_to": ["Resource::*"], + }, + { + "id": "AwsSolutions-IAM5", + "reason": "S3 session objects require wildcard suffix for read/write/delete operations.", + "applies_to": [ + "Resource::/*", + ], + }, + { + "id": "AwsSolutions-IAM5", + "reason": "AgentCore runtime log group name is determined at deploy time.", + "applies_to": [ + f"Resource::arn:aws:logs:{stack.region}::log-group:/aws/bedrock-agentcore/runtimes/*", + ], + }, + ], + ) + @staticmethod def _build_policies(stack, agent_image, session_bucket): """Build IAM inline policies for the runtime.""" diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/standard_lambda.py b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/standard_lambda.py index d9c0af2c44..567ae3c3e5 100644 --- a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/standard_lambda.py +++ b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/standard_lambda.py @@ -8,6 +8,7 @@ Stack, ) from constructs import Construct +from cdk_nag import NagSuppressions class StandardLambda(Construct): @@ -137,6 +138,23 @@ def __init__( # my_table.grant_read_write_data(my_lambda.function) self.role = self.function.role + # Suppress cdk-nag for the AWS managed Lambda basic execution role. + # CDK attaches this automatically and it is the standard practice + # for Lambda functions. + NagSuppressions.add_resource_suppressions( + self.function, + [ + { + "id": "AwsSolutions-IAM4", + "reason": "AWSLambdaBasicExecutionRole is the standard managed policy for Lambda logging.", + "applies_to": [ + "Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + ], + }, + ], + apply_to_children=True, + ) + def _build_code(self, code_path: str, runtime: lambda_.Runtime) -> lambda_.Code: """ Build the Lambda deployment package with automatic dependency detection. diff --git a/appsync-events-lambda-agnetcore-cdk/requirements.txt b/appsync-events-lambda-agnetcore-cdk/requirements.txt index 83cee3900e..8bd62fdee4 100644 --- a/appsync-events-lambda-agnetcore-cdk/requirements.txt +++ b/appsync-events-lambda-agnetcore-cdk/requirements.txt @@ -1,2 +1,3 @@ aws-cdk-lib>=2.242.0,<3.0.0 constructs>=10.5.1,<11.0.0 +cdk-nag>=2.35.0,<3.0.0 From c11bf54b6dd2fb80f32259f6d3cb4ad4334f2949 Mon Sep 17 00:00:00 2001 From: Pete Davis Date: Wed, 11 Mar 2026 17:15:37 +0000 Subject: [PATCH 08/19] refactor: consolidate naming conventions and construct structure --- appsync-events-lambda-agnetcore-cdk/app.py | 4 +- .../cdk/cdk_stack.py | 125 -------------- .../cdk/constructs/agent_invoker.py | 59 ------- .../cdk/constructs/appsync_events.py | 69 -------- .../cdk/constructs/chat_agent.py | 4 +- .../cdk/constructs/chat_service.py | 137 ++++++++++++++++ .../cdk/constructs/stream_relay.py | 10 +- .../cdk/stack.py | 153 ++++++++++++++++++ .../tests/integration/conftest.py | 6 +- 9 files changed, 300 insertions(+), 267 deletions(-) delete mode 100644 appsync-events-lambda-agnetcore-cdk/cdk/cdk_stack.py delete mode 100644 appsync-events-lambda-agnetcore-cdk/cdk/constructs/agent_invoker.py delete mode 100644 appsync-events-lambda-agnetcore-cdk/cdk/constructs/appsync_events.py create mode 100644 appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_service.py create mode 100644 appsync-events-lambda-agnetcore-cdk/cdk/stack.py diff --git a/appsync-events-lambda-agnetcore-cdk/app.py b/appsync-events-lambda-agnetcore-cdk/app.py index dd404660d3..62425a8b01 100644 --- a/appsync-events-lambda-agnetcore-cdk/app.py +++ b/appsync-events-lambda-agnetcore-cdk/app.py @@ -4,7 +4,7 @@ import aws_cdk as cdk from cdk_nag import AwsSolutionsChecks -from cdk.cdk_stack import CdkStack +from cdk.stack import ChatStack app = cdk.App() @@ -15,7 +15,7 @@ if not region: raise EnvironmentError("AWS_REGION environment variable must be set") -CdkStack( +ChatStack( app, stack_name, env=cdk.Environment(region=region), diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/cdk_stack.py b/appsync-events-lambda-agnetcore-cdk/cdk/cdk_stack.py deleted file mode 100644 index 01fd348378..0000000000 --- a/appsync-events-lambda-agnetcore-cdk/cdk/cdk_stack.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Main CDK stack for the AppSync Events + Lambda + AgentCore architecture.""" - -from aws_cdk import Stack -from cdk_nag import NagSuppressions -from constructs import Construct - -from cdk.constructs.appsync_events import AppSyncEventsConstruct -from cdk.constructs.agent_invoker import AgentInvokerConstruct -from cdk.constructs.chat_agent import ChatAgentConstruct -from cdk.constructs.stream_relay import StreamRelayConstruct - - -class CdkStack(Stack): - """AppSync Events chat stack with Lambda invoker and AgentCore runtime.""" - - def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: - super().__init__(scope, construct_id, **kwargs) - - self.appsync_events = AppSyncEventsConstruct(self, "AppSyncEvents") - - self.chat_agent = ChatAgentConstruct( - self, - "AgentCoreRuntime", - model_id=self.node.try_get_context("model_id"), - ) - - self.stream_relay = StreamRelayConstruct( - self, - "StreamRelay", - agent_runtime_arn=self.chat_agent.runtime.attr_agent_runtime_arn, - appsync_http_endpoint=self.appsync_events.api.http_dns, - appsync_api_key=self.appsync_events.api.api_keys["Default"].attr_api_key, - ) - - self.agent_invoker = AgentInvokerConstruct( - self, - "AgentInvoker", - event_api=self.appsync_events.api, - stream_relay_function=self.stream_relay.lambda_function.function, - ) - - self._add_nag_suppressions() - - def _add_nag_suppressions(self): - """Add cdk-nag suppressions for CDK-managed resources and grant_invoke wildcards.""" - - # CDK's LogRetention custom resource Lambda — managed by CDK, not user code. - # Suppress by finding the LogRetention singleton via node tree traversal - # to avoid hardcoding the hash suffix in the construct path. - for child in self.node.children: - if child.node.id.startswith("LogRetention"): - NagSuppressions.add_resource_suppressions( - child, - [ - { - "id": "AwsSolutions-IAM4", - "reason": "CDK LogRetention custom resource uses AWS managed policy.", - "applies_to": [ - "Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", - ], - }, - { - "id": "AwsSolutions-IAM5", - "reason": "CDK LogRetention custom resource requires wildcard to set retention on any log group.", - "applies_to": ["Resource::*"], - }, - ], - apply_to_children=True, - ) - - # grant_invoke adds :* suffix for Lambda version/alias invocations. - # The finding string includes the target Lambda's logical ID. - NagSuppressions.add_resource_suppressions( - self.agent_invoker, - [ - { - "id": "AwsSolutions-IAM5", - "reason": "grant_invoke appends :* to allow invocation of any version/alias of the target Lambda.", - "applies_to": [ - "Resource:::*", - ], - }, - { - "id": "AwsSolutions-IAM5", - "reason": "X-Ray actions do not support resource-level permissions.", - "applies_to": ["Resource::*"], - }, - ], - apply_to_children=True, - ) - - # AppSync data source role for invoking the agent invoker Lambda - NagSuppressions.add_resource_suppressions( - self.appsync_events, - [ - { - "id": "AwsSolutions-IAM5", - "reason": "AppSync data source grant_invoke appends :* for Lambda version/alias invocations.", - "applies_to": [ - "Resource:::*", - ], - }, - ], - apply_to_children=True, - ) - - # Stream relay: AgentCore runtime ARN wildcard and X-Ray permissions - NagSuppressions.add_resource_suppressions( - self.stream_relay, - [ - { - "id": "AwsSolutions-IAM5", - "reason": "AgentCore runtime ARN requires /* suffix for endpoint invocation.", - "applies_to": [ - "Resource::/*", - ], - }, - { - "id": "AwsSolutions-IAM5", - "reason": "X-Ray actions do not support resource-level permissions.", - "applies_to": ["Resource::*"], - }, - ], - apply_to_children=True, - ) diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/agent_invoker.py b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/agent_invoker.py deleted file mode 100644 index a1a226c74c..0000000000 --- a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/agent_invoker.py +++ /dev/null @@ -1,59 +0,0 @@ -"""CDK Construct for the Agent Invoker Lambda integrated with AppSync Events. - -Thin dispatcher — receives events from AppSync, invokes the stream relay -Lambda asynchronously, and returns immediately. -""" - -from aws_cdk import ( - Duration, - aws_appsync as appsync, - aws_lambda as lambda_, -) -from constructs import Construct - -from cdk.constructs.standard_lambda import StandardLambda - - -class AgentInvokerConstruct(Construct): - """Creates the agent invoker Lambda and wires it to an AppSync Event API.""" - - def __init__( - self, - scope: Construct, - construct_id: str, - event_api: appsync.EventApi, - stream_relay_function: lambda_.IFunction, - **kwargs, - ) -> None: - super().__init__(scope, construct_id, **kwargs) - - self.lambda_function = StandardLambda( - self, - "AgentInvokerLambda", - handler="index.handler", - code_path="functions/agent_invoker", - timeout=Duration.seconds(10), - environment={ - "STREAM_RELAY_ARN": stream_relay_function.function_arn, - }, - ) - - # Grant permission to invoke the stream relay async - stream_relay_function.grant_invoke(self.lambda_function.function) - - # Register Lambda as a data source on the Event API - lambda_ds = event_api.add_lambda_data_source( - "AgentInvokerDS", - self.lambda_function.function, - ) - - # Add the chat namespace with direct Lambda integration - # Validation is handled in the Lambda (direct mode cannot use JS handlers) - event_api.add_channel_namespace( - "chat", - publish_handler_config=appsync.HandlerConfig( - data_source=lambda_ds, - direct=True, - lambda_invoke_type=appsync.LambdaInvokeType.REQUEST_RESPONSE, - ), - ) diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/appsync_events.py b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/appsync_events.py deleted file mode 100644 index a4e5b3b201..0000000000 --- a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/appsync_events.py +++ /dev/null @@ -1,69 +0,0 @@ -"""CDK Construct for AppSync Events API used by the chat interface.""" - -from constructs import Construct -from aws_cdk import ( - CfnOutput, - aws_appsync as appsync, - aws_logs as logs, -) -from cdk_nag import NagSuppressions - - -class AppSyncEventsConstruct(Construct): - """Creates an AppSync Event API with a chat channel namespace.""" - - def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: - super().__init__(scope, construct_id, **kwargs) - - # Auth provider — using API key for now, swap to Cognito/IAM as needed - api_key_provider = appsync.AppSyncAuthProvider( - authorization_type=appsync.AppSyncAuthorizationType.API_KEY, - ) - - # Event API - self.api = appsync.EventApi( - self, - "ChatEventApi", - api_name="ChatEventApi", - authorization_config=appsync.EventApiAuthConfig( - auth_providers=[api_key_provider], - connection_auth_mode_types=[ - appsync.AppSyncAuthorizationType.API_KEY, - ], - default_publish_auth_mode_types=[ - appsync.AppSyncAuthorizationType.API_KEY, - ], - default_subscribe_auth_mode_types=[ - appsync.AppSyncAuthorizationType.API_KEY, - ], - ), - log_config=appsync.AppSyncLogConfig( - field_log_level=appsync.AppSyncFieldLogLevel.INFO, - retention=logs.RetentionDays.ONE_WEEK, - ), - ) - - # Responses namespace — no handler, used by stream relay to publish - # agent responses without re-triggering the invoker Lambda - self.api.add_channel_namespace("responses") - - # Outputs - CfnOutput(self, "EventApiHttpEndpoint", value=self.api.http_dns) - CfnOutput(self, "EventApiRealtimeEndpoint", value=self.api.realtime_dns) - CfnOutput(self, "EventApiApiKey", value=self.api.api_keys["Default"].attr_api_key) - - # Suppress cdk-nag for the AppSync managed logging policy. - # CDK creates this role automatically when log_config is set. - NagSuppressions.add_resource_suppressions( - self.api, - [ - { - "id": "AwsSolutions-IAM4", - "reason": "AWSAppSyncPushToCloudWatchLogs is the standard managed policy for AppSync API logging.", - "applies_to": [ - "Policy::arn::iam::aws:policy/service-role/AWSAppSyncPushToCloudWatchLogs", - ], - }, - ], - apply_to_children=True, - ) diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_agent.py b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_agent.py index 4ade6cd852..f5504388e5 100644 --- a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_agent.py +++ b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_agent.py @@ -60,7 +60,7 @@ def __init__( # IAM role for the runtime self.runtime_role = iam.Role( self, - "Role", + "RuntimeRole", assumed_by=iam.ServicePrincipal( "bedrock-agentcore.amazonaws.com", ), @@ -135,7 +135,7 @@ def __init__( "id": "AwsSolutions-IAM5", "reason": "S3 session objects require wildcard suffix for read/write/delete operations.", "applies_to": [ - "Resource::/*", + "Resource::/*", ], }, { diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_service.py b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_service.py new file mode 100644 index 0000000000..d6ac02a3b6 --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_service.py @@ -0,0 +1,137 @@ +"""CDK Construct for the AppSync Events API and its chat handler Lambda. + +Creates: +- AppSync Event API with API key auth +- Agent invoker Lambda (thin dispatcher) wired as a direct Lambda + integration on the ``chat`` channel namespace +- Responses namespace for outbound streaming (no handler) +""" + +from constructs import Construct +from aws_cdk import ( + CfnOutput, + Duration, + aws_appsync as appsync, + aws_lambda as lambda_, + aws_logs as logs, +) +from cdk_nag import NagSuppressions + +from cdk.constructs.standard_lambda import StandardLambda + + +class ChatServiceConstruct(Construct): + """AppSync Event API with an integrated chat handler Lambda.""" + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + stream_relay_function: lambda_.IFunction, + **kwargs, + ) -> None: + super().__init__(scope, construct_id, **kwargs) + + # --- Event API --- + + api_key_provider = appsync.AppSyncAuthProvider( + authorization_type=appsync.AppSyncAuthorizationType.API_KEY, + ) + + self.api = appsync.EventApi( + self, + "EventApi", + api_name="ChatEventApi", + authorization_config=appsync.EventApiAuthConfig( + auth_providers=[api_key_provider], + connection_auth_mode_types=[ + appsync.AppSyncAuthorizationType.API_KEY, + ], + default_publish_auth_mode_types=[ + appsync.AppSyncAuthorizationType.API_KEY, + ], + default_subscribe_auth_mode_types=[ + appsync.AppSyncAuthorizationType.API_KEY, + ], + ), + log_config=appsync.AppSyncLogConfig( + field_log_level=appsync.AppSyncFieldLogLevel.INFO, + retention=logs.RetentionDays.ONE_WEEK, + ), + ) + + # Responses namespace — no handler, used by stream relay to publish + # agent responses without re-triggering the invoker Lambda + self.api.add_channel_namespace("responses") + + # --- Agent invoker Lambda (thin dispatcher) --- + + self.agent_invoker = StandardLambda( + self, + "AgentInvoker", + handler="index.handler", + code_path="functions/agent_invoker", + timeout=Duration.seconds(10), + environment={ + "STREAM_RELAY_ARN": stream_relay_function.function_arn, + }, + ) + + # Grant permission to invoke the stream relay async + stream_relay_function.grant_invoke( + self.agent_invoker.function, + ) + + # Register Lambda as a data source on the Event API + lambda_ds = self.api.add_lambda_data_source( + "AgentInvokerDS", + self.agent_invoker.function, + ) + + # Chat namespace with direct Lambda integration + self.api.add_channel_namespace( + "chat", + publish_handler_config=appsync.HandlerConfig( + data_source=lambda_ds, + direct=True, + lambda_invoke_type=( + appsync.LambdaInvokeType.REQUEST_RESPONSE + ), + ), + ) + + # --- Outputs --- + + CfnOutput( + self, "EventApiHttpEndpoint", + value=self.api.http_dns, + ) + CfnOutput( + self, "EventApiRealtimeEndpoint", + value=self.api.realtime_dns, + ) + CfnOutput( + self, "EventApiApiKey", + value=self.api.api_keys["Default"].attr_api_key, + ) + + # --- cdk-nag suppressions --- + + NagSuppressions.add_resource_suppressions( + self.api, + [ + { + "id": "AwsSolutions-IAM4", + "reason": ( + "AWSAppSyncPushToCloudWatchLogs is the standard " + "managed policy for AppSync API logging." + ), + "applies_to": [ + "Policy::arn::iam::aws:policy/" + "service-role/AWSAppSyncPushToCloudWatchLogs", + ], + }, + ], + apply_to_children=True, + ) diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/stream_relay.py b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/stream_relay.py index 409d59fcfa..0b7de92187 100644 --- a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/stream_relay.py +++ b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/stream_relay.py @@ -22,27 +22,23 @@ def __init__( construct_id: str, *, agent_runtime_arn: str, - appsync_http_endpoint: str, - appsync_api_key: str, **kwargs, ) -> None: super().__init__(scope, construct_id, **kwargs) - self.lambda_function = StandardLambda( + self.standard_lambda = StandardLambda( self, - "StreamRelayLambda", + "Lambda", handler="index.handler", code_path="functions/stream_relay", timeout=Duration.minutes(5), environment={ "AGENT_RUNTIME_ARN": agent_runtime_arn, - "APPSYNC_HTTP_ENDPOINT": appsync_http_endpoint, - "APPSYNC_API_KEY": appsync_api_key, }, ) # Grant permission to invoke AgentCore runtime - self.lambda_function.function.add_to_role_policy( + self.standard_lambda.function.add_to_role_policy( iam.PolicyStatement( actions=["bedrock-agentcore:InvokeAgentRuntime"], resources=[ diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/stack.py b/appsync-events-lambda-agnetcore-cdk/cdk/stack.py new file mode 100644 index 0000000000..51c4b59c63 --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/cdk/stack.py @@ -0,0 +1,153 @@ +"""Main CDK stack for the AppSync Events + Lambda + AgentCore architecture.""" + +from aws_cdk import Stack +from cdk_nag import NagSuppressions +from constructs import Construct + +from cdk.constructs.chat_service import ChatServiceConstruct +from cdk.constructs.chat_agent import ChatAgentConstruct +from cdk.constructs.stream_relay import StreamRelayConstruct + + +class ChatStack(Stack): + """AppSync Events chat stack with Lambda invoker and AgentCore runtime.""" + + def __init__( + self, scope: Construct, construct_id: str, **kwargs, + ) -> None: + super().__init__(scope, construct_id, **kwargs) + + self.chat_agent = ChatAgentConstruct( + self, + "ChatAgent", + model_id=self.node.try_get_context("model_id"), + ) + + self.stream_relay = StreamRelayConstruct( + self, + "StreamRelay", + agent_runtime_arn=( + self.chat_agent.runtime.attr_agent_runtime_arn + ), + ) + + self.chat_service = ChatServiceConstruct( + self, + "ChatService", + stream_relay_function=( + self.stream_relay.standard_lambda.function + ), + ) + + # Stream relay needs the AppSync endpoint and API key + # to publish chunks — add them after the API is created. + self.stream_relay.standard_lambda.function.add_environment( + "APPSYNC_HTTP_ENDPOINT", + self.chat_service.api.http_dns, + ) + self.stream_relay.standard_lambda.function.add_environment( + "APPSYNC_API_KEY", + self.chat_service.api.api_keys["Default"].attr_api_key, + ) + + self._add_nag_suppressions() + + def _add_nag_suppressions(self): + """Add cdk-nag suppressions for CDK-managed resources.""" + + # CDK's LogRetention custom resource Lambda + for child in self.node.children: + if child.node.id.startswith("LogRetention"): + NagSuppressions.add_resource_suppressions( + child, + [ + { + "id": "AwsSolutions-IAM4", + "reason": ( + "CDK LogRetention custom resource " + "uses AWS managed policy." + ), + "applies_to": [ + "Policy::arn::iam::aws:" + "policy/service-role/" + "AWSLambdaBasicExecutionRole", + ], + }, + { + "id": "AwsSolutions-IAM5", + "reason": ( + "CDK LogRetention custom resource " + "requires wildcard to set retention " + "on any log group." + ), + "applies_to": ["Resource::*"], + }, + ], + apply_to_children=True, + ) + + # AppSync Events construct: grant_invoke wildcards and + # X-Ray Resource::* for the agent invoker Lambda + NagSuppressions.add_resource_suppressions( + self.chat_service, + [ + { + "id": "AwsSolutions-IAM5", + "reason": ( + "grant_invoke appends :* to allow invocation " + "of any version/alias of the target Lambda." + ), + "applies_to": [ + "Resource:::*", + ], + }, + { + "id": "AwsSolutions-IAM5", + "reason": ( + "AppSync data source grant_invoke appends :* " + "for Lambda version/alias invocations." + ), + "applies_to": [ + "Resource:::*", + ], + }, + { + "id": "AwsSolutions-IAM5", + "reason": ( + "X-Ray actions do not support " + "resource-level permissions." + ), + "applies_to": ["Resource::*"], + }, + ], + apply_to_children=True, + ) + + # Stream relay: AgentCore runtime ARN wildcard and X-Ray + NagSuppressions.add_resource_suppressions( + self.stream_relay, + [ + { + "id": "AwsSolutions-IAM5", + "reason": ( + "AgentCore runtime ARN requires /* suffix " + "for endpoint invocation." + ), + "applies_to": [ + "Resource::/*", + ], + }, + { + "id": "AwsSolutions-IAM5", + "reason": ( + "X-Ray actions do not support " + "resource-level permissions." + ), + "applies_to": ["Resource::*"], + }, + ], + apply_to_children=True, + ) diff --git a/appsync-events-lambda-agnetcore-cdk/tests/integration/conftest.py b/appsync-events-lambda-agnetcore-cdk/tests/integration/conftest.py index a16f2aa22c..71a7303ea4 100644 --- a/appsync-events-lambda-agnetcore-cdk/tests/integration/conftest.py +++ b/appsync-events-lambda-agnetcore-cdk/tests/integration/conftest.py @@ -15,9 +15,9 @@ # --------------------------------------------------------------------------- # Output key prefixes (CDK appends hash suffixes) # --------------------------------------------------------------------------- -_PREFIX_HTTP = "AppSyncEventsEventApiHttpEndpoint" -_PREFIX_WS = "AppSyncEventsEventApiRealtimeEndpoint" -_PREFIX_KEY = "AppSyncEventsEventApiApiKey" +_PREFIX_HTTP = "ChatServiceEventApiHttpEndpoint" +_PREFIX_WS = "ChatServiceEventApiRealtimeEndpoint" +_PREFIX_KEY = "ChatServiceEventApiApiKey" _DEFAULT_STACK_NAME = "AppsyncLambdaAgentcore" From a1b0be480bf4dd42b758f7c258d7fc253d8b8d66 Mon Sep 17 00:00:00 2001 From: Pete Davis Date: Wed, 11 Mar 2026 17:28:17 +0000 Subject: [PATCH 09/19] feat(init): install all requirements files and add Windows fallback --- appsync-events-lambda-agnetcore-cdk/mise.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/appsync-events-lambda-agnetcore-cdk/mise.toml b/appsync-events-lambda-agnetcore-cdk/mise.toml index 4f146c94ca..6ac54ea2a7 100644 --- a/appsync-events-lambda-agnetcore-cdk/mise.toml +++ b/appsync-events-lambda-agnetcore-cdk/mise.toml @@ -13,6 +13,10 @@ uv = "latest" [tasks.init] description = "Initialise the environment" run = [ + "for f in functions/*/requirements.txt agents/*/requirements.txt requirements.txt requirements-dev.txt; do [ -f \"$f\" ] && uv pip install -r \"$f\"; done", + "cdk bootstrap" +] +run_windows = [ "uv pip install -r requirements.txt", "uv pip install -r requirements-dev.txt", "cdk bootstrap" From 1c3e48665cf436af89b412b62faa020411d5ef0f Mon Sep 17 00:00:00 2001 From: Pete Davis Date: Wed, 11 Mar 2026 17:51:14 +0000 Subject: [PATCH 10/19] style: fix formatting and minor linting issues --- appsync-events-lambda-agnetcore-cdk/.pylintrc | 2 + appsync-events-lambda-agnetcore-cdk/app.py | 1 + .../cdk/constructs/__init__.py | 1 - .../cdk/constructs/chat_agent.py | 14 +- .../cdk/constructs/chat_service.py | 21 ++- .../cdk/constructs/standard_lambda.py | 13 +- .../cdk/stack.py | 62 ++------ .../functions/agent_invoker/index.py | 38 +++-- .../functions/stream_relay/index.py | 71 +++++---- .../pyproject.toml | 2 + .../tests/integration/__init__.py | 1 - .../tests/integration/conftest.py | 54 ++++--- .../tests/integration/test_appsync_events.py | 149 ++++++++++-------- .../tests/unit/conftest.py | 13 +- .../tests/unit/test_agent_invoker.py | 15 +- .../tests/unit/test_stream_relay.py | 52 +++--- 16 files changed, 265 insertions(+), 244 deletions(-) create mode 100644 appsync-events-lambda-agnetcore-cdk/.pylintrc create mode 100644 appsync-events-lambda-agnetcore-cdk/pyproject.toml diff --git a/appsync-events-lambda-agnetcore-cdk/.pylintrc b/appsync-events-lambda-agnetcore-cdk/.pylintrc new file mode 100644 index 0000000000..d889384734 --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/.pylintrc @@ -0,0 +1,2 @@ +[format] +max-line-length=150 diff --git a/appsync-events-lambda-agnetcore-cdk/app.py b/appsync-events-lambda-agnetcore-cdk/app.py index 62425a8b01..8cbd5e0773 100644 --- a/appsync-events-lambda-agnetcore-cdk/app.py +++ b/appsync-events-lambda-agnetcore-cdk/app.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +"""CDK app entrypoint for the AppSync Events + Lambda + AgentCore stack.""" import os import aws_cdk as cdk diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/__init__.py b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/__init__.py index 8b13789179..e69de29bb2 100644 --- a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/__init__.py +++ b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/__init__.py @@ -1 +0,0 @@ - diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_agent.py b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_agent.py index f5504388e5..5d07788513 100644 --- a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_agent.py +++ b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_agent.py @@ -65,7 +65,9 @@ def __init__( "bedrock-agentcore.amazonaws.com", ), inline_policies=self._build_policies( - stack, agent_image, self.session_bucket, + stack, + agent_image, + self.session_bucket, ), ) @@ -100,7 +102,8 @@ def __init__( CfnOutput(self, "RuntimeArn", value=self.runtime.attr_agent_runtime_arn) CfnOutput(self, "RuntimeName", value=runtime_name) CfnOutput( - self, "SessionBucketName", + self, + "SessionBucketName", value=self.session_bucket.bucket_name, ) @@ -108,7 +111,12 @@ def __init__( NagSuppressions.add_resource_suppressions( self.session_bucket, - [{"id": "AwsSolutions-S1", "reason": "Access logs not required for sample code."}], + [ + { + "id": "AwsSolutions-S1", + "reason": "Access logs not required for sample code.", + } + ], ) NagSuppressions.add_resource_suppressions( diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_service.py b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_service.py index d6ac02a3b6..ae7a24f163 100644 --- a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_service.py +++ b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_service.py @@ -95,24 +95,25 @@ def __init__( publish_handler_config=appsync.HandlerConfig( data_source=lambda_ds, direct=True, - lambda_invoke_type=( - appsync.LambdaInvokeType.REQUEST_RESPONSE - ), + lambda_invoke_type=(appsync.LambdaInvokeType.REQUEST_RESPONSE), ), ) # --- Outputs --- CfnOutput( - self, "EventApiHttpEndpoint", + self, + "EventApiHttpEndpoint", value=self.api.http_dns, ) CfnOutput( - self, "EventApiRealtimeEndpoint", + self, + "EventApiRealtimeEndpoint", value=self.api.realtime_dns, ) CfnOutput( - self, "EventApiApiKey", + self, + "EventApiApiKey", value=self.api.api_keys["Default"].attr_api_key, ) @@ -123,13 +124,9 @@ def __init__( [ { "id": "AwsSolutions-IAM4", - "reason": ( - "AWSAppSyncPushToCloudWatchLogs is the standard " - "managed policy for AppSync API logging." - ), + "reason": ("AWSAppSyncPushToCloudWatchLogs is the standard " "managed policy for AppSync API logging."), "applies_to": [ - "Policy::arn::iam::aws:policy/" - "service-role/AWSAppSyncPushToCloudWatchLogs", + "Policy::arn::iam::aws:policy/" "service-role/AWSAppSyncPushToCloudWatchLogs", ], }, ], diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/standard_lambda.py b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/standard_lambda.py index 567ae3c3e5..6c7d5a8a7e 100644 --- a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/standard_lambda.py +++ b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/standard_lambda.py @@ -1,3 +1,5 @@ +"""Reusable Lambda blueprint construct with Powertools, X-Ray, and log groups.""" + from pathlib import Path from aws_cdk import ( aws_lambda as lambda_, @@ -104,12 +106,11 @@ def __init__( # take precedence over defaults (e.g. a custom timeout or memory_size). merged_config = {**defaults, **kwargs} - # Merge layers additively — the consumer's layers are appended # after the Powertools layer so all layers are included. if user_layers is not None: merged_config["layers"] = defaults.get("layers", []) + user_layers - + # Merge environment variables additively — the consumer's env vars # are added alongside the Powertools defaults, not replacing them. if user_environment is not None: @@ -119,16 +120,14 @@ def __init__( } # Create the Lambda function with the merged configuration. - self.function = lambda_.Function( - self, "Function", handler=handler, code=code, **merged_config - ) + self.function = lambda_.Function(self, "Function", handler=handler, code=code, **merged_config) # Expose convenience attributes so to keep the syntax similar to a standard lambda self.function_arn = self.function.function_arn self.function_name = self.function.function_name # Grant the Lambda function permission to write logs to its dedicated - # CloudWatch Log Group. + # CloudWatch Log Group. log_group.grant_write(self.function) # Expose the function's IAM execution role as a public attribute. @@ -183,7 +182,7 @@ def _build_code(self, code_path: str, runtime: lambda_.Runtime) -> lambda_.Code: "-c", " && ".join( [ - # Install dependencies + # Install dependencies "pip install -r requirements.txt -t /asset-output/", # Copy the function source code alongside the # installed dependencies diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/stack.py b/appsync-events-lambda-agnetcore-cdk/cdk/stack.py index 51c4b59c63..16882e52ee 100644 --- a/appsync-events-lambda-agnetcore-cdk/cdk/stack.py +++ b/appsync-events-lambda-agnetcore-cdk/cdk/stack.py @@ -13,7 +13,10 @@ class ChatStack(Stack): """AppSync Events chat stack with Lambda invoker and AgentCore runtime.""" def __init__( - self, scope: Construct, construct_id: str, **kwargs, + self, + scope: Construct, + construct_id: str, + **kwargs, ) -> None: super().__init__(scope, construct_id, **kwargs) @@ -26,17 +29,13 @@ def __init__( self.stream_relay = StreamRelayConstruct( self, "StreamRelay", - agent_runtime_arn=( - self.chat_agent.runtime.attr_agent_runtime_arn - ), + agent_runtime_arn=(self.chat_agent.runtime.attr_agent_runtime_arn), ) self.chat_service = ChatServiceConstruct( self, "ChatService", - stream_relay_function=( - self.stream_relay.standard_lambda.function - ), + stream_relay_function=(self.stream_relay.standard_lambda.function), ) # Stream relay needs the AppSync endpoint and API key @@ -63,23 +62,14 @@ def _add_nag_suppressions(self): [ { "id": "AwsSolutions-IAM4", - "reason": ( - "CDK LogRetention custom resource " - "uses AWS managed policy." - ), + "reason": ("CDK LogRetention custom resource " "uses AWS managed policy."), "applies_to": [ - "Policy::arn::iam::aws:" - "policy/service-role/" - "AWSLambdaBasicExecutionRole", + "Policy::arn::iam::aws:" + "policy/service-role/" + "AWSLambdaBasicExecutionRole", ], }, { "id": "AwsSolutions-IAM5", - "reason": ( - "CDK LogRetention custom resource " - "requires wildcard to set retention " - "on any log group." - ), + "reason": ("CDK LogRetention custom resource " + "requires wildcard to set retention " + "on any log group."), "applies_to": ["Resource::*"], }, ], @@ -93,32 +83,21 @@ def _add_nag_suppressions(self): [ { "id": "AwsSolutions-IAM5", - "reason": ( - "grant_invoke appends :* to allow invocation " - "of any version/alias of the target Lambda." - ), + "reason": ("grant_invoke appends :* to allow invocation " "of any version/alias of the target Lambda."), "applies_to": [ - "Resource:::*", + "Resource:::*", ], }, { "id": "AwsSolutions-IAM5", - "reason": ( - "AppSync data source grant_invoke appends :* " - "for Lambda version/alias invocations." - ), + "reason": ("AppSync data source grant_invoke appends :* " "for Lambda version/alias invocations."), "applies_to": [ - "Resource:::*", + "Resource:::*", ], }, { "id": "AwsSolutions-IAM5", - "reason": ( - "X-Ray actions do not support " - "resource-level permissions." - ), + "reason": ("X-Ray actions do not support " + "resource-level permissions."), "applies_to": ["Resource::*"], }, ], @@ -131,21 +110,14 @@ def _add_nag_suppressions(self): [ { "id": "AwsSolutions-IAM5", - "reason": ( - "AgentCore runtime ARN requires /* suffix " - "for endpoint invocation." - ), + "reason": ("AgentCore runtime ARN requires /* suffix " "for endpoint invocation."), "applies_to": [ - "Resource::/*", + "Resource::/*", ], }, { "id": "AwsSolutions-IAM5", - "reason": ( - "X-Ray actions do not support " - "resource-level permissions." - ), + "reason": ("X-Ray actions do not support " "resource-level permissions."), "applies_to": ["Resource::*"], }, ], diff --git a/appsync-events-lambda-agnetcore-cdk/functions/agent_invoker/index.py b/appsync-events-lambda-agnetcore-cdk/functions/agent_invoker/index.py index 33c6283b84..63d599e600 100644 --- a/appsync-events-lambda-agnetcore-cdk/functions/agent_invoker/index.py +++ b/appsync-events-lambda-agnetcore-cdk/functions/agent_invoker/index.py @@ -37,22 +37,26 @@ def handler(event: dict, context) -> dict: message = payload.get("message") if not message or not str(message).strip(): - results.append({ - "id": event_id, - "payload": { - "error": "message is required and cannot be empty", - }, - }) + results.append( + { + "id": event_id, + "payload": { + "error": "message is required and cannot be empty", + }, + } + ) continue session_id = payload.get("sessionId") if not session_id or not str(session_id).strip(): - results.append({ - "id": event_id, - "payload": { - "error": "sessionId is required and cannot be empty", - }, - }) + results.append( + { + "id": event_id, + "payload": { + "error": "sessionId is required and cannot be empty", + }, + } + ) continue relay_payload = { @@ -77,9 +81,11 @@ def handler(event: dict, context) -> dict: Payload=json.dumps(relay_payload).encode(), ) - results.append({ - "id": event_id, - "payload": {**payload, "sessionId": session_id}, - }) + results.append( + { + "id": event_id, + "payload": {**payload, "sessionId": session_id}, + } + ) return {"events": results} diff --git a/appsync-events-lambda-agnetcore-cdk/functions/stream_relay/index.py b/appsync-events-lambda-agnetcore-cdk/functions/stream_relay/index.py index 08465c45e3..fb4d865374 100644 --- a/appsync-events-lambda-agnetcore-cdk/functions/stream_relay/index.py +++ b/appsync-events-lambda-agnetcore-cdk/functions/stream_relay/index.py @@ -30,10 +30,12 @@ def _publish_to_channel(channel: str, event: dict): """Publish an event to an AppSync Events channel via HTTP.""" url = f"https://{APPSYNC_HTTP_ENDPOINT}/event" - body = json.dumps({ - "channel": channel, - "events": [json.dumps(event)], - }).encode() + body = json.dumps( + { + "channel": channel, + "events": [json.dumps(event)], + } + ).encode() req = urllib.request.Request( url, @@ -70,10 +72,12 @@ def handler(event: dict, context) -> dict: ) # Invoke AgentCore Runtime - payload = json.dumps({ - "content": content, - "sessionId": session_id, - }).encode() + payload = json.dumps( + { + "content": content, + "sessionId": session_id, + } + ).encode() response = agentcore_client.invoke_agent_runtime( agentRuntimeArn=AGENT_RUNTIME_ARN, payload=payload, @@ -108,10 +112,16 @@ def handler(event: dict, context) -> dict: continue # Skip Strands control events (init_event_loop, start, etc.) - if any(k in data for k in ( - "init_event_loop", "start", "start_event_loop", - "force_stop", "complete", - )): + if any( + k in data + for k in ( + "init_event_loop", + "start", + "start_event_loop", + "force_stop", + "complete", + ) + ): logger.debug("Control event", extra={"data": data}) continue @@ -121,16 +131,16 @@ def handler(event: dict, context) -> dict: current_chunk += text full_response += text - if ( - len(current_chunk) >= 50 - or text.endswith((".", "!", "?", "\n")) - ): - _publish_to_channel(channel, { - "type": "chunk", - "sequence": sequence, - "content": current_chunk, - "eventId": event_id, - }) + if len(current_chunk) >= 50 or text.endswith((".", "!", "?", "\n")): + _publish_to_channel( + channel, + { + "type": "chunk", + "sequence": sequence, + "content": current_chunk, + "eventId": event_id, + }, + ) sequence += 1 current_chunk = "" else: @@ -143,13 +153,16 @@ def handler(event: dict, context) -> dict: full_response = raw # Publish completion event (includes any remaining chunk content) - _publish_to_channel(channel, { - "type": "complete", - "sequence": sequence, - "content": current_chunk, - "response": full_response, - "eventId": event_id, - }) + _publish_to_channel( + channel, + { + "type": "complete", + "sequence": sequence, + "content": current_chunk, + "response": full_response, + "eventId": event_id, + }, + ) logger.info( "Stream relay complete", diff --git a/appsync-events-lambda-agnetcore-cdk/pyproject.toml b/appsync-events-lambda-agnetcore-cdk/pyproject.toml new file mode 100644 index 0000000000..6b313bcfc6 --- /dev/null +++ b/appsync-events-lambda-agnetcore-cdk/pyproject.toml @@ -0,0 +1,2 @@ +[tool.black] +line-length = 150 diff --git a/appsync-events-lambda-agnetcore-cdk/tests/integration/__init__.py b/appsync-events-lambda-agnetcore-cdk/tests/integration/__init__.py index 8b13789179..e69de29bb2 100644 --- a/appsync-events-lambda-agnetcore-cdk/tests/integration/__init__.py +++ b/appsync-events-lambda-agnetcore-cdk/tests/integration/__init__.py @@ -1 +0,0 @@ - diff --git a/appsync-events-lambda-agnetcore-cdk/tests/integration/conftest.py b/appsync-events-lambda-agnetcore-cdk/tests/integration/conftest.py index 71a7303ea4..3a84a57433 100644 --- a/appsync-events-lambda-agnetcore-cdk/tests/integration/conftest.py +++ b/appsync-events-lambda-agnetcore-cdk/tests/integration/conftest.py @@ -1,5 +1,7 @@ """Shared fixtures for integration tests.""" +# pylint: disable=redefined-outer-name + import asyncio import base64 import json @@ -26,6 +28,7 @@ # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture(scope="session") def stack_outputs(): """Fetch all CloudFormation stack outputs once for the test session.""" @@ -44,12 +47,14 @@ def stack_outputs(): @pytest.fixture(scope="session") def get_output(stack_outputs): """Return a callable that looks up a stack output by key prefix.""" + def _lookup(prefix: str) -> str: for key, value in stack_outputs.items(): if key.startswith(prefix): return value pytest.skip(f"Stack output starting with '{prefix}' not found") return "" + return _lookup @@ -66,22 +71,27 @@ def api_config(get_output): @pytest.fixture(scope="session") def auth_subprotocol(api_config): """Base64-encoded auth subprotocol string for WebSocket connections.""" - header = json.dumps({ - "host": api_config["http_endpoint"], - "x-api-key": api_config["api_key"], - }).encode() + header = json.dumps( + { + "host": api_config["http_endpoint"], + "x-api-key": api_config["api_key"], + } + ).encode() return "header-" + base64.b64encode(header).decode().rstrip("=") @pytest.fixture(scope="session") def publish(api_config): """Return a callable that publishes to a channel via HTTP.""" + def _do_publish(channel: str, message: dict) -> dict: url = f"https://{api_config['http_endpoint']}/event" - payload = json.dumps({ - "channel": f"/{channel}", - "events": [json.dumps(message)], - }).encode() + payload = json.dumps( + { + "channel": f"/{channel}", + "events": [json.dumps(message)], + } + ).encode() req = urllib.request.Request( url, data=payload, @@ -93,6 +103,7 @@ def _do_publish(channel: str, message: dict) -> dict: ) with urllib.request.urlopen(req) as resp: return json.loads(resp.read().decode()) + return _do_publish @@ -106,11 +117,10 @@ def subscribe(api_config, auth_subprotocol): async with subscribe("/responses/chat/abc") as (ws, sub_id): ... """ + @asynccontextmanager async def _subscribe(channel: str): - ws_url = ( - f"wss://{api_config['realtime_endpoint']}/event/realtime" - ) + ws_url = f"wss://{api_config['realtime_endpoint']}/event/realtime" async with websockets.connect( ws_url, subprotocols=["aws-appsync-event-ws", auth_subprotocol], @@ -122,15 +132,19 @@ async def _subscribe(channel: str): assert ack["type"] == "connection_ack" sub_id = str(uuid.uuid4()) - await ws.send(json.dumps({ - "type": "subscribe", - "id": sub_id, - "channel": channel, - "authorization": { - "x-api-key": api_config["api_key"], - "host": api_config["http_endpoint"], - }, - })) + await ws.send( + json.dumps( + { + "type": "subscribe", + "id": sub_id, + "channel": channel, + "authorization": { + "x-api-key": api_config["api_key"], + "host": api_config["http_endpoint"], + }, + } + ) + ) while True: msg = json.loads( diff --git a/appsync-events-lambda-agnetcore-cdk/tests/integration/test_appsync_events.py b/appsync-events-lambda-agnetcore-cdk/tests/integration/test_appsync_events.py index a8f5d3540f..e8c5de9ece 100644 --- a/appsync-events-lambda-agnetcore-cdk/tests/integration/test_appsync_events.py +++ b/appsync-events-lambda-agnetcore-cdk/tests/integration/test_appsync_events.py @@ -60,12 +60,13 @@ async def test_http_request_tool(subscribe, publish): publish_channel = f"chat/{conversation_id}" async with subscribe(f"/responses/{publish_channel}") as (ws, sub_id): - publish(publish_channel, { - "message": ( - "Fetch the AWS blog homepage at https://aws.amazon.com/blogs/ and show me the latest 5 blog posts titles." - ), - "sessionId": conversation_id, - }) + publish( + publish_channel, + { + "message": ("Fetch the AWS blog homepage at https://aws.amazon.com/blogs/ and show me the latest 5 blog posts titles."), + "sessionId": conversation_id, + }, + ) received, complete = await _collect_response(ws, sub_id) @@ -87,26 +88,29 @@ async def test_conversation_with_session(subscribe, publish): async with subscribe(f"/responses/{publish_channel}") as (ws, sub_id): # Turn 1: fetch and summarise an AWS blog post - publish(publish_channel, { - "message": ( - "Fetch https://aws.amazon.com/blogs/aws/ and give me " - "a short summary of the first blog post you see. " - "Keep it under 100 words." - ), - "sessionId": session_id, - }) + publish( + publish_channel, + { + "message": ( + "Fetch https://aws.amazon.com/blogs/aws/ and give me " + "a short summary of the first blog post you see. " + "Keep it under 100 words." + ), + "sessionId": session_id, + }, + ) _, complete_1 = await _collect_response(ws, sub_id) assert complete_1 is not None, "Turn 1 did not complete" # Turn 2: ask a follow-up — agent should remember the blog post - publish(publish_channel, { - "message": ( - "Based on that blog post, what AWS services were mentioned? " - "List them out." - ), - "sessionId": session_id, - }) + publish( + publish_channel, + { + "message": ("Based on that blog post, what AWS services were mentioned? " "List them out."), + "sessionId": session_id, + }, + ) _, complete_2 = await _collect_response(ws, sub_id) assert complete_2 is not None, "Turn 2 did not complete" @@ -114,6 +118,7 @@ async def test_conversation_with_session(subscribe, publish): assert len(response_2) > 0, "Turn 2 should have a non-empty response" + @pytest.mark.asyncio async def test_unsubscribe_stops_receiving_events(subscribe, publish): """After unsubscribing, the client should stop receiving events @@ -124,18 +129,25 @@ async def test_unsubscribe_stops_receiving_events(subscribe, publish): async with subscribe(response_channel) as (ws, sub_id): # Verify subscription works — publish and collect response - publish(publish_channel, { - "message": "Say hello", - "sessionId": conversation_id, - }) + publish( + publish_channel, + { + "message": "Say hello", + "sessionId": conversation_id, + }, + ) _, complete = await _collect_response(ws, sub_id, timeout=30) assert complete is not None, "Should receive response while subscribed" # Unsubscribe from the channel - await ws.send(json.dumps({ - "type": "unsubscribe", - "id": sub_id, - })) + await ws.send( + json.dumps( + { + "type": "unsubscribe", + "id": sub_id, + } + ) + ) # Wait for unsubscribe ack deadline = asyncio.get_event_loop().time() + 5 @@ -149,10 +161,13 @@ async def test_unsubscribe_stops_receiving_events(subscribe, publish): # Publish again — should NOT receive anything on this sub new_session = str(uuid.uuid4()) - publish(publish_channel, { - "message": "Say goodbye", - "sessionId": new_session, - }) + publish( + publish_channel, + { + "message": "Say goodbye", + "sessionId": new_session, + }, + ) # Wait briefly — we should only see keepalives, no data got_data = False @@ -182,14 +197,19 @@ async def test_channel_isolation(subscribe, publish): async with subscribe(f"/responses/chat/{id_a}") as (ws_a, sub_a): async with subscribe(f"/responses/chat/{id_b}") as (ws_b, sub_b): # Publish only to channel A - publish(f"chat/{id_a}", { - "message": "Say hello from channel A", - "sessionId": id_a, - }) + publish( + f"chat/{id_a}", + { + "message": "Say hello from channel A", + "sessionId": id_a, + }, + ) # Channel A should receive the response _, complete_a = await _collect_response( - ws_a, sub_a, timeout=30, + ws_a, + sub_a, + timeout=30, ) assert complete_a is not None, "Channel A should receive a response" @@ -208,10 +228,7 @@ async def test_channel_isolation(subscribe, publish): got_data_b = True break - assert not got_data_b, ( - "Channel B should not receive events from channel A" - ) - + assert not got_data_b, "Channel B should not receive events from channel A" async def _expect_error_event(subscribe, publish, channel_id, payload, expected_text): @@ -237,9 +254,7 @@ async def _expect_error_event(subscribe, publish, channel_id, payload, expected_ event_data = json.loads(event_data) error = event_data.get("payload", event_data).get("error", "") if error: - assert expected_text in error.lower(), ( - f"Expected '{expected_text}' in error: {error}" - ) + assert expected_text in error.lower(), f"Expected '{expected_text}' in error: {error}" return pytest.fail(f"Did not receive error event for payload: {payload}") @@ -253,17 +268,18 @@ async def test_calculator_tool(subscribe, publish): publish_channel = f"chat/{conversation_id}" async with subscribe(f"/responses/{publish_channel}") as (ws, sub_id): - publish(publish_channel, { - "message": "Use the calculator to compute 347 * 29. Reply with only the number.", - "sessionId": conversation_id, - }) + publish( + publish_channel, + { + "message": "Use the calculator to compute 347 * 29. Reply with only the number.", + "sessionId": conversation_id, + }, + ) _, complete = await _collect_response(ws, sub_id) assert complete is not None, "Did not receive a completion event" response = complete.get("response", "") - assert "10063" in response, ( - f"Expected calculator result 10063 in response: {response}" - ) + assert "10063" in response, f"Expected calculator result 10063 in response: {response}" @pytest.mark.asyncio @@ -274,14 +290,15 @@ async def test_current_time_tool(subscribe, publish): publish_channel = f"chat/{conversation_id}" async with subscribe(f"/responses/{publish_channel}") as (ws, sub_id): - publish(publish_channel, { - "message": ( - "Get the current time in UTC. " - "Reply with ONLY the time in this exact format: YYYY-MM-DD HH:MM " - "For example: 2026-03-11 14:05" - ), - "sessionId": conversation_id, - }) + publish( + publish_channel, + { + "message": ( + "Get the current time in UTC. " "Reply with ONLY the time in this exact format: YYYY-MM-DD HH:MM " "For example: 2026-03-11 14:05" + ), + "sessionId": conversation_id, + }, + ) now_utc = datetime.now(timezone.utc) _, complete = await _collect_response(ws, sub_id) @@ -295,22 +312,16 @@ async def test_current_time_tool(subscribe, publish): tzinfo=timezone.utc, ) diff = abs((reported - now_utc).total_seconds()) - assert diff <= 120, ( - f"Reported time {match.group()} differs from actual " - f"{now_utc.strftime('%Y-%m-%d %H:%M')} by {diff:.0f}s (max 120s)" - ) + assert diff <= 120, f"Reported time {match.group()} differs from actual " f"{now_utc.strftime('%Y-%m-%d %H:%M')} by {diff:.0f}s (max 120s)" @pytest.mark.asyncio async def test_missing_session_id_returns_error(subscribe, publish): """Publishing without a sessionId should return an error via WebSocket.""" await _expect_error_event( - subscribe, publish, + subscribe, + publish, channel_id=str(uuid.uuid4()), payload={"message": "hello"}, expected_text="sessionid", ) - - - - diff --git a/appsync-events-lambda-agnetcore-cdk/tests/unit/conftest.py b/appsync-events-lambda-agnetcore-cdk/tests/unit/conftest.py index 6f582ea609..3c4e233565 100644 --- a/appsync-events-lambda-agnetcore-cdk/tests/unit/conftest.py +++ b/appsync-events-lambda-agnetcore-cdk/tests/unit/conftest.py @@ -18,16 +18,12 @@ os.environ["POWERTOOLS_SERVICE_NAME"] = "test" # Agent invoker Lambda env vars -os.environ["STREAM_RELAY_ARN"] = ( - "arn:aws:lambda:eu-west-1:123456789012:function:stream-relay" -) +os.environ["STREAM_RELAY_ARN"] = "arn:aws:lambda:eu-west-1:123456789012:function:stream-relay" # Stream relay Lambda env vars os.environ["APPSYNC_HTTP_ENDPOINT"] = "test.appsync-api.eu-west-1.amazonaws.com" os.environ["APPSYNC_API_KEY"] = "test-api-key" -os.environ["AGENT_RUNTIME_ARN"] = ( - "arn:aws:bedrock-agentcore:eu-west-1:123456789012:runtime/test" -) +os.environ["AGENT_RUNTIME_ARN"] = "arn:aws:bedrock-agentcore:eu-west-1:123456789012:runtime/test" @dataclass @@ -36,12 +32,11 @@ class FakeLambdaContext: function_name: str = "test-function" memory_limit_in_mb: int = 256 - invoked_function_arn: str = ( - "arn:aws:lambda:eu-west-1:123456789012:function:test" - ) + invoked_function_arn: str = "arn:aws:lambda:eu-west-1:123456789012:function:test" aws_request_id: str = "test-request-id" @pytest.fixture def lambda_context(): + """Provide a fake Lambda context for Powertools.""" return FakeLambdaContext() diff --git a/appsync-events-lambda-agnetcore-cdk/tests/unit/test_agent_invoker.py b/appsync-events-lambda-agnetcore-cdk/tests/unit/test_agent_invoker.py index ec2b79877d..d2c4be5d2c 100644 --- a/appsync-events-lambda-agnetcore-cdk/tests/unit/test_agent_invoker.py +++ b/appsync-events-lambda-agnetcore-cdk/tests/unit/test_agent_invoker.py @@ -18,10 +18,7 @@ def _make_event(payload, channel="/chat/test-123"): def _make_multi_event(payloads, channel="/chat/test-123"): """Build an event with multiple published messages.""" return { - "events": [ - {"id": f"evt-{i}", "payload": p} - for i, p in enumerate(payloads) - ], + "events": [{"id": f"evt-{i}", "payload": p} for i, p in enumerate(payloads)], "info": {"channel": {"path": channel}}, } @@ -111,10 +108,12 @@ def test_response_channel_prefixed_with_responses(mock_client, lambda_context): @patch("functions.agent_invoker.index.lambda_client") def test_multiple_events_processed_independently(mock_client, lambda_context): """Batch with one valid and one invalid event returns mixed results.""" - event = _make_multi_event([ - {"message": "hello", "sessionId": "s1"}, - {"message": "world"}, # missing sessionId - ]) + event = _make_multi_event( + [ + {"message": "hello", "sessionId": "s1"}, + {"message": "world"}, # missing sessionId + ] + ) result = handler(event, lambda_context) assert mock_client.invoke.call_count == 1 # only valid event invoked diff --git a/appsync-events-lambda-agnetcore-cdk/tests/unit/test_stream_relay.py b/appsync-events-lambda-agnetcore-cdk/tests/unit/test_stream_relay.py index 12dfdf5077..0c9aabf78c 100644 --- a/appsync-events-lambda-agnetcore-cdk/tests/unit/test_stream_relay.py +++ b/appsync-events-lambda-agnetcore-cdk/tests/unit/test_stream_relay.py @@ -6,8 +6,7 @@ from functions.stream_relay.index import handler -def _make_event(content="hello", channel="/responses/chat/conv-1", - event_id="evt-1", session_id="sess-1"): +def _make_event(content="hello", channel="/responses/chat/conv-1", event_id="evt-1", session_id="sess-1"): return { "content": content, "channel": channel, @@ -31,10 +30,12 @@ def _mock_sse_response(lines): @patch("functions.stream_relay.index.agentcore_client") def test_streaming_sse_publishes_chunks(mock_ac, mock_publish, lambda_context): """SSE stream with data events publishes chunks to AppSync channel.""" - mock_ac.invoke_agent_runtime.return_value = _mock_sse_response([ - 'data: {"data": "Hello, "}', - 'data: {"data": "how are you?"}', - ]) + mock_ac.invoke_agent_runtime.return_value = _mock_sse_response( + [ + 'data: {"data": "Hello, "}', + 'data: {"data": "how are you?"}', + ] + ) result = handler(_make_event(), lambda_context) @@ -51,10 +52,12 @@ def test_streaming_sse_publishes_chunks(mock_ac, mock_publish, lambda_context): @patch("functions.stream_relay.index.agentcore_client") def test_completion_event_includes_full_response(mock_ac, mock_publish, lambda_context): """Final publish contains the assembled full response text.""" - mock_ac.invoke_agent_runtime.return_value = _mock_sse_response([ - 'data: {"data": "Part one. "}', - 'data: {"data": "Part two."}', - ]) + mock_ac.invoke_agent_runtime.return_value = _mock_sse_response( + [ + 'data: {"data": "Part one. "}', + 'data: {"data": "Part two."}', + ] + ) handler(_make_event(), lambda_context) @@ -68,12 +71,14 @@ def test_completion_event_includes_full_response(mock_ac, mock_publish, lambda_c @patch("functions.stream_relay.index.agentcore_client") def test_control_events_are_skipped(mock_ac, mock_publish, lambda_context): """Strands control events should not produce chunk publishes.""" - mock_ac.invoke_agent_runtime.return_value = _mock_sse_response([ - 'data: {"init_event_loop": true}', - 'data: {"start": true}', - 'data: {"data": "actual text."}', - 'data: {"complete": true}', - ]) + mock_ac.invoke_agent_runtime.return_value = _mock_sse_response( + [ + 'data: {"init_event_loop": true}', + 'data: {"start": true}', + 'data: {"data": "actual text."}', + 'data: {"complete": true}', + ] + ) handler(_make_event(), lambda_context) @@ -108,16 +113,15 @@ def test_non_streaming_response_handled(mock_ac, mock_publish, lambda_context): @patch("functions.stream_relay.index.agentcore_client") def test_chunk_batching_on_punctuation(mock_ac, mock_publish, lambda_context): """Chunks should flush on sentence-ending punctuation.""" - mock_ac.invoke_agent_runtime.return_value = _mock_sse_response([ - 'data: {"data": "Short."}', - 'data: {"data": " More text after."}', - ]) + mock_ac.invoke_agent_runtime.return_value = _mock_sse_response( + [ + 'data: {"data": "Short."}', + 'data: {"data": " More text after."}', + ] + ) handler(_make_event(), lambda_context) - chunk_publishes = [ - c[0][1] for c in mock_publish.call_args_list - if c[0][1]["type"] == "chunk" - ] + chunk_publishes = [c[0][1] for c in mock_publish.call_args_list if c[0][1]["type"] == "chunk"] assert len(chunk_publishes) >= 1 assert chunk_publishes[0]["content"] == "Short." From aed1333b0a3776619f630c1d0feb491dbd454dfb Mon Sep 17 00:00:00 2001 From: Pete Davis Date: Wed, 11 Mar 2026 17:58:02 +0000 Subject: [PATCH 11/19] docs: trim low-value comments --- .../cdk/constructs/chat_agent.py | 8 +-- .../cdk/constructs/chat_service.py | 18 ++--- .../cdk/constructs/standard_lambda.py | 68 +++---------------- .../cdk/stack.py | 8 +-- .../functions/stream_relay/index.py | 5 +- 5 files changed, 20 insertions(+), 87 deletions(-) diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_agent.py b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_agent.py index 5d07788513..4a41a41821 100644 --- a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_agent.py +++ b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_agent.py @@ -36,7 +36,6 @@ def __init__( stack = Stack.of(self) - # S3 bucket for conversation sessions self.session_bucket = s3.Bucket( self, "SessionBucket", @@ -46,7 +45,6 @@ def __init__( enforce_ssl=True, ) - # Build Docker image from agents/chat/ agent_image = ecr_assets.DockerImageAsset( self, "AgentImage", @@ -57,7 +55,6 @@ def __init__( exclude=["**/__pycache__", "**/*.pyc"], ) - # IAM role for the runtime self.runtime_role = iam.Role( self, "RuntimeRole", @@ -71,7 +68,6 @@ def __init__( ), ) - # Merge environment variables merged_env = { "BEDROCK_MODEL_ID": model_id, "AWS_REGION": stack.region, @@ -79,7 +75,7 @@ def __init__( **(environment_variables or {}), } - # AgentCore Runtime (L1) + # AgentCore Runtime — L1 construct (no L2 available yet) runtime_name = f"{stack.stack_name.replace('-', '_')}_chat_agent" self.runtime = agentcore.CfnRuntime( self, @@ -107,8 +103,6 @@ def __init__( value=self.session_bucket.bucket_name, ) - # --- cdk-nag suppressions --- - NagSuppressions.add_resource_suppressions( self.session_bucket, [ diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_service.py b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_service.py index ae7a24f163..76d28db2f6 100644 --- a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_service.py +++ b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_service.py @@ -33,8 +33,6 @@ def __init__( ) -> None: super().__init__(scope, construct_id, **kwargs) - # --- Event API --- - api_key_provider = appsync.AppSyncAuthProvider( authorization_type=appsync.AppSyncAuthorizationType.API_KEY, ) @@ -61,12 +59,11 @@ def __init__( ), ) - # Responses namespace — no handler, used by stream relay to publish - # agent responses without re-triggering the invoker Lambda + # Separate "responses" namespace for outbound streaming — avoids + # re-invocation loops where the Lambda's own publishes would + # re-trigger the invoker. self.api.add_channel_namespace("responses") - # --- Agent invoker Lambda (thin dispatcher) --- - self.agent_invoker = StandardLambda( self, "AgentInvoker", @@ -78,18 +75,17 @@ def __init__( }, ) - # Grant permission to invoke the stream relay async stream_relay_function.grant_invoke( self.agent_invoker.function, ) - # Register Lambda as a data source on the Event API lambda_ds = self.api.add_lambda_data_source( "AgentInvokerDS", self.agent_invoker.function, ) - # Chat namespace with direct Lambda integration + # "chat" namespace with direct Lambda integration — AppSync invokes + # the agent invoker Lambda synchronously on each publish. self.api.add_channel_namespace( "chat", publish_handler_config=appsync.HandlerConfig( @@ -99,8 +95,6 @@ def __init__( ), ) - # --- Outputs --- - CfnOutput( self, "EventApiHttpEndpoint", @@ -117,8 +111,6 @@ def __init__( value=self.api.api_keys["Default"].attr_api_key, ) - # --- cdk-nag suppressions --- - NagSuppressions.add_resource_suppressions( self.api, [ diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/standard_lambda.py b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/standard_lambda.py index 6c7d5a8a7e..e067d44ebb 100644 --- a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/standard_lambda.py +++ b/appsync-events-lambda-agnetcore-cdk/cdk/constructs/standard_lambda.py @@ -49,13 +49,8 @@ def __init__( stack = Stack.of(self) - # Automatically detect whether the Lambda code directory contains a - # requirements.txt with real dependencies. If it does, Container bundling - # is used to install them into the deployment package. If not, the code - # directory is packaged as-is. code = self._build_code(code_path, runtime) - # Create a dedicated CloudWatch Log Group for this Lambda function. log_group = logs.LogGroup( self, "LogGroup", @@ -63,16 +58,12 @@ def __init__( removal_policy=log_removal_policy, ) - # Resolve the architecture — default to ARM_64 for better price/performance, - # but allow the consumer to override it via kwargs. + # Default to ARM_64 for better price/performance architecture = kwargs.pop("architecture", lambda_.Architecture.ARM_64) - # Determine the correct Powertools layer ARN based on both architecture. - # Compare on .name because CDK Architecture objects are not singletons - # (Architecture.ARM_64 == Architecture.ARM_64 is False). + # Resolve Powertools layer ARN from architecture + runtime. + # Compare on .name because CDK Architecture objects are not singletons. arch_suffix = "arm64" if architecture.name == "arm64" else "x86_64" - - # Extract the Python version string from the runtime name (e.g. "python3.14"becomes "python314") runtime_suffix = runtime.name.replace(".", "") powertools_layer = lambda_.LayerVersion.from_layer_version_arn( @@ -81,7 +72,6 @@ def __init__( f"arn:aws:lambda:{stack.region}:017000801446:layer:AWSLambdaPowertoolsPythonV3-{runtime_suffix}-{arch_suffix}:27", ) - # Define the default configuration that every Lambda function created defaults = { "runtime": runtime, "architecture": architecture, @@ -96,50 +86,29 @@ def __init__( "log_group": log_group, } - # Handle layers and environment variables separately from other kwargs. - # We pop them out before the general merge so we can combine them - # (append/merge) rather than replace the defaults entirely. + # Pop layers and environment before the general merge so we can + # combine them additively rather than replace the defaults. user_layers = kwargs.pop("layers", None) user_environment = kwargs.pop("environment", None) - # Merge all remaining kwargs with defaults. User-provided values - # take precedence over defaults (e.g. a custom timeout or memory_size). merged_config = {**defaults, **kwargs} - # Merge layers additively — the consumer's layers are appended - # after the Powertools layer so all layers are included. if user_layers is not None: merged_config["layers"] = defaults.get("layers", []) + user_layers - # Merge environment variables additively — the consumer's env vars - # are added alongside the Powertools defaults, not replacing them. if user_environment is not None: merged_config["environment"] = { **defaults.get("environment", {}), **user_environment, } - # Create the Lambda function with the merged configuration. self.function = lambda_.Function(self, "Function", handler=handler, code=code, **merged_config) - - # Expose convenience attributes so to keep the syntax similar to a standard lambda self.function_arn = self.function.function_arn self.function_name = self.function.function_name - # Grant the Lambda function permission to write logs to its dedicated - # CloudWatch Log Group. log_group.grant_write(self.function) - - # Expose the function's IAM execution role as a public attribute. - # This allows consumers to attach additional permissions after - # creating the construct, e.g.: - # my_lambda.role.add_managed_policy(...) - # my_table.grant_read_write_data(my_lambda.function) self.role = self.function.role - # Suppress cdk-nag for the AWS managed Lambda basic execution role. - # CDK attaches this automatically and it is the standard practice - # for Lambda functions. NagSuppressions.add_resource_suppressions( self.function, [ @@ -155,24 +124,16 @@ def __init__( ) def _build_code(self, code_path: str, runtime: lambda_.Runtime) -> lambda_.Code: - """ - Build the Lambda deployment package with automatic dependency detection. + """Build the Lambda deployment package. - Checks for a requirements.txt in the code directory. If one exists and - contains real dependencies (not just comments or blank lines), Container - bundling is used: a container with the matching Lambda runtime image - runs pip install into /asset-output, then copies the function code - alongside the installed packages. This produces a flat deployment zip - where Python can import everything directly. - - If no requirements.txt is found (or it's empty), the code directory - is simply packaged as-is with no Docker overhead. + If the code directory has a requirements.txt with real dependencies, + uses container bundling to pip install them into the deployment zip. + Otherwise packages the code directory as-is. """ code_dir = Path(code_path) requirements_file = code_dir / "requirements.txt" if requirements_file.exists() and self._has_dependencies(requirements_file): - # Use Container bundling return lambda_.Code.from_asset( code_path, bundling=BundlingOptions( @@ -182,10 +143,7 @@ def _build_code(self, code_path: str, runtime: lambda_.Runtime) -> lambda_.Code: "-c", " && ".join( [ - # Install dependencies "pip install -r requirements.txt -t /asset-output/", - # Copy the function source code alongside the - # installed dependencies "cp -r . /asset-output", ] ), @@ -193,20 +151,14 @@ def _build_code(self, code_path: str, runtime: lambda_.Runtime) -> lambda_.Code: ), ) - # No dependencies found — package the code directory directly without spinning up a container return lambda_.Code.from_asset(code_path) def _has_dependencies(self, requirements_file: Path) -> bool: - """ - Check if a requirements.txt file contains actual package dependencies. - Returns False if the file is empty or contains only comments and blank lines. - This avoids unnecessary Docker bundling for functions with no external deps. - """ + """Check if a requirements.txt contains actual package dependencies (not just comments).""" try: with open(requirements_file, "r", encoding="utf-8") as f: for line in f: line = line.strip() - # Skip empty lines and comment-only lines if line and not line.startswith("#"): return True return False diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/stack.py b/appsync-events-lambda-agnetcore-cdk/cdk/stack.py index 16882e52ee..91d57d193e 100644 --- a/appsync-events-lambda-agnetcore-cdk/cdk/stack.py +++ b/appsync-events-lambda-agnetcore-cdk/cdk/stack.py @@ -38,8 +38,8 @@ def __init__( stream_relay_function=(self.stream_relay.standard_lambda.function), ) - # Stream relay needs the AppSync endpoint and API key - # to publish chunks — add them after the API is created. + # Pass AppSync endpoint and API key to the stream relay so it + # can publish response chunks back to the client. self.stream_relay.standard_lambda.function.add_environment( "APPSYNC_HTTP_ENDPOINT", self.chat_service.api.http_dns, @@ -54,7 +54,6 @@ def __init__( def _add_nag_suppressions(self): """Add cdk-nag suppressions for CDK-managed resources.""" - # CDK's LogRetention custom resource Lambda for child in self.node.children: if child.node.id.startswith("LogRetention"): NagSuppressions.add_resource_suppressions( @@ -76,8 +75,6 @@ def _add_nag_suppressions(self): apply_to_children=True, ) - # AppSync Events construct: grant_invoke wildcards and - # X-Ray Resource::* for the agent invoker Lambda NagSuppressions.add_resource_suppressions( self.chat_service, [ @@ -104,7 +101,6 @@ def _add_nag_suppressions(self): apply_to_children=True, ) - # Stream relay: AgentCore runtime ARN wildcard and X-Ray NagSuppressions.add_resource_suppressions( self.stream_relay, [ diff --git a/appsync-events-lambda-agnetcore-cdk/functions/stream_relay/index.py b/appsync-events-lambda-agnetcore-cdk/functions/stream_relay/index.py index fb4d865374..a369feb490 100644 --- a/appsync-events-lambda-agnetcore-cdk/functions/stream_relay/index.py +++ b/appsync-events-lambda-agnetcore-cdk/functions/stream_relay/index.py @@ -71,7 +71,6 @@ def handler(event: dict, context) -> dict: }, ) - # Invoke AgentCore Runtime payload = json.dumps( { "content": content, @@ -111,7 +110,7 @@ def handler(event: dict, context) -> dict: logger.debug("Non-dict SSE event", extra={"data": data}) continue - # Skip Strands control events (init_event_loop, start, etc.) + # Skip Strands SDK control events if any( k in data for k in ( @@ -125,7 +124,7 @@ def handler(event: dict, context) -> dict: logger.debug("Control event", extra={"data": data}) continue - # Extract text from the event — Strands uses "data" key + # Strands streams text tokens in the "data" key text = data.get("data", "") if isinstance(text, str) and text: current_chunk += text From 80da8a76aae071566de0af1dfdb981e08f0e9b50 Mon Sep 17 00:00:00 2001 From: Pete Davis Date: Wed, 11 Mar 2026 18:03:19 +0000 Subject: [PATCH 12/19] fix(agent): require AWS_REGION instead of defaulting to eu-west-1 --- appsync-events-lambda-agnetcore-cdk/agents/chat/entrypoint.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/appsync-events-lambda-agnetcore-cdk/agents/chat/entrypoint.py b/appsync-events-lambda-agnetcore-cdk/agents/chat/entrypoint.py index d54adbe315..129e73d0f3 100644 --- a/appsync-events-lambda-agnetcore-cdk/agents/chat/entrypoint.py +++ b/appsync-events-lambda-agnetcore-cdk/agents/chat/entrypoint.py @@ -23,7 +23,9 @@ if not MODEL_ID: raise ValueError("BEDROCK_MODEL_ID environment variable is required") -REGION = os.environ.get("AWS_REGION", "eu-west-1") +REGION = os.environ.get("AWS_REGION") +if not REGION: + raise ValueError("AWS_REGION environment variable is required") SESSION_BUCKET = os.environ.get("SESSION_BUCKET") SYSTEM_PROMPT = """\ From 395a9cd765acdd83331ad45a0a19e64476657a08 Mon Sep 17 00:00:00 2001 From: Pete Davis Date: Wed, 11 Mar 2026 18:03:53 +0000 Subject: [PATCH 13/19] refactor(tests): derive unit test region from AWS_REGION env var --- .../tests/unit/conftest.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/appsync-events-lambda-agnetcore-cdk/tests/unit/conftest.py b/appsync-events-lambda-agnetcore-cdk/tests/unit/conftest.py index 3c4e233565..23bed0f686 100644 --- a/appsync-events-lambda-agnetcore-cdk/tests/unit/conftest.py +++ b/appsync-events-lambda-agnetcore-cdk/tests/unit/conftest.py @@ -6,24 +6,26 @@ import pytest # Prevent any real AWS service calls — fake credentials and region +_REGION = os.environ.get("AWS_REGION", "eu-west-1") + os.environ["AWS_ACCESS_KEY_ID"] = "testing" os.environ["AWS_SECRET_ACCESS_KEY"] = "testing" os.environ["AWS_SECURITY_TOKEN"] = "testing" os.environ["AWS_SESSION_TOKEN"] = "testing" -os.environ["AWS_DEFAULT_REGION"] = "eu-west-1" -os.environ["AWS_REGION"] = "eu-west-1" +os.environ.setdefault("AWS_DEFAULT_REGION", _REGION) +os.environ.setdefault("AWS_REGION", _REGION) # Disable X-Ray tracing so Tracer uses a no-op provider os.environ["POWERTOOLS_TRACE_DISABLED"] = "true" os.environ["POWERTOOLS_SERVICE_NAME"] = "test" # Agent invoker Lambda env vars -os.environ["STREAM_RELAY_ARN"] = "arn:aws:lambda:eu-west-1:123456789012:function:stream-relay" +os.environ["STREAM_RELAY_ARN"] = f"arn:aws:lambda:{_REGION}:123456789012:function:stream-relay" # Stream relay Lambda env vars -os.environ["APPSYNC_HTTP_ENDPOINT"] = "test.appsync-api.eu-west-1.amazonaws.com" +os.environ["APPSYNC_HTTP_ENDPOINT"] = f"test.appsync-api.{_REGION}.amazonaws.com" os.environ["APPSYNC_API_KEY"] = "test-api-key" -os.environ["AGENT_RUNTIME_ARN"] = "arn:aws:bedrock-agentcore:eu-west-1:123456789012:runtime/test" +os.environ["AGENT_RUNTIME_ARN"] = f"arn:aws:bedrock-agentcore:{_REGION}:123456789012:runtime/test" @dataclass @@ -32,7 +34,7 @@ class FakeLambdaContext: function_name: str = "test-function" memory_limit_in_mb: int = 256 - invoked_function_arn: str = "arn:aws:lambda:eu-west-1:123456789012:function:test" + invoked_function_arn: str = f"arn:aws:lambda:{_REGION}:123456789012:function:test" aws_request_id: str = "test-request-id" From b771fddd17d764845ee927964d8e625a71b645a5 Mon Sep 17 00:00:00 2001 From: Pete Davis Date: Thu, 12 Mar 2026 08:48:18 +0000 Subject: [PATCH 14/19] chore: fixed typo in folder name --- .../.gitignore | 0 .../.pylintrc | 0 .../README.md | 0 .../agents/chat/Dockerfile | 0 .../agents/chat/entrypoint.py | 0 .../agents/chat/requirements.txt | 0 .../app.py | 0 .../cdk.json | 0 .../cdk/__init__.py | 0 .../cdk/constructs/__init__.py | 0 .../cdk/constructs/chat_agent.py | 0 .../cdk/constructs/chat_service.py | 0 .../cdk/constructs/standard_lambda.py | 0 .../cdk/constructs/stream_relay.py | 0 .../cdk/stack.py | 0 .../example-pattern.json | 59 ++++++++++++++++++ .../functions/agent_invoker/index.py | 0 .../functions/agent_invoker/requirements.txt | 0 .../functions/stream_relay/index.py | 0 .../functions/stream_relay/requirements.txt | 0 .../images/appsync-pubsub-publish.jpg | Bin .../appsync-pubsub-subscribe-result.jpg | Bin .../images/appsync-pubsub-subscribe.jpg | Bin .../images/architecture.drawio | 0 .../images/architecture.png | Bin .../mise.toml | 0 .../pyproject.toml | 0 .../requirements-dev.txt | 0 .../requirements.txt | 0 .../tests/__init__.py | 0 .../tests/integration/__init__.py | 0 .../tests/integration/conftest.py | 0 .../tests/integration/test_appsync_events.py | 0 .../tests/unit/__init__.py | 0 .../tests/unit/conftest.py | 0 .../tests/unit/test_agent_invoker.py | 0 .../tests/unit/test_stream_relay.py | 0 37 files changed, 59 insertions(+) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/.gitignore (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/.pylintrc (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/README.md (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/agents/chat/Dockerfile (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/agents/chat/entrypoint.py (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/agents/chat/requirements.txt (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/app.py (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/cdk.json (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/cdk/__init__.py (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/cdk/constructs/__init__.py (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/cdk/constructs/chat_agent.py (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/cdk/constructs/chat_service.py (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/cdk/constructs/standard_lambda.py (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/cdk/constructs/stream_relay.py (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/cdk/stack.py (100%) create mode 100644 appsync-events-lambda-agentcore-cdk/example-pattern.json rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/functions/agent_invoker/index.py (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/functions/agent_invoker/requirements.txt (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/functions/stream_relay/index.py (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/functions/stream_relay/requirements.txt (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/images/appsync-pubsub-publish.jpg (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/images/appsync-pubsub-subscribe-result.jpg (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/images/appsync-pubsub-subscribe.jpg (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/images/architecture.drawio (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/images/architecture.png (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/mise.toml (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/pyproject.toml (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/requirements-dev.txt (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/requirements.txt (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/tests/__init__.py (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/tests/integration/__init__.py (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/tests/integration/conftest.py (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/tests/integration/test_appsync_events.py (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/tests/unit/__init__.py (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/tests/unit/conftest.py (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/tests/unit/test_agent_invoker.py (100%) rename {appsync-events-lambda-agnetcore-cdk => appsync-events-lambda-agentcore-cdk}/tests/unit/test_stream_relay.py (100%) diff --git a/appsync-events-lambda-agnetcore-cdk/.gitignore b/appsync-events-lambda-agentcore-cdk/.gitignore similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/.gitignore rename to appsync-events-lambda-agentcore-cdk/.gitignore diff --git a/appsync-events-lambda-agnetcore-cdk/.pylintrc b/appsync-events-lambda-agentcore-cdk/.pylintrc similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/.pylintrc rename to appsync-events-lambda-agentcore-cdk/.pylintrc diff --git a/appsync-events-lambda-agnetcore-cdk/README.md b/appsync-events-lambda-agentcore-cdk/README.md similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/README.md rename to appsync-events-lambda-agentcore-cdk/README.md diff --git a/appsync-events-lambda-agnetcore-cdk/agents/chat/Dockerfile b/appsync-events-lambda-agentcore-cdk/agents/chat/Dockerfile similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/agents/chat/Dockerfile rename to appsync-events-lambda-agentcore-cdk/agents/chat/Dockerfile diff --git a/appsync-events-lambda-agnetcore-cdk/agents/chat/entrypoint.py b/appsync-events-lambda-agentcore-cdk/agents/chat/entrypoint.py similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/agents/chat/entrypoint.py rename to appsync-events-lambda-agentcore-cdk/agents/chat/entrypoint.py diff --git a/appsync-events-lambda-agnetcore-cdk/agents/chat/requirements.txt b/appsync-events-lambda-agentcore-cdk/agents/chat/requirements.txt similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/agents/chat/requirements.txt rename to appsync-events-lambda-agentcore-cdk/agents/chat/requirements.txt diff --git a/appsync-events-lambda-agnetcore-cdk/app.py b/appsync-events-lambda-agentcore-cdk/app.py similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/app.py rename to appsync-events-lambda-agentcore-cdk/app.py diff --git a/appsync-events-lambda-agnetcore-cdk/cdk.json b/appsync-events-lambda-agentcore-cdk/cdk.json similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/cdk.json rename to appsync-events-lambda-agentcore-cdk/cdk.json diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/__init__.py b/appsync-events-lambda-agentcore-cdk/cdk/__init__.py similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/cdk/__init__.py rename to appsync-events-lambda-agentcore-cdk/cdk/__init__.py diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/__init__.py b/appsync-events-lambda-agentcore-cdk/cdk/constructs/__init__.py similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/cdk/constructs/__init__.py rename to appsync-events-lambda-agentcore-cdk/cdk/constructs/__init__.py diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_agent.py b/appsync-events-lambda-agentcore-cdk/cdk/constructs/chat_agent.py similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_agent.py rename to appsync-events-lambda-agentcore-cdk/cdk/constructs/chat_agent.py diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_service.py b/appsync-events-lambda-agentcore-cdk/cdk/constructs/chat_service.py similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/cdk/constructs/chat_service.py rename to appsync-events-lambda-agentcore-cdk/cdk/constructs/chat_service.py diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/standard_lambda.py b/appsync-events-lambda-agentcore-cdk/cdk/constructs/standard_lambda.py similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/cdk/constructs/standard_lambda.py rename to appsync-events-lambda-agentcore-cdk/cdk/constructs/standard_lambda.py diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/constructs/stream_relay.py b/appsync-events-lambda-agentcore-cdk/cdk/constructs/stream_relay.py similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/cdk/constructs/stream_relay.py rename to appsync-events-lambda-agentcore-cdk/cdk/constructs/stream_relay.py diff --git a/appsync-events-lambda-agnetcore-cdk/cdk/stack.py b/appsync-events-lambda-agentcore-cdk/cdk/stack.py similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/cdk/stack.py rename to appsync-events-lambda-agentcore-cdk/cdk/stack.py diff --git a/appsync-events-lambda-agentcore-cdk/example-pattern.json b/appsync-events-lambda-agentcore-cdk/example-pattern.json new file mode 100644 index 0000000000..8616bc19b6 --- /dev/null +++ b/appsync-events-lambda-agentcore-cdk/example-pattern.json @@ -0,0 +1,59 @@ +{ + "title": "Step Functions to Athena", + "description": "Create a Step Functions workflow to query Amazon Athena.", + "language": "Python", + "level": "200", + "framework": "AWS CDK", + "introBox": { + "headline": "How it works", + "text": [ + "This sample project demonstrates how to use an AWS Step Functions state machine to query Athena and get the results. This pattern is leveraging the native integration between these 2 services which means only JSON-based, structured language is used to define the implementation.", + "With Amazon Athena you can get up to 1000 results per invocation of the GetQueryResults method and this is the reason why the Step Function has a loop to get more results. The results are sent to a Map which can be configured to handle (the DoSomething state) the items in parallel or one by one by modifying the max_concurrency parameter.", + "This pattern deploys one Step Functions, two S3 Buckets, one Glue table and one Glue database." + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/sfn-athena-cdk-python", + "templateURL": "serverless-patterns/sfn-athena-cdk-python", + "projectFolder": "sfn-athena-cdk-python", + "templateFile": "sfn_athena_cdk_python_stack.py" + } + }, + "resources": { + "bullets": [ + { + "text": "Call Athena with Step Functions", + "link": "https://docs.aws.amazon.com/step-functions/latest/dg/connect-athena.html" + }, + { + "text": "Amazon Athena - Serverless Interactive Query Service", + "link": "https://aws.amazon.com/athena/" + } + ] + }, + "deploy": { + "text": [ + "sam deploy" + ] + }, + "testing": { + "text": [ + "See the GitHub repo for detailed testing instructions." + ] + }, + "cleanup": { + "text": [ + "Delete the stack: cdk delete." + ] + }, + "authors": [ + { + "name": "Your name", + "image": "link-to-your-photo.jpg", + "bio": "Your bio.", + "linkedin": "linked-in-ID", + "twitter": "twitter-handle" + } + ] +} diff --git a/appsync-events-lambda-agnetcore-cdk/functions/agent_invoker/index.py b/appsync-events-lambda-agentcore-cdk/functions/agent_invoker/index.py similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/functions/agent_invoker/index.py rename to appsync-events-lambda-agentcore-cdk/functions/agent_invoker/index.py diff --git a/appsync-events-lambda-agnetcore-cdk/functions/agent_invoker/requirements.txt b/appsync-events-lambda-agentcore-cdk/functions/agent_invoker/requirements.txt similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/functions/agent_invoker/requirements.txt rename to appsync-events-lambda-agentcore-cdk/functions/agent_invoker/requirements.txt diff --git a/appsync-events-lambda-agnetcore-cdk/functions/stream_relay/index.py b/appsync-events-lambda-agentcore-cdk/functions/stream_relay/index.py similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/functions/stream_relay/index.py rename to appsync-events-lambda-agentcore-cdk/functions/stream_relay/index.py diff --git a/appsync-events-lambda-agnetcore-cdk/functions/stream_relay/requirements.txt b/appsync-events-lambda-agentcore-cdk/functions/stream_relay/requirements.txt similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/functions/stream_relay/requirements.txt rename to appsync-events-lambda-agentcore-cdk/functions/stream_relay/requirements.txt diff --git a/appsync-events-lambda-agnetcore-cdk/images/appsync-pubsub-publish.jpg b/appsync-events-lambda-agentcore-cdk/images/appsync-pubsub-publish.jpg similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/images/appsync-pubsub-publish.jpg rename to appsync-events-lambda-agentcore-cdk/images/appsync-pubsub-publish.jpg diff --git a/appsync-events-lambda-agnetcore-cdk/images/appsync-pubsub-subscribe-result.jpg b/appsync-events-lambda-agentcore-cdk/images/appsync-pubsub-subscribe-result.jpg similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/images/appsync-pubsub-subscribe-result.jpg rename to appsync-events-lambda-agentcore-cdk/images/appsync-pubsub-subscribe-result.jpg diff --git a/appsync-events-lambda-agnetcore-cdk/images/appsync-pubsub-subscribe.jpg b/appsync-events-lambda-agentcore-cdk/images/appsync-pubsub-subscribe.jpg similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/images/appsync-pubsub-subscribe.jpg rename to appsync-events-lambda-agentcore-cdk/images/appsync-pubsub-subscribe.jpg diff --git a/appsync-events-lambda-agnetcore-cdk/images/architecture.drawio b/appsync-events-lambda-agentcore-cdk/images/architecture.drawio similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/images/architecture.drawio rename to appsync-events-lambda-agentcore-cdk/images/architecture.drawio diff --git a/appsync-events-lambda-agnetcore-cdk/images/architecture.png b/appsync-events-lambda-agentcore-cdk/images/architecture.png similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/images/architecture.png rename to appsync-events-lambda-agentcore-cdk/images/architecture.png diff --git a/appsync-events-lambda-agnetcore-cdk/mise.toml b/appsync-events-lambda-agentcore-cdk/mise.toml similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/mise.toml rename to appsync-events-lambda-agentcore-cdk/mise.toml diff --git a/appsync-events-lambda-agnetcore-cdk/pyproject.toml b/appsync-events-lambda-agentcore-cdk/pyproject.toml similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/pyproject.toml rename to appsync-events-lambda-agentcore-cdk/pyproject.toml diff --git a/appsync-events-lambda-agnetcore-cdk/requirements-dev.txt b/appsync-events-lambda-agentcore-cdk/requirements-dev.txt similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/requirements-dev.txt rename to appsync-events-lambda-agentcore-cdk/requirements-dev.txt diff --git a/appsync-events-lambda-agnetcore-cdk/requirements.txt b/appsync-events-lambda-agentcore-cdk/requirements.txt similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/requirements.txt rename to appsync-events-lambda-agentcore-cdk/requirements.txt diff --git a/appsync-events-lambda-agnetcore-cdk/tests/__init__.py b/appsync-events-lambda-agentcore-cdk/tests/__init__.py similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/tests/__init__.py rename to appsync-events-lambda-agentcore-cdk/tests/__init__.py diff --git a/appsync-events-lambda-agnetcore-cdk/tests/integration/__init__.py b/appsync-events-lambda-agentcore-cdk/tests/integration/__init__.py similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/tests/integration/__init__.py rename to appsync-events-lambda-agentcore-cdk/tests/integration/__init__.py diff --git a/appsync-events-lambda-agnetcore-cdk/tests/integration/conftest.py b/appsync-events-lambda-agentcore-cdk/tests/integration/conftest.py similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/tests/integration/conftest.py rename to appsync-events-lambda-agentcore-cdk/tests/integration/conftest.py diff --git a/appsync-events-lambda-agnetcore-cdk/tests/integration/test_appsync_events.py b/appsync-events-lambda-agentcore-cdk/tests/integration/test_appsync_events.py similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/tests/integration/test_appsync_events.py rename to appsync-events-lambda-agentcore-cdk/tests/integration/test_appsync_events.py diff --git a/appsync-events-lambda-agnetcore-cdk/tests/unit/__init__.py b/appsync-events-lambda-agentcore-cdk/tests/unit/__init__.py similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/tests/unit/__init__.py rename to appsync-events-lambda-agentcore-cdk/tests/unit/__init__.py diff --git a/appsync-events-lambda-agnetcore-cdk/tests/unit/conftest.py b/appsync-events-lambda-agentcore-cdk/tests/unit/conftest.py similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/tests/unit/conftest.py rename to appsync-events-lambda-agentcore-cdk/tests/unit/conftest.py diff --git a/appsync-events-lambda-agnetcore-cdk/tests/unit/test_agent_invoker.py b/appsync-events-lambda-agentcore-cdk/tests/unit/test_agent_invoker.py similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/tests/unit/test_agent_invoker.py rename to appsync-events-lambda-agentcore-cdk/tests/unit/test_agent_invoker.py diff --git a/appsync-events-lambda-agnetcore-cdk/tests/unit/test_stream_relay.py b/appsync-events-lambda-agentcore-cdk/tests/unit/test_stream_relay.py similarity index 100% rename from appsync-events-lambda-agnetcore-cdk/tests/unit/test_stream_relay.py rename to appsync-events-lambda-agentcore-cdk/tests/unit/test_stream_relay.py From f12e09ba50667c2ebee6c60582c4b982048d3ee1 Mon Sep 17 00:00:00 2001 From: Pete Davis Date: Thu, 12 Mar 2026 11:27:28 +0000 Subject: [PATCH 15/19] docs: replace template placeholders and improve documentation --- appsync-events-lambda-agentcore-cdk/README.md | 47 ++++++++++++++----- .../example-pattern.json | 46 +++++++++--------- 2 files changed, 58 insertions(+), 35 deletions(-) diff --git a/appsync-events-lambda-agentcore-cdk/README.md b/appsync-events-lambda-agentcore-cdk/README.md index 04e40c3bca..0059c42730 100644 --- a/appsync-events-lambda-agentcore-cdk/README.md +++ b/appsync-events-lambda-agentcore-cdk/README.md @@ -11,8 +11,10 @@ Important: this application uses various AWS services and there are costs associ * [Create an AWS account](https://portal.aws.amazon.com/gp/aws/developer/registration/index.html) if you do not already have one and log in. The IAM user that you use must have sufficient permissions to make necessary AWS service calls and manage AWS resources. * [AWS CLI installed and configured](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) * [Git installed](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) -* [mise installed](https://mise.jdx.dev/) (installs Python 3.14, Node.js 22, AWS CDK, and uv automatically) -* [Finch](https://runfinch.com/) or [Docker installed](https://docs.docker.com/get-docker/) (used for CDK bundling) +* [Python 3.14](https://www.python.org/downloads/) with [pip](https://pip.pypa.io/en/stable/installation/) +* [Node.js 22](https://nodejs.org/en/download/) +* [AWS CDK v2](https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html) (`npm install -g aws-cdk`) +* [Finch](https://runfinch.com/) or [Docker](https://docs.docker.com/get-docker/) (used for CDK bundling) ## Deployment Instructions @@ -24,19 +26,30 @@ Important: this application uses various AWS services and there are costs associ ``` cd appsync-events-lambda-agentcore-cdk ``` -1. Review `mise.toml` and update `AWS_REGION` and `STACK_NAME` in the `[env]` section as appropriate for your environment. If you are using Docker instead of Finch, comment out the `CDK_DOCKER = "finch"` line. -1. Trust the mise configuration for this project: +1. Create and activate a Python virtual environment: ``` - mise trust + python -m venv .venv + source .venv/bin/activate # On Windows: .venv\Scripts\activate ``` -1. Install tools and dependencies. +1. Install Python dependencies: ``` - mise install - mise run init + pip install -r requirements.txt + ``` +1. Set your target AWS region: + ``` + export AWS_REGION=eu-west-1 # On Windows: set AWS_REGION=eu-west-1 + ``` +1. If you are using [Finch](https://runfinch.com/) instead of Docker, set the `CDK_DOCKER` environment variable: + ``` + export CDK_DOCKER=finch # On Windows: set CDK_DOCKER=finch + ``` +1. Bootstrap CDK in your account/region (if not already done): + ``` + cdk bootstrap ``` 1. Deploy the stack: ``` - mise run cdk:deploy + cdk deploy ``` 1. Note the outputs from the CDK deployment process. These contain the AppSync Events HTTP endpoint, WebSocket endpoint, and API key needed for testing. @@ -48,7 +61,7 @@ Figure 1 - Architecture 1. The client publishes a message to the inbound channel (`/chat/{conversationId}`) via HTTP POST to AppSync Events. 2. AppSync Events triggers the agent invoker Lambda via direct Lambda integration. -3. The agent invoker validates the payload, invokes the stream relay Lambda asynchronously, and returns immediately. +3. The agent invoker validates the payload, invokes the stream relay Lambda asynchronously, and returns immediately. This two-Lambda split is necessary because AppSync invokes the handler synchronously — a long-running stream would block the response. 4. The stream relay calls `invoke_agent_runtime` on the Bedrock AgentCore Runtime, which hosts a Strands agent container, and consumes the Server-Sent Events (SSE) stream. 5. The stream relay publishes each chunk back to the response channel on AppSync Events (`/responses/chat/{conversationId}`). 6. The client receives agent response tokens in real time via the WebSocket subscription. @@ -61,9 +74,17 @@ The agent is a Strands-based research assistant with access to `http_request`, ` ### Automated tests +Install the test dependencies: + +```bash +pip install -r requirements-dev.txt +``` + +Run the tests: + ```bash -mise run test:unit # unit tests (no deployed stack needed) -mise run test:integration:verbose # integration tests with streaming output +pytest tests/unit -v # unit tests (no deployed stack needed) +pytest tests/integration -v -s # integration tests with streaming output ``` ### Using the AppSync Pub/Sub Editor @@ -133,7 +154,7 @@ You can configure multiple authorization modes on a single API and apply differe 1. Delete the stack ``` - mise run cdk:destroy + cdk destroy ``` ---- diff --git a/appsync-events-lambda-agentcore-cdk/example-pattern.json b/appsync-events-lambda-agentcore-cdk/example-pattern.json index 8616bc19b6..dec0a254fe 100644 --- a/appsync-events-lambda-agentcore-cdk/example-pattern.json +++ b/appsync-events-lambda-agentcore-cdk/example-pattern.json @@ -1,59 +1,61 @@ { - "title": "Step Functions to Athena", - "description": "Create a Step Functions workflow to query Amazon Athena.", + "title": "Real-time streaming chat with AppSync Events, Lambda, and Bedrock AgentCore", + "description": "Stream AI agent responses in real time using AppSync Events WebSockets with a two-phase Lambda architecture and Bedrock AgentCore Runtime.", "language": "Python", - "level": "200", + "level": "300", "framework": "AWS CDK", "introBox": { "headline": "How it works", "text": [ - "This sample project demonstrates how to use an AWS Step Functions state machine to query Athena and get the results. This pattern is leveraging the native integration between these 2 services which means only JSON-based, structured language is used to define the implementation.", - "With Amazon Athena you can get up to 1000 results per invocation of the GetQueryResults method and this is the reason why the Step Function has a loop to get more results. The results are sent to a Map which can be configured to handle (the DoSomething state) the items in parallel or one by one by modifying the max_concurrency parameter.", - "This pattern deploys one Step Functions, two S3 Buckets, one Glue table and one Glue database." + "This pattern deploys a real-time streaming chat service using AWS AppSync Events with a two-phase Lambda architecture to invoke a Strands agent running on Amazon Bedrock AgentCore Runtime.", + "The client publishes messages to an inbound channel via HTTP. AppSync Events triggers an agent invoker Lambda via direct Lambda integration (synchronous only), which asynchronously invokes a separate stream relay Lambda and returns immediately. Two Lambdas are needed because AppSync Events does not support async Lambda invocation, so the invoker must return quickly. The stream relay calls the AgentCore Runtime, consumes the Server-Sent Events (SSE) stream, and publishes response chunks back to a separate response channel.", + "The client receives agent response tokens in real time via a WebSocket subscription. Separate channel namespaces for inbound and outbound traffic prevent re-invocation loops." ] }, "gitHub": { "template": { - "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/sfn-athena-cdk-python", - "templateURL": "serverless-patterns/sfn-athena-cdk-python", - "projectFolder": "sfn-athena-cdk-python", - "templateFile": "sfn_athena_cdk_python_stack.py" + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/appsync-events-lambda-agentcore-cdk", + "templateURL": "serverless-patterns/appsync-events-lambda-agentcore-cdk", + "projectFolder": "appsync-events-lambda-agentcore-cdk", + "templateFile": "cdk/stack.py" } }, "resources": { "bullets": [ { - "text": "Call Athena with Step Functions", - "link": "https://docs.aws.amazon.com/step-functions/latest/dg/connect-athena.html" + "text": "AppSync Events API", + "link": "https://docs.aws.amazon.com/appsync/latest/eventapi/event-api-welcome.html" }, { - "text": "Amazon Athena - Serverless Interactive Query Service", - "link": "https://aws.amazon.com/athena/" + "text": "Amazon Bedrock AgentCore", + "link": "https://docs.aws.amazon.com/bedrock/latest/userguide/agentcore.html" } ] }, "deploy": { "text": [ - "sam deploy" + "python -m venv .venv && source .venv/bin/activate", + "pip install -r requirements.txt", + "cdk bootstrap", + "cdk deploy" ] }, "testing": { "text": [ - "See the GitHub repo for detailed testing instructions." + "See the GitHub repo for detailed testing instructions using the AppSync Pub/Sub Editor and automated tests." ] }, "cleanup": { "text": [ - "Delete the stack: cdk delete." + "Delete the stack: cdk destroy." ] }, "authors": [ { - "name": "Your name", - "image": "link-to-your-photo.jpg", - "bio": "Your bio.", - "linkedin": "linked-in-ID", - "twitter": "twitter-handle" + "name": "Pete Davis", + "image": "https://github.com/pjdavis-aws.png", + "bio": "Senior Partner Solution Architect at AWS", + "linkedin": "peter-davis-2676585" } ] } From 9d88f895ecc1adf8684317a7c9bb928778a281b6 Mon Sep 17 00:00:00 2001 From: Pete Davis Date: Thu, 12 Mar 2026 11:28:17 +0000 Subject: [PATCH 16/19] chore: add deploy, destroy tasks and Windows fallback to mise.toml --- appsync-events-lambda-agentcore-cdk/mise.toml | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/appsync-events-lambda-agentcore-cdk/mise.toml b/appsync-events-lambda-agentcore-cdk/mise.toml index 6ac54ea2a7..935adc7536 100644 --- a/appsync-events-lambda-agentcore-cdk/mise.toml +++ b/appsync-events-lambda-agentcore-cdk/mise.toml @@ -28,6 +28,7 @@ run = [ "rm -rf .venv cdk.out", "find . -type d \\( -name '__pycache__' -o -name '.pytest_cache' \\) -exec rm -rf {} +" ] +run_windows = "echo Not implemented" [tasks."cdk:synth"] description = "Synthesize CDK Stack" @@ -41,10 +42,59 @@ run = "cdk deploy --watch -c stack_name=$STACK_NAME" description = "Deploy CDK Stack" run = "cdk deploy --require-approval never -c stack_name=$STACK_NAME" +[tasks.deploy] +description = "Install dependencies, bootstrap, and deploy" +depends = "init" +depends_post = "cdk:deploy" + [tasks."cdk:destroy"] description = "Destroy CDK Stack" run = "cdk destroy -c stack_name=$STACK_NAME" +[tasks.destroy] +description = "Destroy stack and clean up orphaned CloudWatch log groups" +run = """ +echo "Collecting log group names from stack $STACK_NAME..." + +# AppSync API log group — extract API ID from ARN +APPSYNC_ARN=$(aws cloudformation list-stack-resources --stack-name $STACK_NAME --region $AWS_REGION \ + --query "StackResourceSummaries[?ResourceType=='AWS::AppSync::Api'].PhysicalResourceId" --output text) +APPSYNC_API_ID=$(echo "$APPSYNC_ARN" | grep -oE '[^/]+$') +APPSYNC_LG="/aws/appsync/apis/$APPSYNC_API_ID" + +# AgentCore Runtime log group +RUNTIME_ID=$(aws cloudformation list-stack-resources --stack-name $STACK_NAME --region $AWS_REGION \ + --query "StackResourceSummaries[?ResourceType=='AWS::BedrockAgentCore::Runtime'].PhysicalResourceId" --output text) +AGENTCORE_LG="/aws/bedrock-agentcore/runtimes/${RUNTIME_ID}-DEFAULT" + +# S3 Auto-Delete custom resource Lambda log group +S3_AUTO_DELETE_FN=$(aws cloudformation list-stack-resources --stack-name $STACK_NAME --region $AWS_REGION \ + --query "StackResourceSummaries[?ResourceType=='AWS::Lambda::Function' && contains(LogicalResourceId,'CustomS3AutoDelete')].PhysicalResourceId" --output text) +S3_AUTO_DELETE_LG="/aws/lambda/$S3_AUTO_DELETE_FN" + +# Log Retention Lambda log group +LOG_RETENTION_FN=$(aws cloudformation list-stack-resources --stack-name $STACK_NAME --region $AWS_REGION \ + --query "StackResourceSummaries[?ResourceType=='AWS::Lambda::Function' && contains(LogicalResourceId,'LogRetention')].PhysicalResourceId" --output text) +LOG_RETENTION_LG="/aws/lambda/$LOG_RETENTION_FN" + +LOG_GROUPS="$APPSYNC_LG $AGENTCORE_LG $S3_AUTO_DELETE_LG $LOG_RETENTION_LG" + +echo "Log groups to clean up after destroy:" +for lg in $LOG_GROUPS; do echo " $lg"; done + +mise run cdk:destroy + +echo "Deleting orphaned log groups..." +for lg in $LOG_GROUPS; do + if aws logs describe-log-groups --log-group-name-prefix "$lg" --region $AWS_REGION --query "logGroups[?logGroupName=='$lg'].logGroupName" --output text | grep -q .; then + echo "Deleting $lg" + aws logs delete-log-group --log-group-name "$lg" --region $AWS_REGION + fi +done +echo "Done." +""" +run_windows = "echo Not implemented" + [tasks.test] run = [ "mise run test:unit", From 25107c1845fd9cdab6dcd5fd3c6faa1e4c0c07f6 Mon Sep 17 00:00:00 2001 From: Pete Davis Date: Thu, 12 Mar 2026 13:59:54 +0000 Subject: [PATCH 17/19] chore: remove orphaned example-pattern.json from typo'd directory --- .../example-pattern.json | 59 ------------------- 1 file changed, 59 deletions(-) delete mode 100644 appsync-events-lambda-agnetcore-cdk/example-pattern.json diff --git a/appsync-events-lambda-agnetcore-cdk/example-pattern.json b/appsync-events-lambda-agnetcore-cdk/example-pattern.json deleted file mode 100644 index 8616bc19b6..0000000000 --- a/appsync-events-lambda-agnetcore-cdk/example-pattern.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "title": "Step Functions to Athena", - "description": "Create a Step Functions workflow to query Amazon Athena.", - "language": "Python", - "level": "200", - "framework": "AWS CDK", - "introBox": { - "headline": "How it works", - "text": [ - "This sample project demonstrates how to use an AWS Step Functions state machine to query Athena and get the results. This pattern is leveraging the native integration between these 2 services which means only JSON-based, structured language is used to define the implementation.", - "With Amazon Athena you can get up to 1000 results per invocation of the GetQueryResults method and this is the reason why the Step Function has a loop to get more results. The results are sent to a Map which can be configured to handle (the DoSomething state) the items in parallel or one by one by modifying the max_concurrency parameter.", - "This pattern deploys one Step Functions, two S3 Buckets, one Glue table and one Glue database." - ] - }, - "gitHub": { - "template": { - "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/sfn-athena-cdk-python", - "templateURL": "serverless-patterns/sfn-athena-cdk-python", - "projectFolder": "sfn-athena-cdk-python", - "templateFile": "sfn_athena_cdk_python_stack.py" - } - }, - "resources": { - "bullets": [ - { - "text": "Call Athena with Step Functions", - "link": "https://docs.aws.amazon.com/step-functions/latest/dg/connect-athena.html" - }, - { - "text": "Amazon Athena - Serverless Interactive Query Service", - "link": "https://aws.amazon.com/athena/" - } - ] - }, - "deploy": { - "text": [ - "sam deploy" - ] - }, - "testing": { - "text": [ - "See the GitHub repo for detailed testing instructions." - ] - }, - "cleanup": { - "text": [ - "Delete the stack: cdk delete." - ] - }, - "authors": [ - { - "name": "Your name", - "image": "link-to-your-photo.jpg", - "bio": "Your bio.", - "linkedin": "linked-in-ID", - "twitter": "twitter-handle" - } - ] -} From 71086968a483ae4dcde46bd046b14a47938a63a7 Mon Sep 17 00:00:00 2001 From: Pete Davis Date: Thu, 12 Mar 2026 14:01:37 +0000 Subject: [PATCH 18/19] feat: add cross-region inference profile resolution for multi-model support --- appsync-events-lambda-agentcore-cdk/cdk.json | 2 +- .../cdk/constructs/chat_agent.py | 39 ++++- .../cdk/constructs/inference_profiles.json | 153 ++++++++++++++++++ .../mise-tasks/bedrock/models/generate | 135 ++++++++++++++++ .../mise-tasks/bedrock/models/refresh | 50 ++++++ 5 files changed, 374 insertions(+), 5 deletions(-) create mode 100644 appsync-events-lambda-agentcore-cdk/cdk/constructs/inference_profiles.json create mode 100755 appsync-events-lambda-agentcore-cdk/mise-tasks/bedrock/models/generate create mode 100755 appsync-events-lambda-agentcore-cdk/mise-tasks/bedrock/models/refresh diff --git a/appsync-events-lambda-agentcore-cdk/cdk.json b/appsync-events-lambda-agentcore-cdk/cdk.json index 08a898325a..0dc343d198 100644 --- a/appsync-events-lambda-agentcore-cdk/cdk.json +++ b/appsync-events-lambda-agentcore-cdk/cdk.json @@ -15,7 +15,7 @@ ] }, "context": { - "model_id": "eu.anthropic.claude-sonnet-4-20250514-v1:0", + "model_id": "anthropic.claude-haiku-4-5-20251001-v1:0", "@aws-cdk/aws-signer:signingProfileNamePassedToCfn": true, "@aws-cdk/aws-ecs-patterns:secGroupsDisablesImplicitOpenListener": true, "@aws-cdk/aws-lambda:recognizeLayerVersion": true, diff --git a/appsync-events-lambda-agentcore-cdk/cdk/constructs/chat_agent.py b/appsync-events-lambda-agentcore-cdk/cdk/constructs/chat_agent.py index 4a41a41821..877f42e14f 100644 --- a/appsync-events-lambda-agentcore-cdk/cdk/constructs/chat_agent.py +++ b/appsync-events-lambda-agentcore-cdk/cdk/constructs/chat_agent.py @@ -7,6 +7,9 @@ - CfnRuntime with HTTP protocol and PUBLIC network """ +import json +from pathlib import Path + from constructs import Construct from aws_cdk import ( CfnOutput, @@ -19,6 +22,31 @@ ) from cdk_nag import NagSuppressions +INFERENCE_PROFILES_FILE = Path(__file__).parent / "inference_profiles.json" + + +def resolve_inference_profile(model_id: str, region: str) -> str: + """Resolve a base model ID to its cross-region inference profile for the given region. + + Raises ValueError if the model or region is not found in the mapping. + """ + if not INFERENCE_PROFILES_FILE.exists(): + raise ValueError(f"Inference profiles mapping not found at {INFERENCE_PROFILES_FILE}. ") + + with open(INFERENCE_PROFILES_FILE, encoding="utf-8") as f: + mappings = json.load(f) + + if model_id not in mappings: + available = ", ".join(sorted(mappings.keys())) or "(none)" + raise ValueError(f"Model '{model_id}' not found in inference profiles mapping. Available models: {available}. ") + + region_map = mappings[model_id] + if region not in region_map: + available = ", ".join(sorted(region_map.keys())) + raise ValueError(f"Model '{model_id}' does not support cross-region inference in '{region}'. Supported regions: {available}. ") + + return region_map[region] + class ChatAgentConstruct(Construct): """Creates the chat agent runtime with session persistence.""" @@ -36,6 +64,9 @@ def __init__( stack = Stack.of(self) + # Resolve base model ID to cross-region inference profile + inference_profile_id = resolve_inference_profile(model_id, stack.region) + self.session_bucket = s3.Bucket( self, "SessionBucket", @@ -69,7 +100,7 @@ def __init__( ) merged_env = { - "BEDROCK_MODEL_ID": model_id, + "BEDROCK_MODEL_ID": inference_profile_id, "AWS_REGION": stack.region, "SESSION_BUCKET": self.session_bucket.bucket_name, **(environment_variables or {}), @@ -118,9 +149,9 @@ def __init__( [ { "id": "AwsSolutions-IAM5", - "reason": "Bedrock foundation model ARNs require wildcards for model version flexibility.", + "reason": "Bedrock foundation model and inference profile ARNs require wildcards — the model is resolved at synth time from inference_profiles.json.", "applies_to": [ - "Resource::arn:aws:bedrock:*::foundation-model/anthropic.claude-*", + "Resource::arn:aws:bedrock:*::foundation-model/*", f"Resource::arn:aws:bedrock:{stack.region}::inference-profile/*", ], }, @@ -162,7 +193,7 @@ def _build_policies(stack, agent_image, session_bucket): "bedrock:InvokeModelWithResponseStream", ], resources=[ - "arn:aws:bedrock:*::foundation-model/anthropic.claude-*", + "arn:aws:bedrock:*::foundation-model/*", f"arn:aws:bedrock:{stack.region}:{stack.account}:inference-profile/*", ], ), diff --git a/appsync-events-lambda-agentcore-cdk/cdk/constructs/inference_profiles.json b/appsync-events-lambda-agentcore-cdk/cdk/constructs/inference_profiles.json new file mode 100644 index 0000000000..aedde985d6 --- /dev/null +++ b/appsync-events-lambda-agentcore-cdk/cdk/constructs/inference_profiles.json @@ -0,0 +1,153 @@ +{ + "anthropic.claude-sonnet-4-20250514-v1:0": { + "ap-northeast-1": "apac.anthropic.claude-sonnet-4-20250514-v1:0", + "ap-northeast-2": "apac.anthropic.claude-sonnet-4-20250514-v1:0", + "ap-northeast-3": "apac.anthropic.claude-sonnet-4-20250514-v1:0", + "ap-south-1": "apac.anthropic.claude-sonnet-4-20250514-v1:0", + "ap-south-2": "apac.anthropic.claude-sonnet-4-20250514-v1:0", + "ap-southeast-1": "apac.anthropic.claude-sonnet-4-20250514-v1:0", + "ap-southeast-2": "apac.anthropic.claude-sonnet-4-20250514-v1:0", + "ap-southeast-4": "apac.anthropic.claude-sonnet-4-20250514-v1:0", + "eu-central-1": "eu.anthropic.claude-sonnet-4-20250514-v1:0", + "eu-north-1": "eu.anthropic.claude-sonnet-4-20250514-v1:0", + "eu-south-1": "eu.anthropic.claude-sonnet-4-20250514-v1:0", + "eu-south-2": "eu.anthropic.claude-sonnet-4-20250514-v1:0", + "eu-west-1": "eu.anthropic.claude-sonnet-4-20250514-v1:0", + "eu-west-3": "eu.anthropic.claude-sonnet-4-20250514-v1:0", + "us-east-1": "us.anthropic.claude-sonnet-4-20250514-v1:0", + "us-east-2": "us.anthropic.claude-sonnet-4-20250514-v1:0", + "us-west-1": "us.anthropic.claude-sonnet-4-20250514-v1:0", + "us-west-2": "us.anthropic.claude-sonnet-4-20250514-v1:0" + }, + "anthropic.claude-haiku-4-5-20251001-v1:0": { + "ap-northeast-1": "jp.anthropic.claude-haiku-4-5-20251001-v1:0", + "ap-northeast-3": "jp.anthropic.claude-haiku-4-5-20251001-v1:0", + "ap-southeast-2": "au.anthropic.claude-haiku-4-5-20251001-v1:0", + "ap-southeast-4": "au.anthropic.claude-haiku-4-5-20251001-v1:0", + "ca-central-1": "us.anthropic.claude-haiku-4-5-20251001-v1:0", + "eu-central-1": "eu.anthropic.claude-haiku-4-5-20251001-v1:0", + "eu-north-1": "eu.anthropic.claude-haiku-4-5-20251001-v1:0", + "eu-south-1": "eu.anthropic.claude-haiku-4-5-20251001-v1:0", + "eu-south-2": "eu.anthropic.claude-haiku-4-5-20251001-v1:0", + "eu-west-1": "eu.anthropic.claude-haiku-4-5-20251001-v1:0", + "eu-west-2": "eu.anthropic.claude-haiku-4-5-20251001-v1:0", + "eu-west-3": "eu.anthropic.claude-haiku-4-5-20251001-v1:0", + "us-east-1": "us.anthropic.claude-haiku-4-5-20251001-v1:0", + "us-east-2": "us.anthropic.claude-haiku-4-5-20251001-v1:0", + "us-west-1": "us.anthropic.claude-haiku-4-5-20251001-v1:0", + "us-west-2": "us.anthropic.claude-haiku-4-5-20251001-v1:0" + }, + "anthropic.claude-sonnet-4-6": { + "ap-northeast-1": "jp.anthropic.claude-sonnet-4-6", + "ap-northeast-3": "jp.anthropic.claude-sonnet-4-6", + "ap-southeast-2": "au.anthropic.claude-sonnet-4-6", + "ap-southeast-4": "au.anthropic.claude-sonnet-4-6", + "ca-central-1": "us.anthropic.claude-sonnet-4-6", + "eu-central-1": "eu.anthropic.claude-sonnet-4-6", + "eu-north-1": "eu.anthropic.claude-sonnet-4-6", + "eu-south-1": "eu.anthropic.claude-sonnet-4-6", + "eu-south-2": "eu.anthropic.claude-sonnet-4-6", + "eu-west-1": "eu.anthropic.claude-sonnet-4-6", + "eu-west-2": "eu.anthropic.claude-sonnet-4-6", + "eu-west-3": "eu.anthropic.claude-sonnet-4-6", + "us-east-1": "us.anthropic.claude-sonnet-4-6", + "us-east-2": "us.anthropic.claude-sonnet-4-6", + "us-west-1": "us.anthropic.claude-sonnet-4-6", + "us-west-2": "us.anthropic.claude-sonnet-4-6" + }, + "anthropic.claude-sonnet-4-5-20250929-v1:0": { + "ap-northeast-1": "jp.anthropic.claude-sonnet-4-5-20250929-v1:0", + "ap-northeast-3": "jp.anthropic.claude-sonnet-4-5-20250929-v1:0", + "ap-southeast-2": "au.anthropic.claude-sonnet-4-5-20250929-v1:0", + "ap-southeast-4": "au.anthropic.claude-sonnet-4-5-20250929-v1:0", + "ca-central-1": "us.anthropic.claude-sonnet-4-5-20250929-v1:0", + "eu-central-1": "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", + "eu-north-1": "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", + "eu-south-1": "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", + "eu-south-2": "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", + "eu-west-1": "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", + "eu-west-2": "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", + "eu-west-3": "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", + "us-east-1": "us.anthropic.claude-sonnet-4-5-20250929-v1:0", + "us-east-2": "us.anthropic.claude-sonnet-4-5-20250929-v1:0", + "us-west-1": "us.anthropic.claude-sonnet-4-5-20250929-v1:0", + "us-west-2": "us.anthropic.claude-sonnet-4-5-20250929-v1:0" + }, + "anthropic.claude-3-haiku-20240307-v1:0": { + "ap-northeast-1": "apac.anthropic.claude-3-haiku-20240307-v1:0", + "ap-northeast-2": "apac.anthropic.claude-3-haiku-20240307-v1:0", + "ap-south-1": "apac.anthropic.claude-3-haiku-20240307-v1:0", + "ap-southeast-1": "apac.anthropic.claude-3-haiku-20240307-v1:0", + "ap-southeast-2": "apac.anthropic.claude-3-haiku-20240307-v1:0", + "eu-central-1": "eu.anthropic.claude-3-haiku-20240307-v1:0", + "eu-west-1": "eu.anthropic.claude-3-haiku-20240307-v1:0", + "eu-west-3": "eu.anthropic.claude-3-haiku-20240307-v1:0", + "us-east-1": "us.anthropic.claude-3-haiku-20240307-v1:0", + "us-east-2": "us.anthropic.claude-3-haiku-20240307-v1:0", + "us-west-2": "us.anthropic.claude-3-haiku-20240307-v1:0" + }, + "amazon.nova-2-lite-v1:0": { + "ap-northeast-1": "jp.amazon.nova-2-lite-v1:0", + "ap-northeast-3": "jp.amazon.nova-2-lite-v1:0", + "ca-central-1": "us.amazon.nova-2-lite-v1:0", + "eu-central-1": "eu.amazon.nova-2-lite-v1:0", + "eu-north-1": "eu.amazon.nova-2-lite-v1:0", + "eu-south-1": "eu.amazon.nova-2-lite-v1:0", + "eu-south-2": "eu.amazon.nova-2-lite-v1:0", + "eu-west-1": "eu.amazon.nova-2-lite-v1:0", + "eu-west-3": "eu.amazon.nova-2-lite-v1:0", + "us-east-1": "us.amazon.nova-2-lite-v1:0", + "us-east-2": "us.amazon.nova-2-lite-v1:0", + "us-west-1": "us.amazon.nova-2-lite-v1:0", + "us-west-2": "us.amazon.nova-2-lite-v1:0" + }, + "amazon.nova-lite-v1:0": { + "ap-northeast-1": "apac.amazon.nova-lite-v1:0", + "ap-northeast-2": "apac.amazon.nova-lite-v1:0", + "ap-northeast-3": "apac.amazon.nova-lite-v1:0", + "ap-south-1": "apac.amazon.nova-lite-v1:0", + "ap-southeast-1": "apac.amazon.nova-lite-v1:0", + "ap-southeast-2": "apac.amazon.nova-lite-v1:0", + "ca-central-1": "ca.amazon.nova-lite-v1:0", + "ca-west-1": "ca.amazon.nova-lite-v1:0", + "eu-central-1": "eu.amazon.nova-lite-v1:0", + "eu-north-1": "eu.amazon.nova-lite-v1:0", + "eu-west-1": "eu.amazon.nova-lite-v1:0", + "eu-west-3": "eu.amazon.nova-lite-v1:0", + "us-east-1": "us.amazon.nova-lite-v1:0", + "us-east-2": "us.amazon.nova-lite-v1:0", + "us-west-1": "us.amazon.nova-lite-v1:0", + "us-west-2": "us.amazon.nova-lite-v1:0" + }, + "amazon.nova-micro-v1:0": { + "ap-northeast-1": "apac.amazon.nova-micro-v1:0", + "ap-northeast-2": "apac.amazon.nova-micro-v1:0", + "ap-northeast-3": "apac.amazon.nova-micro-v1:0", + "ap-south-1": "apac.amazon.nova-micro-v1:0", + "ap-southeast-1": "apac.amazon.nova-micro-v1:0", + "ap-southeast-2": "apac.amazon.nova-micro-v1:0", + "eu-central-1": "eu.amazon.nova-micro-v1:0", + "eu-north-1": "eu.amazon.nova-micro-v1:0", + "eu-west-1": "eu.amazon.nova-micro-v1:0", + "eu-west-3": "eu.amazon.nova-micro-v1:0", + "us-east-1": "us.amazon.nova-micro-v1:0", + "us-east-2": "us.amazon.nova-micro-v1:0", + "us-west-2": "us.amazon.nova-micro-v1:0" + }, + "amazon.nova-pro-v1:0": { + "ap-northeast-1": "apac.amazon.nova-pro-v1:0", + "ap-northeast-2": "apac.amazon.nova-pro-v1:0", + "ap-northeast-3": "apac.amazon.nova-pro-v1:0", + "ap-south-1": "apac.amazon.nova-pro-v1:0", + "ap-southeast-1": "apac.amazon.nova-pro-v1:0", + "ap-southeast-2": "apac.amazon.nova-pro-v1:0", + "eu-central-1": "eu.amazon.nova-pro-v1:0", + "eu-north-1": "eu.amazon.nova-pro-v1:0", + "eu-west-1": "eu.amazon.nova-pro-v1:0", + "eu-west-3": "eu.amazon.nova-pro-v1:0", + "us-east-1": "us.amazon.nova-pro-v1:0", + "us-east-2": "us.amazon.nova-pro-v1:0", + "us-west-1": "us.amazon.nova-pro-v1:0", + "us-west-2": "us.amazon.nova-pro-v1:0" + } +} diff --git a/appsync-events-lambda-agentcore-cdk/mise-tasks/bedrock/models/generate b/appsync-events-lambda-agentcore-cdk/mise-tasks/bedrock/models/generate new file mode 100755 index 0000000000..eb297da462 --- /dev/null +++ b/appsync-events-lambda-agentcore-cdk/mise-tasks/bedrock/models/generate @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +#MISE description="Refresh cross-region inference profile mapping for a model" +#USAGE arg "" help="Base model ID (e.g. anthropic.claude-haiku-4-5-20251001-v1:0)" + +"""Query all standard AWS regions for cross-region inference profiles +matching the given model ID and upsert the mapping into +cdk/constructs/inference_profiles.json. +""" + +import json +import os +import re +import sys + +from pathlib import Path + +import boto3 + +MAPPING_FILE = Path(os.environ.get("MISE_PROJECT_DIR", ".")) / "cdk" / "constructs" / "inference_profiles.json" + +REGIONS = [ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "eu-central-1", + "eu-north-1", + "ap-southeast-1", + "ap-southeast-2", + "ap-northeast-1", + "ap-northeast-2", + "ap-northeast-3", + "ap-south-1", + "ca-central-1", + "sa-east-1", +] + +MODEL_ID_PATTERN = re.compile(r"^[a-z][a-z0-9-]+\.[a-z][a-z0-9._-]+(:\d+)?$") + + +def validate_model_id(model_id: str) -> None: + if not MODEL_ID_PATTERN.match(model_id): + print(f"Error: '{model_id}' is not a valid model ID.", file=sys.stderr) + print("Expected format: provider.model-name-version:N", file=sys.stderr) + print("Example: anthropic.claude-haiku-4-5-20251001-v1:0", file=sys.stderr) + sys.exit(1) + + +def find_profiles_in_region(region: str, model_id: str) -> dict[str, str]: + """Query a region for cross-region inference profiles containing the model. + + Returns {covered_region: profile_id} for each region in the profile's models array. + """ + client = boto3.client("bedrock", region_name=region) + region_map = {} + + try: + paginator = client.get_paginator("list_inference_profiles") + for page in paginator.paginate(typeEquals="SYSTEM_DEFINED"): + for profile in page.get("inferenceProfileSummaries", []): + profile_id = profile["inferenceProfileId"] + + # Match profiles ending with the model ID, skip global profiles + if not profile_id.endswith(model_id): + continue + if profile_id.startswith("global."): + continue + + # Extract covered regions from the models array + for model_ref in profile.get("models", []): + arn = model_ref.get("modelArn", "") + arn_parts = arn.split(":") + if len(arn_parts) >= 4 and arn_parts[3]: + region_map[arn_parts[3]] = profile_id + + except Exception as e: + print(f" Warning: could not query {region}: {e}", file=sys.stderr) + + return region_map + + +def main(): + model_id = os.environ.get("usage_model_id", "").strip() + if not model_id: + print("Error: model_id argument is required.", file=sys.stderr) + sys.exit(1) + + validate_model_id(model_id) + print(f"Refreshing inference profiles for: {model_id}") + + all_regions: dict[str, str] = {} + + for region in REGIONS: + print(f" Querying {region}...", end="", flush=True) + region_map = find_profiles_in_region(region, model_id) + new_count = len(set(region_map.keys()) - set(all_regions.keys())) + all_regions.update(region_map) + + if region_map: + print(f" found {new_count} new region(s)") + else: + print(" no profiles") + + if not all_regions: + print(f"\nError: No cross-region inference profiles found for '{model_id}'.", file=sys.stderr) + print("Check the model ID is correct and supports cross-region inference.", file=sys.stderr) + sys.exit(1) + + sorted_regions = dict(sorted(all_regions.items())) + + # Load existing mapping or start fresh + if MAPPING_FILE.exists(): + with open(MAPPING_FILE, encoding="utf-8") as f: + mappings = json.load(f) + else: + mappings = {} + + mappings[model_id] = sorted_regions + + MAPPING_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(MAPPING_FILE, "w", encoding="utf-8") as f: + json.dump(mappings, f, indent=2) + f.write("\n") + + print(f"\nUpdated {MAPPING_FILE}") + print(f"Regions mapped for {model_id}:") + for r, pid in sorted_regions.items(): + print(f" {r} -> {pid}") + + +if __name__ == "__main__": + main() diff --git a/appsync-events-lambda-agentcore-cdk/mise-tasks/bedrock/models/refresh b/appsync-events-lambda-agentcore-cdk/mise-tasks/bedrock/models/refresh new file mode 100755 index 0000000000..08c4ca4bde --- /dev/null +++ b/appsync-events-lambda-agentcore-cdk/mise-tasks/bedrock/models/refresh @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +#MISE description="Refresh inference profiles for all models in the mapping file" + +"""Reads model IDs from cdk/constructs/inference_profiles.json and +runs 'mise run bedrock:models:refresh' for each one. +""" + +import json +import os +import subprocess +import sys +from pathlib import Path + +MAPPING_FILE = Path(os.environ.get("MISE_PROJECT_DIR", ".")) / "cdk" / "constructs" / "inference_profiles.json" + + +def main(): + if not MAPPING_FILE.exists(): + print(f"Error: {MAPPING_FILE} not found. Run 'mise run bedrock:models:generate ' first.", file=sys.stderr) + sys.exit(1) + + with open(MAPPING_FILE) as f: + mappings = json.load(f) + + if not mappings: + print("No models found in mapping file.", file=sys.stderr) + sys.exit(1) + + model_ids = sorted(mappings.keys()) + print(f"Refreshing {len(model_ids)} model(s):\n") + + failed = [] + for model_id in model_ids: + print(f"{'=' * 60}") + print(f" {model_id}") + print(f"{'=' * 60}") + result = subprocess.run(["mise", "run", "bedrock:models:generate", model_id]) + if result.returncode != 0: + failed.append(model_id) + print() + + if failed: + print(f"\nFailed to refresh: {', '.join(failed)}", file=sys.stderr) + sys.exit(1) + + print(f"All {len(model_ids)} model(s) refreshed successfully.") + + +if __name__ == "__main__": + main() From 959c7afdbc97bb28054a249ee6262a5c622bd6cc Mon Sep 17 00:00:00 2001 From: Pete Davis Date: Thu, 12 Mar 2026 14:02:13 +0000 Subject: [PATCH 19/19] chore: improve portability and add API key security note --- appsync-events-lambda-agentcore-cdk/.gitignore | 5 ++++- .../cdk/constructs/chat_service.py | 3 +++ appsync-events-lambda-agentcore-cdk/mise.toml | 3 +-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/appsync-events-lambda-agentcore-cdk/.gitignore b/appsync-events-lambda-agentcore-cdk/.gitignore index 5d72866e23..84778f1deb 100644 --- a/appsync-events-lambda-agentcore-cdk/.gitignore +++ b/appsync-events-lambda-agentcore-cdk/.gitignore @@ -9,4 +9,7 @@ __pycache__ # CDK asset staging directory .cdk.staging cdk.out -.kiro \ No newline at end of file + +# Dev tooling +.kiro +mise.local.toml \ No newline at end of file diff --git a/appsync-events-lambda-agentcore-cdk/cdk/constructs/chat_service.py b/appsync-events-lambda-agentcore-cdk/cdk/constructs/chat_service.py index 76d28db2f6..271f17d2bc 100644 --- a/appsync-events-lambda-agentcore-cdk/cdk/constructs/chat_service.py +++ b/appsync-events-lambda-agentcore-cdk/cdk/constructs/chat_service.py @@ -105,6 +105,9 @@ def __init__( "EventApiRealtimeEndpoint", value=self.api.realtime_dns, ) + # NOTE: The API key is output in plaintext for convenience in this + # sample. For production, store it in Secrets Manager or SSM + # SecureString and retrieve it at runtime instead. CfnOutput( self, "EventApiApiKey", diff --git a/appsync-events-lambda-agentcore-cdk/mise.toml b/appsync-events-lambda-agentcore-cdk/mise.toml index 935adc7536..cd8e4ea0bf 100644 --- a/appsync-events-lambda-agentcore-cdk/mise.toml +++ b/appsync-events-lambda-agentcore-cdk/mise.toml @@ -1,6 +1,5 @@ [env] _.python.venv = { path = ".venv", create = true } -CDK_DOCKER = "finch" AWS_REGION = "eu-west-1" STACK_NAME = "AppsyncLambdaAgentcore" @@ -8,7 +7,7 @@ STACK_NAME = "AppsyncLambdaAgentcore" node = "22" python = "3.14" uv = "latest" -"npm:aws-cdk" = "2.1110" +"npm:aws-cdk" = "latest" [tasks.init] description = "Initialise the environment"