Skip to content

Commit 18fa2c9

Browse files
authored
Merge pull request #1057 from aws/sync-preview/merge-main-20260430-v2
sync-preview: merge main into preview
2 parents 26b1c4c + 7590650 commit 18fa2c9

7 files changed

Lines changed: 120 additions & 65 deletions

File tree

.github/harness/Dockerfile

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
FROM public.ecr.aws/docker/library/python:3.12-slim
2+
3+
# Install system dependencies
4+
RUN apt-get update && apt-get install -y \
5+
git \
6+
curl \
7+
jq \
8+
&& rm -rf /var/lib/apt/lists/*
9+
10+
# Install GitHub CLI
11+
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /usr/share/keyrings/githubcli-archive-keyring.gpg \
12+
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
13+
> /etc/apt/sources.list.d/github-cli.list \
14+
&& apt-get update \
15+
&& apt-get install -y gh \
16+
&& rm -rf /var/lib/apt/lists/*
17+
18+
# Tokens are baked into the image at build time. This image must be treated as a
19+
# secret and stored only in a registry with equivalent access controls.
20+
ARG CLONE_TOKEN
21+
ARG GITHUB_TOKEN
22+
23+
# Configure git to use clone token for HTTPS clones
24+
RUN git config --global url."https://${CLONE_TOKEN}@github.com/".insteadOf "https://github.com/"
25+
26+
# Persist gh CLI auth so GITHUB_TOKEN doesn't need to be in the environment
27+
RUN mkdir -p /root/.config/gh \
28+
&& echo "github.com:" > /root/.config/gh/hosts.yml \
29+
&& echo " oauth_token: ${GITHUB_TOKEN}" >> /root/.config/gh/hosts.yml \
30+
&& echo " user: agentcore-cli-automation" >> /root/.config/gh/hosts.yml \
31+
&& echo " git_protocol: https" >> /root/.config/gh/hosts.yml
32+
33+
WORKDIR /opt/workspace

.github/harness/README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Harness Resources
2+
3+
Container and scripts for AI-powered automation via
4+
[AgentCore Harness](https://docs.aws.amazon.com/bedrock/latest/userguide/agentcore.html).
5+
6+
## Structure
7+
8+
```
9+
harness/
10+
├── Dockerfile # Container image for the harness runtime
11+
├── harness_review.py # Invokes the harness to review PRs (SigV4 + event stream)
12+
└── prompts/
13+
├── system.md # System prompt (workspace context)
14+
└── review.md # PR review task prompt
15+
```
16+
17+
## Current: PR Reviewer
18+
19+
Reviews pull requests on open/reopen via `.github/workflows/pr-ai-review.yml`.
20+
21+
### Dual-token setup
22+
23+
The Dockerfile takes two build args:
24+
25+
- **`CLONE_TOKEN`** — baked into git config for cloning private repos
26+
- **`GITHUB_TOKEN`** — baked into `gh` CLI auth for posting PR comments
27+
28+
### Building the container
29+
30+
```bash
31+
finch build \
32+
--build-arg CLONE_TOKEN=<pat-for-cloning> \
33+
--build-arg GITHUB_TOKEN=<pat-for-gh-api> \
34+
-t pr-reviewer .github/harness/
35+
```
36+
37+
## Future: Tester
38+
39+
This directory will also house a harness-based test runner.
Lines changed: 46 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Invoke Bedrock AgentCore Harness to review a GitHub PR.
22
33
Reads PR_URL from the environment. Streams harness output to stdout.
4-
Uses raw HTTP with SigV4 signing — no custom service model needed.
4+
Uses the boto3 bedrock-agentcore client's invoke_harness API.
55
"""
66

77
import json
@@ -11,11 +11,6 @@
1111
import uuid
1212

1313
import boto3
14-
from botocore.auth import SigV4Auth
15-
from botocore.awsrequest import AWSRequest
16-
from botocore.eventstream import EventStreamBuffer
17-
from urllib.parse import quote
18-
import urllib3
1914

2015
# ANSI color codes
2116
CYAN = "\033[36m"
@@ -25,7 +20,7 @@
2520
DIM = "\033[2m"
2621
RESET = "\033[0m"
2722

28-
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..")
23+
SCRIPTS_DIR = os.path.dirname(__file__)
2924

3025

3126
def read_prompt(filename):
@@ -35,50 +30,37 @@ def read_prompt(filename):
3530
return f.read()
3631

3732

38-
def invoke_harness(harness_arn, body, region):
39-
"""Send a SigV4-signed request to the harness invoke endpoint. Returns a streaming response.
40-
41-
InvokeHarness is not in standard boto3, so we call the REST API directly.
42-
boto3 is only used to resolve AWS credentials (from env vars, OIDC, etc.)
43-
and sign the request with SigV4. The response is an AWS binary event stream.
44-
"""
45-
session = boto3.Session(region_name=region)
46-
credentials = session.get_credentials().get_frozen_credentials()
47-
url = f"https://bedrock-agentcore.{region}.amazonaws.com/harnesses/invoke?harnessArn={quote(harness_arn, safe='')}"
48-
request = AWSRequest(method="POST", url=url, data=body, headers={
49-
"Content-Type": "application/json",
50-
"Accept": "application/vnd.amazon.eventstream",
51-
})
52-
SigV4Auth(credentials, "bedrock-agentcore", region).add_auth(request)
53-
return urllib3.PoolManager().urlopen(
54-
"POST", url, body=body,
55-
headers=dict(request.headers),
56-
preload_content=False,
57-
timeout=urllib3.Timeout(connect=10, read=600),
33+
def invoke_harness_streaming(harness_arn, session_id, system_prompt, messages, model_id, region):
34+
"""Call invoke_harness via boto3 and return the event stream."""
35+
client = boto3.client("bedrock-agentcore", region_name=region)
36+
response = client.invoke_harness(
37+
harnessArn=harness_arn,
38+
runtimeSessionId=session_id,
39+
systemPrompt=[{"text": system_prompt}],
40+
messages=messages,
41+
model={"bedrockModelConfig": {"modelId": model_id}},
5842
)
59-
60-
61-
def parse_events(http_response):
62-
"""Yield (event_type, payload) tuples from the harness binary event stream.
63-
64-
The response arrives as raw bytes in AWS binary event stream format.
65-
EventStreamBuffer reassembles complete events from the 4KB chunks,
66-
and we decode each event's JSON payload before yielding it.
67-
"""
68-
event_buffer = EventStreamBuffer()
69-
for chunk in http_response.stream(4096):
70-
event_buffer.add_data(chunk)
71-
for event in event_buffer:
72-
if event.headers.get(":message-type") == "exception":
73-
payload = json.loads(event.payload.decode("utf-8"))
74-
print(f"\n{RED}ERROR: {payload}{RESET}", file=sys.stderr)
75-
sys.exit(1)
76-
event_type = event.headers.get(":event-type", "")
77-
if event.payload:
78-
yield event_type, json.loads(event.payload.decode("utf-8"))
79-
80-
81-
def print_stream(http_response):
43+
return response["stream"]
44+
45+
46+
def parse_events(event_stream):
47+
"""Yield (event_type, payload) tuples from the boto3 event stream."""
48+
for event in event_stream:
49+
if "contentBlockStart" in event:
50+
yield "contentBlockStart", event["contentBlockStart"]
51+
elif "contentBlockDelta" in event:
52+
yield "contentBlockDelta", event["contentBlockDelta"]
53+
elif "contentBlockStop" in event:
54+
yield "contentBlockStop", event["contentBlockStop"]
55+
elif "messageStop" in event:
56+
yield "messageStop", event["messageStop"]
57+
elif "internalServerException" in event:
58+
yield "internalServerException", event["internalServerException"]
59+
elif "runtimeClientError" in event:
60+
yield "runtimeClientError", event["runtimeClientError"]
61+
62+
63+
def print_stream(event_stream):
8264
"""Display harness events with GitHub Actions log groups.
8365
8466
The harness streams events as the agent works:
@@ -112,7 +94,7 @@ def flush_text():
11294
print(f"{DIM}{line}{RESET}", flush=True)
11395
text_buffer = ""
11496

115-
for event_type, payload in parse_events(http_response):
97+
for event_type, payload in parse_events(event_stream):
11698

11799
if event_type == "contentBlockStart":
118100
start = payload.get("start", {})
@@ -171,6 +153,11 @@ def flush_text():
171153
print(f"\n{RED}ERROR: {payload}{RESET}", file=sys.stderr)
172154
sys.exit(1)
173155

156+
elif event_type == "runtimeClientError":
157+
close_group()
158+
print(f"\n{RED}ERROR: {payload.get('message', payload)}{RESET}", file=sys.stderr)
159+
sys.exit(1)
160+
174161
close_group()
175162
total = time.time() - start_time
176163
print(f"\n{GREEN}Review complete.{RESET} {DIM}({iteration} tool calls, {int(total)}s total){RESET}")
@@ -200,18 +187,14 @@ def flush_text():
200187
SYSTEM_PROMPT = read_prompt("system.md")
201188
REVIEW_PROMPT = read_prompt("review.md").format(pr_url=PR_URL)
202189

203-
request_body = json.dumps({
204-
"runtimeSessionId": SESSION_ID,
205-
"systemPrompt": [{"text": SYSTEM_PROMPT}],
206-
"messages": [{"role": "user", "content": [{"text": REVIEW_PROMPT}]}],
207-
"model": {"bedrockModelConfig": {"modelId": MODEL_ID}},
208-
})
190+
messages = [{"role": "user", "content": [{"text": REVIEW_PROMPT}]}]
209191

210-
http_response = invoke_harness(HARNESS_ARN, request_body, REGION)
211-
212-
if http_response.status != 200:
213-
error = http_response.read().decode("utf-8")
214-
print(f"{RED}ERROR: HTTP {http_response.status}: {error}{RESET}", file=sys.stderr)
192+
try:
193+
event_stream = invoke_harness_streaming(
194+
HARNESS_ARN, SESSION_ID, SYSTEM_PROMPT, messages, MODEL_ID, REGION
195+
)
196+
except Exception as e:
197+
print(f"{RED}ERROR: Failed to invoke harness: {e}{RESET}", file=sys.stderr)
215198
sys.exit(1)
216199

217-
print_stream(http_response)
200+
print_stream(event_stream)
File renamed without changes.
File renamed without changes.

.github/workflows/pr-ai-review.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ jobs:
139139
env:
140140
PR_URL: ${{ steps.pr-url.outputs.url }}
141141
HARNESS_ARN: ${{ secrets.HARNESS_ARN }}
142-
run: python .github/scripts/python/harness_review.py
142+
run: python .github/harness/harness_review.py
143143

144144
- name: Remove agentcore-harness-reviewing label
145145
if: always()

.prettierignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
CHANGELOG.md
22
src/assets/**/*.md
3-
.github/scripts/prompts/
3+
.github/harness/prompts/

0 commit comments

Comments
 (0)