From 1abe547feceb8eb8f8086bfeddef745c6a406b2c Mon Sep 17 00:00:00 2001 From: antoinebgit Date: Fri, 18 Sep 2026 22:21:06 +0800 Subject: [PATCH] Add Amazon Bedrock AgentCore Runtime to Amazon EventBridge via AgentCore Gateway pattern --- agentcore-gateway-eventbridge-cdk/README.md | 103 +++++++++ .../agent-code/.dockerignore | 5 + .../agent-code/.gitignore | 3 + .../agent-code/Dockerfile | 20 ++ .../agent-code/agent.py | 67 ++++++ .../agent-code/requirements.txt | 2 + .../agent-code/sigv4.py | 42 ++++ .../architecture.png | Bin 0 -> 42314 bytes .../cdk/.gitignore | 7 + .../cdk/bin/app.ts | 14 ++ .../cdk/cdk.json | 21 ++ .../agentcore-gateway-eventbridge-stack.ts | 199 ++++++++++++++++++ .../cdk/package.json | 25 +++ .../cdk/tsconfig.json | 24 +++ .../example-pattern.json | 123 +++++++++++ .../src/emit_event/handler.py | 73 +++++++ 16 files changed, 728 insertions(+) create mode 100644 agentcore-gateway-eventbridge-cdk/README.md create mode 100644 agentcore-gateway-eventbridge-cdk/agent-code/.dockerignore create mode 100644 agentcore-gateway-eventbridge-cdk/agent-code/.gitignore create mode 100644 agentcore-gateway-eventbridge-cdk/agent-code/Dockerfile create mode 100644 agentcore-gateway-eventbridge-cdk/agent-code/agent.py create mode 100644 agentcore-gateway-eventbridge-cdk/agent-code/requirements.txt create mode 100644 agentcore-gateway-eventbridge-cdk/agent-code/sigv4.py create mode 100644 agentcore-gateway-eventbridge-cdk/architecture.png create mode 100644 agentcore-gateway-eventbridge-cdk/cdk/.gitignore create mode 100644 agentcore-gateway-eventbridge-cdk/cdk/bin/app.ts create mode 100644 agentcore-gateway-eventbridge-cdk/cdk/cdk.json create mode 100644 agentcore-gateway-eventbridge-cdk/cdk/lib/agentcore-gateway-eventbridge-stack.ts create mode 100644 agentcore-gateway-eventbridge-cdk/cdk/package.json create mode 100644 agentcore-gateway-eventbridge-cdk/cdk/tsconfig.json create mode 100644 agentcore-gateway-eventbridge-cdk/example-pattern.json create mode 100644 agentcore-gateway-eventbridge-cdk/src/emit_event/handler.py diff --git a/agentcore-gateway-eventbridge-cdk/README.md b/agentcore-gateway-eventbridge-cdk/README.md new file mode 100644 index 0000000000..a875c6a1ed --- /dev/null +++ b/agentcore-gateway-eventbridge-cdk/README.md @@ -0,0 +1,103 @@ +# Amazon Bedrock AgentCore Runtime to Amazon EventBridge via AgentCore Gateway + +This pattern demonstrates how an AI agent on **AgentCore Runtime** emits structured business events to **EventBridge** through a governed **AgentCore Gateway MCP tool**, authenticated with **IAM (SigV4)**. The Gateway provides governance, observability, and schema control over what the agent can emit — without the agent needing direct access to the EventBridge SDK. + +The CDK stack is **fully self-contained**: it builds and deploys the agent container, the Gateway with its Lambda tool backend, and an EventBridge custom bus. + +![Architecture](architecture.png) + +``` +Strands Agent (AgentCore Runtime) + │ MCP Streamable HTTP, SigV4-signed + ▼ +AgentCore Gateway (authorizerType=AWS_IAM) + │ emit_event tool + ▼ +Lambda tool backend (validates + PutEvents) + │ + ▼ +EventBridge Custom Bus +``` + +Learn more about this pattern at Serverless Land Patterns: https://serverlessland.com/patterns/ + +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. + +## Why route through AgentCore Gateway instead of calling EventBridge directly? + +In a mesh of many agents, Gateway is a governed chokepoint between agent reasoning and infrastructure side effects: + +- **Single point of schema enforcement** — one tool definition constrains what every agent in the mesh can emit. +- **Centralized rate limiting across the fleet** — throughput budgets are enforced at the Gateway, not per agent. +- **Blast radius containment** — only the Gateway's backend touches EventBridge; a misbehaving agent can't take down the bus. +- **Credential isolation** — agents authenticate with scoped, revocable identities +- **Tool discovery in the mesh** — agents find the `emit_event` capability over MCP without any hardcoded SDK dependency. +- **Observability without per-agent instrumentation** — every tool invocation is logged centrally, out of the box. +- **Policy evolution without redeployment** — schema tightening, freezes, or scope changes ship at the Gateway, not in agent code. + +The tradeoff is added network latency per emission, which is generally negligible for asynchronous event-driven workflows. + +## How it works + +1. The agent (Strands, Claude Haiku 4.5) connects to the AgentCore Gateway via the **MCP Streamable HTTP transport** (2025-03-26 spec). +2. The Gateway's inbound authorization is **`AWS_IAM`** — every request must carry a valid AWS SigV4 signature (service `bedrock-agentcore`). The Runtime's execution role is granted `bedrock-agentcore:InvokeGateway` scoped to the Gateway ARN. +3. **No MCP client SDK signs streamable-HTTP requests with SigV4 natively.** This pattern signs requests manually: [`agent-code/sigv4.py`](agent-code/sigv4.py) wraps `botocore.auth.SigV4Auth` as an `httpx.Auth` implementation and passes it to `streamablehttp_client(url, auth=sigv4_auth)`. +4. The Gateway exposes an `emit_event` tool backed by a Lambda function. +5. When the agent decides to emit an event, it calls `emit_event` with `source`, `detail_type`, and `detail`. +6. The Lambda validates the source prefix (`agent.*` only) and calls `events:PutEvents` on the custom bus. + +## Prerequisites + +- [AWS account](https://portal.aws.amazon.com/gp/aws/developer/registration/index.html) with sufficient permissions +- [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/install-cli.html) installed and configured +- [Node.js 20+](https://nodejs.org/en/download/) and npm +- [AWS CDK CLI](https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html) (`npm i -g aws-cdk`), bootstrapped in the target account/region +- [Docker](https://docs.docker.com/get-docker/) installed and running +- Access to the Amazon Bedrock Claude Haiku 4.5 model (enable in the Amazon Bedrock console) + +## Deployment + +```bash +git clone https://github.com/aws-samples/serverless-patterns +cd serverless-patterns/agentcore-gateway-eventbridge-cdk/cdk +npm install +cdk deploy +``` + +Note the stack outputs — in particular `AgentRuntimeArn`. + +## Testing + +Invoke the agent with a prompt that triggers the `emit_event` tool: + +```bash +RUNTIME_ARN="" + +aws bedrock-agentcore invoke-agent-runtime \ + --agent-runtime-arn "$RUNTIME_ARN" \ + --qualifier DEFAULT \ + --runtime-session-id "test-session-$(uuidgen | tr -d '-')" \ + --payload '{"prompt": "Use the emit_event tool to emit an event with source=agent.claims-processor, detail_type=ClaimApproved, detail={claimId: CLM-001, decision: approved, confidence: 0.94}"}' \ + --region us-east-1 +``` + +Verify success in the Runtime's CloudWatch Logs (`/aws/bedrock-agentcore/runtimes/-DEFAULT`): + +- `POST https:///mcp "HTTP/1.1 200 OK"` confirms the SigV4-signed request to the Gateway succeeded. +- The agent's response includes the EventBridge `Event ID` and a `Failed Count: 0`. + +Common failure causes: +- `403 Forbidden` from the Gateway → the Runtime role is missing `bedrock-agentcore:InvokeGateway` on the Gateway ARN, or the SigV4 signature is malformed (check that the `connection` header was stripped before signing). +- `AttributeError` on tool listing → ensure `strands-agents` and `mcp` package versions are compatible (see `agent-code/requirements.txt`). + +## Cleanup + +```bash +cdk destroy +``` + +--- + +Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +SPDX-License-Identifier: MIT-0 diff --git a/agentcore-gateway-eventbridge-cdk/agent-code/.dockerignore b/agentcore-gateway-eventbridge-cdk/agent-code/.dockerignore new file mode 100644 index 0000000000..caf4e5aa45 --- /dev/null +++ b/agentcore-gateway-eventbridge-cdk/agent-code/.dockerignore @@ -0,0 +1,5 @@ +__pycache__ +*.pyc +.git +.gitignore +.venv diff --git a/agentcore-gateway-eventbridge-cdk/agent-code/.gitignore b/agentcore-gateway-eventbridge-cdk/agent-code/.gitignore new file mode 100644 index 0000000000..309de5763b --- /dev/null +++ b/agentcore-gateway-eventbridge-cdk/agent-code/.gitignore @@ -0,0 +1,3 @@ +__pycache__ +*.pyc +.venv diff --git a/agentcore-gateway-eventbridge-cdk/agent-code/Dockerfile b/agentcore-gateway-eventbridge-cdk/agent-code/Dockerfile new file mode 100644 index 0000000000..acc74f75fc --- /dev/null +++ b/agentcore-gateway-eventbridge-cdk/agent-code/Dockerfile @@ -0,0 +1,20 @@ +FROM public.ecr.aws/docker/library/python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +RUN useradd -m -u 1000 bedrock_agentcore +USER bedrock_agentcore + +EXPOSE 8080 + +COPY . . + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8080/ping || exit 1 + +CMD ["python", "agent.py"] diff --git a/agentcore-gateway-eventbridge-cdk/agent-code/agent.py b/agentcore-gateway-eventbridge-cdk/agent-code/agent.py new file mode 100644 index 0000000000..8061cd7412 --- /dev/null +++ b/agentcore-gateway-eventbridge-cdk/agent-code/agent.py @@ -0,0 +1,67 @@ +""" +Strands Agent on AgentCore Runtime that connects to an AgentCore Gateway +to discover and use the emit_event MCP tool. + +The agent connects to the Gateway using the MCP Streamable HTTP transport. +Authentication: the Gateway's authorizerType is AWS_IAM, so every MCP +request must be signed with SigV4 (service "bedrock-agentcore"). The +Runtime's execution role is granted bedrock-agentcore:InvokeGateway +scoped to this Gateway's ARN. See sigv4.py for the signing implementation +— no MCP client SDK signs streamable-HTTP requests natively, so this is +done manually by wrapping botocore's SigV4Auth as an httpx.Auth. +""" +import os +import logging + +from bedrock_agentcore import BedrockAgentCoreApp +from strands import Agent +from strands.tools.mcp import MCPClient +from mcp.client.streamable_http import streamablehttp_client + +from sigv4 import SigV4HTTPXAuth + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = BedrockAgentCoreApp() + +GATEWAY_URL = os.environ.get("GATEWAY_MCP_URL", "") +AWS_REGION = os.environ.get("AWS_REGION", "us-east-1") + + +def create_mcp_client(): + """Create a fresh, SigV4-authenticated MCP client per invocation.""" + if not GATEWAY_URL: + return None + sigv4_auth = SigV4HTTPXAuth(region=AWS_REGION) + return MCPClient(lambda: streamablehttp_client(GATEWAY_URL, auth=sigv4_auth)) + + +@app.entrypoint +def invoke(payload: dict) -> dict: + """Process a request and let the agent decide whether to emit events.""" + prompt = payload.get("prompt", "No prompt provided.") + logger.info("Received prompt: %s", prompt[:200]) + + try: + mcp_client = create_mcp_client() + if mcp_client: + with mcp_client: + agent = Agent( + model="us.anthropic.claude-haiku-4-5-20251001-v1:0", + tools=mcp_client.list_tools_sync(), + ) + result = agent(prompt) + else: + agent = Agent(model="us.anthropic.claude-haiku-4-5-20251001-v1:0") + result = agent(prompt) + + logger.info("Agent completed: %s", str(result)[:500]) + return {"status": "completed", "result": str(result)[:2000]} + except Exception as e: + logger.exception("Agent invocation failed") + return {"status": "error", "error": str(e)[:500]} + + +if __name__ == "__main__": + app.run() diff --git a/agentcore-gateway-eventbridge-cdk/agent-code/requirements.txt b/agentcore-gateway-eventbridge-cdk/agent-code/requirements.txt new file mode 100644 index 0000000000..d4c4ad71a8 --- /dev/null +++ b/agentcore-gateway-eventbridge-cdk/agent-code/requirements.txt @@ -0,0 +1,2 @@ +strands-agents==1.50.2 +bedrock-agentcore==1.18.1 diff --git a/agentcore-gateway-eventbridge-cdk/agent-code/sigv4.py b/agentcore-gateway-eventbridge-cdk/agent-code/sigv4.py new file mode 100644 index 0000000000..53b10f1047 --- /dev/null +++ b/agentcore-gateway-eventbridge-cdk/agent-code/sigv4.py @@ -0,0 +1,42 @@ +""" +SigV4 authentication for the MCP streamable-HTTP transport. + +The MCP Python SDK's streamablehttp_client is a plain httpx-based client +with no native AWS SigV4 support. AgentCore Gateway's AWS_IAM inbound +authorizer requires each HTTP request to be signed with SigV4 (service +"bedrock-agentcore"). This wraps botocore's SigV4Auth as an httpx.Auth +so it can be passed directly to streamablehttp_client's `auth=` parameter. + +Reference pattern: awslabs/agentcore-samples gatewaylabproject/streamable_http_sigv4.py +""" +import boto3 +import httpx +from botocore.auth import SigV4Auth +from botocore.awsrequest import AWSRequest + + +class SigV4HTTPXAuth(httpx.Auth): + """httpx.Auth implementation that signs requests with AWS SigV4.""" + + def __init__(self, region: str, service: str = "bedrock-agentcore"): + session = boto3.Session() + credentials = session.get_credentials() + if credentials is None: + raise RuntimeError("No AWS credentials available to sign Gateway requests") + self._signer = SigV4Auth(credentials, service, region) + + def auth_flow(self, request: httpx.Request): + headers = dict(request.headers) + # The "connection" header is not part of the canonical request and + # including it breaks the signature validation on the server side. + headers.pop("connection", None) + + aws_request = AWSRequest( + method=request.method, + url=str(request.url), + data=request.content, + headers=headers, + ) + self._signer.add_auth(aws_request) + request.headers.update(dict(aws_request.headers)) + yield request diff --git a/agentcore-gateway-eventbridge-cdk/architecture.png b/agentcore-gateway-eventbridge-cdk/architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..453183d5bddd5edce685126927f2452272ac912d GIT binary patch literal 42314 zcmZU51ymK=_dg)r9fEKX5S8xkmQs;!=?;lYmvkcnO1DTilG5GXak(^??)ncezVE%? zf2~=p@y^USXYcdbdG`Yo6L>7_| zdC0_b;tHUF!d}34mT0uX^}?=}%;M0J2Vtf>&i!AO8wJdiyoP;Tls9HSnNH!|=Q8^3 zH6qB&t|(wBua@c1C$@{0of=9dND@G0 z_soi^T7x^?bWY3W6Ib@sH|^-5BEis|yuyDd5=9ULNb)zxQ5hjnn?eqgvc?V5Bq-i#)7EZhH_~#(WjwKVa!I97EjhC$ zTB{CgU#9>~q);eVt$#V_0Z}Xcq65GTSxkge|G)Ty9{|U!K+sO$ zKAMa8^L5_~4F*U%7y7EIuI5h;y`(e$YLAV=^qMc(HSY&g-;LjySuPi1sh-p4n&j%?W3(^aqm;-(D3fFU>+e6IK0X6;?S1~MeP`^tQXN4oPWbr z7XFl*@nJXHPi6wt>_k7mgRy41<84#zJkhMyjtqYhgFK|Nn@ilt=WhRP4r-}iV0g0I zZ(})KkV(17Wm9OeWmrvTscI=x=r%9&RCK=n6AuNA2=@7OBF?1nTLFzo@_$f4%Lvau z<%MYPFy~cV3ALqOVPc9{s$)7HE3Sq_mgCCmeQ3O)ss3%ug<2}%oL&&5*qH+6(Dg9y zaH_7I6dVoY3`r5;!uCfMZp>Ouo;-iQTYT<2cVOA7tuORjNdI6*7+mRYUpSX&**f2H zX5h@l=q4zA(L>OPiC1a;bl+AZA!HZ}#vqZNHZc=E5v2-|{u}v!hyWAn%38UU@wIf0 z_i&_xw3ZcmF;+_zt027g=Uc3ggYln-3D}6`k*K3gEwDpCj8qFM?K5vl#;DssRiL`1 z(a)`xNs9hk;*>&8wO(gL7Z%W+dp@oOnsc1RU|U=rTn${SOpDC!OH7K=SX@VuZOVP+ zQK5tvJIrfAH~p;bfsVKVxR!Vv9De^um@LQ(ZN;C%%Ol-X$PQN#KDD*N^2oJW4BROM z3Cs<|71IbpX^)7j?ZCZoHcUXj(20B>4XRJrrMyCS!Wj*``Ng?q>$A-OgZP`b3uz_p z9EnWEsJkl8i(`>-+10!WP$V~Kn20HuK=6c+iDs;f@qiU8g}~U%>Qa|*^5KN|fj3h~DcjS;;tjOq@~2$`aj*p}1WcSRUs3Xvt|E8MpRH1sxQ_K! zb7i!+7ZGAxZn5B`ZYuY(*zdKgcqd8UPNGt~eiKJ}pw>Udf(Uai{vxM@ts-BFBNRyb zdQtzaHOxie4?b4hag_&srXTTcC2}QkNGDDpxL21K7i^`RKD4vUo-7mVSQUPa%0$s_ zZ%0CDdQ?s+D!Q$7m~^?%iF+FY7BqkI5hXfcI>&Tg)g6(INAhCkuq@Jp^Dyfv3}-yG zVHKX-&U_1z%aihO1v{kL!aV}(!pDsxKn9dGsy8KcEJpefxu_Z%@;&uW-JAoqbN|BtYKWraaRryjdY*6>{PU5fF^UL-=&#)5*9VKF@lHtC{%=zY%S`@-o_VG#TOAwDx0hG5oF79*q$^KJK>t zsML>8CAc<*x7Ie=jLfZ*nxHZrhbT4J-F*Ubwrvtyav`7na1!jf?d2lNK-wMt3l!5@ z1H(APizqj7rAwLrhmc~w!%sQ$-?$D0^j;0gX3=Li~L0w?Hxr^Wtl*m4Bm z!%CcdP|6>^Z&tUC{ntyrh;hNbUj$3W#%pkY{Ct@_Vj5mxEhnda z`;qKFrc#y!n0(tYZ&n#!_9KE*>OVuoeFMxkd%DoI(jTOcd_|lg{!1NKg#wR~%gS;8 zQO`~!@HO%P{nh~ear+UY`v08;5#f~D6qt!E`=j)Ko&b9jk`9b^JFxbuwedf^6h*L5 z^37rxT!!28uE#YH`8N~l!+~#HW6c`=V0skdjM-nK%1QvUSb)c7P5%DD6=6^0ah*tleWk+hkr{IvtP3ciMaFwpN5Bs%()a`W3?V11<0 zPZp6>8G&}B7uailGS4EHr@Gf?mU@GS?`kCRaMmE*4O0r2hp`9y`$U(Egk z3r7S7X!u)T0KLzKI(_fBcpNhQk|W^+Sw{guA2r zLr?$7Rsep4x}jjTErLgaXgT2j(vQHHBaiaUK_U1T|A9pjA?%_2@ftb+21!1G(`*S; z-Y*YAepB4Y&m0jn(9me9;iJfG$T@y9p(*p|zVO4QE?YdVER_5uPi#7%vvA_qD{>o{>U)FZ<|E_f(0z>&)!LQw0%U=l`-YxW7Ht{+Pj z@%U43|4l1x%-SRDZV@Xzj_?d@|H~zF1jQ@{`j#Ir>01oM*)?U_4ZQw9DhP24`R{!b zMaQ$3Qg_iaEU$*=BD1qOCcRb4{M1_V7Z64L@iGMd1oShp->m})AxxG(2I5I+2)?vF zy{+aKQ2#K2yTS1HXpazhgKv;83+hFynoCm%#5%MAp-K1-4*;8)QJVh; z^FMEZz_4cO8Ew6qs-KBRs5w@pW8tlF2qM0p=x9)K|D~9Ot~{d8))QdxHG-A8*vrij z$~aMVyqW$Cs+#-Zd!*_KJ-3?E9 z_alOJ6e7Mfj415yTtEa|7eW*{n#=nwY&2Rpzk!q>`Ge~n{DM^e^2H$c5hhfYJ&RB= zVDZNb>|F^j#+q~wyH6rL@VA%|H1OBaU!_hSd(ISt!y*{HwNmnJV?)yi90%nCUIw7H z2PSCSj=W_F^#C+DC-rX!8bu|}204U%+PCVeKmD!5QD!8k2?o*}YWk&Oaar)B)WyLv z^s*o^R4|F+xJ(ot>Z>{=Kcr06W5`E}dTxibFlMd}nr9m?wlMm{d{^gfg(BP}=Gn|l z0R!*%nc8>Q)&cTf-1B~wvwM&~>3kHXh@LoReny?I-28b66g<43?r zj-7(akuL045&Z(~X(jkENi=#6n8X<83{GUFzIUSNml@_^BI|!QS%U|*RviDg*;k0P zc-m8bDFh2KxPHRX(T){>#-*zbhEykr zEFh8h{U*twR(K4!1raEC@DEZ(Xz*oa5K{KvG*&8&eg$tzZoR+2KV<&y-;&#M1Cu?C936^&Ln`K!=4Yj5X8Zm z#`<>>OGY;c+AkF`pO6THBgM8>mv9E+TS-_bNx*t_1Q$i$0quv3a5><(5CTtF31Hmm z75spU8`iOFgCPE5ZT{Ad@VPY~JQ}!C7MC~4(0^;&l4q2A?(`hB9n@SF?8XD7QK0dg zppTgmTCaxagl687FFtfa{U=qb-gu zq!akB@PmPVJ7f#|s8qz?5=JO`K%yVO^eDxkF@F%k#}{eqE&y7qEvc_pm#6DrGqrJGyBE+t2)*JD14KLOZ} zC}{7TENB+Zc!LmK@}1RGg7!2U1Q z4(#pJ8r#sKqc^S9i#`j`>(7Ok47EP>DIRXR4Z`eQ*3irn9-z`V9X^G?AC>C8Xkrk5C9!c*M(M%iI8qqe+6`iIH7tV8^{ zYqKWlvKgOH4F-H27VNK%ACp0!;SfhzNJv9F=V(#{9lB>rVhBd0f(doSGT0zzGY@HB z@4C}K+0sVH|2*&s-A(ulD+Fbq@Po zmmglrq&qfyzH54W>*s3_WKlaB-0v5h&X~)wWYu>)f8f&-T)ElzpxF2U6qQh_F&eMN zed0B1H*mzhk;Jhcbwl~3f$Fw2nfsxV#dxpW?9FI1Wc?V8=Z($2Bag5SRbFB37%JQhzR#5*RA2j0xmDX%PD>t3v+GVw+n=)}wM$7|J zYG~i^;t&xXOY?P}Lj8l9+pV8Al3@tLx*zq!k(mx-KBdoR>#vLYZ~B*sP$M_f>-3q0 zFFyRTZhPn*miLxYbAv|KntT{#-EM(ye6LM!)MX!!texs}=2@nGxN0ecMkT+fe}33s z+Uzi1@^;mA$fCP9Tv_;~w~uJx@SUw^o$#JoUEyAnM`nuG+))Jt`m z(BX{n^aC5p!DgPXf+ebXP3@0mYux*y6th;+yPYb3@te)0UVdlRv zCevj75GCRmaeD|Et}yOp!7qbXmn~_U`hL8&j@OV5x54DY|MvR)HW23+Qs!vpzI(J~gV7`jb&u30ysm6wEx#Pul-mna zals5)2Zmn~!G*V6DY0{Km@Es)^i9Gqfcd!aGeti!dV=R^svz?KV7sYa4R$pLjRI(9B?+R@{YY*(F2yt zV>}+o6HittY#%rsUaIs7vgvvJ&G=&ZeV~TVEI1o$J>%}&9ztDY|1gOoa$a?D zGIc}MU;#$kf3haN-$3O(p;`K7dSmcG$DU7qTwzq)qfa_@LW`%9McqpfQP)fAz5JD3anR;6^MN~z3+#Z%g#*K8-# z8d7gyhaK#u|nh#9blYi*qav@v6d{(&lu&umJ~!)t{$^rb*Zl8LX_ zq+ir@su^k!?JO21daRuAWr+`AQB^&py{>SDtt=JvD ztbZ^B1L-t){z`GZJ}QYhOxX6ixQi}HDl9BiKT>0niQs!%v-Dd25I3x}~|eigrDzLcimL@SM*Xv%40XxEj6iKL3j;}n{tS6xYQ14SZPVUj@q6&@i2Z&q?YQYIEFO{&P-*!#O?MCAQ8u;z@K`_Tc-N~Op1pB~ z?*LC!=w?#)7;-Q^x(~aaBjor%7G*GS{BvEyGsd|!FObYaWH}JA<&fxh&5ZpFt3b0U ze}P_%=+Z+@D{^-muJHY~h{E&tk}d3AU;{eLsI5s07l#gd@MiQ-ccBW4M?)Wb!A_a) zu7d{Y!pHC%C}HAkXHo)Nz^RJjr?UZ?VbBnwVZ!dnlk?c!(&oC}`x=Ro8-p;Ln!W^A zk)jxr{(Hw=F9|6!1PSELu;l|+v$?larfK%kBx6~cqWzL@s5tF?Z?AU`Y}rlInx`~{ zDLSq>JAFlb_Z&2b(}Z#irc;gknXc<~q|Hg&N?i|-SIN~)YzFppImL{{5Rue5yc;s{ zNnOPxkDyeu76{HB4{&b#_YAK)@-iJ1Yq` zwuankU~yG>-p#MKA0 z3+kWZog}8U{w#GdR3fGn{o}nOLmh6f>(|OOcFu00fZ+!$FzrAc9%QD~A^X!3O3~NH z@MeEPl|!fLLW?MBvR3QfR=wy4KjWhxklpo+Q9}TdmOkh0CxgE^6 zSp_lanlM|)wdd{f(pMG)Ckwg{53)mBE_#|CekplIL74LyyiM3ELX^!a^xUouTfG#M zgz7h^te`{5^nLPx#@|eJ@YkLm;7R{#gR)+C)s)DFvd^F_;*xlKre3_hiS2YEQre8w zOGP`Ysa-#dP1%%4?vpwc)5H!n?A@siB&RCCLD?CYpV*|ATgtFmL~+w`T(YqBK0p#~ zzMYULS>dx^7FutQON3?$JkaszefpMx)8o2*m0$3ZA5m<22@GS~&aeDn-f&t?ue%y7 z*iFTyR@Q+Ro#V=}gF%T{(cOdh6#pWV`eNB7fILV<<;5b?baabkUejS6*lnRh$sLVI z0apxHyEO|f>t)KN_$=VOB8g+d+)QGrxSU=PGvz+=Nz7K>GzIhCB?4%cN_C77h ze&rbOt_pXDwZMCo$9*;4pNg}BKwTIVx;d7eus(EJ!5}&_R$4J z9;cjwH36Z?<08)L!8v%rY5vYXpXmOgVACWzYgJK-dgm|$E1lqolN%}pceZ-}>a#Y- zdS{bWG4y1NgM(ucB@Ens1UnMNuOic7ehkGg>uB2Q?9qu7T1gZ$ZA2B2!dc?anCoA7 zv&(0F#VRhWjHa#%Q^i%9#*on1+S4(s9ln+W;XQh?2aLds4wvp16 z{$%TiEul@>FKyLEf3!6WlZCcxf7Uh5BwX?i+Rq8}s6zQ5{BYF}x9-ro+FKewX@qBI z_v>|dqg%#NhlOMNS7N>l?=^wnfQO>3tWxM z?mAj3`2pq^EDzlJ`Sazgf)YlKE?ydf!4(q9q|sGE{5o+#>1Q}wGiK3QrpodW{ZQJ$ zh>NW1ypf$ED|O7E%PtZU>R5ZEa7IR^u=rAeH6b>%ma-4*U>9t0Os`$+7XCs-!6Slx zJAp#)spLi=*y!+vjoB=Oq$*59J5)T3ESRP|%O^Sonb4fh>PE)`df1l6#P-?;!IRuW zE+)n#a&@OYTs%1w++j5}33fjMOUrTJjzZ%(jjN}QUp6Ak^!fGce}C@sfSq6MHjYd!LpxGB^nc> z>|M2TYLo>I)+8_K{>}xwA*(t`d#~NlsG0BtTR<-C{Mj_&#gGS8Z%9*j&o>f1I$43; ztioK=QFYsEXP(9FsiWGqQFicUbH#EJ1iHPc=P>TCf2e}l39a3L{Wj(0g}v0t z6K{9#{P}^z=>w-(iOcY$E*4}-P@};!x@J%rsHQO-CxkGGc3c0)L0!_Dgp!r+U(3v? zv+J_#1_iXp|aS?la{j|lH1zaDi1<0)N4CPU-eBY=T*z zIZ?+sfN)PpO^NTq@!Q#bx1!ts4cHWq^x)j0`OXmdE<;oho!$CPbbyh&+5bSy-v<%dPykRqmA>oBj*Y4oo6$Ieu&hV7ag z<9|hapH2+&_7ViB;vZwLT@fukPn+-arK=uyx+M=6y0&R<>C7<=l$WInEM788;KC2E zx(mFk6$qcLR$ZdL&yDU-J-AVz9lT&c(x)EY;+$R-_Dx!O*^Fl@mH znd0Ee>%luE#pj9(wh4Ei;;}9H>msw;BwZr=F@Nfp-1XK;mGi2ED_M? zb79ZH3A@n^(qZ(Cg4D!1b{=MhZU3G6Zlb7HGM)|)s{&;_>lI_Mszh{jhv^CK zi2i2$#Y4ZPcQWrE$Z-0BP!ldZo`sW=Lz}e&l1W+|%A^xwP=I~ho@exGEh7`Prnd~S zj0Vfgmu8FLmQhWNPuEM50#hEVJlfc5Scg?O$xS^-DBb0091AYl65zk;dhF|M(aAdx zCbHWn!z*^9UQ%=e5+x`^(5>MekQ-q4bs)yduD-qLvUk}V6sObvMVjSbpE7kds1?}KL@f)|Kwz<3v1QV`!gZ}$Dp?&<+nK1iDn zDvWEtR)0^Y3rTzRD>-e8P6}g(Pn&C+H4__+%W}xPbz&6 z=_i7tJeM67G#HB`*#I9Kn$}`W!NY&tgfFkl`~#KcGU9eOJ+a?vSn#!w$7u|SUh2k% z&h1(4aj%(v562f+!oBwS*TRxJAYpOf)2BGCNA0>xLS_5H>*~|?dpF3H-1XI*YIm^! zm-M=ia?8v9#L}2e$`)nxp#6y|OmJ5bPL7OeUgPnpgySxjI$E0j9x<$YpN~J)e(Gk7aE|Q~S{J#G2!~EUqGO6H zZscUO=B%=d>k#`aZPOrS^x%C) zyIo97Qxys$R{d(s=TODPyV(wfS1LJrC39Z!x!G8j8*CJ6c8Z}S z+f>9-070`D8&>Od9uY>GF}lN5d$dR|@@^?T)sSsc@tm&N{oy#FTR=^0bFwWXjmu*1 z-QDlO8{5})=9WLG&!-FQzM^}Z9ZnXE>z1EE)t6CWVt)ES!fQhfG1IkYs|kq&Ebo1# z>V3Iw=4WS4z~SFBQD~;jw>L5hQuy&8BD69GZKh1s?rIIk^@ z-8d)wa9lDQ6dn(zD63uBR7b{I(CV($MNHrJo42N0zB@GfGA~2r60%aubVR0g2~|J7 zq4Gg*7UaJ!3ozNQo+7+;dC|-&4)2+!%x{_^cr!+Rypoe&m6neCY%*$$4ZDSj|EGNt z* zpqWOwh^)xEo+G1WhXwUUPU~zuW#*jHF>;oasj_Q%5S!9=P0c%XZ_zh1De}Boj2>LO zyLXSo;e<-81cfH{z3P4jTOz2QZtX@j-QF0u`A{<*&R`VbJ$_q^SR3sz<@uViJmn}Y zn2Ll#VkWZ$ybo9gLLJG;$;n+Ql_m0njU&c2Gxo*UbWnw zH;C3{F)?N~9378E*BovUbw*9H8JELa z`broKiz9*!_Uege-@D@WpfQvP_#C!->2HQncI{WT`XJrdW%ZDE=VRy zNOsBXg61v{9JI&gh*)9%tX(HIBp+|BAOa;qK+f_(iy3U+@t|1u;joz7^z*_1o?Zge zjlAX4^dZs|lhSvAV}N%|TJ|fZyCc)m4$|k{Hyx5zSKzGWseoX1u ztZJVF$$#R3CBqHN^^iWMl; zqRb1N3QtbgC?fIMa%Q5UgN=~SdwFdUopbvGG=xJmFBzBxRmv!yD$7JphaS5=ZB}T* zHyKbWEzl?4*W+Axru=rgjfDgQa}`iqePr~{_J>9wr24i7RHcTdltxQAy~BO2{6QI> zRSr0nn@KEW_@~CNSM}czjFfxb{>1*yJ!oD0n#u#!A04@z2quo` zP$w%!xo1?WiP%p(|M93QHp0y-3KEvQ#LwUl{nY9;^YKIxI_IPFGC$QP_l6fN_yZnc z@0LReb7;yEyuz*BTZCiU59FdjM%Z)e_M=CDx0xt}E%+vi!Xsk^!II=|<6=1X(G8AD z5W?YqBPDIuDukm4)o@JWG0*S_5VV!;_9E7h{(O_*vgn4wLw?SnUJ~ErOpJM!Zhxxb z>SDE!d4mB9{~bW*J*i16n2Wa@*AOVKCtQ+3pKs+Ok!5l!oSpOYTE2Dg1{{ClGLMbR z2*ntjo;zAbwqtQ!5|J|VmAj+ba_-+A;Le?V4&b13Hc{YyYJuhb!H#AE*GD44P?`t~LY zWg+-TkPV8|%DyQ>=`*Joet~omul7gsufTA?(q=*84h+Ui5E0Jiuzkjbow+1g4tvVe zcGd#ai&_Rq#w6yeLGYqz2O6CQf~uu>t7WQMS+tB1U<&FI6|XF2T=%V%yx+YOoEmEo zeb>+1m4oi5s*j$;OrT9Ep!~tDJNEntotaUtqoT8hlrj#!oVYj9II`tJWV(0BgVQtq zp`dN&>S6J~1?l3$*T(aB@-B?yfu!q*1-BEfP{%bkh|VheW-8A@W0L0g>sy~i`$G&^ z`D0q|YrCW$j$R9k&;uutRB)Z)nPW<;RO!S-fgXC!Oxe@ESwIQ0^n1<66I-KukuD9 z@dt0mXm?$9YC_vk6P>oJZClrejs!1H-!bSMw2pCtzcUx|NTNC zq<{b6JLsVK&VR*QrUQ~T5vQIWul)cNbb~{N+1CzdTa`^$3@Lc-PSD+8M;=HBSSErP z_Yo*JaOq3m&WjQihxqovt>B%lnIPE+&DAa;RQbzLLE+SQKG5ypMQ)l%iJruWsZ>Z% zH^%cs9%O z!+g3H)CBIpxi!0Ht3GtUutr6^cL-PtwN6OJ@z#oRKc*WFt-`Ejd?C{E;B8^sD%!Df zwq)DJcjGr+#az?4MhZGuE0qyPv~{>$FHxD&$31UkJAfohW4R&GqV(#35{&% zpPg_$(7Lk0H?}s^$#EwdBl62zyIojyhGm<#51QO&WR{yof5*89Sv90Mngh-azV9$m z^3}W=fJ3WFgiByH3^n+H)w|r>r0Oc~7DZkg!qaTOu$52?B3BegNaeEa2zVYBf?k}> zEgR!d!w|2uM(LWm^+pHzlra)D{k#_H3A3-C| zW^~nB=)}scS+C!-SnjooRl;a+5PkNn>YMnS?G1~OOD4F zNEzdb@Yfai*;~m&fe2ny&;&X({1&0OMECOkDIS|m|A5V_r3}lNDco{=Diy)h2P6$* zFsBY#vE~ndROb9vpeKlhW6lDpfwWer5HUW#cNlpunqw$Ob^Kh}ikM#@==S9SdQDeu zWQbL?*oYg{0WYL}mFn6beEP)WDNuOQO>ih|MN0lwK-jKV#5SbC^XXkCP?G5+)Y-%E zufmbXbsUmiSZ>s<#;&9x&WVAr!fTup`XyBLb=Q!ERyQaSn-DSbGq#fa_la!SxZ-$cZQ_L>aIUSd61(2nRK)P?wq9`Cev60S8u%smDKSCefIvM@fPR%*xg(NJ}0 zTB#Lr*pAL&DF#sK>F{Q5<~lr<(Xt^Pl5N(FvN396pye>YD>J&Sz0|{YR?W4=vpWDL zo>1iqVV6Y05Txk-n5MtYJx_#@3_hvL!U$wFiq}zlkI({4PKF3d(JCaVl*5(Q&|8NF z&b0BYq`q<&Tm~(HstgW0Jurn4vo~=r0Ep?z{Jyc4w!%wSjUpdKWs5`{3l=Ry{fVXZ znv?-P{z6i_ioH6rAJM3nTnk51+p5wc(*#VBCi-OyJ$*m}A63TJq0am}CFFhETdg|Q z_F7165!Wc9*e29(B>?Hh|5{>xo$ax#h~VYa?Cv%NPyK`B$P;{7T!b{#W65{gi{e*v zFSD3S0 zL)clEp%;6g$9m`}ayk3Bt>nuD&$hEQjQo9<{V^Ww|WBg9_!Indyfb$t3J6++?I>g{A<&q4;vQaC6lGGeNfL4opWP=Mi&}U!zBujF$Xz@60 zA?rf(leqw1%l@JXD%Z$(kpo+cv?MDSZ?yPhK`Z_$8=4aV{6RC09U4uVQpim=?y@3t zxh7Dng-Gvv)cTCRhJ44p&!N5ZHwnJ127(Q6#PrOv&>SPJ_;?4kgeQS@$J_!dx;Q%5 zlYcXFYbURA*i;S_?@M2U8x576X~&Je5y$`r4ICkXm$Ei!Iz!BR?UDnJo1l)9%QJ{GoJb!)J*4IBR z=Ai~u`Hn;a$1_eep4^HEx(XRh{%Eio^(w=JK5gu+$v}6DRAr$4_k^r6ke1e$j=zpH zY|=R-Z#%N3g}_d;3U50&@@ND*CtGRHf;OfeF|NhzGR1L0_Wq=YGDg(wu?WB+%F6)9G;gLuufDd93~uWU)C9Lz2rNKow;bThG{;a}h2ttBJ%}5uIlR#ulH} zzRj{4Ak}|ZFw{1~c|D`E$Yr@83o=1n>HS>i>kk&s2jwj!PoQuu!D9CrzXPz4ZWE8zl82=_rqG;~uU#*P%-QKn5B>LU z+fZ*ev0-v6`sDwib(!MIN@v{JDJF4Mek=!2iH2Wbd%qBoTg#P zf-&S$ooH|iH6qr1ox_aB0I?1dDg9Z&+2a0NnjA5ri3K!N$R}{nXlg(1$GHk4d`oLS0v4M8xl0c`t^=GYu~kY+W9KQhP@iz1C?>{wFn)*hurw zwbk@ik7loEn1_a#z#qn&-+9nk^}wu5pd@>R+w=v`Qor!5c`kmU@w27Fg*`{d76bkI zh=F2=DP9O(*SN3<8V^XIPPnb&Re%M(1bE4WDvVlgZo?VB2GmK5X5k$Q_mk55d$l`M z4Rdx!8famD5j`gGH8?gcn42RnZKRKz(^59)8*&N6-(G+uNvco97>C=B<`=I0wdArI%84x#CU zFGsOjy3g~5$~CF+n@sh6Qd@^cqzS#S5ahv^MFs3z8`77}h+52%1{92=Rpz9*3QV>+ zDYCiDSW#Yt2>E~NX}7Qua22Z2IQyY&KcLo$!CB)MY@y7W+uHI%3*&mqk2%*`l`XVH&)X~$pS7Bdybfn@ z#C|;CtH9bwaSc`4Bq1Kfk3 zHBkt>jd$~hUH%Na5q^SQ*sl53iu1eaJvxOGf7Y#u1aioruF zLmni|>bTYBaT8`aDod{lhgEt^BM{5-V?`?-<;1kf09TXMPC8tiL38-^9Z(2B2UO{P zx~g!&mnB4~ejRVAIx?Z2DC@B|+#us(naDaniRYaaow{Sru3pmIOWz;o10iyRc$GjU0HcpigqH?|nHnH+wawKCEd}`6?bjU4VH5hUK?CLVp^4FsZn_#v83exlA&S6{2i`lr<1 zRjKQO%H*7-W}7iKo9>>B{d_HNS5Wd&h-~$HUq#c;&3KVed*E#hEmMduL}bh^g{koM zn*(Xj$&_RApA7v&(xhra`KRaK`^K`#+f2q2OVy^QHK#V!C>|A}Y=#wT8PPwj7C?KC zLnt%n!@dmFohnDIHB9}J*=-m^1Ji_fDDB;^A6e_Q8pkxl)0L)^YN}1m2#?C_kN%$= zqHq4?q<`lE;9dUe)yYNeV5)!r^I{M4-)$9=2nvs#FQh>A_Otfb>i;kRaIdlba!S8u zF>EVS3;eG)I5S|6lA zfBbXSS_hxalKfQ_RrxHjKQus6-*M zPW#H^`8$SMf_?r2JVq?wX2|Zjr)FhkMRd576Eu=@bWMx6&P>hn+nVo77t7b&2T0pY z_#E15yT}?&A(_)vLQ!!q}RvzR9OhVfGE8cK5Z zrK=@np>1>y#umbNVdb!~U@k;Dmp!FL6?T}) zRXomZ^GV%iQ>9z|A`@+b#Hyubc!;4>#P`0Ga%tH9HrO?9*s4jNCaRd~Gq)o$ z33hdtuwf@z{z6Sj3FCnoMP)jkhcdl>PSKA7M(sroqg1@9@9i8@6=uInYm%Jz;Fw&? zzc*Y(R-R?;b6@rftze*irp`#OZQ+R`vm%916d&9ixf_b`DolM2|9P-4AbFQw7!^4* zxL6qt6EMNJ)7eH^r2A-+GI+SDs+zE&jx%E(;9Guy)Zs=%!jrlx$!$7b)JTc@tD?Tb z`qML>iWE@;bvzGuKs&xFuC`DV?XLPQRtz=bN2A5_3n@X~Nin*SJxGjgCFL8w@rj1O zY9vqc8{C}KZ-A~2DPgJk;+zyz^k7x9iaBp|S=L7QDVr)Er^=BLIS@=EtExuKQw2$Eve#`7JeNkS5P%{@IXf z11m%6Vat;s!s2L7AQS9<^W=e4+F=YWS z(QK2*JMSRB;Mzv+t!Ha7pIe{|4dwEJEx&P(<@LQMFMX+joXdU4PYT8HHCVz|{W4WR z4IESCK#7#P%9C+V<5ES|>WjLDZQnXcjP#AdTRr5Wq%%1uQ2n^cV`3XIQAKr#?Q4QYq6@Tg@^ih<$vgw-Hy=Ay53gA5x*yH{7 zWj-Z(BXTP`uXxVWF*n3bsdMVn*yTm%0vXqH)1s&opEnSFCB0RoSvRCl)ypT9C(;Wc zTa0%_yOjFP&x$wKJh8$zEZCGxHls=Njug+VF0r0zM@!wOwEjQB-a4wv?d#tb1Zmhv zhcwdNT@s3bh;&MKcQ;5Q-O?!C-Q6YK-QE4(+c@Vu-`^ka7z}l=-FL1v*Nio<59tg2 z4J>IPP8!0k!ewQtCxtmDyZkO3e!eEf@i0Mxc!Kc$F)M6fE$k3^cJ#^(*lMF1{Ir*7 zZw$OFqua%#;=6X-t_;-R^9K!nB8%$55os~8<+1Fh7?E<@O^srT7kf`)S5JInB+4}> z$Wv^yi+2yFii6P`>lpJ9F^ZPn+i5wK*Z4-Z;}y&RG{wu(I(i6cXpZnPo9mbwIamlTDy0E9 z-J>%g7tPDd_5dQOdoK#R54Y4>1565JiX8x?fpgL{ORorB2On-){Jfo8y1*vmF^Vv| zoRVixRBXlKabm$^m4^BG6LMeyjwSOk-OaedfZTNp@J=R|GZrd~ctm#@R7-nO(Wc}pfv->b<;k;pAsxRDf zD{iv+yzEpwIo}19l&h=HPFnD_Qlw2teAMWYfc-WF%~X4kEIFdpn!! zqLgk{`nw($Ow1yEZb*dax}LHYA@RA$I8tj~!lBTnP-b$ls0CVFFHh1EHAp9G-(O`Q z`s+&!6NDj|ns#WIzv+ARps%(>aXXk+tMPvM)8JV_s>{_XEsyk*pT}F7mZ1GFOr(;2 zUrvgX3%ZYh6+y`LZSlYWMdx(PtrOw*qZEVZ?g^<;yLC3psEH0sJe%hPhbhN9hsQ?p*_S57csxSoicXlWe)1t!*wT z7p_cz0}#o-H92LI8n_>Rk{5YIsROWL?T+y@xr6;?TUz#BFY*>U^LtgTb_mDFD_9f=*6!u;`diAVtHHLwllVp3+Kr!$K7GmLq9No9pD5MF%bO~O&DfZU zPqBQt7tJPaLz9ma&Ow7Cu?0`{76P~140f(`CpYd62)<6EUUpcaiy6*)q2?3(PYP_g z`^1HN4jwW~5}{M(eboF48Mi+7LX$QL%1!9{yNZuFw7JH5EENpdLQW(vUahHB7@GGQ zOvG$V8RZZxJ!n*?Gv6q*(=eLk-03Gx3LrHp%E!t~_)^xEQOw#@5^;+R{QoerN()^SKMNLui~;dmh>S?o1I3v=|5v?=>cw8AF$M>p&p&V+>7mY%MVgLO&2Ky z)O2bOgn_de6TualKZ}?6U7ileo!#5Q8Ma33^m+b`mQ+zNUA2;ADbOW+Z)~x9)0>I z(eiNm`J~w1m+jOpMx(m|n2S{wXNk-thZo`nkB%D+jY|wR_jW@GY~1w(j5tf%Th@9g zQKgk}MVf(Xr5}n9SRbj(_O5mz@H92EHB*ghI#4yy&>COhqA0N0^u6A#d1$=or~Ja4 zT0L1b<<5MVqHCMTPk{+xG7t=O{|9g=-M~H&g-tyjD_*?fuBdjdOYncnf6DUk-6?g) zGX|Qi(7#NF6Wg;&AE!?~I2a%5hun-HZ3nx5yTfBTG8tJYSo>t6^*m*)`)dt>UGo?B z%q*rl`C+R1o3rqD$Kjg!aWt&E49#on+fAKS8Qsoz#tSABcurz&cRjdN=rn7rnO65S zLAK2jg9|?d=)aG#Ug1r7x?9m{OOG>~r{U`yVs)EShI)i?F0sC-45YP#ghbYeuCG=w ze&4d>_<2RFmVwI$AwVCgf|_)(cva8o>&+xhoWE%u(*lW%Gy-=ut3j3X6hc z>AyXH$yEO((ygqT!L*cC?%06rInQvxiHqB~{sx1G`Y@*3*{OE%(xpdeqltn-AhzQD z?emWeBnigq$}iyl<^nP>c5c25pgDb=-gd@@yme}VSo3pdVydbuqn$#%>EO2ZZlXDF zB*NjxYdIyjI?L{~IX}xN&&hzKW0VVD7z{FE-$S$Pc*TO-4Q;Rx(yU*PbE4h(uy2^) z?i_g1wyVLYpfw?I8beu29AmD3q+Lopkw z5HupsyJqCipT1x|Lc5JU6kSY8Pc}vM!&B^4z*^Yc| z@vT_yj*T{2NmznK$GSV8$)xp#t=}4F{hr^1e@V#3<6zcQ&{^S`fh)=6(i9Im-n0q>qrO?4^fFB7oz!G(X!eN=OhBoU_Z9)JOHS#f=|h0KoLq9! z$gy?-S6@)AI!uU-7B{xmrD^D6tzY}|N#T9d(FnOL&E^aZZCx{?>$?S99)bt2o#py3 z!&~hG(Mh-?0vw=&&Uh#7Q1`iU-Y9of_WmZ`ryr(t{5&*d$N{nsLjGDb zR8-#sgpPOpEJ|~vur#$I4i}_`{ED9mIC(F7vJWOJk z_~77_Ua^F%?or0ZA*50|{_X_T@KfW%Fa=kQN;hj) zb!_a$?*MK`SfsLai#xUUiC$=uuN5&xvt@|OHwO2$_Bgt570m}T=V{aaAv#>$PIIHq z(HZ(VPW||~0;Bj>93<{*(>Y8c4>ogO*pZ!)DlTi=3L-bv*KJJ8nKF-B()WIVTz@59 zXSYh$vSLlAX(^}?BamvF(=Z3w;O#^%sf9N#*2;#?M1&+!S!O<1tp ziyNxQ;fW2POA*pd;(yain`AX712K379?5({FF9m`@P4I#+Z4{ZxPm}zmZzq0-iDO{ z0~KXO1$L8YLr1~vm zB=R=_qCoo3%i)3r&x+TgQ2p(y0L2%#+&wtqmcYSz+n$ICEk_AocM^*aQyPln-=ePz zpA38LUS)!hNubd8N$>4()ROfne5+Uz1_g~rhZBkYLt@bB6wp)meFPye73^wfcWvbw zUAPUP3+x2eI5jFJUIe?G58xNCsM}a0s1dU6n|DEly`*8#PHu68VBxo^evN6yr3(c$ z`Cj}%#O3h0LjnGGqiNf`$dWapm!!(uTYZ6zeJWgR^(U#*?zf^Jh6e^*3gWRAHe67< z-WuH)uu-#nFuOjD4b=7_KuRz!?$mNgZ~;gY1=0+)lIVu(y_<6P9Rb7WREkzpv@TXD zv}UGBJUkNjA(sLrZ-ywwwc5!z!WfTS5xwt7J<8loGH4Hb@=JJYA6k^_ZdIKPQ|b`s z+c&DxyI=O?d5Ar9^!d55yzgQO%p_;@>ae9-y(^bGR9UmQ^&m|5He7oZq{Jk%V{q`U zbaPG=gM6_sspTpU=wPFJh zK*B;(mpcp@_cAImsH`_pf8wv7A^seHl{>;^Tx%+u(Anu_W+|`E%mIo1Oy59DT=T@$_fdof*Qa+j zF%_p*ij)35Rx!FVqz3W_6d?; zXH?%*&plzXF_VnKZM)ovhG1PZx;6*Lcm%P9E1ET)kRFDF>Bx+&y`6FLJHuadS;Dp< zk0sm-nJT|sobQ>uERZE;A7-atLYSKJWUAgVctkAJd1NlXXBj+84CB$<)(D16#lKal z3VW+CKpZBprVwf|OQcot00%+(w3FK=hWmKFMk%f&o;aAXY*WyC=4Pi~aO)6?aaUM% zW#s0z?Dp`!f%NdJ%BU6jDra72yG43h{*S)XZaaKuph6UT`&ejE*kUo;g*%z%btgqW z%ZW29Omtc^PfK%~t9@-~ww06I;7!iG=w?z_+sTQXO219NW`Dn_L24a#%i4c3(fDJ9 z;^)wIoaWEr7g_HMaB_9p^+x%(&bEd%skCV>+)kRdHP!v;rr4*@_5^j`N^OJ;7rKw? zD{9_;sbZPp_TukbHFTuUlf%pKXt;)E9FqI-{Y1*$lg8EO zYG8!JLxnhLbVr+YVym$bznk4PVD&gJFAsJ1Dkqz+x?RmZ2#rrFgyStvtGy!EqMvk? zJHOVSik#N8wI9IGvZEa-6@<=w2*9l5))2!V=E+kx%eg4bx8YP@Aexh;gOrcaqX1K! ziRM1E2dt>doZGG>Cm{TQF|7!-+BeimIru@{e{x{+%qa25{bAXRjNd>LDf~wf?Lv<6 z;`#26zA@DwH1A}@2(Wj5q{>xAu`v(a-e>01^usqL@~kOl0lDeH$+bZ<|K^gq4BlL0 z=>(S*O?74dG9Xf$6LNA^kS}tM7XxxXFu-^0g`G=`5)9bf+l!kI575$%B#}^*d32O7 zgC=%mCS3dNa&~fm#iyZ5^^2S$_YejsNGPhT&Fd~wQ^7uEjUQmz7^Qf!jHPEpV4v7+ zX(BafZWR?{>re^-sAWpo4fjxvGekvj*^I_o_4fqzQ{vOM4@2iFrA?LAZNMpRcSP6F z%w1vA3?GS_4djsuoSnKCHQadXRh(9?gKPEl8h70_6ipvMdKy_Lq(3&Jn?fpf`^r)e zp{bC6o;#{1GbAJMb*t*qU)3+|o4Cpx=OxOy`}hw}790X)KU47w2$?S(%-N!elX!5Y zx8V&H>lEL%_*4h+Snz4zj$d^NG~7Y_py|`h8&s;YYB?d?;TVdQ$H&vsTw!?)+YD143(jbUf!l<1DR`gwMe?18s%t=LA) z&{4c0V8QWKccN>&oO69_uS!LpF4x%4g4+has?qCcnNXv7R`x|PTH@byL$qCo&)uQjQbcKQ|b z_x5P_Y)nkj&UX7w-GE0xw7D(`jJ(M+Ss*(V!mh=Jk*)K-Me%3%%o0zOAj~A6 zUXW&I-~A6RawHD*7xJMf1Wi&%K|$yh;6%qV-Vq}XBP|5dtrhMrGj8i_OW{Lr41Q3gqjjx&1+9-FqjrULo zzTPVXW4V-DobQS11orr*eQ3BN%;{+~C9Uzt@@rT!#G)CYQG?~i3ru&JPE68n-?j6c z$dSag8nvWx2rgH|SQo=r!)&w)*LL7Jgf!2SyZFqFnH<_Vxx^WqtvuN)imY=FU*bHFSex_g!VLp{vt4H4`Krgi&uf6)( zuA+MHE1;5aSNqfLJziJB@}AeS<$DZPefY6iRiusw(ijbJktsC_!-!LOZ~R65DNP{PfUMv0RZ1J!WSr0e?86`{foc- z&7$1afHdVJ05$}ijA(28yC32Ahk;Q*(L1?iwRh-$nXCZ4v-1kLRDOudi2p!8zu^Jf z4DEhx(gUjFJEESAe*l?Y=V0Dv>Q+kQ>)*souX6x_48|G@Vxa-JPwiy@clb~B9{7i# zXDmN(!DCx$<+=YY&;$Qo5F~09RpbDDj{2Vu$VR+?uW*B2=fC?5x&L>E#P8yNMI>-5 zc&(+snAx^45LIt~)%{2KpiE!h%gaZ@)559Qsqd$<%k0@b?q&=_4edK6b~QIxNOPYyRw*Paj>Aj&AsT*RupKF8NsEwlOWo5COo? z&?;Rm@xYzVg)gvrt9T9<2MF$#%4PQgDN3DJ;y$YJ)1;D6CB=SSV7xe@Dx^T`3235qzG8d+nLxO%6dM%xT6cB4N%>Jq&n4tmZz zJf~f%+1A~S;Wg#*<%>^BVWSE8;5MIMP=TqbdcAH~ir@I1s1t17-=6-EX_pXuhgrYViB*dQ zUKAVF&%aCrH@$?d)l#k6XTYgAc;=+M_a?O%0G4g;AkMQt zFWVrvuoJWwNVp_%C3tP1c8#KI1&yv>aaebQ>$H6NWOG*7xS41@Iz!Rlkr8o1TG@Z7i@J3pXjPh>l%O^JN*gzV7fLgoaJBs^a+~dAFx?#;|GEJ>nPKb zN9WU(eJ<;fN0&ZejMriaJK{4tTX>bIoc0%=w#x|A-*6n&8l;&xUnM^+9{{8ZsRf7O$f7(Ol=F>;aGDLEDf={RC?u52QJ{7w zP0`SFe@?}uh!|wu`l8jbrKw)hYKF&qrV)UH84_N-)CqY%F;inVsUfM6!mX~LT3GFR zGr@5RfCFog!X}ybipr}5lg-LT{ZAKA1nN!}5pHdd+GTp4hx1Xj2OMt0GcnM;x&&CE zma#M4uOSc5-qx>>Gaf>E*@=uxNwEuQfw9I+up|vT&-`&C$Jf9Rs?@ak#Ye)NY28be|d>Y(#q_T5G*s@-OF1 zI5-6KJuSl(P3WNS-7#40toyj)-maAV+t^yY@<>gKcW<*(Hu)pc&Xyg%A2QNPf22{F zQw(pqS|8@1Id1-GTX$zBGk~XlvfKcLYTAxs#yYskJgwx@aV1A^Hozc*!1>m{uP=Yb_Is_7|i|x@pofA`Wv4Bi%}M_r|xsS1wv`o)TfcTz-f^AAMwZ zbg4aie9L?^K1^;gA~>@(vNjK+H4uN{g`Yu!0?p8HssbE>z+3iU zenbNmvgB#RQC&VmtYQu?t@1;a<#?*gDnCxd_!5%Ni~SU%;kuRQS95YX`~q*Gr>YL0 zbAGrC3Lg5JOIR<-K5Oa5F%0l1PzO?1o}px614|ifRmta4@Q>CfIu%Yobn zafHNiq`C~Gyb?*1)N~ocNOp|q#v7LUDmla>mBVXajgB}6r##*TI7vI7#;1_UJu7=1 z@%KE=@sbwA2=Ioc4z3O}RZi$armzVToHy7wY6GtJSY1~5buQ*o%GUw1hW(ni zCZnGRFw*&U%-ki*1IIbD&0U;v`Mlu7KvB(_g`McMQs zgpr?biWC+k1ReT3>vmfcF8L~AK}MJ`KWG5^xN1({vmsaR zkh2Rd63ocBe(sJhW{MDQ>ZbAFv>f&nMMKXmS3>&?`gr{tfZd6hcbL1dKJqAvnp&d8 zynjYA#@((&@vl1@A-E4XcG?D=$y5^G6o*rRiF<;Cm{V&tPSxfZGz;Lr7zd?4C@YRMNP+J9#j!R%F=X)ylgL?W zdJjdg#d5f5t9oudFJ6?Zz0V(4^{Y4F zRDMi;%{`+G8=%S{QTcVFSb>NRk=RwANKkNZ;9m*E#_K0TKnwvEk zRsDT7XcT%duu`dNWPvS&MHAgWG6wRz)w69kT~1{?c@^J4FAZaSFHCdS7#j$fz>7oD zQYaD+C!l_bN1y|C80rPe0`32ZY^6wfUi|W;Z2>oa^oEg53T{~(*5mdDD}nV!nXBgH zi%=%uh^&?=d*79S$q(e*d!JZcvEoLCL{6c@Z95Ui`B3=il>!!Kb>xZHs|af<@6R-= z5%dS=gK#xTDl1(^EzK0&j_=ez2atzSdQ%{-@H6w`z_B_ik{b!_lwJg_-0oW!`HjdDktZo6FkTQA#VszJo%Zc5wKva-F?Dd#(FT-L`A*zb*03 zSBD3Sdl>Uu`3!uD1a7N1-zTkeJC2p)X?4_&MMI$&=-#;1zcDnKN^!9C_+*J<_T?5s3b3m!0f8EKwDBPC5!j}zLG!XXt++J#REET&&k zBo+C*EYtXc)?@LCjH#Ug4sL(Q_u(1=>%g9n5Jacd*17dj7xP%Er^aqKEx%zasB#&O z_ciFsP**l?YL7pb)A1t-@|^+SOSf%ftM=w<$tBwLa%FCnjxPCTv)ueuhU=-2)A0~q62XS^hNwZ`y1029jCr#VKngDd{koTJ(~SSQ0VIdjfenn191 zQcym8Kr-S`E~VRkF%?{k%^)>VM*yfc?Yao%b~>0@jDwXnGxsI_t@juVMAe>1%Ia zc(v3W_idW3Np4q8smOKVSEQ|52z&@(Vs>6y*!YsG0lHpSNYrNl6k`BvojUkV4aGcR ze#Z<}sChp$e(oa-?O8NQ7S*0YCfxVDI`zvLyjd>~6nH4xFK%832)(ApM*si^eE^df zQika9F(7SyHlN^#Puu>?ThkMl@%$nH;($Y>Bi|fY|qKR+k#SLus%T>NG6p;vGeJPzZHawH@~$>trOK+_}MU5V4wC&g9@y> zCI;CntR*BLYFc`uFLelpAZfAh{PyMZz|^@E**2L5txC!5;Q>|*L^#ecQmizaAk&JK z+f)1A_cI2=B(3jo@Yg0GyE&OJ3O_0v98|6Y@FKm+3FrPeO*hh-W<(ZA$5U&G7-sFM zlwY{ka)w4?6lv#o&`UDrvDNSNdsaLIM%<34mQd!q3(??;rD*J-$;UjdPbg-z9xl|Z z{3=rX*Lz#cs=k8J$fq_i@b3CBCy|Ivq)6HbE=mf`gB$ssOGNSZVxuwkxyi5oBq)A) zK!JkaCbr3&?_cBaYsxj@z1E=4#doL~NNEkY*^%{T)(rZ^DUIPv&`9AaJhTGD3{G6^mkdVUZn0H#G*}I_Oa7hb7>u( zJ3n0DX%eRT3TPpUN|V(K{ils#K`GjwFfc7+<5*93cCNH$rQkSn|w+SDkbnvOXE8CY+S&p*7DlHgmpT}$3D*h zBh_ius|I*XOT~Fo15G01S5Z%bi_l@(4*!qRjvyJNqvwl`=VPU|jDU}PwWTuDa7^9G z_bDJx>C5>zgU<`(2mq9H$$L!6b#PBZPLWUe!|3z%m-rYO#lA&un@ccm1ucwVZ^&Ep zmMbxwW~+BcMbl99FI{9ktDHWDBBz09&OC)QocFO}**)JuAo+lwc1^Q-VQVoiCLw|N z4?VbVT4y*q@{W*x&WuNp9f)5IE2X-@p7JuWh9e~m$#9cnL&IIGDU_-b5hz07NsSuM zqCzOR!Zr}-MF}WR&T!MM{>Oil_?8b>P=2Z6o#xldEL`CR=B9%wx{7MHvAhq{>W6&F zN*P#(2Kvqy`#YO%yQB5Gry|w;PXu*R$CP$yYpi!)u%bQA7d=dF4^GGAP~CvolMxp5viGJj~}_90G*Ut@|@bDllOu zax7g;hfxWm)h_0Z3Wp6&8Q)}nA$`>W(ljf;R0)auqL_U2sEZW)y6I$IhxxJ029=H> zzqlR?$)P=+A)93>O5_uie^+illc-`{ve6}=koBz`w&ZsX(TkPTa>psR7a;fmT^9z! z1cyha?4qnMj#=ne-1T3uya`p#IAmAyP(R_KW0*Se3|nM+#782ifnf6UC)#9Wsk}}) zT3;_`h=L(-`FP2DZ3xBzN6hQunU^%(wS?pZQbNEoRG^XvC;0|ACEK^5f)_BA)K1&1 z7XCk4X@X;DS}?#1s0(@)JD*}TCaXT77EtXoQ<+rM_>p~Hi?3GsDlRW@|5(hM`W(oG ztteB}N}~E?j8jXD#wg?_rTi=H<0bt%B4c7NM7o@gB44w7?G0y7F9W&~ZPPliBL)bD z30cTI?v%=^X=MC|4a$J2ml1~UmhXFjDXOSM8}ffNz<>sDeaJ8CJfeyuDM+{fBRdpf z4S2a952L%ri8uc}`!7SZZ3_5yDN^xZN3TNKlk4A+MdSyhT(ggR^`?IRZk4oU{!eNP zX#EG$p+3}m|6YPu6`B?}XN*n?q+_)yq8I-08o==$u#mT?a0dZSt$3s^-@gg6-#t`)l_TOwMBvvs=8ApoWbo3t(ZW5oLGiyT>irrN6LZ>5pnkEImU1HfX2dKm(p8zIvD zTuHn#fTG%0rCq&LN~qb$^@>c|V4(fqmsqRNBZG~3k@v=8PS}+#h)yS28&wrOhcolv zk?~C)vMc_}Z!k3;brWx?o|)$qUg6jML_~CT^>!|GmCph~Eh)9kT|hEU!Bx}&yBxybSpwm2k}gr_o>Ecfk5c6E8a?wxj#(kte|BnpPYh;EVxUe^zbc3#d zfScQ6(P6wypte;_v)IVAF>wx+(Gs- z@VzJ1?{~}u0#tYq4QjQ&O9}m{Q6U}Q9&b4|);I(a%s8Rj>+=}SN5~c*n z?dHu-?uDNND_Q#0)A_cqwL(L3d#@d_v(E1P-2P;Eyx37o>>EZ_b%=KE;({`0+=gHO zKk8tX;MsT>J(B*DxFz-fv{HJ)iefE6gYPP07i#(+rDdR}9jHhZUeEk})>$YJm(k!+ zF$QkCtV?yeO!_lMz`u)n<^nJs!1`_dk>x^v?+F+d;E`cV0@AR}MuWJFftr2$?GGTR zsIr_I^WPkm4J4ocMcf3#qSU0@${UAp%S6(*Uk>GLvpbC5FG!O)4+n{d#r)E2IQV4y z9lA?>Ifx;! zBmhSV5l9VN(1vuk8!539u37KW0EJMZjiJ{Q?5Oe)?c3QO+cxqYil~aHVx*_f0TbSI zM{3+^8@B^%fNxoH3}9ZotCJH8sE>LU`?5~>ok1n9fIU$BAq`H$-1@GnyGl{6b~Z-T zN7P0%GH>nU@BDWDS8R_5TLRSg1gT#hccA-DGQbFsHRJ!(0;w-qT%)uSJc#iR1QNfO zqzBAauU*r&83*cd$K-jW4wNvwxuBMiVa$CEc0iF-a&Cqs(uqVsE!?fQHAw4Z$Ti)J zZs@N$eHrkKZ-)^Q^pWustW~A;rZ7}e0$y>sX5jfL$$Lp~k+9dMXxoSb)l&is3vp*9TVr@l-U>r zT!j+Bou_{WqM$XJCudMLOe>34VT>1JJD>ImF2VGu%0lUNAAoHK)Gl?uYBTLiHvk9{ z3|eQ#a=*K()x!M38{(o5n8GMmA?a?|s|~Eb7#hFd-i|ntlBb>R5%Ev%I_XJ@-}DtI z)E3&0CDY#9R&g*xNf^=jHa^F#CJ^8Q05{s?cmnd*;553l{WZ$LCiojPgp6P%dpr7Y zqnc7sAdF>)Nvh3B#YW5uKQ}O%wq4^B^`RP+$zbsZ7=_Ml*&r)I*&F%V7y}vhKLhUN zB`~+WuHj0cep$Yk5WJU5LFV8^biBKPTDY%cSMf!zFk;>X?B{N zr6&@J1cXcIqh1i#D_>eZ2ymAqR1^UAfKG;oC#469=#p2~FFOpR+DY0S#DNe8>&+N> zGs^MT;<7`gYr~*ac#c?K4X07YLyM$EpxlL3ZdN*(eUpOaXJF&p@3bNW%iK;YGf}|3 zA@jtc3M5tPcyC3frJPC8Kvc!J=eHWz{-FL#KkfGt28hGEgJ#fpw=v$JT()iw3PX3> z*CG8*!BL}Oh@TL~o>aez zPdEFg5-hld=DFS0&fv~?jBsxEU(G@pP;n>WJlt675mX0GSCNc<%D^-kh2 z{Ze;~5M+V;Bl@`g4zjfkK{%J?sMvaMsj_*FFOV*fz9JlU-wLt4G|53pL<4`E1gM1v z4fs=$jd(wEFGuhXP_K~C{WS(61gBnVnUF<@K@r*3X(yypY|mfn(<4ewOS$9{Bb8fx zW??GB0nV(?>G<1Q`}^k!>h_O$5RV~(a5tJb=b?m1EV_}}-(0{#-Y}XYYyiuEGjUV- zz2L%k-TzlA(<_?%S^C-+V%mef?XVXJlob=B7r8cW>ST3eI81lakb`i`Zv(Cu3Y8FL z-k546ivO{Jf{?w^-}t<#9IFt*ZNj@%OgNMbp%F?98+`iT^v4|<0X4QqWQpoW@offo z0|b&D$y9#pkdE6V)t;>{nP1Z|UF6>^h&Xl{%hG6*{4?1Ct#B02t^%?WD0--2 z-6Q5E+SHQ?tUsX%4NC#z8Wf6VlT8UBWBN;y#sN*3n(@#(aJ8Q%*p)J%yS^P{K6rem zdV=kqnP17Bkg5i?P50~Qg3f?t4dT$jV1O>>?LgfPoQNPabJ{>fHpoLc0og>JYTF|@ zX=%9o8bty|1rSJ$Mt{8wm!kz>)1a(;a?ylAP748f@EDr$3-I*8_t#8oN~y+y_OFMM z$WN4LFPtB(e=!5BKw1jG%%&#;(Q40BfD^&l zXA%kE{(wb570y>^2Uy-h7VR)}QV?}KkvCD5w&qD$T! z`|z`FM&w}0_t)4038IZ8Z$2>jIg=z%Y)6E#f8OKw$j}Ps#C8=-fgM)? z6k#X0NjD{kAwg2<-c(Aae)hm6DY>Q>R3kE_&J>O~^Q>VDBY0Z^3Yd@xDJgp)?8^V7 z=mkxo8fJ(GNQ6o{6pZDIKD3rolV5^5(5>6!mTy5=#>b;9*CDnR;f!y{E~yMsei8>2 zNkjG!gV%YWmPcSHJ*ED(wSTD)e61(8+tm2A-r;$zZcV2h3voZ4~NK zpFtrb(pBr(!$Nk`pb+~y&l+nu{S0uJ3CvZ>*c(AFCd}2=6 zeL}}tOB!eQp7AFArt+3f!_}9!(YqT|bsrxP%ROY_fK}YoDFccG8nf~PB!&MiGHPtg z;TYRX2_(pUXz&nzL%jUurA*$NUR-vn8dudl*h1{pLcVf&%A=dr8?4zE;YomTF&^>} zHxho$Lr4)L*!V6 zAb;WBd5z;mg>hpkovjus#R+*=pa}u#OMv#y_}8P6__hbD^#ZO?Xu3kwR;VJKw}5U# zwur5L9JZ0S^}xxh@W96}X=-V*FUy1*pg|-%Rm~hg5q(feTJVlfEGidmg?u3c?VHZa z?Zq0%u-<9qkVw(6jEC5drMjo%aNA$?XIv7+zzLeBdw@vIFjy&3&h#5VhY|=ySdi3< zb+QBxB3#AIzy$mAsK}OIwzz4+I+lxq7&-lgmIZ$b&M$MyBDy>IZv+?KblU!PfW3x@ zpP{b7MOM)-o$1%8S+Nv&1aQL>2Rh4spF(asQL=P8=SVHvi3K(dEevtq;juu#Qe$^0 za6~QACHx;6wSvmf^A30hjTHYQ~a7-^6PJ0$(PbuTffy}JALGptgoa2>A zEeunGmi_lkf>7-BZ*q5~A6UT=!l^)@0Gs*|_GAN$1noo5 zmk_?;465u2+6V*mNuQ4MzaI*TKzGx@GXd(Zzs=IOIXI|cg_U$%aLAx_gcHU`j5ahg za=LQbfxA^XuFwjV9$58(z@PD7OHSf>SA1-VSS$$=2GM{RG?J^u)k>7w5M$!1j0yRC zPsim;gvdmPaa5`Q4+(Ul?Hwy zXD}zDh#O3*0lckEYw?&VK-gu=|6WQ65~QIti$$$L)YNcDvJZ#bL2Qwgi%r7!^8*2k zQR=j}mXF`2Nk7v)3Vnd{3gIecgCMa~=)&)mxP&2Os*+a}h!-4lctx}kbO#0qO=}Sj zHMsVmy-oI`3E*58=kjfI28DHd_v>8J0i!b&JUW*kjOIN8u>vsyF||CkygpDPtH1}c z((Z_8tQ!pq8+1nyZm73T)>NQc9WcRi1IsVzuD{1N!s;jrfa?S9b>tP)^lKpak%R=% zFlGrJw}37YyPNUvBV1suHaF}@y)`0+lRgWlIwHxZBV)InBq|Qgb*u8HoB<)=*^I%` z!qSwrXg@`HqM_qo`e#lD5Xx2V2uZ@p5@b-SIR%za(!P^3e+iY2XXxs6Y>WuMd)f9Y z@&c!=a9>LF(2Eqbv~dL_kTM{Ze_S~wDxw-LdLSnlg=PvkrSx%AMKp6+Euq^OuSEp* zcc!^rHhy^qg#h&x3!>GR^N4enfJG*r_^boB1EgO~5+lwJoUlO2U_PS~FY$#yLye{I zu;kirX;*OYU;_6BO|~`Fv0N!t5MKJMEtOMb(Qz<8ldlcoz~#b{5W&lz7hN3*|N0%| zAgmxKTQIDR=L{Ka=JJLsNcChO%mym2^1B&P? zcZRG(*UqZ@ES zF_~!CMjIOke$oY(k_BwS-v+W~Qdw?Tj1c%0=U6R?Z)TOpJZ@E6ep ztl%5yVMeD_>YB8@Cd&-LXyPF>F<6HRzuU3Vi}I#pz|(KD`AiE}LG&L^Cmr)Y6B`hJ zyBkf1;w6UuTzPm#0sjUQAO|;}gio=<6%{#hYn3n@r;G6P^|b+}9X{AEk_?pkOh1aT z9i2a>^q;5l>zDCFc?~!Wfef$&`;9L_B0XEL93~0OzR~?Wx@9bA*1AOU*Xf}6n(YnL z8^2p(&ICjgEiW)^(tYk2hqHRZUX{!6+5a5{{33q_zPftr17rX|d?2k<2qYjpQva-R zXV{*%@*B*B1RUaY2Boe*KE#unG{k@CVG&?z;587{ zLQe}JK_WJ`6(}mgGgRQ1f;<U zAAso$0k}u5LXMrP2G#2Zng86h6`CjP85Bx~^gKpI`mPA|*i+~b^Q;A% zTcR0Q!Rrf%hx2r)F+t=@d;LutKyE(0}ct05*YcbHa7Je+I-!0;xnn zwU)^^#QPthGWtx=y3;qaoo+a{nvFg7F{Bhd(By`MFwd9YAkIzjZstoOHerUE{@ zOx}OI9U?B+<X^_tO53Q570XSxAiQzyF`tVK)CQr@v`kThRnji92kW53&@KJ)%Jx! z*IO{?HIVBH5@+Wfv~(U}?f_!9Oh=_8PK#KlK~a zJ2llIs8`0&2>Byr65`2mgr8OBfFWBMiYJ*(3<1m)y5hY>X2Bx@+KvD;6*FajiFD}1 zibL|2$=_br#~4qV%|+#Hdz5fr6bAr zypgs641>aq4833Rq%TE_5;*fr4B~ZxwyHctpa`nIYBR*XAsPeaRDu`GxtUU3Tr^k= zO@Nn3baL1ZkExPe3yJeLn?T0k_2~>>82&0u=u^6d(mU8uKR!gOnzRAQZl=P2^8SKE zO^kccK0V$}EGp&qqjJq`2M>`-n7)w7c^Kx4WvgkOG zLz1XW>y;{x$v?w~2%t4Svw|+&zzO52?mW*2avu*vPP*`Fvq3ZrPu96l-6hf+(w^WksHL`~&RG6`pv6R7JDyFf{AZf~8St^4@sjS(O zbtY*@_MJi~+h~y8n7RKmO7HvrpJ$$V?sMnfGw0lybI$L4f4|@72(OzDU3>VPz|8DzJ-m$*R%k@#qCo3CAxYX1RbbeIGmhTQbIm7({9B0#HF{<&;ifpx0 zvNn6^Mjb9GSqFRapNar?*FLa#9rMf{8gscgq6|OSGUyoHCY{*uyJRigj^j>bpg%WB zj2zbPc{XYky;EBi7OM4iidVX+hnC{lpk>fmx%_9vwAti4@&cP{_r5)=RfTRU#bO4JIw9zcXTax*P0%-DqJ%-8hH6iHw;kg z1I^6MA2#=3~4+>DA`oEkzY+B8N%Socczu zaChA@myUj*Hh5!2lh<+IO|O0qjxhU2_{@0wwM$UHGIS5WAxvey){hy80UDgd=ATy-P419VK$L30RB??OC8m#!S!_RxtKGgJ zL4|{_bFYd-2&kNJfoU16Er_mfIh6$=jft$R)ZW5Ki4?4DW=Fw?73lKW#orV>!&`>A zx9OORCz7~KfH#L~n8N#GzuouIz(S}+Ov7gUsOI?vMq0jpb;e~9$!5wKZmVq!#}CX| zEp221zC=Uo+a;@FDg9#6xdW@j;-=@v;ke!Jl2}QTaVi{Xp_$Yu?<_L4}1KF>1#dm2gDYN zncAwUTb3)h3wce(U)$dVKalVeKP=)FOHw7D+7!@qvjAh`(q8{&Vnv|yDj?F~^3Ux3 z-Q~3ecaA38oX~+5%0<6y1O%p2r%Z>or;4bWWjmE9FS3rQf4(C4b&ThDtI z`sK~&Uy5z_me~2jcOBL@PG|tC=vcTwSs@obpXRTAi{E|XwKH~mf7%;McMoW#QoV8x zm+|8c%4OM0IkV444t*^~#x4KnR>ulHv|EYGivV>booW3DpN^jl<5t%=phlSz46*n1 zKl1ahFBm4-eRk2XFu5&l;gj6B^p?f=8nO&XOnZJ>^7Y(T`zPs|y6Xvxw#pqEQgFUj zN1Ugdog?XF$t8;W5nNSL_SneDj!SX}uchzyfp0QIT$wZjWyw)CgdFE7kCuV zb>AJnP3V6*A2d@wikw{503b7~%X>;Ah$m^*esp;8dz>(A|k!M9gT^f~~WaaaIp@^2~ z;rNuIVqf1f^S7l%Cc88o;q9LQXjX2RmXd+(nIxRP`i4OJu5E*t;kdrQ& zQeY*Qz7X{n` zfN+WgBuhkIK}pwlo;VM&5Sy>ucQF_v z(`EmoM>$dGQIJ%7QDKB5?zO(hwakDky-5_7U<;DNV9PMg84K?fE}`^uua_I>;FqS+ zUk`hf7?fu!m!gpi{FXi!>HaX(6mffItZ`LOeea%Shj2HC4{mAN(dE{Y^og~Hx=gTVXW6J0tU1NbWdXo%IYaGzes4tt{#Bu(8CW+ zRA?2?T#TRm8!}b%8lT=I?y9Gfoqp(v^y)&U*Lauv<9s)0!+KO_5^bZZvW6-5>vT%HRacKu?_r1U4qUU?Br>yb^=a^f!+UP1zh zU%37}pJb5$* zAQkq#>hiuAc6IjnLFa1)89Z7tz}aM~6t%eFVh;11vXw1mFemUz;!CW*xMg3y&QMJP=xr7Eu(A>BdM5pMjo+=A8=08W_uYATTk>{0(RWef;@swuh2(tPD)p!pP>*~% zw<}d}?^)XM&Kd9e2>*CQQ90vaGQs?3KHs3K)HX$05@t@kaG5gEb0Y1)j&&u!LU?w_ z1PQYGOKe`hI&_WlQc2Qu3^GS}S;=n_r=Me}c^zUSFlopTwg`l??`g_C;-S(~URub6 zN^Qc3j*7idd&C_E*ee8@j6fb736RIpBvn%@z(a*tbAr>p+WE@vJ=J*M9n(-nTCvSk zYcz}YMUu8M5WlQf>kuc^V7J*!)p00HXiN^Su{E;uZ5mp#x&SZHVQDeNSS1Vj2*4A& zY1cdEBFoF$!&K#t22qz_o+orrBXM0C1a;AxsiKMSgXTYfv{a(ZuqX`*RWZh!z^Ue*UxFPdzb z{XV$X=Uw{^&xJO_j8I*vn}PR^Ag)-;nCNC!h?U+JLMwe+WQq2hHy-{h|GrkmSKacv zrH>XM6WDzBuPoNTb7sH7I>+?}cVk7+N6tEdy!^%x(d2h5Gd&wV(E&dFn6o)X&AxA% zQwMWK8ke6#DHmX`Yxeqv^XHB|dUW!mm>K-?g_802YFF>!m!vS<;p*BmCt=5Db2}op z5=m**)9!FzcMd>yhdMB;46Jva(4)Na$*SI}h+qnH817t0CX&_M=p{l}i=aaGd1&I3 zL%qFs=D3Plgth5==6+M&ut8LN;tkSs#Z@}Z(?rS(PdK$lC&WJNO^@C6sH8_j%4KL1 zk0AQ3P)=_1>J&*9APz>2HRHZEVQ*z2@y$JC0wYgDO3qM=aX~&PRzXY#jx<{7AQUi2 zvTycPL{j1>v#9ry7~u)D+r4|LM>_8tOTMe$H2{@95gyRnc)AOk+spmLPZ!WP*0&buK@<#S7k$@Xp497+)V)sUXtGF_*a@Ca1Di}oHV~%TJI})T z8IvMH2ZS7q8xvQ^k`F(Mo^nWG_<>m^Ifto&IcqH25HyNyI-Z*+u%a5d>~dxE-YI#z zGtJAAJe}xZO=KF>J0WrJhH%|so86li_Z0|Q+6+k)hV%$FJPjf1q6A|Dn`5Ro(`#Da z@xcj~Ef06 zi13M=F2{ry3oanX0FI?+A;QhQQB25TCew1^b8?F7li&+0Z4SYALdxPuh^gB`p2VTX zw}r1#cviuKJ?xOc7-*J`Xgj;OmiuX_N8S4jnRIN6O7ix)xe~kFs`GA1i$AW&9kXMN zh{L|8o(%ktOuTFd z@BS4E9ub}|*)ykeMjY-I0x|U&c^!iv2}q*6jcc)H+iB*tB7vo-H%kM9$PN2sauDfr zFqr~C)!{~Oz>JF&@9PRY+;JnG?5{R~kQ(0ajthc;!Q_Lwo67nAA*@th-@Q7j4j~}L zl8#G0l;y|j>-utpO_d(ky=$wej{5M3=v{d1i#4X!MV;3(TB}mvHS7mP4;TaJr%5g= z^8ilhk?{-dG2bHp3kCwa+UP(~)ZNgoes^ zicd(zUU?M-O?Bwe>w0;?HUuhsC)iRfT6bPLc~`!7aEbE&kwF-$I$|1HEPN&UKzcI3 zC}vq^c}NgQiaMWrn`ZtxY_4!k3Em(Hu(rMi2;uNLGs+_Z@OZrLuVg;K_V4u`BK zO=rY)OL`VVH8lV3$DQb+@6bYRp0Z|6BBPfn`?zR?ymX-7fC+hQz@wI2Wva7DxtRou ztD;G96T~bW>H)C#-rLGi*vIik>$e?Xw*^0gq2=_Z zlU6wfH=EHJiWiX1gp8@Fa9f#MgE(QsojMbNAzDa!kGNNBG%e8FDz;j1Jw1^!lsy`q=aw<3Ud=&bBkJR)N8a7Fmy zWxM7t_Zz=p-s%YI@TV><&1G5@?jF_s^e3>jC64;=FdJ|nW-^8!X8P_%ppO@93o$}> zJDlNXLh5#jslM#kr5cP3qF-GtXG)BO9dE^v>NeU2T*wwFJIc^#fdYsU2~Q4>ZoGcv zr&B6B#ra)){yUbQ1`*NLe-x?4Ma_ox4*R!eN8MIB|NVDfTmZDZ=Ln{3<31&&{ynW6m}jAoqQxG+Kq9N$ps}v^tdY_9gY=zV@Ixhw2Y*QvAW%V? z%EMXH?^juG*&Teq6Bx)ZGFakg1sr8QUb=f4@DO4ecmnC1&$8-&1;s^kn<_wJ_1&(oTvEP*SimY;Q5t|8XdSu5j29;iw3W2U<8myU+hF z@;WEvgI7fHmz?zX)^*EkA-*4VK;^6&cCr2lQfE%DpS{abimW-(%n?ba-;MmG%?6#8 z)`A2dfoe-8ODz8z!NAc}X1QC8ybiFs9XKK7|9;(%1b4KM6kv(#xw|(0qc-giQsQWE z`qED>bE1lahPl00-LIU=HwgiqsU%q8^PdW=1&2ltf=@24aOeB|yOFs9cE((vjz)eA>e3${puGuxvpZJ(JVfI(EVT1M$)lU;qFB literal 0 HcmV?d00001 diff --git a/agentcore-gateway-eventbridge-cdk/cdk/.gitignore b/agentcore-gateway-eventbridge-cdk/cdk/.gitignore new file mode 100644 index 0000000000..459d585459 --- /dev/null +++ b/agentcore-gateway-eventbridge-cdk/cdk/.gitignore @@ -0,0 +1,7 @@ +node_modules +cdk.out +*.js +!jest.config.js +*.d.ts +.cdk.staging +*.tsbuildinfo diff --git a/agentcore-gateway-eventbridge-cdk/cdk/bin/app.ts b/agentcore-gateway-eventbridge-cdk/cdk/bin/app.ts new file mode 100644 index 0000000000..9e4a4a0047 --- /dev/null +++ b/agentcore-gateway-eventbridge-cdk/cdk/bin/app.ts @@ -0,0 +1,14 @@ +#!/usr/bin/env node +import * as cdk from 'aws-cdk-lib'; +import { AgentCoreGatewayEventBridgeStack } from '../lib/agentcore-gateway-eventbridge-stack'; + +const app = new cdk.App(); + +new AgentCoreGatewayEventBridgeStack(app, 'AgentCoreGatewayEventBridgeStack', { + description: + 'ServerlessLand pattern: AgentCore Runtime agent emits events to EventBridge via AgentCore Gateway MCP tool', + env: { + account: process.env.CDK_DEFAULT_ACCOUNT, + region: process.env.CDK_DEFAULT_REGION, + }, +}); diff --git a/agentcore-gateway-eventbridge-cdk/cdk/cdk.json b/agentcore-gateway-eventbridge-cdk/cdk/cdk.json new file mode 100644 index 0000000000..020a631ce8 --- /dev/null +++ b/agentcore-gateway-eventbridge-cdk/cdk/cdk.json @@ -0,0 +1,21 @@ +{ + "app": "npx ts-node --prefer-ts-exts bin/app.ts", + "watch": { + "include": ["**"], + "exclude": [ + "README.md", + "cdk*.json", + "**/*.d.ts", + "**/*.js", + "tsconfig.json", + "package*.json", + "node_modules", + "cdk.out" + ] + }, + "context": { + "@aws-cdk/aws-iam:minimizePolicies": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/aws-iam:standardizedServicePrincipals": true + } +} diff --git a/agentcore-gateway-eventbridge-cdk/cdk/lib/agentcore-gateway-eventbridge-stack.ts b/agentcore-gateway-eventbridge-cdk/cdk/lib/agentcore-gateway-eventbridge-stack.ts new file mode 100644 index 0000000000..43b6e1b39e --- /dev/null +++ b/agentcore-gateway-eventbridge-cdk/cdk/lib/agentcore-gateway-eventbridge-stack.ts @@ -0,0 +1,199 @@ +import * as path from 'path'; +import * as cdk from 'aws-cdk-lib'; +import * as bedrockagentcore from 'aws-cdk-lib/aws-bedrockagentcore'; +import * as ecrAssets from 'aws-cdk-lib/aws-ecr-assets'; +import * as events from 'aws-cdk-lib/aws-events'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as lambda from 'aws-cdk-lib/aws-lambda'; +import { Construct } from 'constructs'; + +/** + * AgentCore Runtime → AgentCore Gateway → EventBridge (Outbound pattern) + * + * An AI agent on AgentCore Runtime emits structured business events to + * EventBridge through a governed AgentCore Gateway MCP tool. + * + * Flow: + * Strands Agent (Runtime) → emit_event tool (Gateway MCP, Streamable HTTP) + * → Lambda tool backend → events:PutEvents → Custom Bus + * + * Why Gateway over direct SDK: + * - Governance: Gateway tool definition constrains allowed event schemas + * - Observability: Gateway logs every tool invocation automatically + * - Rate limiting: Gateway enforces per-tool rate limits + * - Schema evolution: Update Gateway tool definition only, no agent redeploy + * - Multi-agent consistency: All agents use the same governed tool + * + * Auth model: + * - Runtime → Gateway: authorizerType=AWS_IAM (SigV4). The Runtime's + * execution role is granted bedrock-agentcore:InvokeGateway scoped to + * this Gateway's ARN. The agent code signs each MCP HTTP request with + * SigV4 (service "bedrock-agentcore") since no MCP client SDK does this + * natively for the streamable-HTTP transport — see agent-code/agent.py. + * - Runtime invocation (external caller): IAM SigV4 — caller needs + * bedrock-agentcore:InvokeAgentRuntime permission. + * - Gateway → Lambda: Gateway IAM role has lambda:InvokeFunction, scoped + * to the specific Lambda ARN. + */ +export class AgentCoreGatewayEventBridgeStack extends cdk.Stack { + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + // ------------------------------------------------------------------- + // 1. EventBridge Custom Bus + // ------------------------------------------------------------------- + const eventBus = new events.EventBus(this, 'AgentEventBus', { + eventBusName: 'agent-outbound-events', + }); + + // ------------------------------------------------------------------- + // 2. Lambda Tool Backend (emit_event) + // ------------------------------------------------------------------- + const emitEventFn = new lambda.Function(this, 'EmitEventFunction', { + functionName: 'agentcore-emit-event', + runtime: lambda.Runtime.PYTHON_3_12, + handler: 'handler.handler', + code: lambda.Code.fromAsset(path.join(__dirname, '..', '..', 'src', 'emit_event')), + environment: { + EVENT_BUS_NAME: eventBus.eventBusName, + ALLOWED_SOURCES: 'agent.', + }, + timeout: cdk.Duration.seconds(10), + }); + + eventBus.grantPutEventsTo(emitEventFn); + + // ------------------------------------------------------------------- + // 3. AgentCore Gateway (MCP server with Lambda target, IAM/SigV4 auth) + // ------------------------------------------------------------------- + const gatewayRole = new iam.Role(this, 'GatewayRole', { + assumedBy: new iam.ServicePrincipal('bedrock-agentcore.amazonaws.com', { + conditions: { + StringEquals: { 'aws:SourceAccount': this.account }, + ArnLike: { 'aws:SourceArn': `arn:aws:bedrock-agentcore:${this.region}:${this.account}:*` }, + }, + }), + inlinePolicies: { + GatewayPolicy: new iam.PolicyDocument({ + statements: [ + new iam.PolicyStatement({ + actions: ['lambda:InvokeFunction'], + resources: [emitEventFn.functionArn], + }), + new iam.PolicyStatement({ + actions: ['logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:PutLogEvents'], + resources: [`arn:aws:logs:${this.region}:${this.account}:log-group:/aws/bedrock-agentcore/*`], + }), + ], + }), + }, + }); + + const gateway = new bedrockagentcore.CfnGateway(this, 'EventEmitterGateway', { + name: 'event-emitter-gateway', + authorizerType: 'AWS_IAM', + protocolType: 'MCP', + protocolConfiguration: { + mcp: { + supportedVersions: ['2025-03-26'], + }, + }, + roleArn: gatewayRole.roleArn, + description: 'MCP Gateway (IAM/SigV4 auth) exposing emit_event tool for agents to publish events to EventBridge', + }); + + emitEventFn.addPermission('GatewayInvoke', { + principal: new iam.ServicePrincipal('bedrock-agentcore.amazonaws.com'), + sourceArn: gateway.attrGatewayArn, + }); + + new bedrockagentcore.CfnGatewayTarget(this, 'EmitEventTarget', { + gatewayIdentifier: gateway.attrGatewayIdentifier, + name: 'emit-event-target', + description: 'Lambda tool backend that publishes events to EventBridge', + credentialProviderConfigurations: [ + { credentialProviderType: 'GATEWAY_IAM_ROLE' }, + ], + targetConfiguration: { + mcp: { + lambda: { + lambdaArn: emitEventFn.functionArn, + toolSchema: { + inlinePayload: [ + { + name: 'emit_event', + description: 'Emit a structured business event to the EventBridge bus. Use this to publish results, decisions, or state changes that other systems or agents should react to.', + inputSchema: { + type: 'object', + properties: { + source: { type: 'string', description: "Event source identifier. Must start with 'agent.'" }, + detail_type: { type: 'string', description: "Event type (e.g. 'ClaimApproved', 'RiskAssessed')" }, + detail: { type: 'object', description: 'Event payload with business data' }, + }, + required: ['source', 'detail_type', 'detail'], + }, + }, + ], + }, + }, + }, + }, + }); + + // ------------------------------------------------------------------- + // 4. AgentCore Runtime (self-contained agent) + // ------------------------------------------------------------------- + const agentImage = new ecrAssets.DockerImageAsset(this, 'AgentImage', { + directory: path.join(__dirname, '..', '..', 'agent-code'), + platform: ecrAssets.Platform.LINUX_ARM64, + }); + + const agentRuntimeName = 'agentcore_gateway_eventbridge_demo'; + + const agentRuntimeRole = new iam.Role(this, 'AgentRuntimeRole', { + assumedBy: new iam.ServicePrincipal('bedrock-agentcore.amazonaws.com', { + conditions: { + StringEquals: { 'aws:SourceAccount': this.account }, + ArnLike: { 'aws:SourceArn': `arn:aws:bedrock-agentcore:${this.region}:${this.account}:*` }, + }, + }), + inlinePolicies: { + AgentRuntimePolicy: new iam.PolicyDocument({ + statements: [ + new iam.PolicyStatement({ sid: 'ECRImageAccess', actions: ['ecr:BatchGetImage', 'ecr:GetDownloadUrlForLayer'], resources: [agentImage.repository.repositoryArn] }), + new iam.PolicyStatement({ sid: 'ECRTokenAccess', actions: ['ecr:GetAuthorizationToken'], resources: ['*'] }), + new iam.PolicyStatement({ actions: ['logs:DescribeLogStreams', 'logs:CreateLogGroup', 'logs:DescribeLogGroups'], resources: [`arn:aws:logs:${this.region}:${this.account}:log-group:/aws/bedrock-agentcore/*`] }), + new iam.PolicyStatement({ actions: ['logs:CreateLogStream', 'logs:PutLogEvents'], resources: [`arn:aws:logs:${this.region}:${this.account}:log-group:/aws/bedrock-agentcore/runtimes/*:log-stream:*`] }), + new iam.PolicyStatement({ actions: ['xray:PutTraceSegments', 'xray:PutTelemetryRecords', 'xray:GetSamplingRules', 'xray:GetSamplingTargets'], resources: ['*'] }), + new iam.PolicyStatement({ actions: ['cloudwatch:PutMetricData'], resources: ['*'], conditions: { StringEquals: { 'cloudwatch:namespace': 'bedrock-agentcore' } } }), + new iam.PolicyStatement({ sid: 'GetAgentAccessToken', actions: ['bedrock-agentcore:GetWorkloadAccessToken', 'bedrock-agentcore:GetWorkloadAccessTokenForJWT', 'bedrock-agentcore:GetWorkloadAccessTokenForUserId'], resources: [`arn:aws:bedrock-agentcore:${this.region}:${this.account}:workload-identity-directory/default`, `arn:aws:bedrock-agentcore:${this.region}:${this.account}:workload-identity-directory/default/workload-identity/${agentRuntimeName}-*`] }), + new iam.PolicyStatement({ sid: 'BedrockModelInvocation', actions: ['bedrock:InvokeModel', 'bedrock:InvokeModelWithResponseStream'], resources: ['arn:aws:bedrock:*::foundation-model/*', `arn:aws:bedrock:${this.region}:${this.account}:*`] }), + new iam.PolicyStatement({ + sid: 'InvokeGateway', + actions: ['bedrock-agentcore:InvokeGateway'], + resources: [gateway.attrGatewayArn], + }), + ], + }), + }, + }); + + const agentRuntime = new bedrockagentcore.CfnRuntime(this, 'AgentRuntime', { + agentRuntimeName, + agentRuntimeArtifact: { containerConfiguration: { containerUri: agentImage.imageUri } }, + networkConfiguration: { networkMode: 'PUBLIC' }, + roleArn: agentRuntimeRole.roleArn, + environmentVariables: { GATEWAY_MCP_URL: gateway.attrGatewayUrl }, + }); + + // ------------------------------------------------------------------- + // Outputs + // ------------------------------------------------------------------- + new cdk.CfnOutput(this, 'GatewayUrl', { value: gateway.attrGatewayUrl, description: 'AgentCore Gateway MCP endpoint URL' }); + new cdk.CfnOutput(this, 'GatewayId', { value: gateway.attrGatewayIdentifier, description: 'Gateway identifier' }); + new cdk.CfnOutput(this, 'GatewayArn', { value: gateway.attrGatewayArn, description: 'Gateway ARN' }); + new cdk.CfnOutput(this, 'AgentRuntimeId', { value: agentRuntime.attrAgentRuntimeId, description: 'AgentCore Runtime ID' }); + new cdk.CfnOutput(this, 'AgentRuntimeArn', { value: agentRuntime.attrAgentRuntimeArn, description: 'AgentCore Runtime ARN (use for SigV4 invocation)' }); + new cdk.CfnOutput(this, 'EventBusName', { value: eventBus.eventBusName, description: 'EventBridge custom bus for agent-emitted events' }); + } +} diff --git a/agentcore-gateway-eventbridge-cdk/cdk/package.json b/agentcore-gateway-eventbridge-cdk/cdk/package.json new file mode 100644 index 0000000000..7ab4012ce4 --- /dev/null +++ b/agentcore-gateway-eventbridge-cdk/cdk/package.json @@ -0,0 +1,25 @@ +{ + "name": "agentcore-gateway-eventbridge-cdk", + "version": "1.0.0", + "description": "AgentCore Runtime agent emits events to EventBridge via AgentCore Gateway MCP tool", + "bin": { + "app": "bin/app.js" + }, + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "cdk": "cdk", + "deploy": "cdk deploy", + "destroy": "cdk destroy" + }, + "devDependencies": { + "@types/node": "20.14.9", + "aws-cdk": "2.1136.0", + "ts-node": "10.9.2", + "typescript": "5.5.3" + }, + "dependencies": { + "aws-cdk-lib": "2.264.0", + "constructs": "10.8.1" + } +} diff --git a/agentcore-gateway-eventbridge-cdk/cdk/tsconfig.json b/agentcore-gateway-eventbridge-cdk/cdk/tsconfig.json new file mode 100644 index 0000000000..b1eaa510e9 --- /dev/null +++ b/agentcore-gateway-eventbridge-cdk/cdk/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["es2022"], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": false, + "inlineSourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "strictPropertyInitialization": false, + "typeRoots": ["./node_modules/@types"] + }, + "exclude": ["node_modules", "cdk.out"] +} diff --git a/agentcore-gateway-eventbridge-cdk/example-pattern.json b/agentcore-gateway-eventbridge-cdk/example-pattern.json new file mode 100644 index 0000000000..868ca015d3 --- /dev/null +++ b/agentcore-gateway-eventbridge-cdk/example-pattern.json @@ -0,0 +1,123 @@ +{ + "title": "Amazon Bedrock AgentCore Runtime to Amazon EventBridge via AgentCore Gateway", + "description": "An AI agent on AgentCore Runtime emits business events to EventBridge through a governed AgentCore Gateway MCP tool, authenticated with IAM SigV4.", + "language": "TypeScript", + "level": "300", + "framework": "CDK", + "patternArch": { + "icon1": { + "x": 10, + "y": 50, + "service": "bedrock", + "label": "AgentCore Runtime" + }, + "icon2": { + "x": 35, + "y": 20, + "service": "bedrock", + "label": "Amazon Bedrock" + }, + "icon3": { + "x": 40, + "y": 50, + "service": "bedrock", + "label": "AgentCore Gateway" + }, + "icon4": { + "x": 70, + "y": 50, + "service": "lambda", + "label": "emit_event Lambda" + }, + "icon5": { + "x": 95, + "y": 50, + "service": "eventbridge", + "label": "Amazon EventBridge" + }, + "line1": { + "from": "icon1", + "to": "icon2" + }, + "line2": { + "from": "icon1", + "to": "icon3" + }, + "line3": { + "from": "icon3", + "to": "icon4" + }, + "line4": { + "from": "icon4", + "to": "icon5" + } + }, + "introBox": { + "headline": "How it works", + "text": [ + "This pattern shows an AI agent emitting structured business events to Amazon EventBridge through a governed Amazon Bedrock AgentCore Gateway MCP tool.", + "The agent runs on AgentCore Runtime and connects to the Gateway using the MCP Streamable HTTP transport (2025-03-26 spec). The Gateway's inbound authorization is AWS_IAM, so every request must carry a valid AWS SigV4 signature for the bedrock-agentcore service.", + "No MCP client SDK signs streamable-HTTP requests with SigV4 natively, so this pattern signs requests manually: a small helper wraps botocore's SigV4Auth as an httpx.Auth implementation, passed directly to the MCP client's transport.", + "The Gateway exposes an emit_event tool backed by an AWS Lambda function. When the agent calls emit_event with a source, detail type, and payload, the Lambda validates the source prefix (only agent.* allowed) and publishes to EventBridge via PutEvents.", + "IAM permissions follow least privilege: the Runtime execution role has bedrock-agentcore:InvokeGateway scoped to the specific Gateway ARN, the Gateway role can only invoke the specific Lambda, and the Lambda can only PutEvents to the specific bus." + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/agentcore-gateway-eventbridge-cdk", + "templateURL": "serverless-patterns/agentcore-gateway-eventbridge-cdk", + "projectFolder": "agentcore-gateway-eventbridge-cdk", + "templateFile": "cdk/lib/agentcore-gateway-eventbridge-stack.ts" + } + }, + "resources": { + "bullets": [ + { + "text": "Amazon Bedrock AgentCore Gateway", + "link": "https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway.html" + }, + { + "text": "Set up inbound authorization for your gateway (IAM/SigV4)", + "link": "https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-inbound-auth.html" + }, + { + "text": "Amazon Bedrock AgentCore Runtime", + "link": "https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/agents-tools-runtime.html" + }, + { + "text": "MCP Streamable HTTP Transport", + "link": "https://modelcontextprotocol.io/specification/2025-03-26/basic/transports" + }, + { + "text": "Amazon EventBridge PutEvents", + "link": "https://docs.aws.amazon.com/eventbridge/latest/APIReference/API_PutEvents.html" + } + ] + }, + "deploy": { + "text": [ + "cd cdk", + "npm install", + "cdk deploy" + ] + }, + "testing": { + "headline": "Testing", + "text": [ + "See the GitHub repo README.md for detailed testing instructions.", + "Sample invocation payload: {\"prompt\":\"Use the emit_event tool to emit an event with source=agent.claims-processor, detail_type=ClaimApproved, detail={claimId: CLM-001, decision: approved, confidence: 0.94}\"}", + "After invoking, check the Runtime's CloudWatch Logs for a 200 OK from the Gateway MCP endpoint and the EventBridge event ID returned by the Lambda." + ] + }, + "cleanup": { + "headline": "Cleanup", + "text": ["cd cdk", "cdk destroy"] + }, + "authors": [ + { + "name": "Antoine Boucherie", + "bio": "Principal Solutions Architect, AWS Global Financial Services", + "linkedin": "antoineboucherie" + } + ] +} diff --git a/agentcore-gateway-eventbridge-cdk/src/emit_event/handler.py b/agentcore-gateway-eventbridge-cdk/src/emit_event/handler.py new file mode 100644 index 0000000000..e03d545ee4 --- /dev/null +++ b/agentcore-gateway-eventbridge-cdk/src/emit_event/handler.py @@ -0,0 +1,73 @@ +""" +AgentCore Gateway tool backend: emit_event + +Receives tool invocations from the AgentCore Gateway (MCP Lambda target), +validates the event payload, and publishes to an EventBridge custom bus. + +The Gateway provides governance: schema validation on the tool input, +JWT authentication, rate limiting, and observability. This Lambda focuses +on the EventBridge integration and source-prefix enforcement. +""" +import boto3 +import json +import os + +events_client = boto3.client("events") +ALLOWED_SOURCES = os.environ.get("ALLOWED_SOURCES", "agent.").split(",") +EVENT_BUS_NAME = os.environ["EVENT_BUS_NAME"] + + +def handler(event, context): + """Handle tool invocation from AgentCore Gateway.""" + # Gateway sends the tool input as the Lambda event body + body = event if isinstance(event, dict) and "source" in event else json.loads(event.get("body", "{}")) + + source = body.get("source", "") + detail_type = body.get("detail_type", "") + detail = body.get("detail", {}) + notify = body.get("notify", False) + + # Validate required fields + if not source or not detail_type or not detail: + return { + "statusCode": 400, + "body": json.dumps({"error": "source, detail_type, and detail are required"}), + } + + # Validate source prefix (governance: agents can only emit from allowed namespaces) + if not any(source.startswith(prefix) for prefix in ALLOWED_SOURCES): + return { + "statusCode": 403, + "body": json.dumps( + {"error": f"Source must start with one of: {ALLOWED_SOURCES}"} + ), + } + + # Add notify flag to detail for downstream rule filtering + if notify: + detail["notify"] = True + + # Emit to EventBridge + response = events_client.put_events( + Entries=[ + { + "Source": source, + "DetailType": detail_type, + "Detail": json.dumps(detail), + "EventBusName": EVENT_BUS_NAME, + } + ] + ) + + failed_count = response["FailedEntryCount"] + + return { + "statusCode": 200 if failed_count == 0 else 207, + "body": json.dumps( + { + "success": failed_count == 0, + "failed_count": failed_count, + "event_id": response["Entries"][0].get("EventId", ""), + } + ), + }