Harness GA Samples — AWS Skills, S3 Filesystem, Step Functions, Builder Agent (#1688)
* 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).
This commit is contained in:
@@ -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."
|
||||
```
|
||||
@@ -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()
|
||||
@@ -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/<name>`. 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:<region>:<account>: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/<name>` (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?"
|
||||
```
|
||||
@@ -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": "<S3 Files access point ARN>",
|
||||
"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:<region>:<account>: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/<name> (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/<name> (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()
|
||||
@@ -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/<name> (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()
|
||||
@@ -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
|
||||
```
|
||||
@@ -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()
|
||||
@@ -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/<feature>/`
|
||||
- **By end-to-end scenario** → `02-use-cases/<use-case>/`
|
||||
- **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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user