diff --git a/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/.env.example b/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/.env.example new file mode 100644 index 00000000..a2716d12 --- /dev/null +++ b/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/.env.example @@ -0,0 +1,54 @@ +# Heurist Finance Agent — environment configuration +# Copy to .env and fill in your values before running pay-for-data.ipynb. + +# ── AWS region ───────────────────────────────────────────────────────────── +AWS_REGION=us-west-2 + +# ── IAM roles (required) ─────────────────────────────────────────────────── +# ControlPlaneRole: creates CredentialProvider / Manager / Connector +CONTROL_PLANE_ROLE_ARN=arn:aws:iam::123456789012:role/AgentCoreControlPlaneRole +# ManagementRole: creates sessions, invokes the Runtime endpoint +MANAGEMENT_ROLE_ARN=arn:aws:iam::123456789012:role/AgentCoreManagementRole +# ProcessPaymentRole: the Runtime container execution role; can ProcessPayment +PROCESS_PAYMENT_ROLE_ARN=arn:aws:iam::123456789012:role/AgentCoreProcessPaymentRole +# ResourceRetrievalRole: attached to the Payment Manager as its authorizer role +RESOURCE_RETRIEVAL_ROLE_ARN=arn:aws:iam::123456789012:role/AgentCoreResourceRetrievalRole + +# ── Coinbase CDP credentials (required for embedded wallet) ──────────────── +# Get these from https://portal.cdp.coinbase.com → your project → API keys +CDP_API_KEY_NAME=organizations/your-org-id/apiKeys/your-key-id +CDP_API_KEY_PRIVATE_KEY=-----BEGIN EC PRIVATE KEY-----\n... +CDP_WALLET_SECRET=your-wallet-secret + +# Optional: email to link the embedded wallet to a user identity (for WalletHub login) +WALLET_EMAIL=your@email.com + +# ── Network / blockchain ─────────────────────────────────────────────────── +# base-mainnet: eip155:8453 (default — settles real USDC against the live +# Heurist mesh x402 registry) +# base-sepolia: eip155:84532 (testnet — free faucet USDC at faucet.circle.com, +# but Heurist Sepolia endpoints currently fail +# EIP-712 simulation against Coinbase's signer) +NETWORK=base-mainnet + +# ── Provisioned resource IDs (populated by Step 4 — skip creation on re-runs) ── +# Leave blank on first run; save values printed by the notebook after Step 4. +MANAGER_ARN= +PAYMENT_CONNECTOR_ID= +PAYMENT_INSTRUMENT_ID= + +# ── Session config ───────────────────────────────────────────────────────── +USER_ID=heurist-demo-user +SESSION_MAX_SPEND=0.25 +SESSION_EXPIRY_MINUTES=60 + +# ── Bedrock model ────────────────────────────────────────────────────────── +BEDROCK_MODEL_ID=us.anthropic.claude-sonnet-4-6 + +# ── Heurist catalog ──────────────────────────────────────────────────────── +HEURIST_CATALOG_URL=https://mesh.heurist.xyz/x402/agents?details=true +HEURIST_AGENT_IDS=ExaSearchDigestAgent,YahooFinanceAgent,FredMacroAgent,SecEdgarAgent + +# ── S3 artifacts ─────────────────────────────────────────────────────────── +# Optional: override the auto-generated bucket name +# ARTIFACTS_BUCKET=my-custom-bucket-name diff --git a/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/.gitignore b/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/.gitignore new file mode 100644 index 00000000..79a8cf7b --- /dev/null +++ b/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/.gitignore @@ -0,0 +1,22 @@ +.env +.venv/ +__pycache__/ +*.pyc +*.py[cod] +.DS_Store +.ipynb_checkpoints/ +.pytest_cache/ +.ruff_cache/ + +# AgentCore CLI scaffold + deploy artifacts (regenerated by the notebook). +# The agent source of truth lives in agent/. The scaffold under payfordata/ +# is reproducible by re-running the notebook. +payfordata/ + +# Catalog cache is regenerated by `python agent/sync_registry.py` before each +# deploy — keep the latest version out of git. +agent/catalog_live_cache.json + +# Container .env may carry deployment-specific values (S3 bucket name etc.). +# Keep it out of git; the notebook regenerates it from the user's environment. +agent/.env diff --git a/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/README.md b/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/README.md new file mode 100644 index 00000000..697e0aa5 --- /dev/null +++ b/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/README.md @@ -0,0 +1,234 @@ +# Pay for Data — Heurist Finance Agent + +## Overview + +A finance research agent that pays for real-time market data using **Amazon Bedrock AgentCore payments**. The agent calls paid [Heurist](https://heurist.xyz) endpoints for live prices, SEC filings, and macro indicators, analyzes the data with AgentCore Code Interpreter, and returns charts and reports as S3 presigned URLs — all without any manual payment code in the tools. + +The agent is deployed to **AgentCore Runtime**: a managed container endpoint with HTTPS invocation, SigV4 auth, and automatic observability via CloudWatch. + +> **Mainnet sample.** This walkthrough targets Base mainnet and calls the live [Heurist mesh x402 registry](https://mesh.heurist.xyz/x402/agents?details=true). Every invocation settles real USDC on-chain. Typical per-call prices are $0.002–$0.005, so $1 USDC covers ~200 calls. A Base Sepolia variant of the catalog exists at `/x402/base-sepolia/agents?details=true`, but the EIP-712 signing path on AgentCore's Coinbase connector follows the connector's network selection, so this sample uses Base mainnet. + +Heurist endpoints use the [x402 protocol](https://x402.org) — they return HTTP 402 until a valid payment proof is attached. The `AgentCorePaymentsPlugin` handles payment end-to-end: it intercepts 402 responses, generates a USDC proof via the AgentCore payment manager, attaches it, and retries. Your tool code stays a plain `http_request` call. + +![CloudWatch GenAI Observability — Heurist Finance Agent](images/obs-dashboard.png) + +## Architecture + +``` +App Backend (ManagementRole) AgentCore Runtime + | +------------------------------+ + | create_session(budget=$X) | agent/main.py | + | | BedrockAgentCoreApp | + |-- invoke(manager_arn, session_id, --> | + AgentCorePaymentsPlugin | + | instrument_id, prompt) | | + | | http_request -> 402 | + |<-- {response, artifacts: [{url}]} --- | -> ProcessPayment -> retry | + | | -> Code Interpreter | + | get_session(check spend) | -> export to S3 | + +------------------------------+ + | + v + CloudWatch GenAI Observability + (automatic via OpenTelemetry) +``` + +## How It Works + +`AgentCorePaymentsPlugin` handles the entire x402 payment lifecycle: + +```python +from bedrock_agentcore.payments.integrations.strands import ( + AgentCorePaymentsPlugin, + AgentCorePaymentsPluginConfig, +) + +payment_plugin = AgentCorePaymentsPlugin( + config=AgentCorePaymentsPluginConfig( + payment_manager_arn=PAYMENT_MANAGER_ARN, + user_id=USER_ID, + payment_instrument_id=PAYMENT_INSTRUMENT_ID, + payment_session_id=PAYMENT_SESSION_ID, + region="us-west-2", + ) +) + +agent = Agent( + model=BedrockModel(model_id=MODEL_ID), + tools=[http_request, code_interpreter, export_artifact_to_s3, ...], + plugins=[payment_plugin], +) +``` + +See [`agent/main.py`](agent/main.py) for the full implementation. + +## Sample Details + +| | | +|---|---| +| AgentCore components | AgentCore payments, AgentCore Code Interpreter, AgentCore Runtime | +| Agent framework | [Strands Agents](https://strandsagents.com/) | +| Model | Claude Sonnet 4.6 on Amazon Bedrock (configurable) | +| Payment protocol | [x402](https://x402.org) | +| Payment network | Base (USDC) | + +## Data Sources + +Fetched at runtime from the [Heurist mesh registry](https://mesh.heurist.xyz/x402/agents?details=true). By default the sample loads tools from four agents: + +| Agent | Representative tools | Typical price | +|-------|----------------------|---------------| +| `YahooFinanceAgent` | `price_history`, `quote_snapshot`, `futures_snapshot` | $0.002 | +| `FredMacroAgent` | `macro_series_snapshot`, `macro_regime_context` | $0.003 | +| `SecEdgarAgent` | `filing_timeline`, `filing_diff`, `xbrl_fact_trends` | $0.002 | +| `ExaSearchDigestAgent` | `exa_web_search`, `exa_scrape_url` | $0.005 | + +Override with the `HEURIST_AGENT_IDS` environment variable. + +## Prerequisites + +- An **AgentCore payment manager** created in your AWS account +- A **payment instrument** created and funded — embedded crypto wallet with USDC on **Base mainnet** (default; the notebook walks through this in Step 4) +- Coinbase CDP project with **Delegated signing** enabled, and a per-wallet delegation grant approved via the WalletHub URL returned at instrument creation +- Python 3.11+ +- AWS credentials with Bedrock and AgentCore access in `us-west-2` +- Node.js 20+ (for the `@aws/agentcore` CLI) +- Docker (running, for `agentcore deploy` container build) +- [AWS CDK](https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html) installed globally + +## Layout + +``` +pay-for-data/ +├── README.md +├── .env.example +├── pay-for-data.ipynb # notebook: deploy and invoke via AgentCore Runtime +└── agent/ # everything below ships in the Runtime container + ├── main.py # AgentCore Runtime entry point (BedrockAgentCoreApp) + ├── catalog.py # fetches Heurist registry, formats for system prompt + ├── catalog_live_cache.json # synced catalog (bundled in Runtime image) + ├── config.py # loads .env / payment context + ├── sync_registry.py # CLI: refreshes the catalog cache (run before deploy) + ├── requirements.txt # container Python deps + └── Dockerfile # opentelemetry-instrument python -m main +``` + +## Quick Start + +Open [`pay-for-data.ipynb`](pay-for-data.ipynb) and run the cells in order: + +| Step | What happens | +|------|-------------| +| 1 | Configure credentials and confirm AWS identity | +| 2 | Sync the Heurist tool catalog (bundled in the container image) | +| 3 | Create the S3 artifacts bucket | +| 4 | Provision embedded wallet resources (credential provider, manager, connector, instrument) | +| 5 | Fund the wallet and grant signing delegation via WalletHub | +| 6 | Enable Payment Manager observability (CW Logs + X-Ray vended-log delivery) | +| 7 | Scaffold and deploy to AgentCore Runtime via the `agentcore` CLI | +| 8 | Grant execution-role permissions (payment, Code Interpreter, S3, Bedrock + inference profile) | +| 9 | Invoke the deployed agent and inspect results | +| 10 | View observability traces in CloudWatch | +| 11 | Cleanup | + +## Payment Flow + +When the agent calls a paid Heurist endpoint: + +1. `http_request` sends a POST to the endpoint URL. +2. Heurist returns HTTP 402 with x402 payment terms (network, asset, amount, recipient). +3. `AgentCorePaymentsPlugin` intercepts the response. +4. The plugin asks the AgentCore payment manager to generate a payment proof. +5. The payment manager uses the payment instrument to sign a USDC transfer and returns a proof. +6. The plugin attaches the proof as `X-PAYMENT` and retries — Heurist validates and returns the data. + +The plugin retries up to 3 times per tool call. Payment limits are enforced at the session scope — the agent cannot exceed `maxSpendAmount`. + +## How the Runtime Agent Works + +`agent/main.py` implements the AgentCore Runtime service contract with full feature parity: + +**Stateless, payload-driven** +All payment config (manager ARN, session ID, instrument ID) comes from the invocation payload. The container holds no credentials. The app backend (ManagementRole) creates payment sessions with spending limits before each invocation. The Runtime execution role (ProcessPaymentRole) can only spend within those limits. + +**AgentCore Code Interpreter** +Code Interpreter is a remote AWS API — it works identically from a Runtime container as from any other environment. The agent uses it for pandas/matplotlib analysis and chart generation. + +**S3 artifact storage** +Artifacts produced by Code Interpreter are uploaded to S3 and returned as presigned download URLs. The response shape is: + +```json +{ + "response": "", + "artifacts": [ + {"name": "chart.png", "url": "https://...", "expires_in": 3600} + ] +} +``` + +If `CI_ARTIFACTS_BUCKET` is not configured, the agent degrades gracefully: charts become markdown tables, text returns inline. + +**Observability** +The `agentcore deploy` CLI configures the container to run under `opentelemetry-instrument`. Combined with `aws-opentelemetry-distro` (included in `agent/requirements.txt`), this provides: +- Strands agent spans (LLM calls, tool calls, agent turns) → CloudWatch GenAI Observability +- Code Interpreter calls stitched as child spans via W3C `traceparent` botocore instrumentation +- Payment calls (`ProcessPayment`, `GetPaymentInstrument`) as boto3 child spans + +No instrumentation code required in `agent/main.py`. + +**Execution role permissions** (attached by the notebook, Step 8): + +| Permission set | Actions | Resource scope | +|---|---|---| +| Payment data-plane | `ProcessPayment`, `GetPaymentInstrument`, `GetPaymentInstrumentBalance`, `GetPaymentSession`, `GetResourcePaymentToken` | `payment-manager/*`, `payment-manager/*/instrument/*`, `payment-manager/*/session/*` | +| Code Interpreter | `StartCodeInterpreterSession`, `InvokeCodeInterpreter`, `StopCodeInterpreterSession` | `code-interpreter/*` | +| S3 artifacts | `PutObject`, `GetObject` | `/heurist-finance-artifacts/*` | +| Bedrock model | `InvokeModel`, `InvokeModelWithResponseStream` | `foundation-model/*`, `inference-profile/*`, `application-inference-profile/*` (the latter two are required for CRIS-fronted models like Claude Sonnet 4.6 in us-west-2) | + +## Environment Variables + +See [`.env.example`](.env.example). Required on the host (notebook): + +| Variable | Description | +|----------|-------------| +| `PAYMENT_MANAGER_ARN` | ARN of the AgentCore payment manager | +| `PAYMENT_SESSION_ID` | ID of an active payment session | +| `PAYMENT_INSTRUMENT_ID` | ID of a funded payment instrument (embedded crypto wallet) | +| `USER_ID` | User identifier for payment tracking | +| `BEDROCK_MODEL_ID` | Bedrock model (default: Claude Sonnet 4.6) | +| `HEURIST_AGENT_IDS` | Comma-separated Heurist agents to load | +| `HEURIST_CATALOG_URL` | Catalog endpoint — `https://mesh.heurist.xyz/x402/agents?details=true` (mainnet) or the `/x402/base-sepolia/...` variant for testnet | + +Bundled in the container `.env` (set by Step 7): + +| Variable | Description | +|----------|-------------| +| `CI_ARTIFACTS_BUCKET` | S3 bucket used for artifact upload | +| `CI_ARTIFACTS_PREFIX` | S3 key prefix (default: `heurist-finance-artifacts`) | +| `CI_ARTIFACTS_TTL` | Presigned URL TTL in seconds (default: 3600) | +| `AWS_REGION` | Region for boto3 clients | +| `AGENT_NAME` | Reported in payment observability | +| `BYPASS_TOOL_CONSENT` | Set to `true` so `strands_tools.http_request` skips its TTY confirm prompt — required because the Runtime container has no TTY | +| `AGENT_MAX_TOKENS` | Max Bedrock output tokens per agent turn (default: `32000`). Lower this if you only need short Q&A — Bedrock charges per output token, so a 32k cap is a worst-case ~$0.48 per turn for Claude Sonnet 4.6. Most turns use far less. The SDK default (4k) is too low for workflows that fetch data, run Code Interpreter, and write a markdown report in one turn — it raises `MaxTokensReachedException` mid-run. | + +Payment context (`PAYMENT_MANAGER_ARN`, `PAYMENT_SESSION_ID`, `PAYMENT_INSTRUMENT_ID`, `USER_ID`) is passed in the **invocation payload** at runtime, not via env vars in the container. + +## Costs + +A single agent invocation incurs charges across four categories. Approximate worst-case figures for the default config: + +| Category | Driver | Approx. cost per turn | Notes | +|---|---|---|---| +| **Heurist x402 (USDC on Base mainnet)** | Each paid tool call | $0.002–$0.005 per call | Settles real USDC on-chain. A typical research run uses 3–10 paid calls. The wallet must be funded. | +| **Bedrock model output** | `AGENT_MAX_TOKENS` × Claude Sonnet 4.6 output rate | up to ~$0.90 per turn at the 32k cap | Bedrock charges $0.015 per 1k output tokens for Claude Sonnet 4.6 in us-west-2 (input is cheaper at $0.003 per 1k). Most turns use far less than the cap; lower `AGENT_MAX_TOKENS` for short Q&A. | +| **Bedrock AgentCore Runtime** | Container vCPU × seconds + memory × seconds while invoked | a few cents per minute of active invocation | Idle minutes between invocations are not billed (`idleRuntimeSessionTimeout=600s`). | +| **Bedrock AgentCore Code Interpreter** | Sessions started + minutes active | a few cents per turn | Only billed when the agent actually invokes the Code Interpreter tool. | +| **S3 + CloudWatch** | Artifact storage + log/trace ingestion | rounding error | A small chart + report is well under 1 MB. Vended-log delivery to CW Logs and X-Ray is metered the same as your other CW usage. | + +Tune `AGENT_MAX_TOKENS` and `SESSION_MAX_SPEND` in `.env` to match the workflow you actually run. The notebook uses a $0.25 per-session spend cap by default, which is plenty for a multi-call research workflow. + +## Notes + +- Payment sessions expire. Create a fresh session before each invocation in automated workflows. +- Each paid call settles USDC on Base. Ensure your payment instrument is funded. +- Sync the catalog cache before building the container image (`python agent/sync_registry.py`). The cache is bundled in the image — the container does not call the Heurist registry at startup. +- Presigned artifact URLs expire after `CI_ARTIFACTS_TTL` seconds (default: 1 hour). Download or forward the URL to the end user promptly. diff --git a/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/agent/Dockerfile b/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/agent/Dockerfile new file mode 100644 index 00000000..ee22b466 --- /dev/null +++ b/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/agent/Dockerfile @@ -0,0 +1,37 @@ +FROM public.ecr.aws/docker/library/python:3.13-slim-bookworm + +WORKDIR /app + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd -m -u 1000 bedrock_agentcore + +COPY requirements.txt ./ +RUN pip install -r requirements.txt + +COPY --chown=bedrock_agentcore:bedrock_agentcore . . + +USER bedrock_agentcore + +EXPOSE 8080 + +# AgentCore Runtime monitors container health at the platform level via the +# service contract on :8080; this HEALTHCHECK exists for portability and to +# satisfy security scanners. BedrockAgentCoreApp serves /ping by default. +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD curl -fsS http://localhost:8080/ping || exit 1 + +# opentelemetry-instrument wraps botocore so that boto3 calls +# (InvokeCodeInterpreter, ProcessPayment) appear as child spans in the +# Runtime trace via W3C traceparent header propagation. Without this prefix, +# CI calls and payment calls are invisible in the AgentCore observability +# dashboard. +CMD ["opentelemetry-instrument", "python", "-m", "main"] diff --git a/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/agent/catalog.py b/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/agent/catalog.py new file mode 100644 index 00000000..a88dce9f --- /dev/null +++ b/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/agent/catalog.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""Load the Heurist tool catalog and format it for the agent system prompt.""" + +from __future__ import annotations + +import json +import math +import os +import re +import tempfile +from pathlib import Path +from typing import Any + +import requests + +from config import LIVE_CATALOG_CACHE_PATH, get_config + +# --- Safety limits --------------------------------------------------------- +# These caps are intentionally generous for a sample but prevent accidental +# memory blow-ups from a misconfigured endpoint or disk corruption. +MAX_CATALOG_BYTES = 5 * 1024 * 1024 # 5 MiB on-disk cache +MAX_CATALOG_RESPONSE_BYTES = 10 * 1024 * 1024 # 10 MiB network payload +MAX_PROMPT_FIELD_LEN = 500 # per-field cap when rendered into the system prompt + +_UNSAFE_FIELD_PLACEHOLDER = "(unavailable)" +_UNSAFE_PROMPT_CHARS = re.compile(r"[\x00-\x1f\x7f`|\[\]]") + + +def _sanitize_prompt_text(value: Any, max_len: int = MAX_PROMPT_FIELD_LEN) -> str: + """Return a markdown-safe single-line string derived from ``value``. + + External catalog data is interpolated into the agent's system prompt. + Without sanitization a malicious registry entry could inject links, + code fences, or table pipes that alter the prompt structure. + """ + if value is None: + return "" + text = str(value) + text = _UNSAFE_PROMPT_CHARS.sub(" ", text) + text = re.sub(r"\s+", " ", text).strip() + if len(text) > max_len: + text = text[:max_len].rstrip() + "…" + return text + + +def _sanitize_url(value: Any) -> str: + """Only accept http(s) URLs; otherwise return a placeholder.""" + text = _sanitize_prompt_text(value, max_len=MAX_PROMPT_FIELD_LEN) + if not text: + return _UNSAFE_FIELD_PLACEHOLDER + if not re.match(r"^https?://[^\s]+$", text, re.IGNORECASE): + return _UNSAFE_FIELD_PLACEHOLDER + return text + + +def _coerce_price(raw: Any) -> float: + """Convert a raw price value into a finite, non-negative float.""" + try: + price = float(raw) + except (TypeError, ValueError) as exc: + raise ValueError(f"Invalid price value {raw!r}") from exc + if not math.isfinite(price) or price < 0: + raise ValueError(f"Invalid price value {raw!r}: must be a finite, non-negative number") + return price + + +def _atomic_write_text(path: Path, content: str) -> None: + """Write ``content`` to ``path`` atomically via a same-directory temp file.""" + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)) + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(content) + fh.flush() + os.fsync(fh.fileno()) + os.replace(tmp_name, path) + except Exception: + try: + os.unlink(tmp_name) + except OSError: + pass + raise + + +def fetch_live_catalog(session: requests.Session | None = None) -> dict[str, Any]: + """Fetch the live Heurist mesh registry and cache it locally.""" + cfg = get_config() + http = session or requests.Session() + response = http.get(cfg.heurist_catalog_url, timeout=30, stream=True) + response.raise_for_status() + + chunks: list[bytes] = [] + total = 0 + for chunk in response.iter_content(chunk_size=64 * 1024): + if not chunk: + continue + total += len(chunk) + if total > MAX_CATALOG_RESPONSE_BYTES: + raise ValueError(f"Heurist catalog response exceeded {MAX_CATALOG_RESPONSE_BYTES} bytes") + chunks.append(chunk) + body = b"".join(chunks).decode(response.encoding or "utf-8") + payload = json.loads(body) + + _atomic_write_text(LIVE_CATALOG_CACHE_PATH, json.dumps(payload, indent=2)) + return payload + + +def load_live_catalog(path: Path | None = None) -> dict[str, Any]: + input_path = path or LIVE_CATALOG_CACHE_PATH + if not input_path.exists(): + raise FileNotFoundError(f"Live catalog cache not found: {input_path}") + size = input_path.stat().st_size + if size > MAX_CATALOG_BYTES: + raise ValueError( + f"Catalog cache at {input_path} is {size} bytes which exceeds the " + f"{MAX_CATALOG_BYTES} byte limit. Delete or regenerate the file." + ) + return json.loads(input_path.read_text(encoding="utf-8")) + + +def get_live_catalog(refresh: bool = False, session: requests.Session | None = None) -> dict[str, Any]: + if refresh or not LIVE_CATALOG_CACHE_PATH.exists(): + return fetch_live_catalog(session=session) + return load_live_catalog() + + +def get_tools_for_agents( + agent_ids: tuple[str, ...] | list[str], + refresh: bool = False, + session: requests.Session | None = None, +) -> list[dict[str, Any]]: + """Return normalized tool definitions for the selected Heurist agents.""" + import logging + + logger = logging.getLogger(__name__) + + selected = set(agent_ids) + live_catalog = get_live_catalog(refresh=refresh, session=session) + tools: list[dict[str, Any]] = [] + found_ids: set[str] = set() + + for agent in live_catalog.get("agents", []): + agent_id = agent.get("agentId") + if not agent_id or agent_id not in selected: + continue + found_ids.add(agent_id) + for tool_def in agent.get("tools", []): + try: + price_usd = _coerce_price(tool_def["priceUsd"]) + except (KeyError, ValueError): + continue + tools.append( + { + "agent_id": agent_id, + "tool_name": tool_def.get("name", ""), + "resource_url": tool_def.get("resourceUrl", ""), + "price_usd": price_usd, + "method": tool_def.get("method", "POST"), + "description": tool_def.get("description", ""), + "parameters": tool_def.get("parameters", {}) or {}, + } + ) + + missing = selected - found_ids + if missing: + logger.warning( + "The following agent IDs were not found in the Heurist catalog and will be " + "skipped. They may have been renamed or removed: %s. " + "Run sync_registry to refresh the catalog, or update HEURIST_AGENT_IDS in .env.", + ", ".join(sorted(missing)), + ) + + return tools + + +def format_catalog_for_prompt(tools: list[dict[str, Any]]) -> str: + """Format the tool catalog as a reference table for the agent system prompt.""" + lines = ["## Available Paid Endpoints (Heurist x402)", ""] + lines.append("| Agent | Tool | URL | Method | Price | Description |") + lines.append("|-------|------|-----|--------|-------|-------------|") + + for t in tools: + agent_id = _sanitize_prompt_text(t.get("agent_id"), max_len=80) + tool_name = _sanitize_prompt_text(t.get("tool_name"), max_len=80) + url = _sanitize_url(t.get("resource_url")) + method = _sanitize_prompt_text(t.get("method"), max_len=10) or "POST" + desc = _sanitize_prompt_text(t.get("description"), max_len=80) + price = t.get("price_usd") + price_str = f"${price:.3f}" if isinstance(price, (int, float)) and math.isfinite(price) else "n/a" + lines.append(f"| {agent_id} | {tool_name} | {url} | {method} | {price_str} | {desc} |") + + lines.append("") + lines.append("### Parameter Schemas") + lines.append("") + for t in tools: + params = t.get("parameters", {}) or {} + props = params.get("properties", {}) or {} + if not props: + continue + agent_id = _sanitize_prompt_text(t.get("agent_id"), max_len=80) + tool_name = _sanitize_prompt_text(t.get("tool_name"), max_len=80) + method = _sanitize_prompt_text(t.get("method"), max_len=10) or "POST" + url = _sanitize_url(t.get("resource_url")) + lines.append(f"**{agent_id}/{tool_name}** (`{method} {url}`)") + required_fields = params.get("required", []) or [] + for name, schema in props.items(): + if not isinstance(schema, dict): + schema = {} + safe_name = _sanitize_prompt_text(name, max_len=80) + required = safe_name in {_sanitize_prompt_text(r, max_len=80) for r in required_fields} + req_marker = " (required)" if required else "" + type_name = _sanitize_prompt_text(schema.get("type", "any"), max_len=40) + desc = _sanitize_prompt_text(schema.get("description", ""), max_len=120) + lines.append(f" - `{safe_name}`: {type_name}{req_marker} — {desc}") + lines.append("") + + return "\n".join(lines) diff --git a/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/agent/config.py b/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/agent/config.py new file mode 100644 index 00000000..53d46385 --- /dev/null +++ b/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/agent/config.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Shared configuration for the Heurist finance agent.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +from dotenv import load_dotenv + +AGENT_DIR = Path(__file__).resolve().parent +LIVE_CATALOG_CACHE_PATH = AGENT_DIR / "catalog_live_cache.json" + +# Accept .env in either the agent dir (Runtime container layout) or the +# parent use-case dir (host machine layout for sync_registry). +ENV_CANDIDATE_PATHS: tuple[Path, ...] = ( + AGENT_DIR / ".env", + AGENT_DIR.parent / ".env", +) + +DEFAULT_HEURIST_AGENT_IDS = ( + "ExaSearchDigestAgent", + "YahooFinanceAgent", + "FredMacroAgent", + "SecEdgarAgent", +) + +# Required environment variables for the agent to run host-side scripts. +# The Runtime container does NOT need these — payment context comes from +# the invocation payload at runtime. +_REQUIRED_ENV_VARS: tuple[str, ...] = ( + "PAYMENT_MANAGER_ARN", + "PAYMENT_SESSION_ID", + "PAYMENT_INSTRUMENT_ID", +) + + +def load_environment() -> None: + """Load the local .env file from any of the supported locations.""" + for candidate in ENV_CANDIDATE_PATHS: + if candidate.is_file(): + load_dotenv(candidate, override=False) + + +@dataclass(frozen=True) +class AppConfig: + aws_region: str + aws_profile: str | None + bedrock_profile: str | None + bedrock_model_id: str + payment_manager_arn: str + payment_session_id: str + payment_instrument_id: str + user_id: str + heurist_catalog_url: str + heurist_tool_agent_ids: tuple[str, ...] + code_interpreter_session_name: str + agent_timeout_seconds: int + agent_max_tokens: int + + +def _parse_csv_tuple(raw_value: str | None, default: tuple[str, ...]) -> tuple[str, ...]: + if not raw_value: + return default + values = tuple(item.strip() for item in raw_value.split(",") if item.strip()) + return values or default + + +def _require_env(name: str) -> str: + value = os.environ.get(name) + if value: + return value + searched = ", ".join(str(p) for p in ENV_CANDIDATE_PATHS) + raise RuntimeError( + f"Missing required environment variable {name!r}. " + f"Set it in your shell or in a .env file at one of: {searched}. " + f"See .env.example for the full list of required values." + ) + + +def get_config() -> AppConfig: + load_environment() + missing = [name for name in _REQUIRED_ENV_VARS if not os.environ.get(name)] + if missing: + searched = ", ".join(str(p) for p in ENV_CANDIDATE_PATHS) + raise RuntimeError( + "Missing required environment variables: " + + ", ".join(missing) + + f". Set them in your shell or in a .env file at one of: {searched}. " + + "See .env.example for the full list of required values." + ) + return AppConfig( + aws_region=os.environ.get("AWS_REGION", "us-west-2"), + aws_profile=os.environ.get("AWS_PROFILE"), + bedrock_profile=os.environ.get("BEDROCK_PROFILE") or os.environ.get("AWS_PROFILE"), + bedrock_model_id=os.environ.get("BEDROCK_MODEL_ID", "us.anthropic.claude-sonnet-4-6"), + payment_manager_arn=_require_env("PAYMENT_MANAGER_ARN"), + payment_session_id=_require_env("PAYMENT_SESSION_ID"), + payment_instrument_id=_require_env("PAYMENT_INSTRUMENT_ID"), + user_id=os.environ.get("USER_ID", "demo-user"), + heurist_catalog_url=os.environ.get( + "HEURIST_CATALOG_URL", + "https://mesh.heurist.xyz/x402/base-sepolia/agents?details=true", + ), + heurist_tool_agent_ids=_parse_csv_tuple(os.environ.get("HEURIST_AGENT_IDS"), DEFAULT_HEURIST_AGENT_IDS), + code_interpreter_session_name=os.environ.get("CODE_INTERPRETER_SESSION_NAME", "heurist-finance"), + agent_timeout_seconds=int(os.environ.get("AGENT_TIMEOUT_SECONDS", "300")), + agent_max_tokens=int(os.environ.get("AGENT_MAX_TOKENS", "64000")), + ) diff --git a/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/agent/main.py b/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/agent/main.py new file mode 100644 index 00000000..232972d7 --- /dev/null +++ b/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/agent/main.py @@ -0,0 +1,601 @@ +#!/usr/bin/env python3 +""" +Heurist Finance Agent — AgentCore Runtime entry point. + +Pay-for-data agent that: + - Calls paid Heurist endpoints via x402 (HTTP 402 → ProcessPayment → retry) + - Uses AgentCore Code Interpreter for sandboxed pandas/matplotlib analysis + - Uploads chart/CSV/report artifacts to S3 and returns presigned URLs + - Stateless — payment config (manager ARN, session, instrument) comes from + the invocation payload; the container holds no credentials + +If `CI_ARTIFACTS_BUCKET` is not set, the agent degrades gracefully: charts +become markdown tables, text is returned inline. + +Required IAM permissions for the execution role (see notebook Step 8): + Payments — ProcessPayment, GetPaymentInstrument, GetPaymentSession, + GetPaymentInstrumentBalance, GetResourcePaymentToken + on payment-manager/* and its instrument/* and session/* + Code Interpreter — Start/Stop/Invoke CodeInterpreterSession on code-interpreter/* + S3 — PutObject + GetObject on //* + Bedrock — InvokeModel + InvokeModelWithResponseStream on the model + ARN AND on inference-profile/* (for CRIS-fronted models like + Claude Sonnet 4.6) + CloudWatch — added automatically by `agentcore deploy` + +Environment variables (set via .env bundled in the container image): + CI_ARTIFACTS_BUCKET S3 bucket for artifact storage (optional but recommended) + CI_ARTIFACTS_PREFIX S3 key prefix (default: "heurist-finance-artifacts") + CI_ARTIFACTS_TTL Presigned URL TTL seconds (default: 3600) + HEURIST_AGENT_IDS Comma-separated Heurist agent IDs to load + BEDROCK_MODEL_ID Override the default Bedrock model ID + AGENT_NAME Name reported in payment observability + BYPASS_TOOL_CONSENT Set to "true" so http_request skips its TTY confirm prompt + (Runtime containers have no TTY) + +Invocation payload: + prompt (str, required) — research request + payment_manager_arn (str, required) + user_id (str, required) + payment_session_id (str, required) — created by app backend with budget + payment_instrument_id (str, required) + bedrock_model_id (str, optional) — per-invocation model override + +Response: + { + "response": "", + "artifacts": [ # empty list if no artifacts produced + {"name": "chart.png", "url": "https://...", "expires_in": 3600}, + {"name": "report.md", "url": "https://...", "expires_in": 3600} + ] + } +""" + +from __future__ import annotations + +# --------------------------------------------------------------------------- +# MINIMAL MODULE-LEVEL IMPORTS +# +# Only import what's needed for BedrockAgentCoreApp to start and respond to +# the /ping health check within the Runtime's 120s initialization timeout. +# All heavy imports (strands, bedrock_agentcore.payments, boto3 clients, +# catalog loading) are deferred to first request via _ensure_initialized(). +# +# This is critical because `opentelemetry-instrument` (the CMD prefix in the +# Dockerfile) instruments every import at load time. With the full dependency +# tree (strands + bedrock_agentcore + boto3 + botocore), instrumentation +# alone can exceed 120s on cold start. Deferring keeps startup fast while +# preserving full OTel trace propagation for all request-time operations. +# --------------------------------------------------------------------------- +import json +import logging +import os +import threading +import uuid +from datetime import datetime +from typing import Any + +from bedrock_agentcore.runtime import BedrockAgentCoreApp + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# App instance — must be at module level for the @app.entrypoint decorator. +# BedrockAgentCoreApp is lightweight; it just starts a uvicorn server with +# /ping and /invoke endpoints. +# --------------------------------------------------------------------------- +app = BedrockAgentCoreApp() + +# --------------------------------------------------------------------------- +# Environment config (lightweight — just reads env vars) +# --------------------------------------------------------------------------- +REGION = os.environ.get("AWS_REGION", "us-west-2") +MODEL_ID = os.environ.get("BEDROCK_MODEL_ID", "us.anthropic.claude-sonnet-4-6") + +CI_ARTIFACTS_BUCKET = os.environ.get("CI_ARTIFACTS_BUCKET", "") +CI_ARTIFACTS_PREFIX = os.environ.get("CI_ARTIFACTS_PREFIX", "heurist-finance-artifacts").rstrip("/") +CI_ARTIFACTS_TTL = int(os.environ.get("CI_ARTIFACTS_TTL", "3600")) + +_raw_agent_ids = os.environ.get("HEURIST_AGENT_IDS", "") +DEFAULT_HEURIST_AGENT_IDS = ( + "ExaSearchDigestAgent", + "YahooFinanceAgent", + "FredMacroAgent", + "SecEdgarAgent", +) +HEURIST_AGENT_IDS: tuple[str, ...] = ( + tuple(a.strip() for a in _raw_agent_ids.split(",") if a.strip()) if _raw_agent_ids else DEFAULT_HEURIST_AGENT_IDS +) + +# --------------------------------------------------------------------------- +# Lazy-initialized heavy dependencies. +# +# Deferred from module load to first request so the container can respond to +# the Runtime /ping health check within the 120s init timeout. The +# opentelemetry-instrument wrapper adds significant overhead to module +# imports; deferring keeps cold-start under the limit while preserving full +# OTel trace propagation at request time (all boto3 calls, LLM calls, and +# tool calls are still instrumented). +# --------------------------------------------------------------------------- +_init_lock = threading.Lock() +_initialized = False +_CI_CLIENT = None +_S3_CLIENT = None +_catalog_ref = "" +_http_request_tool = None + + +def _ensure_initialized() -> None: + """Lazily import heavy deps and initialize service clients on first request.""" + global _initialized, _CI_CLIENT, _S3_CLIENT, _catalog_ref, _http_request_tool + + if _initialized: + return + + with _init_lock: + if _initialized: + return + + import boto3 + from strands_tools import http_request + from strands_tools.code_interpreter import AgentCoreCodeInterpreter + from catalog import format_catalog_for_prompt, get_tools_for_agents + + _http_request_tool = http_request + _CI_CLIENT = AgentCoreCodeInterpreter(region=REGION, session_name="runtime-init") + _S3_CLIENT = boto3.client("s3", region_name=REGION) + + try: + _heurist_tools = get_tools_for_agents(HEURIST_AGENT_IDS, refresh=False) + _catalog_ref = format_catalog_for_prompt(_heurist_tools) + logger.info("Loaded %d Heurist tools from catalog cache.", len(_heurist_tools)) + except Exception as e: + logger.warning("Could not load Heurist catalog: %s", e) + _catalog_ref = "(catalog unavailable — sync_registry was not run before image build)" + + _initialized = True + logger.info("Agent dependencies initialized successfully.") + + +# --------------------------------------------------------------------------- +# Per-invocation state (thread-local for concurrent request isolation) +# --------------------------------------------------------------------------- +_invocation = threading.local() + + +def _artifacts() -> list[dict]: + if not hasattr(_invocation, "artifacts"): + _invocation.artifacts = [] + return _invocation.artifacts + + +def _session_name() -> str: + if not hasattr(_invocation, "session_name"): + _invocation.session_name = f"heurist-{uuid.uuid4().hex[:12]}" + return _invocation.session_name + + +def _reset_invocation_state() -> None: + _invocation.artifacts = [] + _invocation.session_name = f"heurist-{uuid.uuid4().hex[:12]}" + + +# --------------------------------------------------------------------------- +# CI result extraction helpers +# --------------------------------------------------------------------------- + + +def _extract_ci_text(tool_result: dict) -> str: + """Extract the printed text output from a Code Interpreter tool result.""" + import ast + + content = tool_result.get("content", []) + if not content: + raise ValueError("Code Interpreter returned empty content") + text_blob = content[0].get("text", "") + if not text_blob: + raise ValueError("Code Interpreter returned no text") + try: + parsed = ast.literal_eval(text_blob) + return parsed[0]["text"] + except Exception: + return text_blob + + +# --------------------------------------------------------------------------- +# Artifact tools — defined as module-level functions with @tool decorator. +# They use the lazily-initialized _S3_CLIENT and _CI_CLIENT globals which +# are guaranteed to be set before any tool is called (handle_request calls +# _ensure_initialized() before constructing the Agent). +# --------------------------------------------------------------------------- +import re +from pathlib import Path + + +def _safe_s3_key_name(raw: str) -> str: + """Return a safe S3 key filename component.""" + name = Path(raw).name + name = re.sub(r"[^A-Za-z0-9._-]", "_", name).strip("._") + return name or "artifact" + + +# We need the @tool decorator from strands, but importing strands at module +# level is what causes the slow startup. Solution: define the tool functions +# as plain functions and wrap them with @tool inside _ensure_initialized(). +# However, the Agent() constructor needs the tool references at request time. +# +# Simpler approach: import just the decorator (it's lightweight) and define +# tools normally. The heavy part is strands.Agent and strands.models, not +# the @tool decorator itself. +from strands import tool + + +@tool +def export_artifact_to_s3(remote_path: str, artifact_name: str | None = None) -> dict[str, Any]: + """Export a file from the AgentCore Code Interpreter sandbox to S3. + + Use this after creating a chart (PNG), CSV, or any file in the Code + Interpreter session. Returns a presigned URL the caller can download. + + If S3 is not configured (CI_ARTIFACTS_BUCKET not set), returns an error + with a suggestion to represent the data as a markdown table instead. + + Args: + remote_path: Path to the file inside the CI sandbox (e.g. "/tmp/chart.png") + artifact_name: Optional override for the output filename + """ + import base64 + + if not CI_ARTIFACTS_BUCKET: + return { + "error": "S3 artifact storage is not configured (CI_ARTIFACTS_BUCKET not set).", + "suggestion": ( + "Represent charts as markdown tables using the underlying data. " + "Use save_report_to_s3 for text/CSV content, which returns it inline." + ), + } + + sn = _session_name() + export_code = f""" +import base64, json, mimetypes +from pathlib import Path +p = Path({remote_path!r}) +if not p.exists(): + raise FileNotFoundError(f"File not found in CI sandbox: {{str(p)}}") +print(json.dumps({{ + "name": p.name, + "mime_type": mimetypes.guess_type(str(p))[0] or "application/octet-stream", + "b64": base64.b64encode(p.read_bytes()).decode(), + "size": p.stat().st_size, +}})) +""" + ci_result = _CI_CLIENT.code_interpreter( + { + "action": { + "type": "executeCode", + "session_name": sn, + "language": "python", + "code": export_code, + } + } + ) + + try: + payload = json.loads(_extract_ci_text(ci_result)) + except Exception as exc: + return {"error": f"Could not parse CI export output: {exc}"} + + if "b64" not in payload: + return {"error": f"Unexpected CI payload — missing b64 field: {payload}"} + + file_bytes = base64.b64decode(payload["b64"]) + safe_name = _safe_s3_key_name(artifact_name or payload.get("name", "artifact")) + s3_key = f"{CI_ARTIFACTS_PREFIX}/{sn}/{safe_name}" + + _S3_CLIENT.put_object( + Bucket=CI_ARTIFACTS_BUCKET, + Key=s3_key, + Body=file_bytes, + ContentType=payload.get("mime_type", "application/octet-stream"), + ) + + url = _S3_CLIENT.generate_presigned_url( + "get_object", + Params={"Bucket": CI_ARTIFACTS_BUCKET, "Key": s3_key}, + ExpiresIn=CI_ARTIFACTS_TTL, + ) + + artifact = { + "name": safe_name, + "url": url, + "s3_key": s3_key, + "size_bytes": len(file_bytes), + "mime_type": payload.get("mime_type", "application/octet-stream"), + "expires_in": CI_ARTIFACTS_TTL, + } + _artifacts().append(artifact) + + logger.info("Exported artifact %s → s3://%s/%s", safe_name, CI_ARTIFACTS_BUCKET, s3_key) + return { + "status": "success", + "name": safe_name, + "url": url, + "expires_in": CI_ARTIFACTS_TTL, + } + + +@tool +def save_report_to_s3(content: str, filename: str) -> dict[str, Any]: + """Save a text report (markdown, CSV, JSON) to S3 and return a presigned URL. + + Use this for structured text output — financial summaries, data tables, + model outputs. For binary files produced in the Code Interpreter sandbox, + use export_artifact_to_s3 instead. + + If S3 is not configured, the content is returned inline. + + Args: + content: The text content to save + filename: Desired filename (e.g. "macro_summary.md", "prices.csv") + """ + if not CI_ARTIFACTS_BUCKET: + return { + "status": "inline", + "note": "S3 not configured — content returned inline.", + "filename": filename, + "content": content, + } + + safe_name = _safe_s3_key_name(filename) + s3_key = f"{CI_ARTIFACTS_PREFIX}/{_session_name()}/{safe_name}" + + content_type = "text/plain" + if safe_name.endswith(".md"): + content_type = "text/markdown" + elif safe_name.endswith(".csv"): + content_type = "text/csv" + elif safe_name.endswith(".json"): + content_type = "application/json" + elif safe_name.endswith(".html"): + content_type = "text/html" + + encoded = content.encode("utf-8") + _S3_CLIENT.put_object( + Bucket=CI_ARTIFACTS_BUCKET, + Key=s3_key, + Body=encoded, + ContentType=content_type, + ) + + url = _S3_CLIENT.generate_presigned_url( + "get_object", + Params={"Bucket": CI_ARTIFACTS_BUCKET, "Key": s3_key}, + ExpiresIn=CI_ARTIFACTS_TTL, + ) + + artifact = { + "name": safe_name, + "url": url, + "s3_key": s3_key, + "size_bytes": len(encoded), + "mime_type": content_type, + "expires_in": CI_ARTIFACTS_TTL, + } + _artifacts().append(artifact) + + logger.info("Saved report %s → s3://%s/%s", safe_name, CI_ARTIFACTS_BUCKET, s3_key) + return { + "status": "success", + "name": safe_name, + "url": url, + "expires_in": CI_ARTIFACTS_TTL, + } + + +@tool +def list_invocation_artifacts() -> dict[str, Any]: + """List all artifacts exported to S3 during this invocation. + + Call this to verify what has been exported before composing the final response. + """ + arts = _artifacts() + return { + "count": len(arts), + "artifacts": [{"name": a["name"], "url": a["url"], "expires_in": a["expires_in"]} for a in arts], + } + + +# --------------------------------------------------------------------------- +# System prompt builder (invocation-specific: includes CI session name) +# --------------------------------------------------------------------------- + + +def _build_system_prompt(ci_session: str) -> str: + s3_instructions = ( + ( + f"- Charts/images: save to `/tmp/` inside the CI session, then call " + f"`export_artifact_to_s3` with that path to upload to S3 and get a download URL.\n" + f"- Text reports/CSVs: call `save_report_to_s3` directly — no need to write to CI first.\n" + f"- Presigned URLs are valid for {CI_ARTIFACTS_TTL} seconds.\n" + f"- After exporting, include the URL in your response so the caller can access the file." + ) + if CI_ARTIFACTS_BUCKET + else ( + "- S3 artifact storage is not configured in this deployment.\n" + "- Represent all chart data as markdown tables using the underlying numbers.\n" + "- Use `save_report_to_s3` for text content — it will return the content inline." + ) + ) + + return f"""You are a finance research and data visualization agent. + +You have access to paid financial data endpoints via the Heurist network. Use the +`http_request` tool to call the endpoint URLs listed below. All endpoints accept POST +requests with JSON bodies. + +**Payment is handled automatically.** When an endpoint returns HTTP 402, the system +settles USDC on-chain and retries the request. You do not need to handle payments. + +{_catalog_ref} + +## Working Rules + +- Use http_request for all Heurist endpoint calls. Always method="POST", params as JSON body. +- Parallelize independent data fetches — issue multiple http_request calls in the same tool-use round when they don't depend on each other's results. Payment is handled per-call. +- Use AgentCore Code Interpreter for pandas/matplotlib analysis. +- Never fabricate data. Only use values returned by tools. +- If a tool call fails, report the error and stop. + +## Code Interpreter — session: `{ci_session}` + +**Session lifecycle** +- Start with `initSession` if the session is not initialized. +- Use `writeFiles` to pass datasets into the sandbox as JSON/CSV files. +- Use `executeCode` for analysis and charting. +- The session is private to this invocation and auto-expires. + +**Artifact export** +{s3_instructions} + +**CI action examples:** +- Init: `{{"action": {{"type": "initSession", "session_name": "{ci_session}", "description": "analysis"}}}}` +- Write: `{{"action": {{"type": "writeFiles", "session_name": "{ci_session}", "content": [{{"path": "data.json", "text": "{{...}}"}}]}}}}` +- Execute: `{{"action": {{"type": "executeCode", "session_name": "{ci_session}", "language": "python", "code": "import pandas as pd; ..." }}}}` + +## Context +- Today: {datetime.now().strftime("%Y-%m-%d")} +- Region: {REGION} +- S3 artifacts: {"enabled (bucket: " + CI_ARTIFACTS_BUCKET + ")" if CI_ARTIFACTS_BUCKET else "not configured — text/table output only"} +""" + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +@app.entrypoint +def handle_request(payload: dict, context=None) -> dict: + """Handle an invocation from the app backend. + + The app backend creates a payment session with an appropriate budget before + invoking. Session ID and instrument ID are passed in the payload — the agent + cannot create or modify sessions (enforced at the IAM level). + + Required payload fields: + prompt (str) — the research request + payment_manager_arn (str) — ARN of the Payment Manager + user_id (str) — user identity for payment isolation + payment_session_id (str) — active session with a spending limit + payment_instrument_id (str) — funded embedded wallet + + Optional payload fields: + bedrock_model_id (str) — per-invocation model override + """ + # Lazy-init heavy deps on first request (keeps cold-start under 120s) + _ensure_initialized() + _reset_invocation_state() + ci_session = _session_name() + + # Import heavy deps (already cached after _ensure_initialized) + import boto3 + from bedrock_agentcore.payments.integrations.strands import ( + AgentCorePaymentsPlugin, + AgentCorePaymentsPluginConfig, + ) + from botocore.config import Config as BotoConfig + from strands import Agent + from strands.models import BedrockModel + + # Unwrap the agentcore invoke double-wrapping: + # `agentcore invoke '{"key": "val"}'` → payload = {"prompt": '{"key":"val"}'} + raw_prompt = payload.get("prompt", "") + if isinstance(raw_prompt, str) and raw_prompt.strip().startswith("{"): + try: + inner = json.loads(raw_prompt) + if isinstance(inner, dict) and "payment_manager_arn" in inner: + payload = inner + except json.JSONDecodeError: + pass + + prompt = payload.get("prompt", "").strip() + payment_manager_arn = payload.get("payment_manager_arn", "").strip() + user_id = payload.get("user_id", "").strip() + session_id = payload.get("payment_session_id", "").strip() + instrument_id = payload.get("payment_instrument_id", "").strip() + + missing = [ + name + for name, val in [ + ("prompt", prompt), + ("payment_manager_arn", payment_manager_arn), + ("user_id", user_id), + ("payment_session_id", session_id), + ("payment_instrument_id", instrument_id), + ] + if not val + ] + if missing: + return {"error": f"Missing required payload fields: {', '.join(missing)}"} + + model_id = payload.get("bedrock_model_id", MODEL_ID) + + payment_plugin = AgentCorePaymentsPlugin( + config=AgentCorePaymentsPluginConfig( + payment_manager_arn=payment_manager_arn, + user_id=user_id, + payment_instrument_id=instrument_id, + payment_session_id=session_id, + region=REGION, + agent_name=os.environ.get("AGENT_NAME", "HeuristFinanceAgent"), + ) + ) + + # Claude Sonnet 4.6 supports up to 64k output tokens. Multi-step workflows + # (5+ paid tool calls + Code Interpreter + chart export + markdown + # report) routinely need more than the SDK's default 4k cap, which + # otherwise raises Strands' MaxTokensReachedException mid-run. + # The custom client config keeps long single-turn streamed responses + # from tripping the default 60s bedrock-runtime read timeout. + model = BedrockModel( + boto_session=boto3.Session(region_name=REGION), + boto_client_config=BotoConfig( + read_timeout=int(os.environ.get("AGENT_BEDROCK_READ_TIMEOUT", "1500")), + connect_timeout=15, + retries={"max_attempts": 1}, + ), + model_id=model_id, + streaming=True, + temperature=0, + max_tokens=int(os.environ.get("AGENT_MAX_TOKENS", "32000")), + ) + + agent = Agent( + system_prompt=_build_system_prompt(ci_session), + model=model, + tools=[ + _http_request_tool, + _CI_CLIENT.code_interpreter, + export_artifact_to_s3, + save_report_to_s3, + list_invocation_artifacts, + ], + plugins=[payment_plugin], + ) + + result = agent(prompt) + + content = result.message.get("content", []) + text = next( + (block.get("text", "") for block in content if isinstance(block, dict) and "text" in block), + str(result), + ) + + return { + "response": text, + "artifacts": [{"name": a["name"], "url": a["url"], "expires_in": a["expires_in"]} for a in _artifacts()], + } + + +if __name__ == "__main__": + app.run() diff --git a/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/agent/requirements.txt b/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/agent/requirements.txt new file mode 100644 index 00000000..9c5c9d2a --- /dev/null +++ b/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/agent/requirements.txt @@ -0,0 +1,8 @@ +bedrock-agentcore[strands-agents]>=1.9.0 +boto3>=1.43.6 +botocore>=1.43.6 +strands-agents[otel]>=1.0.0 +strands-agents-tools>=0.5.0 +aws-opentelemetry-distro +python-dotenv>=1.0.0 +requests>=2.32.0 diff --git a/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/agent/sync_registry.py b/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/agent/sync_registry.py new file mode 100644 index 00000000..e594adbb --- /dev/null +++ b/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/agent/sync_registry.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Fetch the live Heurist catalog and refresh the local cache. + +Run this on the host machine (NOT inside the container) before each +`agentcore deploy` so the catalog cache bundled into the image is fresh. + +Usage (from pay-for-data/): + python agent/sync_registry.py +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# Make sibling modules importable when running as a script +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from catalog import fetch_live_catalog, get_tools_for_agents # noqa: E402 +from config import LIVE_CATALOG_CACHE_PATH, get_config # noqa: E402 + + +def main() -> None: + cfg = get_config() + catalog = fetch_live_catalog() + selected_tools = get_tools_for_agents(cfg.heurist_tool_agent_ids, refresh=False) + print(f"Saved live catalog cache to {LIVE_CATALOG_CACHE_PATH}") + print(f"Catalog url: {cfg.heurist_catalog_url}") + print(f"Catalog agents: {catalog.get('count', '?')}") + print(f"Selected agents: {', '.join(cfg.heurist_tool_agent_ids)}") + print(f"Selected tools: {len(selected_tools)}") + + +if __name__ == "__main__": + main() diff --git a/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/images/obs-dashboard.png b/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/images/obs-dashboard.png new file mode 100644 index 00000000..08e8e1c3 Binary files /dev/null and b/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/images/obs-dashboard.png differ diff --git a/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/pay-for-data.ipynb b/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/pay-for-data.ipynb new file mode 100644 index 00000000..4b8012e6 --- /dev/null +++ b/06-workshops/13-AgentCore-payments/02-use-cases/pay-for-data/pay-for-data.ipynb @@ -0,0 +1,1271 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Pay for Data \u2014 Heurist Finance Agent\n", + "\n", + "## Overview\n", + "\n", + "A finance research agent that pays for real-time market data using **Amazon Bedrock AgentCore payments**. The agent calls paid [Heurist](https://heurist.xyz) endpoints for live prices, SEC filings, and macro indicators, analyzes the data with AgentCore Code Interpreter, and returns charts and reports as S3 presigned URLs \u2014 all without any manual payment code in the tools.\n", + "\n", + "The agent is deployed to **AgentCore Runtime**: a managed container endpoint with HTTPS invocation, SigV4 auth, and automatic observability via CloudWatch.\n", + "\n", + "### Use Case Details\n", + "\n", + "| Information | Details |\n", + "|:---|:---|\n", + "| Use case type | Agentic data retrieval with autonomous micropayments |\n", + "| Agent type | Single |\n", + "| Payment protocol | x402 (HTTP 402 Payment Required) |\n", + "| Agentic framework | [Strands Agents](https://strandsagents.com/) |\n", + "| LLM model | Claude Sonnet 4.6 on Amazon Bedrock (configurable) |\n", + "| SDK used | `bedrock-agentcore[strands-agents]` (public PyPI) |\n", + "| Wallet type | Embedded crypto wallet (Coinbase CDP) |\n", + "| Payment network | Base Sepolia testnet (USDC) \u2014 swap to mainnet via `NETWORK` in `.env` |\n", + "\n", + "### Architecture\n", + "\n", + "```\n", + "Notebook (ManagementRole) AgentCore Runtime (ProcessPaymentRole)\n", + " | +------------------------------+\n", + " | create_payment_session(budget=$X) | runtime_agent.py |\n", + " | | BedrockAgentCoreApp |\n", + " |-- invoke_agent_runtime( --> | + AgentCorePaymentsPlugin |\n", + " | manager_arn, session_id, | |\n", + " | instrument_id, prompt) | http_request -> 402 |\n", + " | | -> ProcessPayment -> retry |\n", + " |<-- {response, artifacts: [{url}]} --- | -> Code Interpreter |\n", + " | | -> export to S3 |\n", + " | get_payment_session(check spend) +------------------------------+\n", + " |\n", + " v\n", + " CloudWatch GenAI Observability\n", + " (automatic via OpenTelemetry)\n", + "```\n", + "\n", + "### AgentCore Capabilities Demonstrated\n", + "\n", + "| Capability | How it is used here |\n", + "|:---|:---|\n", + "| **Payment manager** | Central resource that authorizes and tracks all payment activity. |\n", + "| **Payment instrument** | An embedded crypto wallet (Coinbase CDP, USDC on Base). |\n", + "| **Payment session** | A time-bounded, budget-capped authorization (`maxSpendAmount`). |\n", + "| **Payment processing** | End-to-end x402 negotiation, proof generation, retry, and on-chain settlement. |\n", + "| **AgentCore Runtime** | Managed container hosting with HTTPS endpoint and SigV4 auth. |\n", + "| **AgentCore Code Interpreter** | Remote sandboxed Python environment for pandas/matplotlib analysis. |\n", + "| **Observability** | Automatic OTel traces + logs in CloudWatch GenAI dashboard. |\n", + "\n", + "### Notebook Flow\n", + "\n", + "| Step | What happens |\n", + "|------|-------------|\n", + "| 1 | Configure credentials and confirm AWS identity |\n", + "| 2 | Sync the Heurist tool catalog |\n", + "| 3 | Create the S3 artifacts bucket |\n", + "| 4 | Provision embedded wallet resources (CredentialProvider \u2192 Manager \u2192 Connector \u2192 Instrument) |\n", + "| 5 | Fund the wallet and complete WalletHub delegation |\n", + "| 6 | Enable Payment Manager observability (vended log delivery) |\n", + "| 7 | Deploy to AgentCore Runtime |\n", + "| 8 | Grant execution role permissions |\n", + "| 9 | Invoke the deployed agent |\n", + "| 10 | View observability in CloudWatch |\n", + "| 11 | Cleanup |\n", + "\n", + "**Before running:**\n", + "1. `pip install -r requirements.txt`\n", + "2. `cp .env.example .env` and fill in your Coinbase CDP credentials and IAM role ARNs\n", + "3. Ensure Node.js 20+, Docker, and AWS CDK are installed\n", + "4. Enable **Delegated Signing** in your CDP project: [portal.cdp.coinbase.com](https://portal.cdp.coinbase.com) \u2192 your project \u2192 **Wallet** \u2192 **Embedded Wallets** \u2192 **Policies** \u2192 enable **Delegated signing**.\n", + "\n", + "See [`README.md`](README.md) for full setup details." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Install dependencies" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%pip install -r requirements.txt --quiet" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 1 \u2014 Configure credentials\n", + "\n", + "Load credentials from `.env` and confirm AWS identity. Copy `.env.example` to `.env` and fill in your values.\n", + "\n", + "This step only validates region, account, and role ARNs \u2014 payment resource IDs will be populated in Step 4." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import json\n", + "import time\n", + "import uuid\n", + "from datetime import datetime\n", + "from dotenv import load_dotenv\n", + "import boto3\n", + "from boto3.session import Session\n", + "\n", + "load_dotenv(override=True)\n", + "\n", + "REGION = os.environ.get(\"AWS_REGION\", \"us-west-2\")\n", + "\n", + "# \u2500\u2500 Endpoints \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n", + "CP_ENDPOINT = os.environ.get(\n", + " \"CP_ENDPOINT\", f\"https://bedrock-agentcore-control.{REGION}.amazonaws.com\"\n", + ")\n", + "DP_ENDPOINT = os.environ.get(\n", + " \"DP_ENDPOINT\", f\"https://bedrock-agentcore.{REGION}.amazonaws.com\"\n", + ")\n", + "\n", + "# \u2500\u2500 Coinbase CDP credentials (required for embedded wallet) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n", + "CDP_API_KEY_NAME = os.environ[\"CDP_API_KEY_NAME\"]\n", + "CDP_API_KEY_PRIVATE_KEY = os.environ[\"CDP_API_KEY_PRIVATE_KEY\"]\n", + "CDP_WALLET_SECRET = os.environ[\"CDP_WALLET_SECRET\"]\n", + "WALLET_EMAIL = os.environ.get(\"WALLET_EMAIL\", \"\")\n", + "\n", + "# \u2500\u2500 IAM roles \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n", + "MANAGEMENT_ROLE_ARN = os.environ[\"MANAGEMENT_ROLE_ARN\"]\n", + "PROCESS_PAYMENT_ROLE_ARN = os.environ[\"PROCESS_PAYMENT_ROLE_ARN\"]\n", + "CONTROL_PLANE_ROLE_ARN = os.environ[\"CONTROL_PLANE_ROLE_ARN\"]\n", + "RESOURCE_RETRIEVAL_ROLE_ARN = os.environ[\"RESOURCE_RETRIEVAL_ROLE_ARN\"]\n", + "\n", + "# \u2500\u2500 Previously provisioned resource IDs (loaded from .env on re-runs) \u2500\u2500\u2500\u2500\u2500\u2500\n", + "MANAGER_ARN = os.environ.get(\"MANAGER_ARN\", \"\")\n", + "PAYMENT_CONNECTOR_ID = os.environ.get(\"PAYMENT_CONNECTOR_ID\", \"\")\n", + "PAYMENT_INSTRUMENT_ID = os.environ.get(\"PAYMENT_INSTRUMENT_ID\", \"\")\n", + "\n", + "# \u2500\u2500 Session config \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n", + "USER_ID = os.environ.get(\"USER_ID\", \"heurist-demo-user\")\n", + "SESSION_MAX_SPEND = os.environ.get(\"SESSION_MAX_SPEND\", \"0.25\")\n", + "SESSION_EXPIRY_MINUTES = int(os.environ.get(\"SESSION_EXPIRY_MINUTES\", \"60\"))\n", + "\n", + "# \u2500\u2500 Network / blockchain \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n", + "# base-mainnet: eip155:8453 (default \u2014 mainnet, real USDC settled on-chain)\n", + "# base-sepolia: eip155:84532 (testnet \u2014 free faucet, but Heurist Sepolia x402 currently fails EIP-712 simulation)\n", + "NETWORK_ALIAS = os.environ.get(\"NETWORK\", \"base-mainnet\")\n", + "NETWORK_MAP = {\n", + " \"base-sepolia\": {\n", + " \"caip2\": \"eip155:84532\",\n", + " \"botocore_net\": \"ETHEREUM\",\n", + " \"usdc_address\": \"0x036CbD53842c5426634e7929541eC2318f3dCF7e\",\n", + " \"chain_enum\": \"BASE_SEPOLIA\",\n", + " },\n", + " \"base-mainnet\": {\n", + " \"caip2\": \"eip155:8453\",\n", + " \"botocore_net\": \"ETHEREUM\",\n", + " \"usdc_address\": \"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\",\n", + " \"chain_enum\": \"BASE_MAINNET\",\n", + " },\n", + "}\n", + "if NETWORK_ALIAS not in NETWORK_MAP:\n", + " raise ValueError(f\"Unknown NETWORK '{NETWORK_ALIAS}'. Valid: {list(NETWORK_MAP)}\")\n", + "ACTIVE_NETWORK = NETWORK_MAP[NETWORK_ALIAS]\n", + "\n", + "# \u2500\u2500 Bedrock model \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n", + "BEDROCK_MODEL_ID = os.environ.get(\n", + " \"BEDROCK_MODEL_ID\", \"us.anthropic.claude-sonnet-4-6\"\n", + ")\n", + "\n", + "# \u2500\u2500 AWS clients \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n", + "base_session = Session(region_name=REGION)\n", + "sts = base_session.client(\"sts\")\n", + "ACCOUNT_ID = sts.get_caller_identity()[\"Account\"]\n", + "\n", + "\n", + "def assume_role(role_arn: str, session_name: str) -> Session:\n", + " creds = sts.assume_role(RoleArn=role_arn, RoleSessionName=session_name)[\"Credentials\"]\n", + " sess = Session(\n", + " aws_access_key_id=creds[\"AccessKeyId\"],\n", + " aws_secret_access_key=creds[\"SecretAccessKey\"],\n", + " aws_session_token=creds[\"SessionToken\"],\n", + " region_name=REGION,\n", + " )\n", + " assumed_arn = sess.client(\"sts\").get_caller_identity()[\"Arn\"]\n", + " print(f\" \u2192 {assumed_arn}\")\n", + " return sess\n", + "\n", + "\n", + "# Control-plane client: CreatePaymentCredentialProvider / Manager / Connector\n", + "print(\"Assuming ControlPlaneRole...\")\n", + "cp_session = assume_role(CONTROL_PLANE_ROLE_ARN, f\"cp-setup-{int(datetime.now().timestamp())}\")\n", + "cp_client = cp_session.client(\"bedrock-agentcore-control\", endpoint_url=CP_ENDPOINT)\n", + "print(\"\u2705 CP client ready\")\n", + "\n", + "# Management client: CreatePaymentSession / GetPaymentSession / InvokeAgentRuntime\n", + "print(\"Assuming ManagementRole...\")\n", + "mgmt_session = assume_role(MANAGEMENT_ROLE_ARN, f\"heurist-mgmt-{int(datetime.now().timestamp())}\")\n", + "mgmt_client = mgmt_session.client(\"bedrock-agentcore\", endpoint_url=DP_ENDPOINT)\n", + "print(\"\u2705 Management client ready\")\n", + "\n", + "print(f\"\\nRegion: {REGION}\")\n", + "print(f\"Account: {ACCOUNT_ID}\")\n", + "print(f\"Network: {NETWORK_ALIAS} ({ACTIVE_NETWORK['caip2']})\")\n", + "print(f\"Model: {BEDROCK_MODEL_ID}\")\n", + "if MANAGER_ARN:\n", + " print(f\"Manager ARN loaded from .env \u2014 Step 4 will be skipped: {MANAGER_ARN}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 2 \u2014 Sync the Heurist tool catalog\n", + "\n", + "Fetches the current registry of x402-enabled endpoints from the Heurist mesh and caches it locally. The Runtime container image bundles this cache at build time \u2014 the deployed agent reads the catalog without calling the Heurist registry at startup.\n", + "\n", + "Re-run this cell before deploying to ensure the container has a current catalog." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "sys.path.insert(0, \"agent\")\n", + "from catalog import fetch_live_catalog, get_tools_for_agents\n", + "from config import DEFAULT_HEURIST_AGENT_IDS\n", + "\n", + "HEURIST_AGENT_IDS = tuple(\n", + " a.strip() for a in os.environ.get(\"HEURIST_AGENT_IDS\", \"\").split(\",\")\n", + " if a.strip()\n", + ") or DEFAULT_HEURIST_AGENT_IDS\n", + "\n", + "catalog = fetch_live_catalog()\n", + "selected = get_tools_for_agents(HEURIST_AGENT_IDS)\n", + "\n", + "print(f\"Agents in registry: {catalog['count']}\")\n", + "print(f\"Selected agents: {', '.join(HEURIST_AGENT_IDS)}\")\n", + "print(f\"Loaded paid tools: {len(selected)}\")\n", + "print()\n", + "for t in selected:\n", + " print(f\" {t['agent_id']:30s} {t['tool_name']:35s} ${t['price_usd']:.3f}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 3 \u2014 Create the S3 artifacts bucket\n", + "\n", + "Charts, reports, and CSVs produced by the agent in Code Interpreter are uploaded to S3. The agent returns presigned download URLs valid for `CI_ARTIFACTS_TTL` seconds (default: 1 hour).\n", + "\n", + "The bucket is private; no public access is granted. Skip this cell if you already have a bucket \u2014 just set `ARTIFACTS_BUCKET` to its name." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ARTIFACTS_BUCKET = os.environ.get(\n", + " \"ARTIFACTS_BUCKET\",\n", + " f\"heurist-finance-artifacts-{ACCOUNT_ID}-{REGION}\",\n", + ")\n", + "\n", + "s3 = boto3.client(\"s3\", region_name=REGION)\n", + "try:\n", + " if REGION == \"us-east-1\":\n", + " s3.create_bucket(Bucket=ARTIFACTS_BUCKET)\n", + " else:\n", + " s3.create_bucket(\n", + " Bucket=ARTIFACTS_BUCKET,\n", + " CreateBucketConfiguration={\"LocationConstraint\": REGION},\n", + " )\n", + " s3.put_public_access_block(\n", + " Bucket=ARTIFACTS_BUCKET,\n", + " PublicAccessBlockConfiguration={\n", + " \"BlockPublicAcls\": True,\n", + " \"IgnorePublicAcls\": True,\n", + " \"BlockPublicPolicy\": True,\n", + " \"RestrictPublicBuckets\": True,\n", + " },\n", + " )\n", + " print(f\"Created bucket: {ARTIFACTS_BUCKET}\")\n", + "except s3.exceptions.BucketAlreadyOwnedByYou:\n", + " print(f\"Bucket already exists: {ARTIFACTS_BUCKET}\")\n", + "\n", + "print(f\"Artifacts will be stored at: s3://{ARTIFACTS_BUCKET}/heurist-finance-artifacts/\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 4 \u2014 Provision embedded wallet resources\n", + "\n", + "These cells run **once per user** to create the AgentCore payments resource stack:\n", + "**CredentialProvider \u2192 PaymentManager \u2192 PaymentConnector \u2192 EmbeddedCryptoWallet Instrument**\n", + "\n", + "If you already have `MANAGER_ARN`, `PAYMENT_CONNECTOR_ID`, and `PAYMENT_INSTRUMENT_ID` from a previous run, set them in `.env` and skip to Step 5.\n", + "\n", + "> **Prerequisite:** Enable **Delegated Signing** in your CDP project before running these cells.\n", + "> Go to [portal.cdp.coinbase.com](https://portal.cdp.coinbase.com) \u2192 your project \u2192 **Wallet** \u2192 **Embedded Wallets** \u2192 **Policies** \u2192 enable **Delegated signing**.\n", + "> Without this, `ProcessPayment` will fail with a delegated signing error." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# \u2500\u2500 4a. Credential Provider \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n", + "# Stores your Coinbase CDP API keys securely in AgentCore.\n", + "# For StripePrivy: set credentialProviderVendor=\"StripePrivy\" and\n", + "# replace coinbaseCdpConfiguration with stripePlatformConfiguration.\n", + "if MANAGER_ARN:\n", + " print(f\"MANAGER_ARN already set \u2014 skipping credential provider creation.\")\n", + " print(f\" Manager ARN: {MANAGER_ARN}\")\n", + " CREDENTIAL_PROVIDER_ARN = \"(loaded from .env \u2014 not re-created)\"\n", + "else:\n", + " cred_resp = cp_client.create_payment_credential_provider(\n", + " name=f\"HeuristCdp{int(time.time())}\",\n", + " credentialProviderVendor=\"CoinbaseCDP\",\n", + " providerConfigurationInput={\n", + " \"coinbaseCdpConfiguration\": {\n", + " \"apiKeyId\": CDP_API_KEY_NAME,\n", + " \"apiKeySecret\": CDP_API_KEY_PRIVATE_KEY,\n", + " \"walletSecret\": CDP_WALLET_SECRET,\n", + " }\n", + " },\n", + " )\n", + " CREDENTIAL_PROVIDER_ARN = cred_resp[\"credentialProviderArn\"]\n", + " print(f\"\u2705 Credential Provider: {CREDENTIAL_PROVIDER_ARN}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# \u2500\u2500 4b. Payment Manager \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n", + "if MANAGER_ARN:\n", + " print(f\"Reusing Manager ARN from .env: {MANAGER_ARN}\")\n", + " MANAGER_ID = MANAGER_ARN.split(\"/\")[-1]\n", + "else:\n", + " mgr_resp = cp_client.create_payment_manager(\n", + " name=f\"HeuristPayMgr{int(time.time())}\",\n", + " description=\"AgentCore payments - Heurist Finance Agent\",\n", + " authorizerType=\"AWS_IAM\",\n", + " roleArn=RESOURCE_RETRIEVAL_ROLE_ARN,\n", + " clientToken=str(uuid.uuid4()),\n", + " )\n", + " MANAGER_ARN = mgr_resp[\"paymentManagerArn\"]\n", + " MANAGER_ID = mgr_resp[\"paymentManagerId\"]\n", + " print(f\"\u2705 Payment Manager ARN: {MANAGER_ARN}\")\n", + " print(f\" Manager ID: {MANAGER_ID}\")\n", + " print(\"\\n\ud83d\udccb Save to .env:\")\n", + " print(f\" MANAGER_ARN={MANAGER_ARN}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# \u2500\u2500 4c. Payment Connector \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n", + "if PAYMENT_CONNECTOR_ID:\n", + " print(f\"Reusing Payment Connector from .env: {PAYMENT_CONNECTOR_ID}\")\n", + "else:\n", + " conn_resp = cp_client.create_payment_connector(\n", + " paymentManagerId=MANAGER_ID,\n", + " name=f\"HeuristCoinbaseConn{int(time.time())}\",\n", + " description=\"Coinbase CDP connector for Heurist Finance Agent\",\n", + " type=\"CoinbaseCDP\",\n", + " credentialProviderConfigurations=[{\n", + " \"coinbaseCDP\": {\"credentialProviderArn\": CREDENTIAL_PROVIDER_ARN}\n", + " }],\n", + " clientToken=str(uuid.uuid4()),\n", + " )\n", + " PAYMENT_CONNECTOR_ID = conn_resp[\"paymentConnectorId\"]\n", + " print(f\"\u2705 Payment Connector ID: {PAYMENT_CONNECTOR_ID}\")\n", + " print(\"\\n\ud83d\udccb Save to .env:\")\n", + " print(f\" PAYMENT_CONNECTOR_ID={PAYMENT_CONNECTOR_ID}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# \u2500\u2500 4d. Embedded Crypto Wallet Instrument \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n", + "# AgentCore provisions the on-chain wallet \u2014 no pre-existing CDP wallet needed.\n", + "# WALLET_EMAIL ties the wallet to a user identity via linkedAccounts.\n", + "if PAYMENT_INSTRUMENT_ID:\n", + " print(f\"Reusing Payment Instrument from .env: {PAYMENT_INSTRUMENT_ID}\")\n", + " WALLET_HUB_URL = \"\"\n", + "else:\n", + " linked_accounts = []\n", + " if WALLET_EMAIL:\n", + " linked_accounts = [{\"email\": {\"emailAddress\": WALLET_EMAIL}}]\n", + "\n", + " inst_resp = mgmt_client.create_payment_instrument(\n", + " paymentManagerArn=MANAGER_ARN,\n", + " paymentConnectorId=PAYMENT_CONNECTOR_ID,\n", + " userId=USER_ID,\n", + " paymentInstrumentType=\"EMBEDDED_CRYPTO_WALLET\",\n", + " paymentInstrumentDetails={\n", + " \"embeddedCryptoWallet\": {\n", + " \"network\": ACTIVE_NETWORK[\"botocore_net\"],\n", + " \"linkedAccounts\": linked_accounts,\n", + " }\n", + " },\n", + " clientToken=str(uuid.uuid4()),\n", + " )\n", + " instrument = inst_resp[\"paymentInstrument\"]\n", + " PAYMENT_INSTRUMENT_ID = instrument[\"paymentInstrumentId\"]\n", + " wallet_details = (\n", + " instrument.get(\"paymentInstrumentDetails\", {})\n", + " .get(\"embeddedCryptoWallet\", {})\n", + " )\n", + " WALLET_ADDRESS = wallet_details.get(\"walletAddress\", \"\")\n", + " WALLET_HUB_URL = wallet_details.get(\"redirectUrl\", \"\")\n", + "\n", + " print(f\"\u2705 Payment Instrument ID: {PAYMENT_INSTRUMENT_ID}\")\n", + " print(f\" Wallet Address: {WALLET_ADDRESS}\")\n", + " print(f\" Network: {ACTIVE_NETWORK['caip2']}\")\n", + " if WALLET_HUB_URL:\n", + " print(f\" WalletHub URL: {WALLET_HUB_URL}\")\n", + " print(\"\\n\ud83d\udccb Save to .env:\")\n", + " print(f\" PAYMENT_INSTRUMENT_ID={PAYMENT_INSTRUMENT_ID}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 5 \u2014 Fund the wallet and grant signing delegation\n", + "\n", + "AgentCore has provisioned the embedded wallet. Before the agent can make payments, complete **two** setup actions:\n", + "\n", + "### 5a \u2014 Grant signing permission (WalletHub)\n", + "\n", + "1. Open the **WalletHub URL** printed above in your browser.\n", + "2. Log in with the `WALLET_EMAIL` you configured.\n", + "3. Review the wallet and click **Grant signing permission**.\n", + "\n", + "The agent cannot sign transactions until this step is complete. This is what enables [Coinbase CDP Delegated Signing](https://portal.cdp.coinbase.com) \u2014 AgentCore calls `ProcessPayment` which generates a transaction proof on your behalf without ever holding your private key.\n", + "\n", + "> **If no WalletHub URL was returned and the instrument status is `ACTIVE`**, the wallet is already authorized. Proceed directly to funding.\n", + "\n", + "### 5b \u2014 Fund the wallet\n", + "\n", + "Send testnet USDC to the wallet address:\n", + "- **Base Sepolia:** go to https://faucet.circle.com \u2192 select *Base Sepolia* \u2192 paste the wallet address\n", + "\n", + "For mainnet (`NETWORK=base-mainnet`), fund via an onramp or send USDC from another wallet.\n", + "\n", + "### 5c \u2014 Verify the balance\n", + "\n", + "After funding (faucet transactions take ~30 seconds), run the cell below to confirm the balance. Re-run if it shows 0 \u2014 the transaction may still be pending." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Briefly assume ProcessPaymentRole to call GetPaymentInstrumentBalance.\n", + "# The deployed Runtime container uses this same role automatically.\n", + "print(\"Assuming ProcessPaymentRole for balance check...\")\n", + "pp_session = assume_role(\n", + " PROCESS_PAYMENT_ROLE_ARN,\n", + " f\"heurist-balance-check-{int(datetime.now().timestamp())}\",\n", + ")\n", + "pp_client = pp_session.client(\"bedrock-agentcore\", endpoint_url=DP_ENDPOINT)\n", + "\n", + "try:\n", + " balance_resp = pp_client.get_payment_instrument_balance(\n", + " paymentManagerArn=MANAGER_ARN,\n", + " paymentConnectorId=PAYMENT_CONNECTOR_ID,\n", + " paymentInstrumentId=PAYMENT_INSTRUMENT_ID,\n", + " userId=USER_ID,\n", + " chain=ACTIVE_NETWORK[\"chain_enum\"],\n", + " token=\"USDC\",\n", + " )\n", + " token_balance = balance_resp.get(\"tokenBalance\", {})\n", + " if token_balance:\n", + " amount_units = int(token_balance.get(\"amount\", 0))\n", + " decimals = token_balance.get(\"decimals\", 6)\n", + " readable = amount_units / (10 ** decimals)\n", + " print(f\"\u2705 Wallet balance: {readable:.6f} {token_balance.get('token', 'USDC')} on {token_balance.get('chain', ACTIVE_NETWORK['chain_enum'])}\")\n", + " if readable == 0:\n", + " print(\" \u26a0\ufe0f Balance is 0 \u2014 faucet transaction may still be pending. Wait ~30s and re-run.\")\n", + " else:\n", + " print(\"\u26a0\ufe0f Balance returned empty \u2014 faucet may still be pending.\")\n", + " print(f\" Instrument ID: {PAYMENT_INSTRUMENT_ID}\")\n", + "except Exception as e:\n", + " print(f\"\u26a0\ufe0f GetPaymentInstrumentBalance failed: {e}\")\n", + " print(\" Ensure bedrock-agentcore:GetPaymentInstrumentBalance is in the ProcessPaymentRole policy.\")\n", + " print(\" Continue to Step 6 if the wallet is funded.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 6 \u2014 Enable Payment Manager observability\n", + "\n", + "Payment Manager telemetry is **opt-in** via vended log delivery. Enabling it makes the Payment Manager show up in the **AgentCore Observability \u2192 Payments** dashboard with sessions, transactions, per-API metrics, and the *Agents using Payments* attribution counter.\n", + "\n", + "This cell is **idempotent** \u2014 it skips resources that already exist. Run it once after creating the manager." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "logs = boto3.client(\"logs\", region_name=REGION)\n", + "\n", + "# Use the manager's short ID (first segment before the first hyphen group)\n", + "# as a DNS-safe log group component.\n", + "mgr_short_id = MANAGER_ARN.split(\"/\")[-1].split(\"-\")[0]\n", + "LG_NAME = f\"/aws/vendedlogs/bedrock-agentcore/heurist-{mgr_short_id}\"\n", + "LG_ARN = f\"arn:aws:logs:{REGION}:{ACCOUNT_ID}:log-group:{LG_NAME}\"\n", + "\n", + "SRC_LOGS_NAME = f\"heurist-payments-logs-{mgr_short_id}\"\n", + "SRC_TRACES_NAME = f\"heurist-payments-traces-{mgr_short_id}\"\n", + "DST_LOGS_NAME = f\"heurist-payments-logs-dest-{mgr_short_id}\"\n", + "DST_TRACES_NAME = f\"heurist-payments-traces-dest-{mgr_short_id}\"\n", + "\n", + "# Step 0 \u2014 log group\n", + "try:\n", + " logs.create_log_group(logGroupName=LG_NAME)\n", + " logs.put_retention_policy(logGroupName=LG_NAME, retentionInDays=30)\n", + " print(f\"\u2705 Created log group {LG_NAME}\")\n", + "except logs.exceptions.ResourceAlreadyExistsException:\n", + " print(f\" \u21bb Log group exists: {LG_NAME}\")\n", + "\n", + "# Step 1 \u2014 application logs delivery source\n", + "try:\n", + " logs.put_delivery_source(\n", + " name=SRC_LOGS_NAME,\n", + " resourceArn=MANAGER_ARN,\n", + " logType=\"APPLICATION_LOGS\",\n", + " )\n", + " print(f\"\u2705 Logs delivery source created ({SRC_LOGS_NAME})\")\n", + "except logs.exceptions.ConflictException:\n", + " print(f\" \u21bb Logs delivery source exists ({SRC_LOGS_NAME})\")\n", + "\n", + "# Step 2 \u2014 traces delivery source\n", + "try:\n", + " logs.put_delivery_source(\n", + " name=SRC_TRACES_NAME,\n", + " resourceArn=MANAGER_ARN,\n", + " logType=\"TRACES\",\n", + " )\n", + " print(f\"\u2705 Traces delivery source created ({SRC_TRACES_NAME})\")\n", + "except logs.exceptions.ConflictException:\n", + " print(f\" \u21bb Traces delivery source exists ({SRC_TRACES_NAME})\")\n", + "\n", + "# Step 3a \u2014 CloudWatch Logs delivery destination\n", + "try:\n", + " logs.put_delivery_destination(\n", + " name=DST_LOGS_NAME,\n", + " deliveryDestinationType=\"CWL\",\n", + " deliveryDestinationConfiguration={\"destinationResourceArn\": LG_ARN},\n", + " )\n", + " print(f\"\u2705 Logs delivery destination created ({DST_LOGS_NAME})\")\n", + "except logs.exceptions.ConflictException:\n", + " print(f\" \u21bb Logs delivery destination exists ({DST_LOGS_NAME})\")\n", + "\n", + "# Step 3b \u2014 X-Ray traces destination\n", + "try:\n", + " logs.put_delivery_destination(\n", + " name=DST_TRACES_NAME,\n", + " deliveryDestinationType=\"XRAY\",\n", + " )\n", + " print(f\"\u2705 Traces delivery destination created ({DST_TRACES_NAME})\")\n", + "except logs.exceptions.ConflictException:\n", + " print(f\" \u21bb Traces delivery destination exists ({DST_TRACES_NAME})\")\n", + "\n", + "# Step 4a \u2014 wire logs source \u2192 destination\n", + "try:\n", + " logs.create_delivery(\n", + " deliverySourceName=SRC_LOGS_NAME,\n", + " deliveryDestinationArn=(\n", + " f\"arn:aws:logs:{REGION}:{ACCOUNT_ID}:delivery-destination:{DST_LOGS_NAME}\"\n", + " ),\n", + " )\n", + " print(\"\u2705 Logs delivery created\")\n", + "except logs.exceptions.ConflictException:\n", + " print(\" \u21bb Logs delivery exists\")\n", + "\n", + "# Step 4b \u2014 wire traces source \u2192 destination\n", + "try:\n", + " logs.create_delivery(\n", + " deliverySourceName=SRC_TRACES_NAME,\n", + " deliveryDestinationArn=(\n", + " f\"arn:aws:logs:{REGION}:{ACCOUNT_ID}:delivery-destination:{DST_TRACES_NAME}\"\n", + " ),\n", + " )\n", + " print(\"\u2705 Traces delivery created\")\n", + "except logs.exceptions.ConflictException:\n", + " print(\" \u21bb Traces delivery exists\")\n", + "\n", + "print()\n", + "print(\"Payment Manager observability enabled.\")\n", + "print(\"After your first invocation, the AgentCore Observability \u2192 Payments tab will populate.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 7 \u2014 Deploy to AgentCore Runtime\n", + "\n", + "The `@aws/agentcore` CLI scaffolds a project, builds a Docker image, pushes it to ECR, and deploys via CDK. First deploy takes ~5\u201310 minutes.\n", + "\n", + "> **Prerequisites:** Node.js 20+, Docker running, AWS CDK installed\n", + ">\n", + "> **Cost notice:** This creates billable AWS resources (ECR, Runtime endpoint, CloudWatch logs). Run the cleanup section when finished." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import subprocess\n", + "import shutil\n", + "\n", + "# The agent source of truth lives in `agent/`. The CLI scaffolds a separate\n", + "# project directory called `payfordata/` whose layout the AgentCore service\n", + "# expects (agentcore.json, aws-targets.json, cdk/). On every deploy we copy\n", + "# the contents of `agent/` into `payfordata/app/HeuristFinanceAgent/`.\n", + "PROJECT_NAME = \"payfordata\" # CLI project name \u2014 stays lowercase\n", + "AGENT_NAME = \"HeuristFinanceAgent\" # the deployed agent's name\n", + "\n", + "\n", + "def run(cmd, **kw):\n", + " result = subprocess.run(cmd, capture_output=True, text=True, **kw)\n", + " if result.returncode != 0:\n", + " print(\"stdout:\", result.stdout[-1000:])\n", + " print(\"stderr:\", result.stderr[-1000:])\n", + " raise RuntimeError(f\"Command failed: {' '.join(cmd)}\")\n", + " return result\n", + "\n", + "\n", + "# \u2500\u2500 7a. Scaffold the CLI project (idempotent) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n", + "if not os.path.isdir(PROJECT_NAME):\n", + " print(f\"Scaffolding {PROJECT_NAME}/ ...\")\n", + " run([\n", + " \"agentcore\", \"create\",\n", + " \"--name\", AGENT_NAME,\n", + " \"--project-name\", PROJECT_NAME,\n", + " \"--defaults\",\n", + " \"--no-agent\",\n", + " \"--skip-git\",\n", + " \"--skip-python-setup\",\n", + " \"--skip-install\",\n", + " \"--json\",\n", + " ])\n", + " run([\n", + " \"agentcore\", \"add\", \"agent\",\n", + " \"--type\", \"byo\",\n", + " \"--name\", AGENT_NAME,\n", + " \"--build\", \"Container\",\n", + " \"--language\", \"Python\",\n", + " \"--framework\", \"Strands\",\n", + " \"--model-provider\", \"Bedrock\",\n", + " \"--code-location\", f\"app/{AGENT_NAME}\",\n", + " \"--entrypoint\", \"main.py\",\n", + " \"--network-mode\", \"PUBLIC\",\n", + " \"--protocol\", \"HTTP\",\n", + " \"--idle-timeout\", \"600\",\n", + " \"--max-lifetime\", \"1800\",\n", + " \"--json\",\n", + " ], cwd=PROJECT_NAME)\n", + " print(f\"\u2705 Scaffolded {PROJECT_NAME}/\")\n", + "else:\n", + " print(f\"{PROJECT_NAME}/ already exists \u2014 skipping create\")\n", + "\n", + "# \u2500\u2500 7b. Stage `agent/` into the build context \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n", + "# Everything inside `agent/` ships in the container image. We copy on every\n", + "# deploy so changes to the agent code are picked up.\n", + "build_ctx = f\"{PROJECT_NAME}/app/{AGENT_NAME}\"\n", + "os.makedirs(build_ctx, exist_ok=True)\n", + "\n", + "# Refresh the catalog cache before copying so the image has up-to-date tool URLs.\n", + "print(\"Refreshing Heurist catalog cache...\")\n", + "run([\"python3\", \"agent/sync_registry.py\"])\n", + "\n", + "for fname in (\"main.py\", \"catalog.py\", \"config.py\", \"sync_registry.py\",\n", + " \"catalog_live_cache.json\", \"requirements.txt\", \"Dockerfile\"):\n", + " src = os.path.join(\"agent\", fname)\n", + " dst = os.path.join(build_ctx, fname)\n", + " if os.path.exists(src):\n", + " shutil.copy(src, dst)\n", + "print(f\"\u2705 Staged agent/* \u2192 {build_ctx}/\")\n", + "\n", + "# \u2500\u2500 7c. Write the container .env (service config only \u2014 no payment creds) \u2500\u2500\n", + "runtime_env = f\"\"\"# Runtime config bundled in container image.\n", + "# Payment credentials are NOT here \u2014 they come from the invocation payload.\n", + "CI_ARTIFACTS_BUCKET={ARTIFACTS_BUCKET}\n", + "CI_ARTIFACTS_PREFIX=heurist-finance-artifacts\n", + "CI_ARTIFACTS_TTL=3600\n", + "AWS_REGION={REGION}\n", + "BEDROCK_MODEL_ID={BEDROCK_MODEL_ID}\n", + "AGENT_NAME={AGENT_NAME}\n", + "HEURIST_AGENT_IDS={','.join(HEURIST_AGENT_IDS)}\n", + "\n", + "# Skip strands_tools.http_request interactive confirmation prompt for\n", + "# POST/PUT/DELETE. Required because the Runtime container has no TTY.\n", + "BYPASS_TOOL_CONSENT=true\n", + "\n", + "# Bedrock max output tokens per agent turn. 60_000 gives multi-step workflows\n", + "# (data fetch + Code Interpreter + chart export + markdown report) room to\n", + "# complete; the SDK default of 4k otherwise raises MaxTokensReachedException\n", + "# mid-run. Lower this if you only need short Q&A \u2014 Bedrock charges per output\n", + "# token, so a 60k cap is a worst-case ~$0.90 per turn for Claude Sonnet 4.6\n", + "# (US$0.015 per 1k output tokens). Each agent turn typically uses far less.\n", + "AGENT_MAX_TOKENS=32000\n", + "\"\"\"\n", + "with open(f\"{build_ctx}/.env\", \"w\") as f:\n", + " f.write(runtime_env)\n", + "print(f\"\u2705 Wrote {build_ctx}/.env\")\n", + "\n", + "# \u2500\u2500 7d. Pin executionRoleArn + runtime version in agentcore.json \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n", + "config_path = os.path.join(PROJECT_NAME, \"agentcore\", \"agentcore.json\")\n", + "with open(config_path) as f:\n", + " project_config = json.load(f)\n", + "for runtime in project_config.get(\"runtimes\", []):\n", + " if runtime.get(\"name\") == AGENT_NAME:\n", + " runtime[\"executionRoleArn\"] = PROCESS_PAYMENT_ROLE_ARN\n", + " runtime[\"runtimeVersion\"] = \"PYTHON_3_13\"\n", + " break\n", + "with open(config_path, \"w\") as f:\n", + " json.dump(project_config, f, indent=2)\n", + "print(f\"\u2705 executionRoleArn = {PROCESS_PAYMENT_ROLE_ARN}\")\n", + "print(\"\u2705 runtimeVersion = PYTHON_3_13\")\n", + "\n", + "# \u2500\u2500 7e. Set the deployment target (account + region) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n", + "targets_path = os.path.join(PROJECT_NAME, \"agentcore\", \"aws-targets.json\")\n", + "with open(targets_path, \"w\") as f:\n", + " json.dump([{\n", + " \"name\": \"default\",\n", + " \"description\": \"Heurist Finance Agent \u2014 Runtime deployment\",\n", + " \"account\": ACCOUNT_ID,\n", + " \"region\": REGION,\n", + " }], f, indent=2)\n", + "print(f\"\u2705 Deployment target: {ACCOUNT_ID} / {REGION}\")\n", + "\n", + "# \u2500\u2500 7f. Install CDK npm deps (the CLI needs them at deploy time) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n", + "cdk_dir = os.path.join(PROJECT_NAME, \"agentcore\", \"cdk\")\n", + "if os.path.isdir(cdk_dir) and not os.path.isdir(os.path.join(cdk_dir, \"node_modules\")):\n", + " print(f\"Installing CDK npm deps in {cdk_dir}/ ...\")\n", + " run([\"npm\", \"install\", \"--silent\"], cwd=cdk_dir)\n", + " print(\"\u2705 CDK deps installed\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Deploy (~5-10 min first time \u2014 CodeBuild builds the container image)\n", + "print(\"Deploying to AgentCore Runtime \u2014 this can take 5\u201310 minutes (CodeBuild)...\")\n", + "run([\"agentcore\", \"deploy\", \"--yes\"], cwd=PROJECT_NAME)\n", + "print(\"\u2705 Agent deployed\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Capture the deployed agent runtime ARN\n", + "status_proc = subprocess.run(\n", + " [\"agentcore\", \"status\", \"--type\", \"agent\", \"--json\"],\n", + " cwd=PROJECT_NAME,\n", + " capture_output=True,\n", + " text=True,\n", + " check=True,\n", + ")\n", + "status = json.loads(status_proc.stdout)\n", + "entries = status if isinstance(status, list) else status.get(\"resources\", [])\n", + "\n", + "AGENT_RUNTIME_ARN = None\n", + "for entry in entries:\n", + " name = entry.get(\"name\") or entry.get(\"agentName\")\n", + " if name == AGENT_NAME:\n", + " AGENT_RUNTIME_ARN = (\n", + " entry.get(\"agentRuntimeArn\")\n", + " or entry.get(\"runtimeArn\")\n", + " or entry.get(\"arn\")\n", + " )\n", + " break\n", + "\n", + "if not AGENT_RUNTIME_ARN:\n", + " print(\"Raw status output:\")\n", + " print(json.dumps(status, indent=2))\n", + " raise RuntimeError(\"Could not locate agent runtime ARN in status output\")\n", + "\n", + "print(f\"\u2705 Agent Runtime ARN: {AGENT_RUNTIME_ARN}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 8 \u2014 Grant execution role permissions\n", + "\n", + "The `PROCESS_PAYMENT_ROLE_ARN` you specified becomes the container's execution role. Attach three additional policies:\n", + "\n", + "1. **Payment data-plane** \u2014 `ProcessPayment` and read operations (scoped to the payment manager)\n", + "2. **Code Interpreter** \u2014 `StartCodeInterpreterSession`, `InvokeCodeInterpreter`, `StopCodeInterpreterSession`\n", + "3. **S3 artifacts** \u2014 `PutObject` + `GetObject` scoped to the artifacts bucket prefix\n", + "\n", + "The execution role cannot create payment sessions or instruments \u2014 that authority stays with `ManagementRole`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "iam = boto3.client(\"iam\")\n", + "\n", + "# Derive the role name from the ARN\n", + "RUNTIME_ROLE_NAME = PROCESS_PAYMENT_ROLE_ARN.split(\"/\")[-1]\n", + "print(f\"Execution role: {RUNTIME_ROLE_NAME}\")\n", + "\n", + "# \u2500\u2500 8-0. Ensure AgentCore Runtime can assume the execution role \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n", + "# The Runtime infrastructure assumes this role when it launches the container.\n", + "# If the trust policy doesn't include bedrock-agentcore.amazonaws.com the CDK\n", + "# stack fails with: 'Role validation failed \u2014 trust policy allows assumption\n", + "# by this service' (BedrockAgentCoreControl, Status Code: 400).\n", + "import json as _json\n", + "current_trust = iam.get_role(RoleName=RUNTIME_ROLE_NAME)[\"Role\"][\"AssumeRolePolicyDocument\"]\n", + "principals = [s.get(\"Principal\", {}) for s in current_trust.get(\"Statement\", [])]\n", + "service_principals = [p.get(\"Service\", \"\") for p in principals if isinstance(p, dict)]\n", + "service_principal_flat = [s for item in service_principals for s in ([item] if isinstance(item, str) else item)]\n", + "if \"bedrock-agentcore.amazonaws.com\" not in service_principal_flat:\n", + " current_trust[\"Statement\"].append({\n", + " \"Sid\": \"AllowAgentCoreRuntimeService\",\n", + " \"Effect\": \"Allow\",\n", + " \"Principal\": {\"Service\": \"bedrock-agentcore.amazonaws.com\"},\n", + " \"Action\": \"sts:AssumeRole\",\n", + " })\n", + " iam.update_assume_role_policy(\n", + " RoleName=RUNTIME_ROLE_NAME,\n", + " PolicyDocument=_json.dumps(current_trust),\n", + " )\n", + " print(\"\u2705 Added bedrock-agentcore.amazonaws.com to execution role trust policy\")\n", + "else:\n", + " print(\" \u21bb bedrock-agentcore.amazonaws.com already in trust policy\")\n", + "\n", + "# 1. Payment data-plane (note the bare payment-manager/* ARN \u2014 without it the\n", + "# plugin's GetPaymentInstrument call hits AccessDeniedException since the\n", + "# instrument-scoped resource pattern doesn't cover the manager itself).\n", + "iam.put_role_policy(\n", + " RoleName=RUNTIME_ROLE_NAME,\n", + " PolicyName=\"HeuristPaymentDataPlaneAccess\",\n", + " PolicyDocument=json.dumps({\n", + " \"Version\": \"2012-10-17\",\n", + " \"Statement\": [{\n", + " \"Sid\": \"PaymentDataPlaneAccess\",\n", + " \"Effect\": \"Allow\",\n", + " \"Action\": [\n", + " \"bedrock-agentcore:ProcessPayment\",\n", + " \"bedrock-agentcore:GetPaymentInstrument\",\n", + " \"bedrock-agentcore:GetPaymentInstrumentBalance\",\n", + " \"bedrock-agentcore:GetPaymentSession\",\n", + " \"bedrock-agentcore:GetResourcePaymentToken\",\n", + " ],\n", + " \"Resource\": [\n", + " f\"arn:aws:bedrock-agentcore:{REGION}:{ACCOUNT_ID}:payment-manager/*\",\n", + " f\"arn:aws:bedrock-agentcore:{REGION}:{ACCOUNT_ID}:payment-manager/*/instrument/*\",\n", + " f\"arn:aws:bedrock-agentcore:{REGION}:{ACCOUNT_ID}:payment-manager/*/session/*\",\n", + " ],\n", + " }],\n", + " }),\n", + ")\n", + "print(\"\u2705 Payment data-plane permissions added\")\n", + "\n", + "# 2. Code Interpreter\n", + "iam.put_role_policy(\n", + " RoleName=RUNTIME_ROLE_NAME,\n", + " PolicyName=\"HeuristCodeInterpreterAccess\",\n", + " PolicyDocument=json.dumps({\n", + " \"Version\": \"2012-10-17\",\n", + " \"Statement\": [{\n", + " \"Sid\": \"CodeInterpreterAccess\",\n", + " \"Effect\": \"Allow\",\n", + " \"Action\": [\n", + " \"bedrock-agentcore:StartCodeInterpreterSession\",\n", + " \"bedrock-agentcore:StopCodeInterpreterSession\",\n", + " \"bedrock-agentcore:InvokeCodeInterpreter\",\n", + " ],\n", + " \"Resource\": f\"arn:aws:bedrock-agentcore:{REGION}:{ACCOUNT_ID}:code-interpreter/*\",\n", + " }],\n", + " }),\n", + ")\n", + "print(\"\u2705 Code Interpreter permissions added\")\n", + "\n", + "# 3. S3 artifacts\n", + "iam.put_role_policy(\n", + " RoleName=RUNTIME_ROLE_NAME,\n", + " PolicyName=\"HeuristS3ArtifactsAccess\",\n", + " PolicyDocument=json.dumps({\n", + " \"Version\": \"2012-10-17\",\n", + " \"Statement\": [{\n", + " \"Sid\": \"S3ArtifactsReadWrite\",\n", + " \"Effect\": \"Allow\",\n", + " \"Action\": [\"s3:PutObject\", \"s3:GetObject\"],\n", + " \"Resource\": f\"arn:aws:s3:::{ARTIFACTS_BUCKET}/heurist-finance-artifacts/*\",\n", + " }],\n", + " }),\n", + ")\n", + "print(f\"\u2705 S3 artifact permissions added (bucket: {ARTIFACTS_BUCKET})\")\n", + "\n", + "# 4. Bedrock \u2014 include cross-region inference-profile ARNs.\n", + "# Claude Sonnet 4.6 in us-west-2 is fronted by a CRIS profile, so granting\n", + "# only foundation-model/* gives an AccessDeniedException on InvokeModelWithResponseStream.\n", + "iam.put_role_policy(\n", + " RoleName=RUNTIME_ROLE_NAME,\n", + " PolicyName=\"HeuristBedrockInvoke\",\n", + " PolicyDocument=json.dumps({\n", + " \"Version\": \"2012-10-17\",\n", + " \"Statement\": [{\n", + " \"Sid\": \"BedrockModelInvocation\",\n", + " \"Effect\": \"Allow\",\n", + " \"Action\": [\n", + " \"bedrock:InvokeModel\",\n", + " \"bedrock:InvokeModelWithResponseStream\",\n", + " ],\n", + " \"Resource\": [\n", + " \"arn:aws:bedrock:*::foundation-model/*\",\n", + " f\"arn:aws:bedrock:*:{ACCOUNT_ID}:inference-profile/*\",\n", + " f\"arn:aws:bedrock:*:{ACCOUNT_ID}:application-inference-profile/*\",\n", + " ],\n", + " }],\n", + " }),\n", + ")\n", + "print(\"\u2705 Bedrock model invocation permissions added (incl. inference profiles)\")\n", + "\n", + "print()\n", + "print(\"Permissions summary:\")\n", + "print(\" Payment: ProcessPayment, GetPaymentInstrument, GetPaymentSession\")\n", + "print(\" Code Interpreter: StartSession, StopSession, InvokeCodeInterpreter\")\n", + "print(\" S3: PutObject + GetObject (artifacts bucket prefix only)\")\n", + "print(\" Bedrock: InvokeModel(WithResponseStream) on foundation-model + inference-profile/*\")\n", + "print(\" Not granted: CreateSession, CreateInstrument (ManagementRole only)\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 9 \u2014 Invoke the deployed agent\n", + "\n", + "Create a fresh payment session (the app backend controls the budget via `ManagementRole`), then invoke the deployed agent. The response includes the research summary and presigned S3 URLs for any charts or reports.\n", + "\n", + "```json\n", + "{\n", + " \"response\": \"\",\n", + " \"artifacts\": [\n", + " {\"name\": \"chart.png\", \"url\": \"https://...\", \"expires_in\": 3600}\n", + " ]\n", + "}\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create a fresh payment session for this invocation\n", + "session_response = mgmt_client.create_payment_session(\n", + " paymentManagerArn=MANAGER_ARN,\n", + " userId=USER_ID,\n", + " expiryTimeInMinutes=SESSION_EXPIRY_MINUTES,\n", + " limits={\n", + " \"maxSpendAmount\": {\n", + " \"value\": SESSION_MAX_SPEND,\n", + " \"currency\": \"USD\",\n", + " }\n", + " },\n", + " clientToken=str(uuid.uuid4()),\n", + ")\n", + "\n", + "payment_session = session_response[\"paymentSession\"]\n", + "SESSION_ID = payment_session[\"paymentSessionId\"]\n", + "\n", + "print(\"\u2705 Payment session created\")\n", + "print(f\" Session ID: {SESSION_ID}\")\n", + "print(f\" Budget: ${SESSION_MAX_SPEND} USD\")\n", + "print(f\" Expires: {SESSION_EXPIRY_MINUTES} minutes from now\")\n", + "if \"availableLimits\" in payment_session:\n", + " available = payment_session[\"availableLimits\"][\"availableSpendAmount\"]\n", + " print(f\" Available: {available['value']} {available['currency']}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Invoke the deployed agent via InvokeAgentRuntime\n", + "# Includes retry logic for cold-start handling: AgentCore Runtime containers\n", + "# may exceed the 120s init timeout on first invoke after idle. The first\n", + "# attempt warms the container; subsequent attempts hit it warm.\n", + "from botocore.config import Config as BotoConfig\n", + "from botocore.exceptions import ClientError\n", + "\n", + "invoke_payload = {\n", + " \"prompt\": (\n", + " \"Use FredMacroAgent to fetch the latest US GDP growth rate and unemployment rate. \"\n", + " \"Use Code Interpreter to create a bar chart comparing them and a markdown summary. \"\n", + " \"Save both as artifacts.\"\n", + " ),\n", + " \"payment_manager_arn\": MANAGER_ARN,\n", + " \"user_id\": USER_ID,\n", + " \"payment_session_id\": SESSION_ID,\n", + " \"payment_instrument_id\": PAYMENT_INSTRUMENT_ID,\n", + "}\n", + "\n", + "# Use a dedicated client with a shorter read timeout so cold-start failures\n", + "# return quickly (180s) instead of blocking for the full 25-minute default.\n", + "# Once warm, agent responses arrive well within 180s for simple prompts.\n", + "# For complex multi-step workflows (10+ tool calls + Code Interpreter),\n", + "# increase read_timeout to 900.\n", + "invoke_client = mgmt_session.client(\n", + " \"bedrock-agentcore\",\n", + " endpoint_url=DP_ENDPOINT,\n", + " config=BotoConfig(read_timeout=900, connect_timeout=10, retries={\"max_attempts\": 0}),\n", + ")\n", + "\n", + "MAX_RETRIES = 3\n", + "result = None\n", + "\n", + "for attempt in range(1, MAX_RETRIES + 1):\n", + " print(f\"Invoking {AGENT_NAME} (attempt {attempt}/{MAX_RETRIES})...\")\n", + " try:\n", + " response = invoke_client.invoke_agent_runtime(\n", + " agentRuntimeArn=AGENT_RUNTIME_ARN,\n", + " payload=json.dumps(invoke_payload).encode(\"utf-8\"),\n", + " contentType=\"application/json\",\n", + " accept=\"application/json\",\n", + " )\n", + " raw = response.get(\"response\", b\"\")\n", + " result_bytes = raw.read() if hasattr(raw, \"read\") else raw\n", + " result = json.loads(result_bytes.decode(\"utf-8\")) if result_bytes else {}\n", + " break\n", + " except ClientError as e:\n", + " msg = e.response.get(\"Error\", {}).get(\"Message\", \"\")\n", + " if \"initialization time exceeded\" in msg.lower() and attempt < MAX_RETRIES:\n", + " print(f\" ⏳ Container cold-starting — retrying in 15s (attempt {attempt})...\")\n", + " time.sleep(15)\n", + " else:\n", + " raise\n", + "\n", + "print(\"\\n── Response ──────────────────────────────────────────────────\")\n", + "print(result.get(\"response\", result))\n", + "\n", + "artifacts = result.get(\"artifacts\", [])\n", + "if artifacts:\n", + " print(\"\\n── Artifacts ─────────────────────────────────────────────────\")\n", + " for a in artifacts:\n", + " print(f\" {a['name']} (expires in {a['expires_in']}s)\")\n", + " print(f\" {a['url']}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Check session spend\n", + "session_check = mgmt_client.get_payment_session(\n", + " paymentManagerArn=MANAGER_ARN,\n", + " paymentSessionId=SESSION_ID,\n", + " userId=USER_ID,\n", + ")\n", + "\n", + "session_data = session_check[\"paymentSession\"]\n", + "budget = session_data.get(\"limits\", {}).get(\"maxSpendAmount\", {})\n", + "budget_val = float(budget.get(\"value\", 0))\n", + "available = session_data.get(\"availableLimits\", {}).get(\"availableSpendAmount\", {})\n", + "avail_val = float(available.get(\"value\", budget_val)) if available.get(\"value\") else budget_val\n", + "spent = budget_val - avail_val\n", + "\n", + "print(\"Session spend summary:\")\n", + "print(f\" Budget: ${budget_val:.4f} {budget.get('currency', 'USD')}\")\n", + "print(f\" Remaining: ${avail_val:.4f} {available.get('currency', 'USD')}\")\n", + "print(f\" Spent: ${spent:.4f} USD\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 10 \u2014 Observability\n", + "\n", + "AgentCore Runtime automatically instruments the container with OpenTelemetry. Traces and logs appear in **CloudWatch GenAI Observability** immediately after the first invocation.\n", + "\n", + "![CloudWatch GenAI Observability \u2014 Heurist Finance Agent](images/obs-dashboard.png)\n", + "\n", + "Each invocation produces a unified trace with spans for:\n", + "- **LLM calls** \u2014 model ID, token counts, latency\n", + "- **Tool calls** \u2014 `http_request` invocations including x402 retry attempts\n", + "- **Agent turns** \u2014 full prompt \u2192 tool use \u2192 response cycle\n", + "- **Code Interpreter** \u2014 `StartCodeInterpreterSession`, `InvokeCodeInterpreter`, `StopCodeInterpreterSession` as child spans via W3C `traceparent` propagation\n", + "- **Payment calls** \u2014 `ProcessPayment`, `GetPaymentInstrument` as boto3 child spans\n", + "\n", + "The **Payments tab** is populated by the vended log delivery you configured in Step 6 \u2014 sessions, transactions, per-API metrics, and *Agents using Payments* attribution (driven by the `AGENT_NAME` env var you set in the container).\n", + "\n", + "> **Note:** Payment manager vended logs (`ProcessPayment`, `CreateSession` events) are configured separately via vended log delivery (Step 6). See the [AgentCore Payments observability doc](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-observability.html) for details.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(\"CloudWatch GenAI Observability dashboard:\")\n", + "print(f\" https://{REGION}.console.aws.amazon.com/cloudwatch/home?region={REGION}#gen-ai-observability/agent-core\")\n", + "print()\n", + "print(\"Stream live logs:\")\n", + "print(f\" cd {PROJECT_NAME} && agentcore logs\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 11 \u2014 Cleanup\n", + "\n", + "> \u26a0\ufe0f The following cells permanently delete the Runtime deployment and the S3 artifacts bucket. Download any artifacts you want to keep before proceeding." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Remove the AgentCore Runtime stack (ECR image, CloudWatch logs, CDK stack)\n", + "run([\"agentcore\", \"remove\", \"all\", \"-y\"], cwd=PROJECT_NAME)\n", + "print(\"\u2705 Runtime stack removed\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Remove the scaffolded project directory\n", + "if os.path.exists(PROJECT_NAME):\n", + " shutil.rmtree(PROJECT_NAME)\n", + " print(f\"Removed {PROJECT_NAME}/\")\n", + "else:\n", + " print(f\"{PROJECT_NAME}/ already removed\")\n", + "\n", + "# Empty and delete the S3 artifacts bucket\n", + "s3_resource = boto3.resource(\"s3\")\n", + "bucket = s3_resource.Bucket(ARTIFACTS_BUCKET)\n", + "try:\n", + " bucket.objects.all().delete()\n", + " bucket.delete()\n", + " print(f\"Deleted S3 bucket: {ARTIFACTS_BUCKET}\")\n", + "except Exception as e:\n", + " print(f\"Could not delete bucket: {e}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### AWS resource cleanup\n", + "\n", + "Payment sessions expire automatically when `expiryTimeInMinutes` elapses \u2014 no manual deletion required.\n", + "\n", + "To clean up the payment manager, connector, instrument, and credential provider, use the AWS CLI or boto3:\n", + "\n", + "```python\n", + "# Example \u2014 delete in reverse order\n", + "mgmt_client.delete_payment_instrument(paymentManagerArn=MANAGER_ARN, paymentInstrumentId=PAYMENT_INSTRUMENT_ID, userId=USER_ID)\n", + "cp_client.delete_payment_connector(paymentManagerId=MANAGER_ID, paymentConnectorId=PAYMENT_CONNECTOR_ID)\n", + "cp_client.delete_payment_manager(paymentManagerId=MANAGER_ID)\n", + "cp_client.delete_payment_credential_provider(credentialProviderArn=CREDENTIAL_PROVIDER_ARN)\n", + "```\n", + "\n", + "---\n", + "\n", + "## Shared Responsibility\n", + "\n", + "| Responsibility | AWS | You |\n", + "|:---|:---:|:---:|\n", + "| Securing the AgentCore payments service infrastructure | \u2705 | |\n", + "| Encrypting payment credentials at rest | \u2705 | |\n", + "| Enforcing payment session limits at the service level | \u2705 | |\n", + "| Settling on-chain transactions (Coinbase CDP) | \u2705 | |\n", + "| Managing Runtime container compute and networking | \u2705 | |\n", + "| Configuring IAM roles with least-privilege permissions | | \u2705 |\n", + "| Setting appropriate `maxSpendAmount` payment limits | | \u2705 |\n", + "| Enabling Delegated Signing in the CDP project | | \u2705 |\n", + "| Protecting AWS credentials from exposure | | \u2705 |\n", + "| Funding the payment instrument with sufficient USDC | | \u2705 |\n", + "| Monitoring agent spend and session usage | | \u2705 |\n", + "| Validating prompts to prevent prompt injection | | \u2705 |\n", + "| Reviewing Heurist endpoint terms of service | | \u2705 |\n", + "\n", + "> **Security note:** Never commit `.env` files or payment credentials to source control.\n", + "> Use AWS Secrets Manager for shared deployments.\n", + "> Payment sessions are time-bounded and budget-capped \u2014 set conservative `maxSpendAmount` limits when running agents in automated or unattended contexts." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file