diff --git a/examples/agent_with_kbase.py b/examples/agent_with_kbase.py new file mode 100644 index 0000000..8799621 --- /dev/null +++ b/examples/agent_with_kbase.py @@ -0,0 +1,84 @@ +"""Example: AgentCore runtime with a Knowledge Base attached. + +Usage: + cdk synth # Verify CloudFormation templates + cdk deploy --all # Deploy both stacks to AWS + +After deployment: + 1. Upload documents to the KB data bucket (see DataBucketName output) + 2. Wait for auto-sync to ingest (default 5 min batch window) + 3. Invoke the AgentCore runtime and ask questions about your documents + +Deploy/Test with: + cdk deploy --all --require-approval broadening \ + --app "python agent_with_kb.app.py" + +Testing it: + 1) Set up a small knowledge base file in terminal for the dev account: + + echo "The team standup is every day at 9:30am." > test-doc.txt + aws s3 cp test-doc.txt s3://my-agent-kb1-kb-data-development/ + + 2) Run from the terminal with bedrock-agentcore cli + (assuming deployed in dev account, runtime arn from deploy output): + + aws bedrock-agentcore invoke-agent-runtime \ + --agent-runtime-arn \ + --payload "$(echo -n '{"prompt":"When is standup?"}' | base64)" \ + --region eu-west-2 \ + outfile.json +""" + +import aws_cdk as cdk + +from gds_idea_cdk_constructs import DeploymentConfig +from gds_idea_cdk_constructs.agent_core import ( + DEFAULT_AGENT_CODE_DIR, + AgentCore, + AgentCoreProperties, + CustomAgent, + MemoryConfig, +) +from gds_idea_cdk_constructs.knowledge_base import ( + ChunkingConfig, + KnowledgeBase, + KnowledgeBaseProps, +) + +app = cdk.App() +# --- Environment --- +cdk_env = cdk.Environment( + account="992382722318", + region="eu-west-2", +) +# --- Shared config (resolves from Secrets Manager) --- +config = DeploymentConfig(cdk_env) +# --- Knowledge Base --- +kb = KnowledgeBase( + app, + deployment_config=config, + app_config="my-agent-kb1", + kb_props=KnowledgeBaseProps( + chunking=ChunkingConfig.fixed_size(max_tokens=500, overlap_percentage=10), + description="Knowledge base for the agent demo", + retain_on_delete=False, + ), +) +# --- AgentCore Runtime (with KB attached) --- +agent = AgentCore( + app, + "MyAgentStack", + props=AgentCoreProperties( + runtime_name="my_kb_agent", + memory=MemoryConfig(name="my_kb_agent_session_store"), + agent=CustomAgent( + agent_code_directory=DEFAULT_AGENT_CODE_DIR, + environment_variables=kb.environment_variables, + ), + ), + env=cdk_env, +) +# --- Cross-stack: grant the agent permission to query the KB --- +kb.grant_retrieve(agent.runtime_role) + +app.synth() diff --git a/examples/webapp_with_agent/.devcontainer/docker-compose.yml b/examples/webapp_with_agent/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..32b584f --- /dev/null +++ b/examples/webapp_with_agent/.devcontainer/docker-compose.yml @@ -0,0 +1,53 @@ +# ============================================================================ +# DOCKER COMPOSE CONFIGURATION +# ============================================================================ +# Used by: +# - VS Code dev containers (.devcontainer/devcontainer.json) +# - Smoke test (idea-app smoke-test) +# +# Prerequisites: +# 1. Deploy AgentCore + KB using examples/agent_with_kbase.py +# 2. Set AGENTCORE_RUNTIME_ARN (from the RuntimeArn CloudFormation output) +# 3. Run: AWS_PROFILE= idea-app provide-role +# 4. Run: idea-app smoke-test --wait +# 5. Open: http://localhost:8080 +# ============================================================================ + +services: + app: + build: + context: .. + dockerfile: app_src/Dockerfile + target: ${DOCKER_TARGET:-development} + + volumes: + # Mount app source for live editing and auto-reload + - ../app_src:/app + + # Preserve container's .venv (prevents host .venv overwriting it) + - /app/.venv + + # Mount dev mock files directory + - ../dev_mocks:/app/dev_mocks + + # Mount AWS credentials (generated by: idea-app provide-role) + - ../.aws-dev:/home/appuser/.aws:ro + + environment: + # --------------------------------------------------------------- + # AgentCore Runtime ARN + # --------------------------------------------------------------- + # In production (CDK deploy), this is injected automatically by: + # WebAppContainerProperties( + # environment_variables=agent.environment_variables, + # ) + # agent.grant_invoke(webapp.task_role) + # + # Locally, set from the AgentCore RuntimeArn CloudFormation output: + # export AGENTCORE_RUNTIME_ARN="arn:aws:bedrock-agentcore:eu-west-2:..." + # --------------------------------------------------------------- + - AGENTCORE_RUNTIME_ARN=${AGENTCORE_RUNTIME_ARN:-} + - AWS_DEFAULT_REGION=eu-west-2 + + ports: + - "8080:8080" diff --git a/examples/webapp_with_agent/README.md b/examples/webapp_with_agent/README.md new file mode 100644 index 0000000..1536439 --- /dev/null +++ b/examples/webapp_with_agent/README.md @@ -0,0 +1,176 @@ +# WebApp with AgentCore Runtime + +This example demonstrates how to connect a web application to a deployed +AgentCore runtime with a Knowledge Base attached. All you need is a single environment variable: `AGENTCORE_RUNTIME_ARN`. + +## Using with a Deployed WebApp (CDK) + +When deploying a WebApp to AWS via CDK, the pattern would be: +Knowledge Base → AgentCore → WebApp, with grants wiring them together. + +```python +import aws_cdk as cdk +from gds_idea_cdk_constructs import AppConfig, DeploymentConfig +from gds_idea_cdk_constructs.agent_core import ( + DEFAULT_AGENT_CODE_DIR, + AgentCore, + AgentCoreProperties, + CustomAgent, + MemoryConfig, +) +from gds_idea_cdk_constructs.knowledge_base import ( + ChunkingConfig, + KnowledgeBase, + KnowledgeBaseProps, +) +from gds_idea_cdk_constructs.web_app import WebApp, WebAppContainerProperties + +app = cdk.App() +cdk_env = cdk.Environment(account="...", region="eu-west-2") +config = DeploymentConfig(cdk_env) +app_config = AppConfig(app_name="my-app", framework="streamlit") + +# 1. Knowledge Base +kb = KnowledgeBase( + app, + deployment_config=config, + app_config="my-app", + kb_props=KnowledgeBaseProps( + chunking=ChunkingConfig.fixed_size(max_tokens=500, overlap_percentage=10), + description="Documents for the agent", + ), +) + +# 2. AgentCore Runtime (with KB attached) +agent = AgentCore( + app, + "AgentStack", + props=AgentCoreProperties( + runtime_name="my_agent", + agent=CustomAgent( + agent_code_directory=DEFAULT_AGENT_CODE_DIR, + environment_variables=kb.environment_variables, + ), + ), + env=cdk_env, +) +kb.grant_retrieve(agent.runtime_role) + +# 3. WebApp — pass the runtime ARN and grant invoke permissions +webapp = WebApp( + app, + deployment_config=config, + app_config=app_config, + container_props=WebAppContainerProperties( + environment_variables=agent.environment_variables, + ), +) +agent.grant_invoke(webapp.task_role) + +app.synth() +``` + +This: + +- Passes `AGENTCORE_RUNTIME_ARN` into the Fargate container automatically +- Grants the task role `bedrock-agentcore:InvokeAgentRuntime` permission +- Grants the AgentCore runtime role `bedrock:Retrieve` on the Knowledge Base + +## Application Code + +The integration in an app is straightforward (see `app_src/streamlit_app.py`): + +```python +import boto3 +import json +import os + +client = boto3.client("bedrock-agentcore", region_name="eu-west-2") + +payload = json.dumps({ + "prompt": user_input, + "session_id": session_id, +}) + +response = client.invoke_agent_runtime( + agentRuntimeArn=os.environ["AGENTCORE_RUNTIME_ARN"], + payload=payload.encode(), +) + +# Parse the streaming response (Server-Sent Events) +body = response["response"].read().decode("utf-8") + +output = "" +for line in body.splitlines(): + if not line.startswith("data: "): + continue + event = json.loads(line[6:]) + if event.get("type") == "text": + output += event.get("data", "") + elif event.get("type") == "done" and not output: + output = event.get("response", "") +``` + +## Running Locally (Smoke Test) + +This directory is structured as an idea-app project so you can run the app +locally with `idea-app smoke-test` against a deployed AgentCore runtime. + +### Prerequisites + +- `idea-app` CLI installed +- Docker running +- AWS credentials (via `aws sso login` or similar) +- AgentCore + KB deployed (see `examples/agent_with_kbase.py`) + +### Steps + +1. **Deploy the AgentCore + Knowledge Base** (from the repo root): + + ```bash + cdk deploy --all --require-approval broadening \ + --app "python examples/agent_with_kbase.py" + ``` + + Note the `RuntimeArn` from the stack outputs. + +2. **Upload a test document** to the KB data bucket: + + ```bash + echo "The team standup is every day at 9:30am. The retrospective is on Fridays at 2pm." > test-doc.txt + aws s3 cp test-doc.txt s3:/// + ``` + + Wait ~5 minutes for auto-sync to ingest. + +3. **Set the runtime ARN**: + + ```bash + export AGENTCORE_RUNTIME_ARN="arn:aws:bedrock-agentcore:eu-west-2:992382722318:runtime/..." + ``` + +4. **Provide credentials to the container**: + + ```bash + cd examples/webapp_with_agent + AWS_PROFILE=your-profile idea-app provide-role + ``` + +5. **Run the app**: + + ```bash + idea-app smoke-test --wait + ``` + +6. **Open http://localhost:8080** and ask a question (e.g. "When is the team standup?"). + + Press Enter in the terminal to stop and clean up. + +7. **Destroy the example AgentCore and Knowledge base stacks** (from the repo root): + + Note: It may throw an error during deletion of the S3 bucket and you will need to delete manually in Cloudformation as versioning information may stop deletion of the bucket + and cause a failed stack state. The knowledge base stack implements auto_delete_objects by default when retain_on_delete is set to False. + + ```bash + cdk destroy --all --app "python examples/agent_with_kbase.py" + ``` \ No newline at end of file diff --git a/examples/webapp_with_agent/app_src/Dockerfile b/examples/webapp_with_agent/app_src/Dockerfile new file mode 100644 index 0000000..1b53711 --- /dev/null +++ b/examples/webapp_with_agent/app_src/Dockerfile @@ -0,0 +1,74 @@ +# ============================================================================ +# Multi-stage Dockerfile for Streamlit Application +# ============================================================================ +# Production build (default): docker build -t app . +# Dev container build: docker build --target development -t app:dev . +# ============================================================================ + +# Base stage - common setup +FROM python:3.12-slim AS base + +WORKDIR /app + +ARG USERNAME=appuser +ARG USER_UID=1000 +ARG USER_GID=$USER_UID + +# Install UV (pinned to major version) +COPY --from=ghcr.io/astral-sh/uv:0.9 /uv /usr/local/bin/uv + +# Create non-root user and set ownership of /app +RUN groupadd --gid $USER_GID $USERNAME \ + && useradd --uid $USER_UID --gid $USER_GID -m $USERNAME \ + && chown -R $USERNAME:$USERNAME /app + +# Copy dependency files with correct ownership +COPY --chown=$USERNAME:$USERNAME app_src/pyproject.toml ./ +COPY --chown=$USERNAME:$USERNAME app_src/uv.loc[k] ./ + +# Install minimal base packages +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Switch to non-root user for dependency installation +USER $USERNAME + +# Install dependencies (as non-root user) +RUN uv sync + +# Copy application code +COPY --chown=$USERNAME:$USERNAME app_src/ . + +EXPOSE 8080 + +CMD ["uv", "run", "streamlit", "run", "streamlit_app.py", \ + "--server.port", "8080", \ + "--server.address", "0.0.0.0", \ + "--server.headless", "true", \ + "--server.enableCORS", "false", \ + "--browser.gatherUsageStats", "false"] + +# ============================================================================ +# Development stage - for dev containers (NOT DEFAULT) +# ============================================================================ +FROM base AS development + +# Switch back to root to install dev-only packages +USER root + +# Grant passwordless sudo for development flexibility +RUN apt-get update \ + && apt-get install -y --no-install-recommends sudo \ + && rm -rf /var/lib/apt/lists/* \ + && echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \ + && chmod 0440 /etc/sudoers.d/$USERNAME + +USER $USERNAME + +# ============================================================================ +# Production stage - secure, minimal, non-root (DEFAULT) +# ============================================================================ +FROM base AS production diff --git a/examples/webapp_with_agent/app_src/pyproject.toml b/examples/webapp_with_agent/app_src/pyproject.toml new file mode 100644 index 0000000..9af9e79 --- /dev/null +++ b/examples/webapp_with_agent/app_src/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "my-agent-app" +version = "0.0.0" +requires-python = ">=3.12" +dependencies = [ + "streamlit>=1.38.0", + "boto3>=1.35.0", +] diff --git a/examples/webapp_with_agent/app_src/streamlit_app.py b/examples/webapp_with_agent/app_src/streamlit_app.py new file mode 100644 index 0000000..7f4b4a6 --- /dev/null +++ b/examples/webapp_with_agent/app_src/streamlit_app.py @@ -0,0 +1,73 @@ +"""Minimal Streamlit app demonstrating AgentCore runtime integration. + +This app shows how to call a deployed AgentCore runtime from a web application. +The AGENTCORE_RUNTIME_ARN environment variable is the contract between +infrastructure and application code: + +- Locally: set via docker-compose.yml (from the RuntimeArn CDK output) +- In production: injected by CDK via agent.environment_variables passed to + WebAppContainerProperties +""" + +import json +import os +import uuid + +import boto3 +import streamlit as st + +# --- Configuration from environment --- +RUNTIME_ARN = os.environ.get("AGENTCORE_RUNTIME_ARN", "") +REGION = os.environ.get("AWS_DEFAULT_REGION", "eu-west-2") + +st.title("AgentCore Chat") + +if not RUNTIME_ARN: + st.warning( + "AGENTCORE_RUNTIME_ARN is not set. " + "Deploy an AgentCore runtime (see examples/agent_with_kbase.py) " + "and set the env var in .devcontainer/docker-compose.yml." + ) + st.stop() + +# Session ID for conversation continuity (AgentCore Memory) +if "session_id" not in st.session_state: + st.session_state.session_id = str(uuid.uuid4()) + +prompt = st.text_input("Ask a question:") + +if prompt: + client = boto3.client("bedrock-agentcore", region_name=REGION) + + payload = json.dumps( + { + "prompt": prompt, + "session_id": st.session_state.session_id, + } + ) + + response = client.invoke_agent_runtime( + agentRuntimeArn=RUNTIME_ARN, + payload=payload.encode(), + ) + + # Read the streaming response (Server-Sent Events) + body = response["response"].read().decode("utf-8", errors="replace") + + # Parse SSE events — extract text chunks and assemble the response + output = "" + for line in body.splitlines(): + if not line.startswith("data: "): + continue + try: + event = json.loads(line[6:]) # strip "data: " prefix + if event.get("type") == "text": + output += event.get("data", "") + elif event.get("type") == "done": + # Use full response from "done" if no text chunks were received + if not output: + output = event.get("response", "") + except json.JSONDecodeError: + continue + + st.write(output) diff --git a/examples/webapp_with_agent/pyproject.toml b/examples/webapp_with_agent/pyproject.toml new file mode 100644 index 0000000..5ddeead --- /dev/null +++ b/examples/webapp_with_agent/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "webapp-with-agent-example" +version = "0.0.0" +requires-python = ">=3.12" + +[tool.webapp] +app_name = "my-agent-app" +framework = "streamlit" diff --git a/pyproject.toml b/pyproject.toml index 45e1eb9..b1df07f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gds-idea-cdk-constructs" -version = "0.4.2" +version = "0.4.3" description = "A repo for commonly used constructs in the team." readme = "README.md" authors = [ diff --git a/src/gds_idea_cdk_constructs/agent_core/__init__.py b/src/gds_idea_cdk_constructs/agent_core/__init__.py index 2e24611..f163768 100644 --- a/src/gds_idea_cdk_constructs/agent_core/__init__.py +++ b/src/gds_idea_cdk_constructs/agent_core/__init__.py @@ -1,4 +1,5 @@ from .props import ( + _DEFAULT_AGENT_CODE_DIR as DEFAULT_AGENT_CODE_DIR, AgentCoreProperties, BuiltInAgent, CustomAgent, @@ -12,6 +13,7 @@ "AgentCoreProperties", "BuiltInAgent", "CustomAgent", + "DEFAULT_AGENT_CODE_DIR", "MemoryConfig", "ModelConfig", ] diff --git a/src/gds_idea_cdk_constructs/agent_core/agent_template/agent.py b/src/gds_idea_cdk_constructs/agent_core/agent_template/agent.py index b46b91e..3b1ffc3 100644 --- a/src/gds_idea_cdk_constructs/agent_core/agent_template/agent.py +++ b/src/gds_idea_cdk_constructs/agent_core/agent_template/agent.py @@ -9,6 +9,7 @@ from collections.abc import AsyncGenerator from datetime import date from typing import Any +import os from bedrock_agentcore.memory import MemoryClient from bedrock_agentcore.runtime import BedrockAgentCoreApp @@ -27,6 +28,20 @@ memory_client = MemoryClient(region_name=config.region) if config.memory_id else None logger.info("Agent initialising (Model=%s, Region=%s)", config.model_id, config.region) +# --- Knowledge Base (optional, injected if KB is attached via CDK) --- +KB_ID = os.getenv("KB_ID") +if KB_ID: + os.environ["KNOWLEDGE_BASE_ID"] = KB_ID # Strands retrieve tool needs this + +# --- Tools --- +tools = [] + +# Conditional import and set up of knowledge base if available +if KB_ID: + from strands_tools import retrieve + tools = [retrieve] + logger.info("KB retrieval tool enabled (KB_ID=%s)", KB_ID) + # ========================================================================== @@ -128,6 +143,14 @@ def create_agent(history: list[dict]) -> Agent: "budget_tokens": config.budget_tokens, } + # If knowledge base is available, need to tell LLM that it's available to use via the retrieve tool + if KB_ID: + system_prompt += ( + "\n\nYou have access to a knowledge base via the retrieve tool. " + "Use it to search for relevant information when answering questions " + "that may require specific knowledge or documentation." + ) + return Agent( model=BedrockModel( model_id=config.model_id, @@ -137,6 +160,7 @@ def create_agent(history: list[dict]) -> Agent: ), system_prompt=system_prompt, messages=history, + tools=tools, ) diff --git a/src/gds_idea_cdk_constructs/agent_core/agent_template/pyproject.toml b/src/gds_idea_cdk_constructs/agent_core/agent_template/pyproject.toml index df6916c..44ec823 100644 --- a/src/gds_idea_cdk_constructs/agent_core/agent_template/pyproject.toml +++ b/src/gds_idea_cdk_constructs/agent_core/agent_template/pyproject.toml @@ -8,4 +8,5 @@ dependencies = [ "bedrock-agentcore>=1.3.0", "boto3>=1.42.45", "strands-agents>=1.25.0", + "strands-agents-tools>=0.5.0", ] \ No newline at end of file diff --git a/src/gds_idea_cdk_constructs/agent_core/stack.py b/src/gds_idea_cdk_constructs/agent_core/stack.py index afa0dda..3bea45b 100644 --- a/src/gds_idea_cdk_constructs/agent_core/stack.py +++ b/src/gds_idea_cdk_constructs/agent_core/stack.py @@ -86,6 +86,10 @@ def __init__( environment_variables=env_vars, ) + # Expose cross-stack attributes + self.runtime_role = runtime.role + self.runtime_arn = runtime.agent_runtime_arn + # --- Permissions --- # Model access (only for BuiltInAgent) if model_id: @@ -206,3 +210,56 @@ def __init__( # Show outputs CfnOutput(self, "RuntimeArn", value=runtime.agent_runtime_arn) + CfnOutput(self, "RuntimeRoleArn", value=runtime.role.role_arn) + + # ------------------------------------------------------------------ + # Cross-Stack integration + # ------------------------------------------------------------------ + + def grant_invoke(self, grantee: iam.IGrantable) -> None: + """Grant permissions to invoke this AgentCore runtime. + + Grants the grantee ``bedrock-agentcore:InvokeAgentRuntime`` on the + runtime ARN. + + Args: + grantee: The IAM principal to grant permissions to (e.g. a + task role from a + :class:`~gds_idea_cdk_constructs.web_app.WebApp` stack). + + Example: + :: + + agent = AgentCore(app, "AgentStack", props=AgentCoreProperties(...)) + webapp = WebApp(app, ...) + agent.grant_invoke(webapp.task_role) + """ + grantee.grant_principal.add_to_principal_policy( + iam.PolicyStatement( + sid="AgentCoreInvoke", + actions=["bedrock-agentcore:InvokeAgentRuntime"], + resources=[self.runtime_arn], + ) + ) + + @property + def environment_variables(self) -> dict[str, str]: + """Environment variables for containers invoking this runtime. + + Returns a dict suitable for passing into + :class:`~gds_idea_cdk_constructs.web_app.WebAppContainerProperties` + ``environment_variables``: + + - ``AGENTCORE_RUNTIME_ARN``: The AgentCore runtime ARN. + + Example: + :: + + container_props = WebAppContainerProperties( + environment_variables={ + **agent.environment_variables, + "MY_OTHER_VAR": "value", + }, + ) + """ + return {"AGENTCORE_RUNTIME_ARN": self.runtime_arn} diff --git a/src/gds_idea_cdk_constructs/knowledge_base/stack.py b/src/gds_idea_cdk_constructs/knowledge_base/stack.py index 6d7ab8d..306b376 100644 --- a/src/gds_idea_cdk_constructs/knowledge_base/stack.py +++ b/src/gds_idea_cdk_constructs/knowledge_base/stack.py @@ -95,6 +95,30 @@ class KnowledgeBase(Stack): # Grant the ECS task role permission to query the KB kb.grant_retrieve(webapp.task_role) + + With an AgentCore runtime (cross-stack):: + + from gds_idea_cdk_constructs.agent_core import ( + DEFAULT_AGENT_CODE_DIR, + AgentCore, + AgentCoreProperties, + CustomAgent, + ) + + agent = AgentCore( + app, + "MyAgentStack", + props=AgentCoreProperties( + runtime_name="my-agent", + agent=CustomAgent( + agent_code_directory=DEFAULT_AGENT_CODE_DIR, + environment_variables=kb.environment_variables, + ), + ), + ) + + # Grant the runtime permission to query the KB + kb.grant_retrieve(agent.runtime_role) """ def __init__( @@ -232,6 +256,7 @@ def _create_data_bucket(self, app_prefix: str, env_name: str) -> None: "DataBucket", bucket_name=f"{app_prefix}-kb-data-{env_name}", removal_policy=removal_policy, + auto_delete_objects=not self.kb_props.retain_on_delete, block_public_access=s3.BlockPublicAccess.BLOCK_ALL, enforce_ssl=True, versioned=True, diff --git a/tests/knowledge_base/test_stack.py b/tests/knowledge_base/test_stack.py index 600ec8b..f922fbc 100644 --- a/tests/knowledge_base/test_stack.py +++ b/tests/knowledge_base/test_stack.py @@ -100,6 +100,24 @@ def test_kb_stack_creates_s3_bucket(kb_default): ) +def test_kb_stack_auto_deletes_objects_when_not_retained( + cdk_app, deployment_config, app_config +): + """Test that auto_delete_objects is enabled when retain_on_delete is False.""" + kb = KnowledgeBase( + cdk_app, + deployment_config=deployment_config, + app_config=app_config, + kb_props=KnowledgeBaseProps(retain_on_delete=False), + ) + template = Template.from_stack(kb) + + template.has_resource( + "Custom::S3AutoDeleteObjects", + {"DeletionPolicy": "Delete"}, + ) + + def test_kb_stack_creates_vector_bucket(kb_default): """Test that the stack creates an S3 Vector Bucket.""" template = Template.from_stack(kb_default)