From bb763db66c49cfaea69dfebdfead3d1c2149f699 Mon Sep 17 00:00:00 2001 From: Ed Fraga <39071108+edfragas@users.noreply.github.com> Date: Sat, 20 Jun 2026 02:11:41 +1000 Subject: [PATCH] =?UTF-8?q?Harness=20GA=20Samples=20=E2=80=94=20AWS=20Skil?= =?UTF-8?q?ls,=20S3=20Filesystem,=20Step=20Functions,=20Builder=20Agent=20?= =?UTF-8?q?(#1688)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Harness GA samples: AWS Skills, S3 filesystem, Step Functions, builder agent Four new samples under 01-features/01-harness for the Harness GA launch: - 01-advanced-examples/08-aws-skills: enable native AWS Skills from the AWS Agent Toolkit via the awsSkills source (all / glob / specific / mixed modes) - 01-advanced-examples/09-s3-filesystem: mount an S3 Files access point as the agent filesystem; includes s3_knowledge_base.py — a persistent LLM-wiki knowledge base (ingest / query / lint) on the S3 mount - 01-advanced-examples/10-stepfunctions-orchestration: a Step Functions STANDARD state machine that drives the harness lifecycle (create -> poll READY -> invoke -> delete) via a Lambda task worker - 02-use-cases/03-aws-builder-agent: harness + AWS Skills compose an AWS engineering agent that designs and scaffolds a serverless app All samples use the GA boto3 SDK shapes (create_harness/invoke_harness) with no internal endpoints or configuration. Updated the harness README index. * Rename s3_knowledge_base.py to s3_llm_wiki.py to avoid Bedrock Knowledge Bases confusion The LLM-wiki sample is a self-maintained markdown wiki on the agent's filesystem, unrelated to the Amazon Bedrock Knowledge Bases feature. Rename the file and scrub 'knowledge base' wording (mount default /mnt/wiki, pages/ subdir) across the script and READMEs to prevent customer confusion. * Fix S3 filesystem + Step Functions samples after end-to-end testing Verified the samples against the live GA API and fixed real bugs found: S3 filesystem (s3_filesystem.py, s3_llm_wiki.py): - S3 Files mounts REQUIRE VPC network mode — add networkConfiguration (networkMode=VPC + subnets + security groups) to create_harness; add required --subnet-ids/--security-group-ids CLI args. - Execution role needs s3files permissions (ListMountTargets/Get*/ClientMount/ ClientWrite/ClientRootAccess), not plain s3:* — correct the attached policy. - README: document VPC + mount-target prerequisites and the new args. Step Functions (stepfunctions_orchestration.py): - Lambda role also needs *AgentRuntime* actions (CreateHarness provisions an underlying AgentRuntime) — without them CreateHarness returns AccessDenied. - CheckStatus Choice now matches CREATE_FAILED/UPDATE_FAILED so a failed creation fails fast instead of looping on the WaitForReady default forever. * Rework Step Functions sample to native integration; fix S3 Files IAM/VPC guidance Step Functions: replace the Lambda task-worker approach with the native arn:aws:states:::bedrockagentcore:invokeHarness service integration — a single Task state, no Lambda, no glue. The state machine's own role gets bedrock-agentcore:InvokeHarness; output is Converse-shaped (Output.Message.Content[].Text). Rewrote the README accordingly. S3 filesystem: scope the execution-role IAM to what the runtime actually needs — s3files:GetAccessPoint (unscoped, validated at harness create time) plus s3files:ClientMount/ClientWrite (conditioned on the access point). Document that S3 Files mounts require VPC network mode with private subnets that have egress (route to a NAT gateway); public subnets do not connect. Same IAM fix applied to the s3_llm_wiki.py sample. * Remove Step Functions sample; renumber to avoid collision with merged samples Upstream main now occupies advanced-examples 01-12 (incl. 06-async-step-function, 08-gemini-model-provider, 09-openai-model-provider). Renumber our two samples to free slots and drop the Step Functions sample (upstream added its own): - delete 10-stepfunctions-orchestration - 08-aws-skills -> 13-aws-skills - 09-s3-filesystem -> 14-s3-filesystem Updated all cross-references (harness index README, builder-agent note). --- .../13-aws-skills/README.md | 102 +++++ .../13-aws-skills/aws_skills.py | 297 +++++++++++++ .../14-s3-filesystem/README.md | 174 ++++++++ .../14-s3-filesystem/s3_filesystem.py | 398 ++++++++++++++++++ .../14-s3-filesystem/s3_llm_wiki.py | 380 +++++++++++++++++ .../03-aws-builder-agent/README.md | 97 +++++ .../03-aws-builder-agent/aws_builder_agent.py | 289 +++++++++++++ 01-features/01-harness/README.md | 24 +- 8 files changed, 1758 insertions(+), 3 deletions(-) create mode 100644 01-features/01-harness/01-advanced-examples/13-aws-skills/README.md create mode 100644 01-features/01-harness/01-advanced-examples/13-aws-skills/aws_skills.py create mode 100644 01-features/01-harness/01-advanced-examples/14-s3-filesystem/README.md create mode 100644 01-features/01-harness/01-advanced-examples/14-s3-filesystem/s3_filesystem.py create mode 100644 01-features/01-harness/01-advanced-examples/14-s3-filesystem/s3_llm_wiki.py create mode 100644 01-features/01-harness/02-use-cases/03-aws-builder-agent/README.md create mode 100644 01-features/01-harness/02-use-cases/03-aws-builder-agent/aws_builder_agent.py diff --git a/01-features/01-harness/01-advanced-examples/13-aws-skills/README.md b/01-features/01-harness/01-advanced-examples/13-aws-skills/README.md new file mode 100644 index 00000000..99587d09 --- /dev/null +++ b/01-features/01-harness/01-advanced-examples/13-aws-skills/README.md @@ -0,0 +1,102 @@ +# AWS Skills + +| Information | Details | +|:--------------------|:-------------------------------------------------------------------------| +| Tutorial type | Advanced Example | +| Agent type | AWS engineering assistant | +| Agentic Framework | None (direct boto3) | +| LLM model | Anthropic Claude Haiku 4.5 | +| Tutorial components | AgentCore harness — `skills` parameter, native `awsSkills` source | +| Example complexity | Intermediate | + +## Overview + +Give a harness agent native **AWS Skills** — curated capability bundles from the +[AWS Agent Toolkit](https://github.com/aws/agent-toolkit-for-aws) that are baked +into the harness runtime image. You enable them declaratively in the `skills` +parameter; there is nothing to install on the VM. + +This is the zero-install counterpart to [05-agent-skills](../05-agent-skills), +which hand-installs custom skills onto the VM with `npx`. + +## What are AWS Skills? + +Skills bundle instructions, reference docs, and code templates for a specific AWS +domain (serverless, CloudFormation, observability, cost management, ...). They are +especially valuable with smaller/cheaper models that don't carry deep AWS +knowledge — the skill supplies the patterns the model needs. + +Each entry in the `skills` array is a union; the `awsSkills` member selects skills +from the toolkit: + +```python +# Enable every AWS Skill +skills=[{"awsSkills": {}}] + +# Enable a whole category by glob +skills=[{"awsSkills": {"paths": ["core-skills/*"]}}] + +# Enable one specific skill +skills=[{"awsSkills": {"paths": [ + "specialized-skills/operations-skills/troubleshooting-application-failures"]}}] + +# Mix multiple AWS Skill selections (and other skill sources) +skills=[ + {"awsSkills": {"paths": ["core-skills/aws-serverless"]}}, + {"awsSkills": {"paths": ["core-skills/aws-cdk"]}}, +] +``` + +`core-skills/*` includes domains like `amazon-bedrock`, `aws-cdk`, +`aws-serverless` (Lambda, API Gateway, Step Functions, SAM), `aws-observability`, +`aws-billing-and-cost-management`, and the language SDK usage skills. Skills can be +set on the harness resource (so they apply to every invocation) or passed per +`invoke_harness` call. + +## Sample Prompts + +**Prompt** (`--mode glob`, default): "What AWS skills do you have available? Give a short bulleted summary by category." +**Expected Behavior**: Agent lists the `core-skills/*` it loaded, grouped by category. + +**Prompt** (`--mode mixed`): "Design a Step Functions state machine for order processing and outline the CDK stack." +**Expected Behavior**: Agent draws on the `aws-serverless` and `aws-cdk` skills to propose a design. + +## Key Concepts + +**Zero install**: Unlike custom skills, AWS Skills require no `npx`/VM step — set them on the harness and they're ready on first invocation. + +**Selection modes**: `--mode all | glob | specific | mixed` map to the four `awsSkills` shapes above. + +**Resource vs. per-call**: This sample sets skills at create time. You can also pass `skills=` on `invoke_harness` to apply them to a single call. + +## Clean Up + +```python +control.delete_harness(harnessId=harness_id) +from utils.iam import delete_harness_role +delete_harness_role() +``` + +The script deletes the harness automatically on exit (pass `--skip-cleanup` to keep it). + +## Running the Python Scripts + +```bash +pip install -r ../../requirements.txt +``` + +```bash +# Default — enable core-skills/* and summarize +python aws_skills.py + +# Enable every AWS Skill +python aws_skills.py --mode all + +# One named skill +python aws_skills.py --mode specific \ + --skill-path specialized-skills/operations-skills/troubleshooting-application-failures + +# Combine serverless + CDK skills with a build task +python aws_skills.py --mode mixed \ + -m "Design a Step Functions state machine for order processing and outline the CDK stack." +``` diff --git a/01-features/01-harness/01-advanced-examples/13-aws-skills/aws_skills.py b/01-features/01-harness/01-advanced-examples/13-aws-skills/aws_skills.py new file mode 100644 index 00000000..227cc5d7 --- /dev/null +++ b/01-features/01-harness/01-advanced-examples/13-aws-skills/aws_skills.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +""" +AWS Skills for Harness + +This script shows how to give a Harness agent native **AWS Skills** — curated +capability bundles from the AWS Agent Toolkit (https://github.com/aws/agent-toolkit-for-aws) +that are baked into the Harness runtime image. Unlike custom skills that you +install onto the VM yourself, AWS Skills are enabled declaratively through the +`skills` parameter and are ready the moment the agent starts. + +A skill bundles instructions, reference docs, and code templates for a specific +AWS domain (serverless, CloudFormation, observability, cost management, ...). +They are especially valuable with smaller/cheaper models that don't carry deep +AWS knowledge — the skill supplies the patterns the model needs to succeed. + +Four ways to select AWS Skills (pick with --mode): + + all Enable every AWS Skill in the toolkit + skills=[{"awsSkills": {}}] + + glob Enable a whole category with a glob path (default) + skills=[{"awsSkills": {"paths": ["core-skills/*"]}}] + + specific Enable one named skill + skills=[{"awsSkills": {"paths": [ + "specialized-skills/operations-skills/troubleshooting-application-failures"]}}] + + mixed Combine several AWS Skill selections (and you can add other skill + sources — path/S3 — in the same array) + skills=[ + {"awsSkills": {"paths": ["core-skills/aws-cdk"]}}, + {"awsSkills": {"paths": ["core-skills/aws-serverless"]}}, + ] + +Skills can be set on the Harness resource (CreateHarness/UpdateHarness, so they +apply to every invocation) or passed per call on InvokeHarness. This sample sets +them on the resource at create time, then invokes. + +Usage: + # Default — enable all of core-skills/* and ask the agent what it can do + python aws_skills.py + + # Enable every AWS Skill + python aws_skills.py --mode all + + # Enable a single named skill + python aws_skills.py --mode specific \\ + --skill-path specialized-skills/operations-skills/troubleshooting-application-failures + + # Combine serverless + CDK skills and ask the agent to design a workflow + python aws_skills.py --mode mixed \\ + -m "Design a Step Functions state machine for order processing and outline the CDK stack." + + # Keep the harness after the demo + python aws_skills.py --skip-cleanup + + # See all options + python aws_skills.py --help +""" + +import argparse +import json +import os +import sys +import time +import uuid + +from pathlib import Path + +import boto3 +import botocore.exceptions + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from utils.iam import create_harness_role +from utils.client import get_agentcore_client, get_agentcore_control_client + +REGION = os.getenv("AWS_DEFAULT_REGION") + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- +DEFAULT_MODEL = "global.anthropic.claude-haiku-4-5-20251001-v1:0" +DEFAULT_GLOB = "core-skills/*" +DEFAULT_SPECIFIC = "specialized-skills/operations-skills/troubleshooting-application-failures" +DEFAULT_PROMPT = "What AWS skills do you have available? Give a short bulleted summary by category." + +HARNESS_POLL_INTERVAL = 5 +HARNESS_POLL_TIMEOUT = 120 + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- +parser = argparse.ArgumentParser( + description="Enable native AWS Skills on a Harness and invoke the agent.", + formatter_class=argparse.RawDescriptionHelpFormatter, +) +parser.add_argument( + "--mode", + choices=["all", "glob", "specific", "mixed"], + default="glob", + help="How to select AWS Skills (default: glob — enables core-skills/*)", +) +parser.add_argument( + "--skill-path", + default=DEFAULT_SPECIFIC, + metavar="PATH", + help=f"Skill path used by --mode specific (default: {DEFAULT_SPECIFIC})", +) +parser.add_argument( + "--glob-path", + default=DEFAULT_GLOB, + metavar="PATH", + help=f"Glob path used by --mode glob (default: {DEFAULT_GLOB})", +) +parser.add_argument( + "--model", + default=DEFAULT_MODEL, + metavar="MODEL_ID", + help=f"Bedrock model ID (default: {DEFAULT_MODEL})", +) +parser.add_argument( + "--message", + "-m", + default=DEFAULT_PROMPT, + help="Prompt to send to the agent", +) +parser.add_argument( + "--role-arn", + default=None, + metavar="ARN", + help="Use an existing IAM execution role ARN instead of creating one", +) +parser.add_argument( + "--skip-cleanup", + action="store_true", + help="Keep the harness after the demo", +) +parser.add_argument( + "--raw-events", + action="store_true", + help="Print raw JSON streaming events from invoke", +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +def build_skills(args): + """Translate the chosen --mode into a `skills` parameter value. + + Each entry is a union; the `awsSkills` member selects skills from the AWS + Agent Toolkit. An empty `{}` means "all skills"; `paths` narrows it with + glob patterns or exact skill paths. + """ + if args.mode == "all": + return [{"awsSkills": {}}] + if args.mode == "glob": + return [{"awsSkills": {"paths": [args.glob_path]}}] + if args.mode == "specific": + return [{"awsSkills": {"paths": [args.skill_path]}}] + # mixed — combine two category selections; add {"path": ...} or S3 sources here too + return [ + {"awsSkills": {"paths": ["core-skills/aws-serverless"]}}, + {"awsSkills": {"paths": ["core-skills/aws-cdk"]}}, + ] + + +def poll_harness_status(control, harness_id, target_status="READY", timeout=HARNESS_POLL_TIMEOUT): + """Poll until a Harness reaches the target status or times out.""" + deadline = time.monotonic() + timeout + while True: + resp = control.get_harness(harnessId=harness_id) + status = resp["harness"]["status"] + print(f" Harness status: {status}") + if status == target_status: + return resp + if status in ("FAILED", "DELETE_FAILED"): + raise RuntimeError(f"Harness entered {status}") + if time.monotonic() > deadline: + raise TimeoutError(f"Harness not {target_status} after {timeout}s (current: {status})") + time.sleep(HARNESS_POLL_INTERVAL) + + +def stream_response(client, harness_arn, session_id, message, model_id, raw=False): + """Invoke a Harness and stream the response to stdout.""" + response = client.invoke_harness( + harnessArn=harness_arn, + runtimeSessionId=session_id, + messages=[{"role": "user", "content": [{"text": message}]}], + model={"bedrockModelConfig": {"modelId": model_id}}, + ) + + full_text = "" + try: + for event in response["stream"]: + if raw: + print(json.dumps(event, default=str)) + continue + + if "contentBlockStart" in event: + start = event["contentBlockStart"].get("start", {}) + if "toolUse" in start: + print(f"\n [Tool: {start['toolUse'].get('name', '?')}]", flush=True) + elif "contentBlockDelta" in event: + delta = event["contentBlockDelta"].get("delta", {}) + if "text" in delta: + print(delta["text"], end="", flush=True) + full_text += delta["text"] + elif "messageStop" in event: + print() + elif "internalServerException" in event: + print(f"\n Error: {event['internalServerException']}") + except botocore.exceptions.EventStreamError: + # The stream may send an empty error event on close; safe to ignore + # if we already received content. + if not full_text: + raise + + return full_text + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +def main(args=None): + if args is None: + args = parser.parse_args() + + control = get_agentcore_control_client() + client = get_agentcore_client() + + skills = build_skills(args) + harness_id = None + + try: + # ── Step 0: IAM role ────────────────────────────────────────── + print("=" * 60) + print("Step 0: IAM execution role") + print("=" * 60) + if args.role_arn: + role_arn = args.role_arn + print(f" Using provided role: {role_arn}") + else: + role_arn = create_harness_role() + print(" Waiting for IAM propagation...") + time.sleep(10) + + # ── Step 1: Create Harness with AWS Skills ──────────────────── + print("\n" + "=" * 60) + print(f"Step 1: Create Harness with AWS Skills (mode: {args.mode})") + print("=" * 60) + print(f" skills = {json.dumps(skills)}") + harness_name = f"AwsSkills_{uuid.uuid4().hex[:8]}" + resp = control.create_harness( + harnessName=harness_name, + executionRoleArn=role_arn, + skills=skills, + # Smaller models benefit most from skills; allow the agent to use + # its default tools (fs/shell) so it can act on what the skill teaches. + systemPrompt=[{"text": "You are a helpful AWS engineering assistant."}], + ) + harness_id = resp["harness"]["harnessId"] + harness_arn = resp["harness"]["arn"] + print(f" Harness ID: {harness_id}") + print(f" Harness ARN: {harness_arn}") + poll_harness_status(control, harness_id) + + # ── Step 2: Invoke and observe the loaded skills ────────────── + print("\n" + "=" * 60) + print("Step 2: Invoke agent") + print("=" * 60) + session_id = str(uuid.uuid4()).upper() + print(f" Session ID: {session_id}") + print(f" Model: {args.model}") + print(f" Message: {args.message[:80]}{'...' if len(args.message) > 80 else ''}\n") + + stream_response(client, harness_arn, session_id, args.message, args.model, raw=args.raw_events) + + print("\n" + "=" * 60) + print("Done!") + print("=" * 60) + + finally: + if not args.skip_cleanup and harness_id: + print("\nCleaning up...") + try: + control.delete_harness(harnessId=harness_id) + print(f" Deleted harness: {harness_id}") + except Exception as e: + print(f" Warning: failed to delete harness: {e}") + + +if __name__ == "__main__": + main() diff --git a/01-features/01-harness/01-advanced-examples/14-s3-filesystem/README.md b/01-features/01-harness/01-advanced-examples/14-s3-filesystem/README.md new file mode 100644 index 00000000..87c07160 --- /dev/null +++ b/01-features/01-harness/01-advanced-examples/14-s3-filesystem/README.md @@ -0,0 +1,174 @@ +# S3 Filesystem Mount + +| Information | Details | +|:--------------------|:-------------------------------------------------------------------------| +| Tutorial type | Advanced Example | +| Agent type | Assistant with persistent storage | +| Agentic Framework | None (direct boto3) | +| LLM model | Anthropic Claude Haiku 4.5 | +| Tutorial components | AgentCore harness — `filesystemConfigurations`, S3 Files access point | +| Example complexity | Intermediate | + +## Overview + +A harness session runs in an isolated microVM with an **ephemeral** disk — when +the session ends, anything written to the VM is gone. Mount an **S3 Files access +point** into the VM and the agent gets a normal POSIX path (e.g. `/mnt/data`) +backed by S3, so artifacts persist past the session and are shared across +sessions. + +## What's in this folder + +| File | What it shows | +|---|---| +| [`s3_filesystem.py`](s3_filesystem.py) | **The mechanism.** Session A writes a file under the mount; Session B (a brand-new microVM) reads it back — only possible because the file lives in S3, not on the VM disk. | +| [`s3_llm_wiki.py`](s3_llm_wiki.py) | **The use case: a persistent LLM wiki.** The agent builds and maintains a compounding markdown wiki on the S3 mount across sessions (ingest → query → lint). | + +The first script proves the persistence boundary; the second shows *why you'd +want it*. + +## Configuration + +An S3 Files mount requires the harness to run in **VPC network mode** — the +microVM reaches the access point's mount target over your VPC. So the +environment carries both a `networkConfiguration` and the `filesystemConfigurations`: + +```python +environment={ + "agentCoreRuntimeEnvironment": { + "networkConfiguration": { + "networkMode": "VPC", + "networkModeConfig": { + "subnets": ["subnet-0abc1234"], + "securityGroups": ["sg-0def5678"], + }, + }, + "filesystemConfigurations": [ + { + "s3FilesAccessPoint": { + "accessPointArn": "arn:aws:s3files:us-west-2:111122223333:file-system/fs-abc/access-point/fsap-def", + "mountPath": "/mnt/data", + } + } + ], + } +} +``` + +`mountPath` must look like `/mnt/`. The execution role must be allowed to +mount the access point — when this script creates the role, it attaches the +required `s3files` permissions for you: `s3files:GetAccessPoint` (the runtime +validates this at create time, so it stays unscoped) plus `s3files:ClientMount` +and `s3files:ClientWrite` (scoped to the file system with an `AccessPointArn` +condition, used when the microVM mounts the access point). + +## Prerequisites + +- An **S3 Files access point** backed by a bucket, with a **mount target** in the + subnet you pass. Its ARN looks like: + `arn:aws:s3files:::file-system/fs-xxxx/access-point/fsap-xxxx` +- The **subnet(s) and security group(s)** that reach the mount target. The Harness + must be in the **same VPC** as the mount target, the subnet(s) you pass must be + in an **Availability Zone that has a mount target**, and the security group(s) + must allow **NFS (port 2049)** between the Harness and the mount target (a + self-referencing security group is the simplest setup). +- **Use private subnets with egress** (a route to a NAT gateway). VPC-mode + Harnesses run in private networking; public subnets do not give the microVM the + connectivity it needs and the invoke will fail. See + [Configure AgentCore for VPC](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/agentcore-vpc.html). +- If you bring your own execution role (`--role-arn`), it must already have the + `s3files` mount permissions above. + +## Sample Prompts + +**Prompt (Session A)**: "Write a short markdown travel note about Amsterdam to /mnt/data/harness-note.md." +**Expected Behavior**: Agent writes the file under the mounted path and confirms the absolute path. + +**Prompt (Session B, fresh VM)**: "Read the file /mnt/data/harness-note.md and show me its contents verbatim." +**Expected Behavior**: Agent reads back the note written in Session A — the S3-backed mount persisted it. + +## Key Concepts + +**Persistence boundary**: A different `session_id` means a different VM disk. Surviving that boundary is what proves the mount is S3-backed. + +**Mount path format**: `mountPath` must match `/mnt/` (validated by the script before the call). + +**IAM scope**: The execution role only needs access to the single access point — the script attaches a narrowly scoped policy. + +## Use case: a persistent LLM wiki + +[`s3_llm_wiki.py`](s3_llm_wiki.py) turns the S3 mount into a +**persistent, compounding LLM wiki**, following the pattern Andrej Karpathy +describes in [this gist](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f): +rather than re-deriving answers from raw documents on every query (classic RAG), +the agent **builds and maintains a markdown wiki once and keeps it current**, so +knowledge becomes a compounding artifact. + +> This is a self-maintained markdown wiki on the agent's filesystem — it is +> unrelated to the **Amazon Bedrock Knowledge Bases** feature. + +The S3 mount is what makes this possible — the wiki must outlive any single +session and be shared across invocations. Three layers live under the mount: + +``` +/mnt/wiki/ + sources/ raw, immutable inputs (the agent reads, never edits) + pages/ LLM-owned markdown: summaries, entity pages, concept pages ([[cross-linked]]) + AGENTS.md the schema (how the wiki is organized) + index.md catalog of pages + log.md append-only history +``` + +Three operations, **each run in its own session** to prove the wiki persists +across the microVM boundary: + +- **ingest** — read a raw source and integrate it across the wiki (create/update pages) +- **query** — answer from the wiki, then file the answer back as a new page so explorations compound +- **lint** — health-check: contradictions, stale claims, orphan pages, broken links + +Re-run with `--op query` later and the wiki is still there in S3 — the agent +picks up exactly where it left off. + +## Clean Up + +```python +control.delete_harness(harnessId=harness_id) +from utils.iam import delete_harness_role +delete_harness_role() +``` + +The script deletes the harness on exit (pass `--skip-cleanup` to keep it). It +**leaves your S3 bucket and access point intact**. + +## Running the Python Scripts + +```bash +pip install -r ../../requirements.txt +``` + +```bash +# 1) The mechanism — prove persistence across sessions +python s3_filesystem.py \ + --access-point-arn arn:aws:s3files:us-west-2:111122223333:file-system/fs-abc/access-point/fsap-def \ + --subnet-ids subnet-0abc1234 \ + --security-group-ids sg-0def5678 + +# Custom mount path + filename +python s3_filesystem.py \ + --access-point-arn arn:aws:s3files:... \ + --subnet-ids subnet-0abc1234 --security-group-ids sg-0def5678 \ + --mount-path /mnt/shared \ + --filename trip-notes.md +``` + +```bash +# 2) The LLM wiki — full demo (bootstrap, ingest, query, lint) +python s3_llm_wiki.py \ + --access-point-arn arn:aws:s3files:us-west-2:111122223333:file-system/fs-abc/access-point/fsap-def \ + --subnet-ids subnet-0abc1234 --security-group-ids sg-0def5678 + +# Query the existing wiki (it compounds — answers get filed back) +python s3_llm_wiki.py --access-point-arn arn:aws:s3files:... \ + --subnet-ids subnet-0abc1234 --security-group-ids sg-0def5678 \ + --op query -m "How does the LLM wiki pattern differ from RAG?" +``` diff --git a/01-features/01-harness/01-advanced-examples/14-s3-filesystem/s3_filesystem.py b/01-features/01-harness/01-advanced-examples/14-s3-filesystem/s3_filesystem.py new file mode 100644 index 00000000..c40f0793 --- /dev/null +++ b/01-features/01-harness/01-advanced-examples/14-s3-filesystem/s3_filesystem.py @@ -0,0 +1,398 @@ +#!/usr/bin/env python3 +""" +Mount S3 as the Harness Filesystem + +Every Harness session runs in an isolated microVM with its own ephemeral disk — +when the session ends, that disk is gone. To keep artifacts around, you can mount +an **S3 Files access point** into the VM. The agent then reads and writes a normal +POSIX path (e.g. /mnt/data) that is backed by S3, so files survive session +termination and are shared across sessions. + +An S3 Files mount requires the Harness to run in **VPC network mode** — the +microVM reaches the access point's NFS mount target over your VPC. So the +environment carries both a `networkConfiguration` (VPC + subnets + security +groups) and the `filesystemConfigurations`: + + environment={ + "agentCoreRuntimeEnvironment": { + "networkConfiguration": { + "networkMode": "VPC", + "networkModeConfig": { + "subnets": ["subnet-..."], + "securityGroups": ["sg-..."], + }, + }, + "filesystemConfigurations": [ + { + "s3FilesAccessPoint": { + "accessPointArn": "", + "mountPath": "/mnt/data", + } + } + ], + } + } + +This sample demonstrates persistence across the session boundary: + + 1. Create a Harness with the S3 mount (in your VPC) + 2. Session A — ask the agent to WRITE a file under the mount path + 3. Session B (fresh microVM) — ask the agent to READ that same file back + The file is still there because it lives in S3, not on the VM disk. + +Prerequisites +------------- +* An S3 Files access point backed by a bucket, with a mount target in the subnet + you pass below. Its ARN looks like: + arn:aws:s3files:::file-system/fs-xxxx/access-point/fsap-xxxx + Provide it with --access-point-arn. +* The subnet(s) and security group(s) that reach the mount target. The Harness + must be in the same VPC as the mount target, the subnet(s) you pass must be in an + Availability Zone that has a mount target, and the security group(s) must allow + NFS (port 2049). Use private subnets with egress (a route to a NAT gateway) — + VPC-mode Harnesses run in private networking and public subnets won't connect. +* The Harness execution role must be allowed to mount the access point. If this + script creates the role (the default), it attaches the required `s3files` + permissions for you. If you pass --role-arn, make sure it already has them. + +Usage: + # Mount an existing S3 Files access point at /mnt/data and run the demo + python s3_filesystem.py \\ + --access-point-arn arn:aws:s3files:us-west-2:111122223333:file-system/fs-abc/access-point/fsap-def \\ + --subnet-ids subnet-0abc1234 \\ + --security-group-ids sg-0def5678 + + # Choose a different mount path and filename + python s3_filesystem.py \\ + --access-point-arn arn:aws:s3files:... \\ + --subnet-ids subnet-0abc1234 --security-group-ids sg-0def5678 \\ + --mount-path /mnt/shared \\ + --filename trip-notes.md + + # Keep the harness after the demo + python s3_filesystem.py --access-point-arn ... --subnet-ids ... --security-group-ids ... --skip-cleanup + + # See all options + python s3_filesystem.py --help +""" + +import argparse +import json +import os +import re +import sys +import time +import uuid + +from pathlib import Path + +import boto3 +import botocore.exceptions + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from utils.iam import create_harness_role, ROLE_NAME +from utils.client import get_agentcore_client, get_agentcore_control_client + +REGION = os.getenv("AWS_DEFAULT_REGION") + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- +DEFAULT_MODEL = "global.anthropic.claude-haiku-4-5-20251001-v1:0" +DEFAULT_MOUNT_PATH = "/mnt/data" +DEFAULT_FILENAME = "harness-note.md" +S3_FILES_POLICY_NAME = "HarnessS3FilesAccess" + +# mountPath must match /mnt/ (see the service model: MountPath) +MOUNT_PATH_PATTERN = re.compile(r"^/mnt/[a-zA-Z0-9._-]+/?$") + +HARNESS_POLL_INTERVAL = 5 +HARNESS_POLL_TIMEOUT = 180 + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- +parser = argparse.ArgumentParser( + description="Mount an S3 Files access point into a Harness and prove artifacts persist across sessions.", + formatter_class=argparse.RawDescriptionHelpFormatter, +) +parser.add_argument( + "--access-point-arn", + required=True, + metavar="ARN", + help="S3 Files access point ARN to mount (arn:aws:s3files:...:access-point/fsap-...)", +) +parser.add_argument( + "--subnet-ids", + required=True, + nargs="+", + metavar="SUBNET", + help="VPC subnet(s) that can reach the access point's mount target (NFS/2049)", +) +parser.add_argument( + "--security-group-ids", + required=True, + nargs="+", + metavar="SG", + help="Security group(s) allowing NFS (2049) to the mount target", +) +parser.add_argument( + "--mount-path", + default=DEFAULT_MOUNT_PATH, + metavar="PATH", + help=f"Where to mount it inside the VM (default: {DEFAULT_MOUNT_PATH})", +) +parser.add_argument( + "--filename", + default=DEFAULT_FILENAME, + metavar="NAME", + help=f"File the agent writes/reads under the mount (default: {DEFAULT_FILENAME})", +) +parser.add_argument( + "--model", + default=DEFAULT_MODEL, + metavar="MODEL_ID", + help=f"Bedrock model ID (default: {DEFAULT_MODEL})", +) +parser.add_argument( + "--role-arn", + default=None, + metavar="ARN", + help="Use an existing IAM execution role (must already allow the access point)", +) +parser.add_argument( + "--skip-cleanup", + action="store_true", + help="Keep the harness after the demo", +) +parser.add_argument( + "--raw-events", + action="store_true", + help="Print raw JSON streaming events from invoke", +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +def attach_s3_files_policy(role_name, access_point_arn): + """Allow the execution role to validate and mount the S3 Files access point. + + * `s3files:GetAccessPoint` on `*` — the runtime validates this at harness + create time. Keep it unscoped; a scoped/conditioned form is rejected at + create with "Ensure the role has s3files:GetAccessPoint". + * `s3files:ClientMount`/`ClientWrite` — used when the microVM mounts the + access point; scoped to the file system with an AccessPointArn condition. + """ + fs_arn = access_point_arn.split("/access-point/")[0] + iam = boto3.client("iam") + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "S3FilesValidate", + "Effect": "Allow", + "Action": ["s3files:GetAccessPoint"], + "Resource": "*", + }, + { + "Sid": "S3FilesClientMount", + "Effect": "Allow", + "Action": ["s3files:ClientMount", "s3files:ClientWrite"], + "Resource": fs_arn, + "Condition": {"ArnEquals": {"s3files:AccessPointArn": access_point_arn}}, + }, + ], + } + iam.put_role_policy( + RoleName=role_name, + PolicyName=S3_FILES_POLICY_NAME, + PolicyDocument=json.dumps(policy), + ) + print(f" Attached S3 Files access policy: {S3_FILES_POLICY_NAME}") + + +def poll_harness_status(control, harness_id, target_status="READY", timeout=HARNESS_POLL_TIMEOUT): + """Poll until a Harness reaches the target status or times out.""" + deadline = time.monotonic() + timeout + while True: + resp = control.get_harness(harnessId=harness_id) + status = resp["harness"]["status"] + print(f" Harness status: {status}") + if status == target_status: + return resp + if status in ("FAILED", "DELETE_FAILED"): + reason = resp["harness"].get("failureReason", "") + raise RuntimeError(f"Harness entered {status}. {reason}") + if time.monotonic() > deadline: + raise TimeoutError(f"Harness not {target_status} after {timeout}s (current: {status})") + time.sleep(HARNESS_POLL_INTERVAL) + + +def stream_response(client, harness_arn, session_id, message, model_id, raw=False): + """Invoke a Harness and stream the response to stdout.""" + response = client.invoke_harness( + harnessArn=harness_arn, + runtimeSessionId=session_id, + messages=[{"role": "user", "content": [{"text": message}]}], + model={"bedrockModelConfig": {"modelId": model_id}}, + ) + + full_text = "" + try: + for event in response["stream"]: + if raw: + print(json.dumps(event, default=str)) + continue + + if "contentBlockStart" in event: + start = event["contentBlockStart"].get("start", {}) + if "toolUse" in start: + print(f"\n [Tool: {start['toolUse'].get('name', '?')}]", flush=True) + elif "contentBlockDelta" in event: + delta = event["contentBlockDelta"].get("delta", {}) + if "text" in delta: + print(delta["text"], end="", flush=True) + full_text += delta["text"] + elif "messageStop" in event: + print() + elif "internalServerException" in event: + print(f"\n Error: {event['internalServerException']}") + except botocore.exceptions.EventStreamError: + if not full_text: + raise + + return full_text + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +def main(args=None): + if args is None: + args = parser.parse_args() + + if not MOUNT_PATH_PATTERN.match(args.mount_path): + parser.error(f"--mount-path must look like /mnt/ (got: {args.mount_path})") + + control = get_agentcore_control_client() + client = get_agentcore_client() + + mount = args.mount_path.rstrip("/") + remote_file = f"{mount}/{args.filename}" + harness_id = None + + try: + # ── Step 0: IAM role (with S3 access) ───────────────────────── + print("=" * 60) + print("Step 0: IAM execution role") + print("=" * 60) + if args.role_arn: + role_arn = args.role_arn + print(f" Using provided role: {role_arn}") + print(" (ensure it can access the S3 Files access point)") + else: + role_arn = create_harness_role() + attach_s3_files_policy(ROLE_NAME, args.access_point_arn) + print(" Waiting for IAM propagation...") + time.sleep(10) + + # ── Step 1: Create Harness with S3 mount (VPC network mode) ─── + print("\n" + "=" * 60) + print("Step 1: Create Harness with S3 mounted at " + mount) + print("=" * 60) + # S3 Files mounts require VPC network mode so the microVM can reach the + # access point's mount target over your VPC. + network = { + "networkMode": "VPC", + "networkModeConfig": { + "subnets": args.subnet_ids, + "securityGroups": args.security_group_ids, + }, + } + filesystem = [ + { + "s3FilesAccessPoint": { + "accessPointArn": args.access_point_arn, + "mountPath": mount, + } + } + ] + print(f" networkConfiguration = {json.dumps(network)}") + print(f" filesystemConfigurations = {json.dumps(filesystem)}") + harness_name = f"S3Mount_{uuid.uuid4().hex[:8]}" + resp = control.create_harness( + harnessName=harness_name, + executionRoleArn=role_arn, + environment={ + "agentCoreRuntimeEnvironment": { + "networkConfiguration": network, + "filesystemConfigurations": filesystem, + } + }, + systemPrompt=[ + {"text": f"You are a helpful assistant. A persistent S3-backed directory is mounted at {mount}."} + ], + ) + harness_id = resp["harness"]["harnessId"] + harness_arn = resp["harness"]["arn"] + print(f" Harness ID: {harness_id}") + print(f" Harness ARN: {harness_arn}") + poll_harness_status(control, harness_id) + + # ── Step 2: Session A — write a file to the mount ───────────── + print("\n" + "=" * 60) + print("Step 2: Session A — write a file to the S3 mount") + print("=" * 60) + session_a = str(uuid.uuid4()).upper() + print(f" Session A: {session_a}\n") + stream_response( + client, + harness_arn, + session_a, + f"Write a short markdown travel note about Amsterdam to {remote_file}. " + f"Confirm the absolute path you saved it to.", + args.model, + raw=args.raw_events, + ) + + # Give the S3-backed write a moment to flush before the next session. + time.sleep(5) + + # ── Step 3: Session B — read it back from a fresh VM ────────── + print("\n" + "=" * 60) + print("Step 3: Session B (fresh microVM) — read the file back") + print("=" * 60) + session_b = str(uuid.uuid4()).upper() + print(f" Session B: {session_b}") + print(" Different session = different VM disk. If the agent can still") + print(" read the file, it's because the mount is backed by S3.\n") + stream_response( + client, + harness_arn, + session_b, + f"Read the file {remote_file} and show me its contents verbatim.", + args.model, + raw=args.raw_events, + ) + + print("\n" + "=" * 60) + print("Done! The file written in Session A was readable in Session B.") + print("=" * 60) + + finally: + if not args.skip_cleanup and harness_id: + print("\nCleaning up...") + try: + control.delete_harness(harnessId=harness_id) + print(f" Deleted harness: {harness_id}") + except Exception as e: + print(f" Warning: failed to delete harness: {e}") + print(" Note: the S3 bucket and access point are left intact.") + + +if __name__ == "__main__": + main() diff --git a/01-features/01-harness/01-advanced-examples/14-s3-filesystem/s3_llm_wiki.py b/01-features/01-harness/01-advanced-examples/14-s3-filesystem/s3_llm_wiki.py new file mode 100644 index 00000000..05dc35af --- /dev/null +++ b/01-features/01-harness/01-advanced-examples/14-s3-filesystem/s3_llm_wiki.py @@ -0,0 +1,380 @@ +#!/usr/bin/env python3 +""" +S3-Backed LLM Wiki (a persistent, compounding markdown wiki) + +A use case for the S3 filesystem mount: an agent that maintains its own +persistent markdown wiki. It implements the pattern Andrej Karpathy describes in +https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f : +instead of re-deriving answers from raw documents on every query (classic RAG), +an LLM agent **incrementally builds and maintains a persistent markdown wiki** — +knowledge is compiled once and kept current, becoming a compounding artifact. + +(Note: this is a self-maintained markdown wiki on the agent's filesystem — it is +unrelated to the Amazon Bedrock Knowledge Bases feature.) + +Why the harness S3 mount is a natural fit: the wiki must outlive any single +session and be shared across invocations. Mounting an S3 Files access point at +`/mnt/wiki` gives the agent a normal POSIX directory that is backed by S3, so the +wiki it writes in one session is still there in the next — and the agent picks up +exactly where it left off. + +The three layers from the gist, mapped onto the mount: + + /mnt/wiki/ + sources/ raw, immutable inputs (the agent reads, never edits) + pages/ LLM-owned markdown: summaries, entity pages, concept pages + AGENTS.md the schema — tells the agent how the wiki is organized + index.md catalog of wiki pages + log.md append-only chronological record + +Three operations, each a separate session to PROVE persistence across the +microVM boundary: + + ingest read a raw source, integrate it across the wiki (create/update pages) + query answer a question from the wiki, filing the answer back as a page + lint health-check: find contradictions, stale claims, orphan pages + +An S3 Files mount requires the Harness to run in **VPC network mode** (pass the +subnet(s) and security group(s) that can reach the access point's mount target). + +Usage: + # Full demo: bootstrap schema, ingest two sources, query, lint + python s3_llm_wiki.py \\ + --access-point-arn arn:aws:s3files:us-west-2:111122223333:file-system/fs-abc/access-point/fsap-def \\ + --subnet-ids subnet-0abc1234 --security-group-ids sg-0def5678 + + # Run a single operation against an existing wiki harness/mount + python s3_llm_wiki.py --access-point-arn arn:aws:s3files:... \\ + --subnet-ids subnet-0abc1234 --security-group-ids sg-0def5678 \\ + --op query -m "What do we know about retrieval-augmented generation?" + + # Custom mount path + python s3_llm_wiki.py --access-point-arn ... --subnet-ids ... --security-group-ids ... --mount-path /mnt/notes + + # Keep the harness after the demo + python s3_llm_wiki.py --access-point-arn ... --subnet-ids ... --security-group-ids ... --skip-cleanup + + # See all options + python s3_llm_wiki.py --help +""" + +import argparse +import json +import os +import re +import sys +import time +import uuid +from pathlib import Path + +import boto3 +import botocore.exceptions + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from utils.iam import create_harness_role, ROLE_NAME +from utils.client import get_agentcore_client, get_agentcore_control_client + +REGION = os.getenv("AWS_DEFAULT_REGION") + + +# ── Constants ─────────────────────────────────────────────────────────────── +DEFAULT_MODEL = "global.anthropic.claude-haiku-4-5-20251001-v1:0" +DEFAULT_MOUNT_PATH = "/mnt/wiki" +S3_FILES_POLICY_NAME = "HarnessS3FilesAccess" +MOUNT_PATH_PATTERN = re.compile(r"^/mnt/[a-zA-Z0-9._-]+/?$") + +HARNESS_POLL_INTERVAL = 5 +HARNESS_POLL_TIMEOUT = 180 + +# Two tiny "raw sources" the agent ingests. In a real wiki these are papers, +# tickets, docs — here they're short so the demo runs fast. +SOURCES = { + "rag-overview.md": ( + "# Retrieval-Augmented Generation (RAG)\n\n" + "RAG retrieves relevant document chunks at query time and feeds them to an " + "LLM as context. Strengths: fresh data, source attribution. Weaknesses: " + "re-derives understanding on every query, sensitive to chunking and " + "retrieval quality." + ), + "wiki-pattern.md": ( + "# The LLM Wiki Pattern\n\n" + "Instead of re-retrieving raw chunks per query, an LLM maintains a persistent " + "markdown wiki: summaries, entity pages, and concept pages with cross-links. " + "Knowledge is compiled once and kept current. Contrasts with RAG by making " + "knowledge a compounding artifact rather than a per-query computation." + ), +} + + +# ── CLI ───────────────────────────────────────────────────────────────────── +parser = argparse.ArgumentParser( + description="Build a persistent, S3-backed LLM wiki with the harness.", + formatter_class=argparse.RawDescriptionHelpFormatter, +) +parser.add_argument("--access-point-arn", required=True, metavar="ARN", + help="S3 Files access point ARN to mount (arn:aws:s3files:...:access-point/fsap-...)") +parser.add_argument("--subnet-ids", required=True, nargs="+", metavar="SUBNET", + help="VPC subnet(s) that can reach the access point's mount target (NFS/2049)") +parser.add_argument("--security-group-ids", required=True, nargs="+", metavar="SG", + help="Security group(s) allowing NFS (2049) to the mount target") +parser.add_argument("--mount-path", default=DEFAULT_MOUNT_PATH, metavar="PATH", + help=f"Where to mount the wiki inside the VM (default: {DEFAULT_MOUNT_PATH})") +parser.add_argument("--op", choices=["all", "ingest", "query", "lint"], default="all", + help="Which operation to run (default: all — bootstrap, ingest, query, lint)") +parser.add_argument("--message", "-m", default="How does the LLM wiki pattern differ from RAG?", + help="Question for the query operation") +parser.add_argument("--model", default=DEFAULT_MODEL, metavar="MODEL_ID", + help=f"Bedrock model ID (default: {DEFAULT_MODEL})") +parser.add_argument("--role-arn", default=None, metavar="ARN", + help="Use an existing IAM execution role (must already allow the access point)") +parser.add_argument("--skip-cleanup", action="store_true", help="Keep the harness after the demo") +parser.add_argument("--raw-events", action="store_true", help="Print raw JSON streaming events") + + +# ── Helpers ───────────────────────────────────────────────────────────────── +def attach_s3_files_policy(role_name, access_point_arn): + """Allow the execution role to validate and mount the S3 Files access point. + + * `s3files:GetAccessPoint` on `*` — the runtime validates this at harness + create time. Keep it unscoped; a scoped/conditioned form is rejected at + create with "Ensure the role has s3files:GetAccessPoint". + * `s3files:ClientMount`/`ClientWrite` — used when the microVM mounts the + access point; scoped to the file system with an AccessPointArn condition. + """ + fs_arn = access_point_arn.split("/access-point/")[0] + iam = boto3.client("iam") + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "S3FilesValidate", + "Effect": "Allow", + "Action": ["s3files:GetAccessPoint"], + "Resource": "*", + }, + { + "Sid": "S3FilesClientMount", + "Effect": "Allow", + "Action": ["s3files:ClientMount", "s3files:ClientWrite"], + "Resource": fs_arn, + "Condition": {"ArnEquals": {"s3files:AccessPointArn": access_point_arn}}, + }, + ], + } + iam.put_role_policy(RoleName=role_name, PolicyName=S3_FILES_POLICY_NAME, + PolicyDocument=json.dumps(policy)) + print(f" Attached S3 Files access policy: {S3_FILES_POLICY_NAME}") + + +def poll_harness_status(control, harness_id, target_status="READY", timeout=HARNESS_POLL_TIMEOUT): + """Poll until a Harness reaches the target status or times out.""" + deadline = time.monotonic() + timeout + while True: + resp = control.get_harness(harnessId=harness_id) + status = resp["harness"]["status"] + print(f" Harness status: {status}") + if status == target_status: + return resp + if status in ("FAILED", "DELETE_FAILED"): + reason = resp["harness"].get("failureReason", "") + raise RuntimeError(f"Harness entered {status}. {reason}") + if time.monotonic() > deadline: + raise TimeoutError(f"Harness not {target_status} after {timeout}s (current: {status})") + time.sleep(HARNESS_POLL_INTERVAL) + + +def stream_turn(client, harness_arn, message, model_id, mount, raw=False): + """Run one wiki operation in its OWN session (proves cross-session persistence).""" + session_id = str(uuid.uuid4()).upper() + system = ( + f"You maintain a persistent markdown wiki mounted at {mount}. " + f"Layers: {mount}/sources (raw, read-only), {mount}/pages (your markdown pages), " + f"{mount}/AGENTS.md (schema), {mount}/index.md (catalog), {mount}/log.md (append-only). " + "Use your filesystem and shell tools to read and write files directly. " + "Keep pages concise and cross-linked with [[wiki-links]]." + ) + response = client.invoke_harness( + harnessArn=harness_arn, + runtimeSessionId=session_id, + messages=[{"role": "user", "content": [{"text": message}]}], + model={"bedrockModelConfig": {"modelId": model_id}}, + systemPrompt=[{"text": system}], + timeoutSeconds=300, + ) + full_text = "" + try: + for event in response["stream"]: + if raw: + print(json.dumps(event, default=str)) + continue + if "contentBlockStart" in event: + start = event["contentBlockStart"].get("start", {}) + if "toolUse" in start: + print(f"\n [Tool: {start['toolUse'].get('name', '?')}]", flush=True) + elif "contentBlockDelta" in event: + delta = event["contentBlockDelta"].get("delta", {}) + if "text" in delta: + print(delta["text"], end="", flush=True) + full_text += delta["text"] + elif "messageStop" in event: + print() + elif "internalServerException" in event: + print(f"\n Error: {event['internalServerException']}") + except botocore.exceptions.EventStreamError: + if not full_text: + raise + return full_text + + +def seed_sources(client, harness_arn, mount): + """Write the raw source docs and bootstrap the schema into the mount (one session).""" + session_id = str(uuid.uuid4()).upper() + + def run(cmd): + resp = client.invoke_agent_runtime_command( + agentRuntimeArn=harness_arn, runtimeSessionId=session_id, body={"command": cmd} + ) + for event in resp["stream"]: + if "chunk" in event and "contentDelta" in event["chunk"]: + d = event["chunk"]["contentDelta"] + if "stderr" in d: + print(d["stderr"], end="") + + run(f"mkdir -p {mount}/sources {mount}/pages") + for name, body in SOURCES.items(): + # base64 to avoid any shell-quoting issues with the markdown body + import base64 + b64 = base64.b64encode(body.encode()).decode() + run(f"echo {b64} | base64 -d > {mount}/sources/{name}") + # Bootstrap schema/index/log only if not present (idempotent for re-runs) + run(f"test -f {mount}/AGENTS.md || printf '# Wiki Schema\\n\\nsources/ raw inputs. pages/ LLM pages. index.md catalog. log.md history.\\n' > {mount}/AGENTS.md") + run(f"test -f {mount}/index.md || printf '# Index\\n' > {mount}/index.md") + run(f"test -f {mount}/log.md || printf '# Log\\n' > {mount}/log.md") + print(f" Seeded {len(SOURCES)} source(s) and bootstrapped schema under {mount}") + + +# ── Main ──────────────────────────────────────────────────────────────────── +def main(args=None): + if args is None: + args = parser.parse_args() + + if not MOUNT_PATH_PATTERN.match(args.mount_path): + parser.error(f"--mount-path must look like /mnt/ (got: {args.mount_path})") + + control = get_agentcore_control_client() + client = get_agentcore_client() + mount = args.mount_path.rstrip("/") + harness_id = None + + try: + # ── Step 0: IAM role (with S3 access) ───────────────────────── + print("=" * 60) + print("Step 0: IAM execution role") + print("=" * 60) + if args.role_arn: + role_arn = args.role_arn + print(f" Using provided role: {role_arn}") + print(" (ensure it can access the S3 Files access point)") + else: + role_arn = create_harness_role() + attach_s3_files_policy(ROLE_NAME, args.access_point_arn) + print(" Waiting for IAM propagation...") + time.sleep(10) + + # ── Step 1: Create harness with the wiki mounted (VPC mode) ─── + print("\n" + "=" * 60) + print(f"Step 1: Create harness with the wiki mounted at {mount}") + print("=" * 60) + # S3 Files mounts require VPC network mode so the microVM can reach the + # access point's mount target over your VPC. + network = { + "networkMode": "VPC", + "networkModeConfig": {"subnets": args.subnet_ids, "securityGroups": args.security_group_ids}, + } + filesystem = [{"s3FilesAccessPoint": {"accessPointArn": args.access_point_arn, "mountPath": mount}}] + harness_name = f"S3LlmWiki_{uuid.uuid4().hex[:8]}" + resp = control.create_harness( + harnessName=harness_name, + executionRoleArn=role_arn, + environment={ + "agentCoreRuntimeEnvironment": { + "networkConfiguration": network, + "filesystemConfigurations": filesystem, + } + }, + ) + harness_id = resp["harness"]["harnessId"] + harness_arn = resp["harness"]["arn"] + print(f" Harness ID: {harness_id}") + print(f" Harness ARN: {harness_arn}") + poll_harness_status(control, harness_id) + + # ── Step 2: Seed raw sources (separate session) ─────────────── + if args.op in ("all", "ingest"): + print("\n" + "=" * 60) + print("Step 2: Seed raw sources into the mount") + print("=" * 60) + seed_sources(client, harness_arn, mount) + time.sleep(5) + + # ── Step 3: INGEST — compile sources into the wiki ──────────── + if args.op in ("all", "ingest"): + print("\n" + "=" * 60) + print("Step 3: INGEST — integrate sources into the wiki") + print("=" * 60 + "\n") + stream_turn( + client, harness_arn, + f"Ingest every file in {mount}/sources that isn't represented yet. For each, create or " + f"update concise pages under {mount}/pages (concept/entity pages), cross-link with " + f"[[links]], update {mount}/index.md, and append a line to {mount}/log.md. " + "Summarize what you ingested and which pages you touched.", + args.model, mount, raw=args.raw_events, + ) + time.sleep(5) + + # ── Step 4: QUERY — answer from the wiki, file the answer ───── + if args.op in ("all", "query"): + print("\n" + "=" * 60) + print("Step 4: QUERY — answer from the wiki (fresh session)") + print("=" * 60) + print(f" Question: {args.message}\n") + stream_turn( + client, harness_arn, + f"Using only the wiki under {mount}/pages, answer: \"{args.message}\". Cite the wiki " + f"pages you used. Then file your answer as a new page under {mount}/pages and link it " + f"from {mount}/index.md so the exploration compounds.", + args.model, mount, raw=args.raw_events, + ) + time.sleep(5) + + # ── Step 5: LINT — health-check the wiki ────────────────────── + if args.op in ("all", "lint"): + print("\n" + "=" * 60) + print("Step 5: LINT — check the wiki for issues (fresh session)") + print("=" * 60 + "\n") + stream_turn( + client, harness_arn, + f"Lint the wiki under {mount}: list any contradictions, stale claims, " + f"orphan pages (not linked from {mount}/index.md), or broken [[links]]. Report findings; " + "fix trivial issues directly.", + args.model, mount, raw=args.raw_events, + ) + + print("\n" + "=" * 60) + print("Done! The wiki persists in S3 — re-run with --op query to see it compound.") + print("=" * 60) + + finally: + if not args.skip_cleanup and harness_id: + print("\nCleaning up...") + try: + control.delete_harness(harnessId=harness_id) + print(f" Deleted harness: {harness_id}") + except Exception as e: + print(f" Warning: failed to delete harness: {e}") + print(" Note: the S3 bucket, access point, AND the wiki it holds are left intact.") + + +if __name__ == "__main__": + main() diff --git a/01-features/01-harness/02-use-cases/03-aws-builder-agent/README.md b/01-features/01-harness/02-use-cases/03-aws-builder-agent/README.md new file mode 100644 index 00000000..1342f531 --- /dev/null +++ b/01-features/01-harness/02-use-cases/03-aws-builder-agent/README.md @@ -0,0 +1,97 @@ +# AWS Builder Agent — Harness + AWS Skills + +| Information | Details | +|:--------------------|:-------------------------------------------------------------------------| +| Tutorial type | Use Case | +| Agent type | AWS engineering / coding agent | +| Agentic Framework | None (direct boto3) | +| LLM model | Anthropic Claude Haiku 4.5 | +| Tutorial components | harness — **AWS Skills** (`awsSkills`), filesystem + shell tools, multi-turn | +| Example complexity | Intermediate | + +## Overview + +**This is the "how do you build an agent with the harness?" example — and the +answer is harness + AWS Skills.** The harness *is* the agent: you declare the +model, the tools, and the skills in one `create_harness` call, then invoke. No +orchestration code, no framework. + +Here we build an **AWS engineering assistant** by loading the +[AWS Agent Toolkit](https://github.com/aws/agent-toolkit-for-aws) skills via the +`awsSkills` parameter. Those skills give the agent curated AWS expertise +(serverless, CDK, CloudFormation, observability), and the harness's built-in +filesystem + shell tools let it actually **scaffold a runnable project**, not +just describe one. + +> **Why this matters:** AWS Skills are the fastest way to see the benefit of the +> harness. A small, cheap model + the right skills = an AWS-aware coding agent in +> ~3 API calls. Change the skill paths or the prompt and you have a different +> agent — that is the whole harness model. + +## How AWS Skills power this agent + +```python +control.create_harness( + harnessName=name, + executionRoleArn=role_arn, + # ── This one parameter is what makes it an AWS expert ── + skills=[{"awsSkills": {"paths": ["core-skills/aws-serverless", "core-skills/aws-cdk"]}}], + systemPrompt=[{"text": "You are a senior AWS solutions engineer..."}], +) +``` + +`awsSkills` selects bundles from the AWS Agent Toolkit. See +[13-aws-skills](../../01-advanced-examples/13-aws-skills) for every selection +mode (all / glob / specific / mixed). + +## What it does, end to end + +1. **Create** the agent — harness + `awsSkills` + a builder system prompt +2. **Design** (turn 1) — the agent designs a serverless URL shortener (API GW + Lambda + DynamoDB) +3. **Scaffold** (turn 2, same session) — it writes a real CDK project to the VM filesystem +4. **Inspect** — `ExecuteCommand` lists the files the agent created +5. **Clean up** + +## Sample Prompts + +**Brief (default)**: "Design a minimal serverless URL shortener on AWS: API Gateway + Lambda + DynamoDB..." +**Expected Behavior**: The agent designs the architecture, then scaffolds a TypeScript CDK app with handler files, README, and package.json under `/tmp/url-shortener`. + +**Brief (`-m`)**: "Design and scaffold a CDK app for an S3 + Lambda thumbnail pipeline." +**Expected Behavior**: Same design → scaffold flow for a different serverless use case, drawing on the loaded AWS Skills. + +## Key Concepts + +**The skill is the difference**: Without `awsSkills`, a small model gives generic answers. With it, the agent applies real AWS best practices and current patterns. + +**Multi-turn, one VM**: Design and scaffold run in the same `session_id`, so files from the scaffold step persist and can be inspected. + +**Agent acts, not just talks**: The harness's default filesystem + shell tools let the agent write runnable code, not placeholders. + +## Clean Up + +```python +control.delete_harness(harnessId=harness_id) +from utils.iam import delete_harness_role +delete_harness_role() +``` + +The script deletes the harness on exit (pass `--skip-cleanup` to keep it). + +## Running the Python Scripts + +```bash +pip install -r ../../requirements.txt +``` + +```bash +# Build the default serverless URL-shortener agent +python aws_builder_agent.py + +# Give it your own brief +python aws_builder_agent.py \ + -m "Design and scaffold a CDK app for an S3 + Lambda thumbnail pipeline." + +# Narrow the AWS Skills the agent loads +python aws_builder_agent.py --skill-paths core-skills/aws-cdk core-skills/aws-serverless +``` diff --git a/01-features/01-harness/02-use-cases/03-aws-builder-agent/aws_builder_agent.py b/01-features/01-harness/02-use-cases/03-aws-builder-agent/aws_builder_agent.py new file mode 100644 index 00000000..d50bdbcb --- /dev/null +++ b/01-features/01-harness/02-use-cases/03-aws-builder-agent/aws_builder_agent.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +""" +AWS Builder Agent — building agents with the harness + AWS Skills + +This use case answers a simple question: *how do you use the harness to build a +real agent?* The answer is that the harness IS the agent — you declare the model, +the tools, and the skills in one `create_harness` call, then invoke it. No +orchestration code, no framework. + +Here we build an **AWS engineering assistant**: a harness agent loaded with the +[AWS Agent Toolkit](https://github.com/aws/agent-toolkit-for-aws) skills +(`awsSkills`). The agent gains curated AWS expertise — serverless, CDK, +CloudFormation, observability — and uses its built-in filesystem + shell tools to +actually scaffold a project, not just describe one. + +What it does, end to end: + + 1. Create a harness with AWS Skills + a builder system prompt + 2. Turn 1 — ask the agent to DESIGN a small serverless app (architecture) + 3. Turn 2 — same session: ask it to SCAFFOLD the project (write files to the VM) + 4. Inspect the files the agent created (ExecuteCommand) + 5. Clean up + +The point: a capable, AWS-aware coding agent in ~3 API calls. Swap the skill +paths or the prompt and you have a different agent — that's the harness model. + +Usage: + # Build the default serverless URL-shortener agent + python aws_builder_agent.py + + # Give it your own brief + python aws_builder_agent.py \\ + -m "Design and scaffold a CDK app for an S3 + Lambda thumbnail pipeline." + + # Narrow the skills the agent loads + python aws_builder_agent.py --skill-paths core-skills/aws-cdk core-skills/aws-serverless + + # Keep the harness after the demo + python aws_builder_agent.py --skip-cleanup + + # See all options + python aws_builder_agent.py --help +""" + +import argparse +import json +import os +import sys +import time +import uuid +from pathlib import Path + +import boto3 +import botocore.exceptions + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from utils.iam import create_harness_role +from utils.client import get_agentcore_client, get_agentcore_control_client + +REGION = os.getenv("AWS_DEFAULT_REGION") + + +# ── Constants ─────────────────────────────────────────────────────────────── +DEFAULT_MODEL = "global.anthropic.claude-haiku-4-5-20251001-v1:0" +DEFAULT_SKILL_PATHS = ["core-skills/aws-serverless", "core-skills/aws-cdk"] +PROJECT_DIR = "/tmp/url-shortener" + +DESIGN_PROMPT = ( + "Design a minimal serverless URL shortener on AWS: API Gateway + Lambda + " + "DynamoDB. Describe the architecture, the data model, and the two endpoints " + "(create short URL, resolve short URL). Keep it to a short, concrete design." +) +SCAFFOLD_PROMPT = ( + f"Now scaffold that project under {PROJECT_DIR}. Create a CDK app (TypeScript) " + "with the stack definition, a lambda/ directory with the two handler files, a " + "README.md, and a package.json. Write real, runnable starter code — not " + "placeholders. When done, list the files you created." +) + +HARNESS_POLL_INTERVAL = 5 +HARNESS_POLL_TIMEOUT = 180 + + +# ── CLI ───────────────────────────────────────────────────────────────────── +parser = argparse.ArgumentParser( + description="Build an AWS engineering agent with the harness + AWS Skills.", + formatter_class=argparse.RawDescriptionHelpFormatter, +) +parser.add_argument( + "--message", + "-m", + default=None, + help="Override the design brief (the scaffold step follows automatically)", +) +parser.add_argument( + "--skill-paths", + nargs="+", + default=DEFAULT_SKILL_PATHS, + metavar="PATH", + help=f"AWS skill paths to load (default: {' '.join(DEFAULT_SKILL_PATHS)})", +) +parser.add_argument( + "--model", + default=DEFAULT_MODEL, + metavar="MODEL_ID", + help=f"Bedrock model ID (default: {DEFAULT_MODEL})", +) +parser.add_argument( + "--role-arn", + default=None, + metavar="ARN", + help="Use an existing IAM execution role ARN instead of creating one", +) +parser.add_argument( + "--skip-cleanup", + action="store_true", + help="Keep the harness after the demo", +) +parser.add_argument( + "--raw-events", + action="store_true", + help="Print raw JSON streaming events from invoke", +) + + +# ── Helpers ───────────────────────────────────────────────────────────────── +def poll_harness_status(control, harness_id, target_status="READY", timeout=HARNESS_POLL_TIMEOUT): + """Poll until a Harness reaches the target status or times out.""" + deadline = time.monotonic() + timeout + while True: + resp = control.get_harness(harnessId=harness_id) + status = resp["harness"]["status"] + print(f" Harness status: {status}") + if status == target_status: + return resp + if status in ("FAILED", "DELETE_FAILED"): + reason = resp["harness"].get("failureReason", "") + raise RuntimeError(f"Harness entered {status}. {reason}") + if time.monotonic() > deadline: + raise TimeoutError(f"Harness not {target_status} after {timeout}s (current: {status})") + time.sleep(HARNESS_POLL_INTERVAL) + + +def stream_turn(client, harness_arn, session_id, message, model_id, raw=False): + """Invoke the harness for one conversational turn and stream the response.""" + response = client.invoke_harness( + harnessArn=harness_arn, + runtimeSessionId=session_id, + messages=[{"role": "user", "content": [{"text": message}]}], + model={"bedrockModelConfig": {"modelId": model_id}}, + timeoutSeconds=300, + ) + + full_text = "" + try: + for event in response["stream"]: + if raw: + print(json.dumps(event, default=str)) + continue + + if "contentBlockStart" in event: + start = event["contentBlockStart"].get("start", {}) + if "toolUse" in start: + print(f"\n [Tool: {start['toolUse'].get('name', '?')}]", flush=True) + elif "contentBlockDelta" in event: + delta = event["contentBlockDelta"].get("delta", {}) + if "text" in delta: + print(delta["text"], end="", flush=True) + full_text += delta["text"] + elif "messageStop" in event: + print() + elif "internalServerException" in event: + print(f"\n Error: {event['internalServerException']}") + except botocore.exceptions.EventStreamError: + if not full_text: + raise + + return full_text + + +def run_command(client, harness_arn, session_id, command): + """Run a shell command on the agent's VM and print stdout/stderr.""" + print(f" $ {command}") + resp = client.invoke_agent_runtime_command( + agentRuntimeArn=harness_arn, + runtimeSessionId=session_id, + body={"command": command}, + ) + for event in resp["stream"]: + if "chunk" in event and "contentDelta" in event["chunk"]: + delta = event["chunk"]["contentDelta"] + if "stdout" in delta: + print(delta["stdout"], end="") + if "stderr" in delta: + print(delta["stderr"], end="") + print() + + +# ── Main ──────────────────────────────────────────────────────────────────── +def main(args=None): + if args is None: + args = parser.parse_args() + + control = get_agentcore_control_client() + client = get_agentcore_client() + + design_prompt = args.message or DESIGN_PROMPT + skills = [{"awsSkills": {"paths": args.skill_paths}}] + harness_id = None + + try: + # ── Step 0: IAM role ────────────────────────────────────────── + print("=" * 60) + print("Step 0: IAM execution role") + print("=" * 60) + if args.role_arn: + role_arn = args.role_arn + print(f" Using provided role: {role_arn}") + else: + role_arn = create_harness_role() + print(" Waiting for IAM propagation...") + time.sleep(10) + + # ── Step 1: Create the agent (harness + AWS Skills) ─────────── + print("\n" + "=" * 60) + print("Step 1: Create the AWS builder agent") + print("=" * 60) + print(f" AWS skills: {args.skill_paths}") + harness_name = f"AwsBuilder_{uuid.uuid4().hex[:8]}" + resp = control.create_harness( + harnessName=harness_name, + executionRoleArn=role_arn, + skills=skills, + systemPrompt=[ + { + "text": ( + "You are a senior AWS solutions engineer. Use your AWS skills to " + "design and build well-architected, runnable projects. Prefer " + "infrastructure-as-code and serverless best practices. When asked to " + "scaffold, write real files to the filesystem using your tools." + ) + } + ], + ) + harness_id = resp["harness"]["harnessId"] + harness_arn = resp["harness"]["arn"] + print(f" Harness ID: {harness_id}") + print(f" Harness ARN: {harness_arn}") + poll_harness_status(control, harness_id) + + session_id = str(uuid.uuid4()).upper() + print(f" Session ID: {session_id}") + + # ── Step 2: Design ──────────────────────────────────────────── + print("\n" + "=" * 60) + print("Step 2: Design the solution") + print("=" * 60) + print(f" Brief: {design_prompt[:80]}{'...' if len(design_prompt) > 80 else ''}\n") + stream_turn(client, harness_arn, session_id, design_prompt, args.model, raw=args.raw_events) + + # ── Step 3: Scaffold (same session — VM state persists) ─────── + print("\n" + "=" * 60) + print("Step 3: Scaffold the project on the agent's VM") + print("=" * 60 + "\n") + stream_turn(client, harness_arn, session_id, SCAFFOLD_PROMPT, args.model, raw=args.raw_events) + + # ── Step 4: Inspect what the agent built ────────────────────── + print("\n" + "=" * 60) + print("Step 4: Inspect the generated project") + print("=" * 60) + run_command(client, harness_arn, session_id, f"find {PROJECT_DIR} -type f 2>/dev/null | head -40") + + print("=" * 60) + print("Done! The harness + AWS Skills produced a working AWS agent.") + print("=" * 60) + + finally: + if not args.skip_cleanup and harness_id: + print("\nCleaning up...") + try: + control.delete_harness(harnessId=harness_id) + print(f" Deleted harness: {harness_id}") + except Exception as e: + print(f" Warning: failed to delete harness: {e}") + + +if __name__ == "__main__": + main() diff --git a/01-features/01-harness/README.md b/01-features/01-harness/README.md index a0d7630c..bf1ac10c 100644 --- a/01-features/01-harness/README.md +++ b/01-features/01-harness/README.md @@ -11,8 +11,8 @@ The new managed agent harness feature in AgentCore replaces all that upfront bui | Folder | What's inside | |:-------|:--------------| | `00-getting-started/` | Core workflow: create harness, invoke, ExecuteCommand | -| `01-advanced-examples/` | Custom containers, gateway, execution limits, MCP, skills, VPC, OAuth | -| `02-use-cases/` | End-to-end applications (travel agent, webapp visual testing) | +| `01-advanced-examples/` | Custom containers, gateway, execution limits, MCP, skills, VPC, OAuth, AWS Skills, S3 filesystem | +| `02-use-cases/` | End-to-end applications (travel agent, webapp visual testing, AWS builder agent) | | `utils/` | Shared IAM and boto3 client helpers used by all scripts | ## How this tree is organized @@ -25,8 +25,10 @@ self-contained — copy any folder and it runs independently. - **By feature** → `01-advanced-examples//` - **By end-to-end scenario** → `02-use-cases//` -- **By tool type** → MCP: `04-mcp-integration/`, Browser: `01-travel-agent/` (Part 5), Skills: `05-agent-skills/` +- **By tool type** → MCP: `04-mcp-integration/`, Browser: `01-travel-agent/` (Part 5), Skills: `05-agent-skills/` (custom) and `13-aws-skills/` (native AWS Skills) - **Auth patterns** → `07-oauth/` (JWT inbound + OAuth outbound) +- **Persistent storage** → `14-s3-filesystem/` (mount S3 as the agent filesystem; includes an LLM wiki) +- **Build an agent with AWS Skills** → `02-use-cases/03-aws-builder-agent/` (harness + AWS Skills = an AWS engineering agent) ## AgentCore CLI @@ -99,11 +101,27 @@ python 01-advanced-examples/04-mcp-integration/mcp_integration.py # Agent skills (xlsx spreadsheets) python 01-advanced-examples/05-agent-skills/agent_skills.py +# AWS Skills (native skill bundles from the AWS Agent Toolkit) +python 01-advanced-examples/13-aws-skills/aws_skills.py + +# S3 filesystem mount (persistent storage across sessions; requires VPC subnets + SGs) +python 01-advanced-examples/14-s3-filesystem/s3_filesystem.py \ + --access-point-arn arn:aws:s3files:REGION:ACCOUNT:file-system/fs-xxxx/access-point/fsap-xxxx \ + --subnet-ids subnet-xxxx --security-group-ids sg-xxxx + +# S3-backed LLM wiki (ingest → query → lint) +python 01-advanced-examples/14-s3-filesystem/s3_llm_wiki.py \ + --access-point-arn arn:aws:s3files:REGION:ACCOUNT:file-system/fs-xxxx/access-point/fsap-xxxx \ + --subnet-ids subnet-xxxx --security-group-ids sg-xxxx + # OAuth + JWT auth export HARNESS_USER_NAME="testuser" export HARNESS_USER_PASS="TestPassword123!" python 01-advanced-examples/07-oauth/oauth_gateway.py +# AWS builder agent (harness + AWS Skills builds a serverless app) +python 02-use-cases/03-aws-builder-agent/aws_builder_agent.py + # Travel guide agent python 02-use-cases/01-travel-agent/travel_agent.py