chore: import upstream snapshot with attribution
Harness Compat / harness compat (push) Failing after 0s
CI / test on 3.12 (standard) (push) Has been cancelled
CI / test on 3.13 (standard) (push) Has been cancelled
CI / test on 3.14 (standard) (push) Has been cancelled
CI / test on 3.10 (all-extras) (push) Has been cancelled
CI / test on 3.11 (all-extras) (push) Has been cancelled
CI / test on 3.12 (all-extras) (push) Has been cancelled
CI / test on 3.14 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.10 (pydantic-evals) (push) Has been cancelled
CI / test on 3.11 (pydantic-evals) (push) Has been cancelled
CI / test on 3.12 (pydantic-evals) (push) Has been cancelled
CI / deploy-docs-preview (push) Has been cancelled
CI / build release artifacts (push) Has been cancelled
CI / publish to PyPI (push) Has been cancelled
CI / Send tweet (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / mypy (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / test on 3.10 (standard) (push) Has been cancelled
CI / test on 3.11 (standard) (push) Has been cancelled
CI / test on 3.13 (all-extras) (push) Has been cancelled
CI / test on 3.14 (all-extras) (push) Has been cancelled
CI / test on 3.10 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.11 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.12 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.13 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.13 (pydantic-evals) (push) Has been cancelled
CI / test on 3.14 (pydantic-evals) (push) Has been cancelled
CI / test on 3.10 (lowest-versions) (push) Has been cancelled
CI / test on 3.11 (lowest-versions) (push) Has been cancelled
CI / test on 3.12 (lowest-versions) (push) Has been cancelled
CI / test on 3.13 (lowest-versions) (push) Has been cancelled
CI / test on 3.14 (lowest-versions) (push) Has been cancelled
CI / test examples on 3.11 (push) Has been cancelled
CI / test examples on 3.12 (push) Has been cancelled
CI / test examples on 3.13 (push) Has been cancelled
CI / test examples on 3.14 (push) Has been cancelled
CI / coverage (push) Has been cancelled
CI / check (push) Has been cancelled
CI / deploy-docs (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:27:52 +08:00
commit 9201ef759e
2096 changed files with 1232387 additions and 0 deletions
+890
View File
@@ -0,0 +1,890 @@
from __future__ import annotations
import argparse
import json
import os
import re
import ssl
import statistics
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Literal
SCHEMA_VERSION = 1
CI_WORKFLOW_FILE = 'ci.yml'
REPORT_MARKER = '<!-- ci-duration-report -->'
REPORT_LABEL = 'trigger:ci-duration-report'
BASELINE_MAIN_RUN_LIMIT = 30
BASELINE_PR_RUN_LIMIT = 60
BASELINE_COLLECTION_MAX_SECONDS = 90
MIN_BASELINE_SAMPLES = 10
WARNING_MIN_SECONDS = 60
SLOW_THRESHOLD_MULTIPLIER = 1.25
FAST_THRESHOLD_MULTIPLIER = 0.75
VERY_SLOW_MIN_SECONDS = 300
VERY_SLOW_THRESHOLD_MULTIPLIER = 1.5
JsonValue = None | bool | int | float | str | list['JsonValue'] | dict[str, 'JsonValue']
JsonObject = dict[str, JsonValue]
MetricAttributes = dict[str, bool | int | float | str]
@dataclass(frozen=True)
class WorkflowRunRecord:
repo: str
workflow_id: int
workflow_name: str
workflow_path: str
run_id: int
run_attempt: int
run_number: int
event: str
status: str
conclusion: str | None
head_branch: str | None
base_branch: str | None
head_sha: str
pr_numbers: list[int]
run_started_at: str | None
updated_at: str | None
duration_seconds: float | None
html_url: str
actor: str | None
@dataclass(frozen=True)
class StepRecord:
number: int
name: str
status: str
conclusion: str | None
started_at: str | None
completed_at: str | None
duration_seconds: float | None
@dataclass(frozen=True)
class JobRecord:
job_id: int
raw_name: str
job_family: str
job_signature: str
matrix_python: str | None
matrix_extra: str | None
conclusion: str | None
status: str
started_at: str | None
completed_at: str | None
duration_seconds: float | None
runner_name: str | None
runner_group_name: str | None
runner_class: str
html_url: str
steps: list[StepRecord]
@dataclass(frozen=True)
class Baseline:
sample_size: int
median_seconds: float
p75_seconds: float
p90_seconds: float
mad_seconds: float
@dataclass(frozen=True)
class ReportRow:
job_name: str
job_signature: str
duration_seconds: float | None
baseline: Baseline | None
delta_seconds: float | None
delta_percent: float | None
status: Literal['normal', 'fast', 'slow', 'very_slow', 'no_baseline', 'not_completed']
class GitHubClient:
def __init__(self, repo: str, token: str):
self.repo = repo
self.token = token
self.ssl_context = _ssl_context()
def request_json(self, path: str, *, method: str = 'GET', body: JsonObject | None = None) -> JsonValue:
data = json.dumps(body).encode() if body is not None else None
request = urllib.request.Request(
self._url(path),
data=data,
method=method,
headers={
'Accept': 'application/vnd.github+json',
'Authorization': f'Bearer {self.token}',
'Content-Type': 'application/json',
'X-GitHub-Api-Version': '2022-11-28',
},
)
with urllib.request.urlopen(request, timeout=30, context=self.ssl_context) as response:
response_body = response.read()
if not response_body:
return None
return json.loads(response_body)
def request_paginated(self, path: str, *, max_items: int | None = None) -> list[JsonObject]:
parsed = urllib.parse.urlsplit(path)
query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
query = [item for item in query if item[0] not in {'page', 'per_page'}]
query.append(('per_page', '100'))
page = 1
results: list[JsonObject] = []
while True:
page_query = [*query, ('page', str(page))]
page_path = urllib.parse.urlunsplit(
(parsed.scheme, parsed.netloc, parsed.path, urllib.parse.urlencode(page_query), '')
)
value = self.request_json(page_path)
page_items = _extract_page_items(value)
if not page_items:
return results
results.extend(page_items)
if max_items is not None and len(results) >= max_items:
return results[:max_items]
if len(page_items) < 100:
return results
page += 1
def _url(self, path: str) -> str:
if path.startswith('http://') or path.startswith('https://'):
return path
if not path.startswith('/'):
path = f'/repos/{self.repo}/{path}'
return f'https://api.github.com{path}'
def main() -> None:
parser = argparse.ArgumentParser(description='Collect and report GitHub Actions CI duration telemetry.')
subparsers = parser.add_subparsers(dest='command', required=True)
collect_parser = subparsers.add_parser('collect', help='Collect one CI workflow run and emit it to Logfire.')
collect_parser.add_argument('--run-id', default=os.getenv('CI_RUN_ID') or os.getenv('GITHUB_EVENT_WORKFLOW_RUN_ID'))
collect_parser.add_argument(
'--run-attempt', default=os.getenv('CI_RUN_ATTEMPT') or os.getenv('GITHUB_EVENT_WORKFLOW_RUN_ATTEMPT')
)
collect_parser.add_argument('--output', default='ci-duration-record.json')
collect_parser.add_argument('--skip-logfire', action='store_true')
report_parser = subparsers.add_parser('report', help='Post or update a PR CI duration report.')
report_parser.add_argument('--pr-number', default=os.getenv('PR_NUMBER'))
report_parser.add_argument('--head-sha', default=os.getenv('HEAD_SHA'))
report_parser.add_argument('--poll-seconds', type=int, default=0)
report_parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
client = _github_client_from_env()
if args.command == 'collect':
if args.run_id is None:
raise SystemExit('CI_RUN_ID is required')
run_attempt = int(args.run_attempt) if args.run_attempt else None
record = collect_run(client, int(args.run_id), run_attempt)
Path(args.output).write_text(json.dumps(record, indent=2, sort_keys=True) + '\n', encoding='utf-8')
print(f'Wrote CI duration record to {args.output}')
if not args.skip_logfire:
emit_logfire(record)
else:
if args.pr_number is None:
raise SystemExit('PR_NUMBER is required')
if args.head_sha is None:
raise SystemExit('HEAD_SHA is required')
body = build_pr_report(client, int(args.pr_number), args.head_sha, args.poll_seconds)
if args.dry_run:
print(body)
else:
upsert_pr_comment(client, int(args.pr_number), body)
def collect_run(client: GitHubClient, run_id: int, run_attempt: int | None) -> JsonObject:
run = _expect_object(client.request_json(f'actions/runs/{run_id}'), 'workflow run')
attempt = run_attempt or _expect_int(run.get('run_attempt'), 'run_attempt')
jobs = client.request_paginated(f'actions/runs/{run_id}/attempts/{attempt}/jobs')
workflow = normalize_workflow_run(client.repo, run, attempt)
job_records: list[JobRecord] = []
for job_object in jobs:
job = normalize_job(job_object)
if is_tracked_test_job(job):
job_records.append(job)
return {
'schema_version': SCHEMA_VERSION,
'fetched_at': _now(),
'workflow_run': workflow_to_json(workflow),
'jobs': [job_to_json(job) for job in job_records],
}
def build_pr_report(client: GitHubClient, pr_number: int, head_sha: str, poll_seconds: int) -> str:
run = wait_for_completed_ci_run(client, head_sha, poll_seconds)
if run is None:
return render_waiting_report(head_sha)
run_id = _expect_int(run.get('id'), 'run id')
run_attempt = _expect_int(run.get('run_attempt'), 'run_attempt')
current_record = collect_run(client, run_id, run_attempt)
current_jobs = [_job_from_json(job) for job in _expect_list(current_record['jobs'], 'jobs')]
baselines = collect_baselines(client, head_sha)
rows = [classify_job(job, baselines.get(job.job_signature)) for job in current_jobs]
workflow = _expect_object(current_record['workflow_run'], 'workflow_run')
return render_report(pr_number, head_sha, workflow, rows)
def wait_for_completed_ci_run(client: GitHubClient, head_sha: str, poll_seconds: int) -> JsonObject | None:
deadline = time.monotonic() + poll_seconds
while True:
run = find_latest_ci_run(client, head_sha)
if run is not None and run.get('status') == 'completed':
return run
if poll_seconds <= 0 or time.monotonic() >= deadline:
return None
time.sleep(20)
def find_latest_ci_run(client: GitHubClient, head_sha: str) -> JsonObject | None:
runs = client.request_paginated(f'actions/workflows/{CI_WORKFLOW_FILE}/runs?head_sha={head_sha}', max_items=10)
matching = [run for run in runs if run.get('head_sha') == head_sha]
if not matching:
return None
matching.sort(key=lambda run: str(run.get('created_at') or ''), reverse=True)
return matching[0]
def collect_baselines(client: GitHubClient, current_head_sha: str) -> dict[str, Baseline]:
try:
main_runs = client.request_paginated(
f'actions/workflows/{CI_WORKFLOW_FILE}/runs?branch=main&event=push&status=success',
max_items=BASELINE_MAIN_RUN_LIMIT,
)
pr_runs = client.request_paginated(
f'actions/workflows/{CI_WORKFLOW_FILE}/runs?event=pull_request&status=success',
max_items=BASELINE_PR_RUN_LIMIT,
)
except (TimeoutError, urllib.error.URLError) as exc:
print(f'Unable to collect baseline CI runs: {exc}', file=sys.stderr)
return {}
durations: dict[str, list[float]] = {}
seen_run_ids: set[int] = set()
baseline_deadline = time.monotonic() + BASELINE_COLLECTION_MAX_SECONDS
for run in [*main_runs, *pr_runs]:
if time.monotonic() >= baseline_deadline:
print('Baseline collection time budget exhausted; using partial baseline samples', file=sys.stderr)
break
run_id = _expect_int(run.get('id'), 'baseline run id')
if run_id in seen_run_ids or run.get('head_sha') == current_head_sha:
continue
seen_run_ids.add(run_id)
run_attempt = _expect_int(run.get('run_attempt'), 'baseline run_attempt')
try:
jobs = client.request_paginated(f'actions/runs/{run_id}/attempts/{run_attempt}/jobs')
except (TimeoutError, urllib.error.URLError) as exc:
print(f'Unable to collect baseline jobs for run {run_id}: {exc}', file=sys.stderr)
continue
for job_object in jobs:
job = normalize_job(job_object)
if job.conclusion == 'success' and job.duration_seconds is not None and is_tracked_test_job(job):
durations.setdefault(job.job_signature, []).append(job.duration_seconds)
return {
signature: compute_baseline(values)
for signature, values in durations.items()
if len(values) >= MIN_BASELINE_SAMPLES
}
def normalize_workflow_run(repo: str, run: JsonObject, run_attempt: int) -> WorkflowRunRecord:
run_started_at = _expect_optional_str(run.get('run_started_at'), 'run_started_at')
updated_at = _expect_optional_str(run.get('updated_at'), 'updated_at')
return WorkflowRunRecord(
repo=repo,
workflow_id=_expect_int(run.get('workflow_id'), 'workflow_id'),
workflow_name=_expect_str(run.get('name'), 'workflow name'),
workflow_path=_expect_str(run.get('path'), 'workflow path'),
run_id=_expect_int(run.get('id'), 'run id'),
run_attempt=run_attempt,
run_number=_expect_int(run.get('run_number'), 'run_number'),
event=_expect_str(run.get('event'), 'event'),
status=_expect_str(run.get('status'), 'status'),
conclusion=_expect_optional_str(run.get('conclusion'), 'conclusion'),
head_branch=_expect_optional_str(run.get('head_branch'), 'head_branch'),
base_branch=_expect_optional_str(run.get('base_ref'), 'base_ref'),
head_sha=_expect_str(run.get('head_sha'), 'head_sha'),
pr_numbers=_pull_request_numbers(run.get('pull_requests')),
run_started_at=run_started_at,
updated_at=updated_at,
duration_seconds=_duration_seconds(run_started_at, updated_at),
html_url=_expect_str(run.get('html_url'), 'html_url'),
actor=_actor_login(run.get('actor')),
)
def normalize_job(job: JsonObject) -> JobRecord:
raw_name = _expect_str(job.get('name'), 'job name')
started_at = _expect_optional_str(job.get('started_at'), 'job started_at')
completed_at = _expect_optional_str(job.get('completed_at'), 'job completed_at')
matrix_python, matrix_extra = parse_job_matrix(raw_name)
job_family = parse_job_family(raw_name)
runner_class = parse_runner_class(
_expect_optional_str(job.get('runner_group_name'), 'runner_group_name'),
_expect_optional_str(job.get('runner_name'), 'runner_name'),
_expect_list_or_none(job.get('labels'), 'labels'),
)
return JobRecord(
job_id=_expect_int(job.get('id'), 'job id'),
raw_name=raw_name,
job_family=job_family,
job_signature=job_signature(job_family, matrix_python, matrix_extra, runner_class),
matrix_python=matrix_python,
matrix_extra=matrix_extra,
conclusion=_expect_optional_str(job.get('conclusion'), 'job conclusion'),
status=_expect_str(job.get('status'), 'job status'),
started_at=started_at,
completed_at=completed_at,
duration_seconds=_duration_seconds(started_at, completed_at),
runner_name=_expect_optional_str(job.get('runner_name'), 'runner_name'),
runner_group_name=_expect_optional_str(job.get('runner_group_name'), 'runner_group_name'),
runner_class=runner_class,
html_url=_expect_str(job.get('html_url'), 'job html_url'),
steps=[normalize_step(step) for step in _expect_list_or_none(job.get('steps'), 'steps') or []],
)
def normalize_step(step: JsonValue) -> StepRecord:
step_object = _expect_object(step, 'step')
started_at = _expect_optional_str(step_object.get('started_at'), 'step started_at')
completed_at = _expect_optional_str(step_object.get('completed_at'), 'step completed_at')
return StepRecord(
number=_expect_int(step_object.get('number'), 'step number'),
name=_expect_str(step_object.get('name'), 'step name'),
status=_expect_str(step_object.get('status'), 'step status'),
conclusion=_expect_optional_str(step_object.get('conclusion'), 'step conclusion'),
started_at=started_at,
completed_at=completed_at,
duration_seconds=_duration_seconds(started_at, completed_at),
)
def workflow_to_json(workflow: WorkflowRunRecord) -> JsonObject:
pr_numbers: list[JsonValue] = [number for number in workflow.pr_numbers]
return {
'repo': workflow.repo,
'workflow_id': workflow.workflow_id,
'workflow_name': workflow.workflow_name,
'workflow_path': workflow.workflow_path,
'run_id': workflow.run_id,
'run_attempt': workflow.run_attempt,
'run_number': workflow.run_number,
'event': workflow.event,
'status': workflow.status,
'conclusion': workflow.conclusion,
'head_branch': workflow.head_branch,
'base_branch': workflow.base_branch,
'head_sha': workflow.head_sha,
'pr_numbers': pr_numbers,
'run_started_at': workflow.run_started_at,
'updated_at': workflow.updated_at,
'duration_seconds': workflow.duration_seconds,
'html_url': workflow.html_url,
'actor': workflow.actor,
}
def job_to_json(job: JobRecord) -> JsonObject:
return {
'job_id': job.job_id,
'raw_name': job.raw_name,
'job_family': job.job_family,
'job_signature': job.job_signature,
'matrix_python': job.matrix_python,
'matrix_extra': job.matrix_extra,
'conclusion': job.conclusion,
'status': job.status,
'started_at': job.started_at,
'completed_at': job.completed_at,
'duration_seconds': job.duration_seconds,
'runner_name': job.runner_name,
'runner_group_name': job.runner_group_name,
'runner_class': job.runner_class,
'html_url': job.html_url,
'steps': [step_to_json(step) for step in job.steps],
}
def step_to_json(step: StepRecord) -> JsonObject:
return {
'number': step.number,
'name': step.name,
'status': step.status,
'conclusion': step.conclusion,
'started_at': step.started_at,
'completed_at': step.completed_at,
'duration_seconds': step.duration_seconds,
}
def parse_job_matrix(job_name: str) -> tuple[str | None, str | None]:
match = re.fullmatch(r'test on (?P<python>[^ ]+) \((?P<extra>[^)]+)\)', job_name)
if match:
return match.group('python'), match.group('extra')
match = re.fullmatch(r'test examples on (?P<python>[^ ]+)', job_name)
if match:
return match.group('python'), 'examples'
return None, None
def parse_job_family(job_name: str) -> str:
if job_name.startswith('test on '):
return 'test'
if job_name.startswith('test examples on '):
return 'test-examples'
if job_name in {'lint', 'mypy', 'docs', 'coverage', 'check'}:
return job_name
return job_name
def is_tracked_test_job(job: JobRecord) -> bool:
return job.job_family == 'test'
def parse_runner_class(runner_group_name: str | None, runner_name: str | None, labels: list[JsonValue] | None) -> str:
label_values = [value for value in labels or [] if isinstance(value, str)]
lower_values = ' '.join([runner_group_name or '', runner_name or '', *label_values]).lower()
if 'depot' in lower_values:
return 'depot'
if 'ubicloud' in lower_values:
return 'ubicloud'
if 'github actions' in lower_values or 'ubuntu' in lower_values:
return 'github-hosted'
if 'self-hosted' in lower_values:
return 'self-hosted'
return 'unknown'
def job_signature(job_family: str, matrix_python: str | None, matrix_extra: str | None, runner_class: str) -> str:
parts = [f'job={job_family}', f'runner={runner_class}']
if matrix_python is not None:
parts.append(f'py={matrix_python}')
if matrix_extra is not None:
parts.append(f'extra={matrix_extra}')
return ' / '.join(parts)
def compute_baseline(values: list[float]) -> Baseline:
sorted_values = sorted(values)
median = statistics.median(sorted_values)
deviations = [abs(value - median) for value in sorted_values]
return Baseline(
sample_size=len(sorted_values),
median_seconds=median,
p75_seconds=percentile(sorted_values, 75),
p90_seconds=percentile(sorted_values, 90),
mad_seconds=statistics.median(deviations),
)
def percentile(sorted_values: list[float], percentile_value: int) -> float:
if len(sorted_values) == 1:
return sorted_values[0]
index = (len(sorted_values) - 1) * percentile_value / 100
lower = int(index)
upper = min(lower + 1, len(sorted_values) - 1)
fraction = index - lower
return sorted_values[lower] * (1 - fraction) + sorted_values[upper] * fraction
def classify_job(job: JobRecord, baseline: Baseline | None) -> ReportRow:
if job.conclusion != 'success' or job.duration_seconds is None:
return ReportRow(job.raw_name, job.job_signature, job.duration_seconds, baseline, None, None, 'not_completed')
if baseline is None:
return ReportRow(job.raw_name, job.job_signature, job.duration_seconds, None, None, None, 'no_baseline')
delta = job.duration_seconds - baseline.median_seconds
delta_percent = delta / baseline.median_seconds * 100 if baseline.median_seconds else None
slow_threshold = max(
baseline.p75_seconds * SLOW_THRESHOLD_MULTIPLIER, baseline.median_seconds + 2 * baseline.mad_seconds
)
very_slow_threshold = max(
baseline.p90_seconds * VERY_SLOW_THRESHOLD_MULTIPLIER,
baseline.median_seconds + 4 * baseline.mad_seconds,
)
if job.duration_seconds > very_slow_threshold or delta >= VERY_SLOW_MIN_SECONDS:
status: Literal['normal', 'fast', 'slow', 'very_slow', 'no_baseline', 'not_completed'] = 'very_slow'
elif job.duration_seconds > slow_threshold and delta >= WARNING_MIN_SECONDS:
status = 'slow'
elif job.duration_seconds < baseline.median_seconds * FAST_THRESHOLD_MULTIPLIER and delta <= -WARNING_MIN_SECONDS:
status = 'fast'
else:
status = 'normal'
return ReportRow(job.raw_name, job.job_signature, job.duration_seconds, baseline, delta, delta_percent, status)
def render_report(pr_number: int, head_sha: str, workflow: JsonObject, rows: list[ReportRow]) -> str:
slow_rows = [row for row in rows if row.status in {'slow', 'very_slow'}]
fast_rows = [row for row in rows if row.status == 'fast']
failed_rows = [row for row in rows if row.status == 'not_completed']
sorted_rows = sorted(
rows,
key=lambda row: (
row.status not in {'very_slow', 'slow', 'fast', 'not_completed'},
-(row.delta_seconds or 0),
row.job_name,
),
)
tracked_duration = sum(row.duration_seconds or 0 for row in rows)
run_url = _expect_str(workflow.get('html_url'), 'workflow html_url')
sha7 = head_sha[:7]
lines = [
REPORT_MARKER,
'## CI Duration Report',
'',
f'PR #{pr_number}, commit `{sha7}`: [CI run]({run_url})',
'',
'**Summary**',
f'- Tracked test jobs: {len(rows)}',
f'- Total tracked test job duration: {_format_seconds(tracked_duration)}',
f'- Slow jobs: {len(slow_rows)}',
f'- Fast jobs: {len(fast_rows)}',
f'- Failed/cancelled jobs: {len(failed_rows)}',
f'- Baseline: up to {BASELINE_MAIN_RUN_LIMIT} successful `main` CI runs and {BASELINE_PR_RUN_LIMIT} successful PR CI runs, matched by job signature and runner class',
f'- Minimum baseline sample: {MIN_BASELINE_SAMPLES} successful matching jobs',
f'- Slow threshold: duration > max(p75 * {SLOW_THRESHOLD_MULTIPLIER}, median + 2 * MAD), with at least {WARNING_MIN_SECONDS}s increase',
'',
'| Job | Duration | Baseline median | p75 | Delta | Status |',
'|---|---:|---:|---:|---:|---|',
]
for row in sorted_rows[:20]:
lines.append(
'| '
+ ' | '.join(
[
row.job_name,
_format_seconds(row.duration_seconds),
_format_seconds(row.baseline.median_seconds if row.baseline else None),
_format_seconds(row.baseline.p75_seconds if row.baseline else None),
_format_delta(row.delta_seconds, row.delta_percent),
row.status.replace('_', ' '),
]
)
+ ' |'
)
if len(sorted_rows) > 20:
lines.append(f'| ... | {len(sorted_rows) - 20} more jobs omitted | | | | |')
lines.extend(
[
'',
'<sub>Re-add the `trigger:ci-duration-report` label to refresh this report.</sub>',
]
)
return '\n'.join(lines)
def render_waiting_report(head_sha: str) -> str:
return '\n'.join(
[
REPORT_MARKER,
'## CI Duration Report — waiting for CI',
'',
f'No completed `CI` run was found for commit `{head_sha[:7]}` yet.',
'',
'<sub>Re-add the `trigger:ci-duration-report` label after CI completes, or rerun with a longer poll window.</sub>',
]
)
def upsert_pr_comment(client: GitHubClient, pr_number: int, body: str) -> None:
comments = client.request_paginated(f'issues/{pr_number}/comments')
existing_url: str | None = None
for comment in comments:
user = _expect_object(comment.get('user'), 'comment user')
if user.get('login') == 'github-actions[bot]' and str(comment.get('body') or '').startswith(REPORT_MARKER):
existing_url = _expect_str(comment.get('url'), 'comment url')
break
if existing_url:
client.request_json(existing_url, method='PATCH', body={'body': body})
print('Updated existing CI duration report comment')
else:
client.request_json(f'issues/{pr_number}/comments', method='POST', body={'body': body})
print('Created CI duration report comment')
def emit_logfire(record: JsonObject) -> None:
token = os.getenv('LOGFIRE_WRITE_TOKEN') or os.getenv('LOGFIRE_TOKEN')
if not token:
print('LOGFIRE_WRITE_TOKEN is not set; skipping Logfire emission')
return
try:
import logfire
except ImportError:
print('logfire is not installed; skipping Logfire emission')
return
logfire_base_url = os.getenv('LOGFIRE_URL')
advanced_options = logfire.AdvancedOptions(base_url=logfire_base_url) if logfire_base_url else None
logfire.configure(
token=token,
service_name='pydantic-ai-ci',
environment='github-actions',
console=False,
advanced=advanced_options,
)
workflow = _expect_object(record['workflow_run'], 'workflow_run')
jobs = _expect_list(record['jobs'], 'jobs')
tracked_test_duration_seconds = sum(
_expect_optional_float(_expect_object(job, 'job').get('duration_seconds'), 'duration_seconds') or 0
for job in jobs
)
test_run_duration_metric = logfire.metric_histogram(
'ci.test_run.tracked_duration',
unit='s',
description='Total duration of tracked CI test jobs in one workflow run.',
)
test_job_duration_metric = logfire.metric_histogram(
'ci.test_job.duration',
unit='s',
description='Duration of one tracked CI test matrix job.',
)
test_run_duration_metric.record(
tracked_test_duration_seconds,
metric_attributes(
{
'repo': workflow.get('repo'),
'workflow_name': workflow.get('workflow_name'),
'event': workflow.get('event'),
'base_branch': workflow.get('base_branch'),
'conclusion': workflow.get('conclusion'),
}
),
)
with logfire.span(
'ci.duration.test_run',
_tags=['ci-duration'],
schema_version=SCHEMA_VERSION,
repo=workflow.get('repo'),
workflow_name=workflow.get('workflow_name'),
run_id=workflow.get('run_id'),
run_attempt=workflow.get('run_attempt'),
event=workflow.get('event'),
conclusion=workflow.get('conclusion'),
head_branch=workflow.get('head_branch'),
base_branch=workflow.get('base_branch'),
head_sha=workflow.get('head_sha'),
pr_numbers=workflow.get('pr_numbers'),
duration_seconds=workflow.get('duration_seconds'),
tracked_test_jobs=len(jobs),
tracked_test_duration_seconds=tracked_test_duration_seconds,
html_url=workflow.get('html_url'),
):
for job in jobs:
job_object = _expect_object(job, 'job')
duration_seconds = _expect_optional_float(job_object.get('duration_seconds'), 'duration_seconds')
if duration_seconds is not None:
test_job_duration_metric.record(
duration_seconds,
metric_attributes(
{
'repo': workflow.get('repo'),
'workflow_name': workflow.get('workflow_name'),
'event': workflow.get('event'),
'base_branch': workflow.get('base_branch'),
'job_name': job_object.get('raw_name'),
'job_signature': job_object.get('job_signature'),
'matrix_python': job_object.get('matrix_python'),
'matrix_extra': job_object.get('matrix_extra'),
'runner_class': job_object.get('runner_class'),
'conclusion': job_object.get('conclusion'),
}
),
)
logfire.info(
'ci.duration.test_job',
_tags=['ci-duration'],
schema_version=SCHEMA_VERSION,
repo=workflow.get('repo'),
run_id=workflow.get('run_id'),
run_attempt=workflow.get('run_attempt'),
event=workflow.get('event'),
head_branch=workflow.get('head_branch'),
base_branch=workflow.get('base_branch'),
head_sha=workflow.get('head_sha'),
pr_numbers=workflow.get('pr_numbers'),
job_id=job_object.get('job_id'),
job_name=job_object.get('raw_name'),
job_family=job_object.get('job_family'),
job_signature=job_object.get('job_signature'),
matrix_python=job_object.get('matrix_python'),
matrix_extra=job_object.get('matrix_extra'),
runner_class=job_object.get('runner_class'),
conclusion=job_object.get('conclusion'),
duration_seconds=job_object.get('duration_seconds'),
html_url=job_object.get('html_url'),
)
logfire.force_flush()
def _github_client_from_env() -> GitHubClient:
repo = os.getenv('GITHUB_REPOSITORY')
token = os.getenv('GITHUB_TOKEN')
if not repo:
raise SystemExit('GITHUB_REPOSITORY is required')
if not token:
raise SystemExit('GITHUB_TOKEN is required')
return GitHubClient(repo, token)
def metric_attributes(attributes: JsonObject) -> MetricAttributes:
return {key: value for key, value in attributes.items() if isinstance(value, bool | int | float | str)}
def _ssl_context() -> ssl.SSLContext | None:
try:
import certifi
except ImportError:
return None
return ssl.create_default_context(cafile=certifi.where())
def _extract_page_items(value: JsonValue) -> list[JsonObject]:
if isinstance(value, list):
return [_expect_object(item, 'paginated item') for item in value]
if isinstance(value, dict):
for key in ('jobs', 'workflow_runs', 'comments'):
items = value.get(key)
if isinstance(items, list):
return [_expect_object(item, key) for item in items]
raise RuntimeError(f'Unexpected paginated response shape: {value!r}')
def _job_from_json(value: JsonValue) -> JobRecord:
job = _expect_object(value, 'job')
return JobRecord(
job_id=_expect_int(job.get('job_id'), 'job_id'),
raw_name=_expect_str(job.get('raw_name'), 'raw_name'),
job_family=_expect_str(job.get('job_family'), 'job_family'),
job_signature=_expect_str(job.get('job_signature'), 'job_signature'),
matrix_python=_expect_optional_str(job.get('matrix_python'), 'matrix_python'),
matrix_extra=_expect_optional_str(job.get('matrix_extra'), 'matrix_extra'),
conclusion=_expect_optional_str(job.get('conclusion'), 'conclusion'),
status=_expect_str(job.get('status'), 'status'),
started_at=_expect_optional_str(job.get('started_at'), 'started_at'),
completed_at=_expect_optional_str(job.get('completed_at'), 'completed_at'),
duration_seconds=_expect_optional_float(job.get('duration_seconds'), 'duration_seconds'),
runner_name=_expect_optional_str(job.get('runner_name'), 'runner_name'),
runner_group_name=_expect_optional_str(job.get('runner_group_name'), 'runner_group_name'),
runner_class=_expect_str(job.get('runner_class'), 'runner_class'),
html_url=_expect_str(job.get('html_url'), 'html_url'),
steps=[],
)
def _pull_request_numbers(value: JsonValue) -> list[int]:
if not isinstance(value, list):
return []
numbers: list[int] = []
for item in value:
if isinstance(item, dict):
number = item.get('number')
if isinstance(number, int):
numbers.append(number)
return numbers
def _actor_login(value: JsonValue) -> str | None:
if isinstance(value, dict):
login = value.get('login')
if isinstance(login, str):
return login
return None
def _duration_seconds(start: str | None, end: str | None) -> float | None:
if start is None or end is None:
return None
return (_parse_timestamp(end) - _parse_timestamp(start)).total_seconds()
def _parse_timestamp(value: str) -> datetime:
return datetime.fromisoformat(value.replace('Z', '+00:00'))
def _now() -> str:
return datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')
def _format_seconds(value: float | None) -> str:
if value is None:
return 'n/a'
seconds = int(round(value))
minutes, remainder = divmod(seconds, 60)
if minutes:
return f'{minutes}m {remainder:02d}s'
return f'{remainder}s'
def _format_delta(delta_seconds: float | None, delta_percent: float | None) -> str:
if delta_seconds is None or delta_percent is None:
return 'n/a'
sign = '+' if delta_seconds >= 0 else '-'
return f'{sign}{_format_seconds(abs(delta_seconds))} ({delta_percent:+.0f}%)'
def _expect_object(value: JsonValue, label: str) -> JsonObject:
if isinstance(value, dict):
return value
raise RuntimeError(f'Expected {label} to be an object, got {type(value).__name__}')
def _expect_list(value: JsonValue, label: str) -> list[JsonValue]:
if isinstance(value, list):
return value
raise RuntimeError(f'Expected {label} to be a list, got {type(value).__name__}')
def _expect_list_or_none(value: JsonValue, label: str) -> list[JsonValue] | None:
if value is None:
return None
return _expect_list(value, label)
def _expect_str(value: JsonValue, label: str) -> str:
if isinstance(value, str):
return value
raise RuntimeError(f'Expected {label} to be a string, got {type(value).__name__}')
def _expect_optional_str(value: JsonValue, label: str) -> str | None:
if value is None:
return None
return _expect_str(value, label)
def _expect_int(value: JsonValue, label: str) -> int:
if isinstance(value, int) and not isinstance(value, bool):
return value
raise RuntimeError(f'Expected {label} to be an integer, got {type(value).__name__}')
def _expect_optional_float(value: JsonValue, label: str) -> float | None:
if value is None:
return None
if isinstance(value, int | float) and not isinstance(value, bool):
return float(value)
raise RuntimeError(f'Expected {label} to be a number, got {type(value).__name__}')
if __name__ == '__main__':
try:
main()
except urllib.error.HTTPError as exc:
print(f'GitHub API request failed: HTTP {exc.code}\n{exc.read().decode()}', file=sys.stderr)
raise
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Install tools that the agent needs inside the AWF sandbox.
#
# Runs in `pre-agent-steps` (on the runner, open network, after checkout)
# before AWF starts the firewalled container. Tools are exposed via:
#
# 1. /opt/hostedtoolcache/gh-aw-tools/current/x64/bin — AWF auto-scans
# hostedtoolcache bin dirs and merges them into the container PATH.
# 2. $GITHUB_PATH — AWF reads this file at container startup and merges
# entries into AWF_HOST_PATH (the container's PATH).
#
# This follows the pattern used by elastic/ai-github-actions.
set -euo pipefail
toolcache_bin="/opt/hostedtoolcache/gh-aw-tools/current/x64/bin"
sudo mkdir -p "$toolcache_bin"
# --- ripgrep ---
# The agent's native Grep tool wraps `rg` for fast code search.
echo "[install-sandbox-tools] Installing ripgrep..."
uv tool install ripgrep --force --quiet
rg_path="$(uv tool dir --bin)/rg"
if [ -x "$rg_path" ]; then
sudo ln -sf "$rg_path" "$toolcache_bin/rg"
echo "[install-sandbox-tools] rg -> $toolcache_bin/rg"
else
echo "::warning::ripgrep install succeeded but rg binary not found at $rg_path"
fi
# --- uv ---
# Symlink uv into the toolcache so the launcher and Bash tool can find it.
uv_path="$(command -v uv)"
if [ -n "$uv_path" ]; then
sudo ln -sf "$uv_path" "$toolcache_bin/uv"
echo "[install-sandbox-tools] uv -> $toolcache_bin/uv"
fi
# Belt-and-suspenders: also write to $GITHUB_PATH so AWF's GITHUB_PATH
# merge picks it up (works on AWF versions that support this).
echo "$toolcache_bin" >> "${GITHUB_PATH:-/dev/null}"
echo "[install-sandbox-tools] Added $toolcache_bin to GITHUB_PATH"
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
set -euo pipefail
repo="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
out_root="/tmp/gh-aw/agent"
issues_root="${out_root}/issues"
all_dir="${issues_root}/all"
batches_dir="${issues_root}/batches"
index_file="${out_root}/open-issues.tsv"
manifest_file="${issues_root}/batch-manifest.tsv"
raw_file="${issues_root}/open-issues.raw.json"
batch_size="${BATCH_SIZE:-25}"
issue_limit="${ISSUE_LIMIT:-1000}"
rm -rf "${issues_root}"
mkdir -p "${all_dir}" "${batches_dir}"
printf "number\ttitle\tupdated_at\tcreated_at\tlabel_names\n" > "${index_file}"
printf "batch\tissue_number\tupdated_at\tlabel_names\n" > "${manifest_file}"
# Fetch all open issues sorted by oldest update time first.
gh issue list \
--repo "${repo}" \
--state open \
--limit "${issue_limit}" \
--search "sort:updated-asc" \
--json number,title,body,updatedAt,createdAt,url,labels,author,assignees \
> "${raw_file}"
issue_count="$(jq 'length' "${raw_file}")"
if [[ "${issue_count}" == "0" ]]; then
echo "No open issues found."
exit 0
fi
count=0
while IFS= read -r issue_json; do
count=$((count + 1))
number="$(jq -r '.number' <<< "${issue_json}")"
updated_at="$(jq -r '.updatedAt' <<< "${issue_json}")"
created_at="$(jq -r '.createdAt' <<< "${issue_json}")"
title="$(jq -r '.title' <<< "${issue_json}" | tr '\t\n' ' ' | sed 's/ */ /g')"
labels="$(jq -r '[.labels[].name] | join(",")' <<< "${issue_json}")"
printf '%s\n' "${issue_json}" > "${all_dir}/${number}.json"
printf "%s\t%s\t%s\t%s\t%s\n" "${number}" "${title}" "${updated_at}" "${created_at}" "${labels}" >> "${index_file}"
batch_index=$(((count - 1) / batch_size + 1))
batch_name="$(printf 'batch-%03d' "${batch_index}")"
batch_path="${batches_dir}/${batch_name}"
mkdir -p "${batch_path}"
cp "${all_dir}/${number}.json" "${batch_path}/${number}.json"
printf "%s\t%s\t%s\t%s\n" "${batch_name}" "${number}" "${updated_at}" "${labels}" >> "${manifest_file}"
done < <(jq -c '.[]' "${raw_file}")
batch_count="$(find "${batches_dir}" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
echo "Prescanned ${count} open issues into ${all_dir}"
echo "Created ${batch_count} batch folder(s) in ${batches_dir} (batch size: ${batch_size})"
echo "Index file: ${index_file}"
echo "Batch manifest: ${manifest_file}"
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Pre-warm the harness's uv script environment on the OPEN network.
#
# Runs in each workflow's `pre-agent-steps` — after checkout + Setup uv but
# before the firewalled agent step — into the same uv dirs the in-sandbox
# launcher uses, so the agent run reuses the warm cache instead of depending
# on PyPI access through the AWF firewall.
#
# Strictly non-fatal: on any failure the sandboxed `uv run --script` (with
# the `python` allowlist in each workflow) still installs from scratch.
set -uo pipefail
export UV_CACHE_DIR=/tmp/gh-aw/uv/cache
export UV_PYTHON_INSTALL_DIR=/tmp/gh-aw/uv/python
export UV_TOOL_DIR=/tmp/gh-aw/uv/tool
export XDG_DATA_HOME=/tmp/gh-aw/uv/data
export XDG_CACHE_HOME=/tmp/gh-aw/uv/xdg-cache
mkdir -p "$UV_CACHE_DIR" "$UV_PYTHON_INSTALL_DIR" "$UV_TOOL_DIR" "$XDG_DATA_HOME" "$XDG_CACHE_HOME"
runner="${GITHUB_WORKSPACE}/.github/scripts/pydantic-ai-runner"
uv_bin="$(command -v uv 2>/dev/null || true)"
if [ -z "${uv_bin}" ]; then
echo "::warning::uv not found for pre-warm; agent will install under the firewall"
exit 0
fi
echo "[harness-prewarm] using uv=${uv_bin} cache=${UV_CACHE_DIR}"
"${uv_bin}" sync --script "${runner}" \
|| echo "::warning::harness uv pre-warm failed; agent will install under the firewall"
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "pydantic-ai-slim[anthropic,mcp]>=1.105.0",
# # The file/shell tools (Bash/Read/Write/Edit/Grep/Glob/LS) are backed by
# # harness FileSystem/Shell, which ship as stable top-level modules from 0.4.0
# # onwards. Keep this in sync with the lint-group pin in the root pyproject.toml.
# "pydantic-ai-harness>=0.4.0",
# "logfire",
# "opentelemetry-instrumentation-httpx",
# ]
# ///
"""Pydantic AI gh-aw shim launcher.
Thin entry point that defers to the `pydantic_ai_gh_aw_shim` package
beside this script. The real shim lives in `pydantic_ai_gh_aw_shim.cli`;
the package's `__main__.py` calls `cli.main()`. This file exists only to
satisfy gh-aw's expectation of a single executable command (and to
carry the PEP 723 inline-metadata dependency block for `uv run --script`).
"""
import pathlib
import runpy
import sys
# `pydantic_ai_gh_aw_shim/` lives next to this script — put its parent on
# `sys.path` so `runpy.run_module` can find it (and the shim's `from .`
# relative imports resolve).
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
runpy.run_module("pydantic_ai_gh_aw_shim", run_name="__main__")
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# In-sandbox launcher for the Pydantic AI gh-aw shim.
#
# The checked-out workspace is mounted no-exec in the AWF sandbox, so this
# script is installed into gh-aw's exec-able /tmp/gh-aw/bin/ by the workflow's
# pre-step (`install -m 755 ... /tmp/gh-aw/bin/pydantic-ai-runner-launch`).
# It is gh-aw's `engine.command` for every Pydantic AI agentic workflow and
# `uv run --script`s the in-tree runner stub with the agent's argv. The stub
# (`pydantic-ai-runner`) is a tiny `runpy` shim that hands off to the
# `pydantic_ai_gh_aw_shim` package in the same directory.
#
# AWF propagates setup-* tool paths into the container via $GITHUB_PATH and
# /opt/hostedtoolcache — so `uv` and `rg` should be on PATH already. The
# launcher just sets up the uv cache dirs (the default cache dir from
# setup-uv isn't writable by the sandbox user UID 1001).
set -euo pipefail
export UV_CACHE_DIR=/tmp/gh-aw/uv/cache
export UV_PYTHON_INSTALL_DIR=/tmp/gh-aw/uv/python
export UV_TOOL_DIR=/tmp/gh-aw/uv/tool
export XDG_DATA_HOME=/tmp/gh-aw/uv/data
export XDG_CACHE_HOME=/tmp/gh-aw/uv/xdg-cache
mkdir -p "$UV_CACHE_DIR" "$UV_PYTHON_INSTALL_DIR" "$UV_TOOL_DIR" "$XDG_DATA_HOME" "$XDG_CACHE_HOME"
runner="${GITHUB_WORKSPACE}/.github/scripts/pydantic-ai-runner"
echo "[harness-launch] cwd=$(pwd) GITHUB_WORKSPACE=${GITHUB_WORKSPACE:-unset} UV_CACHE_DIR=${UV_CACHE_DIR}" >&2
echo "[harness-launch] runner=${runner} exists=$([ -f "${runner}" ] && echo yes || echo no)" >&2
# Find uv — should be on PATH via AWF's hostedtoolcache propagation.
# Fall back to known paths if not (older AWF versions or non-GHA runners).
uv_bin=""
if command -v uv >/dev/null 2>&1; then
uv_bin="$(command -v uv)"
else
for c in /opt/hostedtoolcache/gh-aw-tools/current/x64/bin/uv /opt/hostedtoolcache/uv/*/*/uv /tmp/gh-aw/bin/uv /usr/local/bin/uv; do
[ -x "$c" ] && uv_bin="$c" && break
done
fi
if [ -z "${uv_bin}" ]; then
echo "[harness-launch] FATAL: uv not found; PATH=${PATH}" >&2
exit 127
fi
echo "[harness-launch] using uv=${uv_bin}" >&2
exec "${uv_bin}" run --script "${runner}" "$@"
@@ -0,0 +1,131 @@
"""Claude Code tools the Pydantic AI gh-aw shim exposes to the agent.
Each tool lives in its own module so individual implementations can be
swapped without touching the registry or the other tools. The toolset
builder below is the public surface the main shim consumes.
To add a new tool: drop `mytool.py` next to this file exporting one
callable, then add the `(name, callable, description)` row in
`_BASE_TOOLS`.
To replace a tool: edit the matching `<tool>.py` file. The signature is
what gh-aw / Claude pass; the docstring becomes the tool's description.
Two tools are *not* in this package:
- **WebFetch** — registered as a `pydantic_ai.capabilities.NativeTool`
wrapping `pydantic_ai.native_tools.WebFetchTool`. The model fetches
server-side through Anthropic's native web-fetch capability.
- **Task** — sub-agent dispatcher; lives in the main shim because it
needs the shim-level `Agent` factory, the `INSTRUCTIONS` /
`SUBAGENT_INSTRUCTIONS` / `RUN_TRIGGER` constants, and the
event-stream handler. Pass it to `build_claude_code_toolset(task=...)` to
add it as a regular tool.
"""
from collections.abc import Awaitable, Callable
from typing import TypeAlias
from pydantic_ai import RunContext
from pydantic_ai.tools import Tool
from pydantic_ai.toolsets import FunctionToolset
from .bash import bash
from .edit import edit_file
from .exit_plan_mode import exit_plan_mode
from .glob import glob_search
from .grep import grep
from .list_dir import list_dir
from .multi_edit import multi_edit
from .read import read_file
from .todo_write import todo_write
from .write import write_file
# Claude Code tools are callables returning `str`, sync or async: the
# harness-backed tools (`Bash`, `Read`, `Write`, `Edit`, `Grep`, `Glob`, `LS`
# awaiting `ShellToolset` / `FileSystemToolset`, and `TodoWrite` awaiting the
# `planning` capability) are async, while the remaining ones (`MultiEdit`,
# `ExitPlanMode`) stay sync.
# Their argument signatures vary by tool (Claude's `Bash` takes
# `(command, timeout?)`, `MultiEdit` takes `(file_path, edits)`, etc.), so the
# precise per-tool shape is enforced at the tool's own definition site — at the
# registry layer the meaningful contract is "callable that returns (or awaits)
# a string the model can read".
ClaudeCodeToolFn: TypeAlias = Callable[..., str | Awaitable[str]]
# `Task` is async like the harness-backed file/shell tools, but unlike them its
# signature is fully pinned here (it takes a `RunContext`) so that consumers of
# `build_claude_code_toolset(task=...)` pass a compatible callable.
TaskCallable: TypeAlias = Callable[[RunContext[object], str, str], Awaitable[str]]
__all__ = [
'MUTATING_TOOLS',
'CLAUDE_CODE_TOOL_NAMES',
'READ_ONLY_SUBAGENT_TOOLS',
'bash',
'build_claude_code_toolset',
'edit_file',
'exit_plan_mode',
'glob_search',
'grep',
'list_dir',
'multi_edit',
'read_file',
'todo_write',
'write_file',
]
# Claude tool name → (callable, one-line description). The function names
# stay idiomatic snake_case; pydantic-ai's `Tool` exposes them under the
# Claude names so the model sees the Claude Code surface it was trained on.
_BASE_TOOLS: tuple[tuple[str, ClaudeCodeToolFn, str], ...] = (
('Bash', bash, 'Run a shell command in the repository workspace.'),
('Read', read_file, 'Read a UTF-8 text file (optional line offset/limit).'),
('Write', write_file, 'Create or overwrite a workspace text file.'),
('Edit', edit_file, 'Replace a string in a workspace file.'),
('MultiEdit', multi_edit, 'Apply multiple string replacements to one file atomically.'),
('Grep', grep, 'Recursively regex-search workspace files.'),
('Glob', glob_search, 'List workspace paths matching a glob pattern.'),
('LS', list_dir, "List a workspace directory's entries."),
('TodoWrite', todo_write, "Record the agent's task checklist."),
('ExitPlanMode', exit_plan_mode, 'Signal the end of planning and proceed.'),
)
# Claude Code tool names the shim implements as Python callables.
# Excludes `WebFetch` (a `NativeTool` capability) and `Task` (registered by
# the main shim via `build_claude_code_toolset(task=...)`). Kept as a separate
# tuple for tests / introspection that just need the name list.
CLAUDE_CODE_TOOL_NAMES: tuple[str, ...] = tuple(name for name, _, _ in _BASE_TOOLS)
# Tools that mutate the workspace — withheld in `plan` permission mode.
MUTATING_TOOLS = frozenset({'Bash', 'Write', 'Edit', 'MultiEdit'})
# Tools handed to read-only `Task` sub-agents — strictly non-mutating and
# excluding `Task` itself to prevent recursive sub-agent spawning.
# `WebFetch` is wired separately as a `NativeTool` capability.
READ_ONLY_SUBAGENT_TOOLS = frozenset({'Read', 'Grep', 'Glob', 'LS', 'TodoWrite', 'ExitPlanMode'})
def build_claude_code_toolset(*, task: TaskCallable | None = None) -> FunctionToolset[object]:
"""Build the shim's Claude Code tool `FunctionToolset`.
Pass `task=` to register the sub-agent dispatcher as an additional
tool named `Task`. Sub-agent toolsets call this with `task=None` so
sub-agents can't spawn their own sub-agents.
Filter for permission mode / allow-list with `.filtered(predicate)`
on the returned toolset (see `select_claude_code_toolset` in the main
shim).
"""
tools: list[Tool[object]] = [Tool(fn, name=name, description=desc) for name, fn, desc in _BASE_TOOLS]
if task is not None:
tools.append(
Tool(
task,
name='Task',
description='Dispatch a read-only sub-agent to investigate a focused task and return its findings.',
)
)
return FunctionToolset(tools=tools)
@@ -0,0 +1,14 @@
"""Entry point for `python -m pydantic_ai_gh_aw_shim` / `runpy.run_module`.
All shim logic lives in `cli.py` (which tests import directly). Keeping
this file a one-call stub avoids the `runpy.run_module(..., run_name="__main__")`
+ PEP-563 corner case where pydantic-ai's runtime annotation inspection
can't find `RunContext` in a module loaded under `__name__ == "__main__"`.
"""
import sys
from .cli import main
if __name__ == '__main__':
sys.exit(main())
@@ -0,0 +1,91 @@
"""Harness-backed toolsets that execute the Claude file/shell tools.
The Claude-named tool callables in this package (`Bash`, `Read`, `Write`,
`Edit`) keep their Claude Code signatures but delegate the actual work to
pydantic-ai-harness's `ShellToolset` and `FileSystemToolset`. The harness owns
the parts that were previously hand-rolled here: subprocess execution and
output truncation, path containment, symlink resolution before access, and
binary-file detection. The shim keeps only the thin signature adapters.
The toolsets are built per call so `workspace()` (and a test's
`GITHUB_WORKSPACE`) is read live; construction is cheap. They are used by
calling their methods directly, not by registering them on an agent, so the
agent still sees exactly the Claude tool surface gh-aw expects.
"""
import os
from pathlib import Path
from pydantic_ai_harness.filesystem import FileSystemToolset
from pydantic_ai_harness.shell import ShellToolset
from .shared import MAX_TOOL_OUTPUT, workspace
# Standard Unix binary locations prepended to PATH so `rg`, `make`, `git`, and
# `uv` are reachable even when the AWF sandbox starts with a minimal inherited
# PATH. Moved here from the old hand-rolled `bash` tool.
_STANDARD_PATHS = [
'/opt/hostedtoolcache/gh-aw-tools/current/x64/bin', # rg + uv (install-sandbox-tools.sh)
'/tmp/gh-aw/bin', # fallback; launcher lives here too
'/usr/local/bin',
'/usr/bin',
'/bin',
'/usr/local/sbin',
'/usr/sbin',
'/sbin',
]
# Claude's `Bash` timeout contract: default 120s, hard-capped at 600s.
BASH_DEFAULT_TIMEOUT = 120
BASH_MAX_TIMEOUT = 600
def augmented_env() -> dict[str, str]:
"""The process environment with the standard tool paths prepended to PATH."""
env = dict(os.environ)
current = env.get('PATH', '')
existing = set(current.split(':'))
extra = ':'.join(p for p in _STANDARD_PATHS if p not in existing)
env['PATH'] = f'{extra}:{current}' if extra else current
return env
def filesystem() -> FileSystemToolset[None]:
"""`FileSystemToolset` rooted at the live workspace.
`protected_patterns=[]` keeps the prior behavior of allowing writes anywhere
under the workspace. The harness still enforces containment (no path escapes
the workspace root) and resolves symlinks before access -- a change from the
old tools, which resolved any absolute path. For gh-aw the agent operates
within `$GITHUB_WORKSPACE`, so containment to that root is the intended scope.
"""
return FileSystemToolset[None](
root_dir=Path(workspace()),
allowed_patterns=[],
denied_patterns=[],
protected_patterns=[],
max_read_lines=2000,
max_search_results=1000,
max_find_results=1000,
)
def shell() -> ShellToolset[None]:
"""`ShellToolset` rooted at the live workspace, PATH augmented for the sandbox.
Command/operator denylists are left empty to preserve the old `Bash` tool's
"run anything" contract; the AWF sandbox is the security boundary. Output is
capped at `MAX_TOOL_OUTPUT`, keeping the tail (where errors and exit info land).
"""
return ShellToolset[None](
cwd=Path(workspace()),
allowed_commands=[],
denied_commands=[],
denied_operators=[],
default_timeout=float(BASH_DEFAULT_TIMEOUT),
max_output_chars=MAX_TOOL_OUTPUT,
persist_cwd=False,
allow_interactive=True,
env=augmented_env(),
denied_env_patterns=[],
)
@@ -0,0 +1,36 @@
"""Claude's `Bash` tool -- run a shell command in the workspace.
Backed by pydantic-ai-harness's `ShellToolset`, which handles subprocess
execution, the per-command timeout, and tail-keeping output truncation. The
Claude `Bash` signature (`command`, optional `timeout` in seconds) and the
sandbox PATH augmentation are preserved by the adapter.
"""
from pydantic_ai.exceptions import ModelRetry
from ._backends import BASH_DEFAULT_TIMEOUT, BASH_MAX_TIMEOUT, shell
async def bash(command: str, timeout: int | None = None) -> str:
"""Run a shell command in the repository workspace.
Returns the command's labeled stdout/stderr (truncated). `timeout` is in
seconds (default 120, capped at 600).
"""
secs = BASH_DEFAULT_TIMEOUT if not timeout or timeout <= 0 else min(int(timeout), BASH_MAX_TIMEOUT)
try:
out = await shell().run_command(command, timeout_seconds=float(secs))
except (ModelRetry, OSError) as exc:
# ModelRetry: the harness blocked the command; the shim's tools have always
# surfaced such conditions as a returned error string rather than a
# model-facing retry. OSError: subprocess startup failed (e.g. the
# workspace cwd does not exist) -- the harness doesn't convert it, so catch
# it here rather than let it abort the whole run.
return f'error: {exc}'
# On timeout the harness *returns* a `[Command timed out after Ns]` sentinel
# rather than raising. The old tool surfaced timeouts as an `error:` string
# (and `Grep` already wraps the same sentinel), so do the same here instead
# of handing the model an unprefixed result it might read as success.
if out.startswith('[Command timed out'):
return f'error: {out}'
return out
@@ -0,0 +1,944 @@
r"""Pydantic AI gh-aw shim — Claude Code CLI compatibility for gh-aw.
gh-aw runs the agent engine like the Claude Code CLI:
<command> --print --no-chrome --allowed-tools '<csv>' --debug-file <path> \\
--verbose --permission-mode <mode> --output-format stream-json \\
--mcp-config <mcp-servers.json> --prompt-file <prompt.txt> \\
[--model <model>] "<rendered prompt>"
With `engine.command` set, `<command>` is this shim. It speaks Claude
Code's argv, recovers the prompt, builds a `pydantic-ai` agent backed by
the gh-aw-injected Anthropic-compatible proxy, exposes Claude-named
tools plus gh-aw's MCP servers (GitHub + the `safeoutputs` write-sink),
enforces gh-aw's `--allowed-tools` allow-list, and emits Claude-compatible
`stream-json` so gh-aw's log parser and token accounting keep working.
Like Claude Code itself, the shim only talks to Anthropic-shape APIs
(`ANTHROPIC_BASE_URL` → real Anthropic, MiniMax's Anthropic-compatible
endpoint, etc.). No OpenAI path — the workflow's `engine.id: claude`
contract is Anthropic-shape end to end.
Credentials note: under gh-aw the real API key is *excluded* from the
agent container (`awf --exclude-env ANTHROPIC_API_KEY`). The AWF
api-proxy injects it transparently; the shim only ever sends a
placeholder bearer to the proxy base URL — never a real upstream key.
This module is loaded as the `pydantic_ai_gh_aw_shim.cli` submodule;
`__main__.py` is a 3-line entry stub that calls `cli.main()`. Tests
import this module directly (`from pydantic_ai_gh_aw_shim import cli`),
which is why the runner stub doesn't live in `__main__.py` — running it
under `runpy.run_module(..., run_name="__main__")` plus PEP-563
annotations breaks pydantic-ai's `takes_run_context` detection.
"""
import argparse
import asyncio
import dataclasses
import json
import logging
import os
import pathlib
import sys
import time
import uuid
from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass
from typing import TypeAlias, cast
import httpx
import logfire
from anthropic import AsyncAnthropic
from pydantic import ValidationError
from pydantic_ai import Agent, RunContext
from pydantic_ai.capabilities import NativeTool, ProcessEventStream, ProcessHistory
from pydantic_ai.mcp import load_mcp_toolsets
from pydantic_ai.messages import (
AgentStreamEvent,
ModelMessage,
ModelRequest,
ModelRequestPart,
ModelResponse,
ModelResponsePart,
NativeToolCallPart,
NativeToolSearchCallPart,
RetryPromptPart,
ToolCallEvent,
ToolCallPart,
ToolResultEvent,
ToolReturnPart,
ToolSearchCallPart,
UserPromptPart,
)
from pydantic_ai.models import Model
from pydantic_ai.models.anthropic import AnthropicModel
from pydantic_ai.native_tools import WebFetchTool
from pydantic_ai.providers.anthropic import AnthropicProvider
from pydantic_ai.tools import ToolDefinition
from pydantic_ai.toolsets import AbstractToolset, PrefixedToolset
from pydantic_ai.usage import RunUsage, UsageLimits
from . import (
CLAUDE_CODE_TOOL_NAMES,
MUTATING_TOOLS,
READ_ONLY_SUBAGENT_TOOLS,
build_claude_code_toolset,
)
from .shared import logger, reset_context_state
# Type aliases for the public surface — the shim runs `None`-deps agents
# throughout, so every `RunContext` is concretely `RunContext[object]`.
MessagePart: TypeAlias = ModelRequestPart | ModelResponsePart
ToolPredicate: TypeAlias = Callable[[RunContext[object], ToolDefinition], bool | Awaitable[bool]]
TaskCallable: TypeAlias = Callable[[RunContext[object], str, str], Awaitable[str]]
# Placeholder bearer token sent to the AWF api-proxy. The proxy strips this
# header and injects the real `ANTHROPIC_API_KEY` on the outbound wire — so
# the agent container never sees the real key. Sent verbatim only when no
# `ANTHROPIC_AUTH_TOKEN` / `ANTHROPIC_API_KEY` env is provided locally.
PROXY_BEARER_PLACEHOLDER = 'gh-aw-proxy-injected'
def _anthropic_native_capabilities() -> list[NativeTool]:
"""`NativeTool(WebFetchTool())` for real Anthropic only.
Anthropic-compatible endpoints (MiniMax, etc.) reject the
`web_fetch_20250910` server-side tool with `invalid_request_error
(2013)` because they don't implement Anthropic's server-side tool
types. Detect via `ANTHROPIC_BASE_URL` — empty/unset means the
Anthropic SDK default (real Anthropic).
"""
base_url = os.environ.get('ANTHROPIC_BASE_URL', '')
if not base_url or 'api.anthropic.com' in base_url:
return [NativeTool(WebFetchTool())]
return []
# pydantic-ai's built-in request_limit default of 50 is too low for the
# deep multi-step workflows here; gh-aw's api-proxy still caps the run.
REQUEST_LIMIT = 200
SUBAGENT_REQUEST_LIMIT = 75
# Per-request HTTP timeout for every LLM call. The read timeout is the
# critical one: MiniMax's proxy can hold a streaming connection open without
# sending data. 5 min is generous enough for large generations but prevents
# indefinite hangs. SDK-level retries cover transient 429/5xx before raising.
_LLM_TIMEOUT = httpx.Timeout(timeout=120.0, connect=10.0)
_LLM_MAX_RETRIES = 4
# Wall-clock caps (seconds). These are last-resort guards on top of the
# per-request timeout so a burst of slow requests can't accumulate forever.
RUN_TIMEOUT_SECS = 28 * 60 # 28 min — just under the 30 min gh-aw job cap
SUBAGENT_TIMEOUT_SECS = 15 * 60 # 15 min per Task sub-agent
COMPACTION_TIMEOUT_SECS = 120 # 2 min for the compaction summariser call
# Static prefix for `Agent(instructions=[INSTRUCTIONS, prompt])`. Sequence
# form lets Anthropic's prompt-prefix cache hit `INSTRUCTIONS` across runs.
INSTRUCTIONS = (
'## Parallel tool calls\n\n'
'The model supports parallel tool calls. When multiple reads, searches, or '
"lookups are independent — meaning one doesn't need another's result — "
'issue them all in the same response. They execute concurrently. Only '
'chain sequentially when one call genuinely needs a previous result.\n\n'
'## File reading\n\n'
'Read files in large ranges (500+ lines per call). MAX_TOOL_OUTPUT is '
'50 000 chars so most Python source files fit in one or two reads. '
'Avoid reading 3080 lines at a time.\n\n'
'## Search tools\n\n'
'Use the native Grep and Glob tools for codebase search. '
'`rg` and `uv` are also available as plain commands via Bash.\n\n'
'## Dev environment\n\n'
'The repo is checked out at $GITHUB_WORKSPACE. Dev dependencies are NOT '
'pre-installed — run `make install` once before using pytest, ruff, or '
'pyright. Prefer `uv run pytest <test_file>` over a bare `pytest` call; '
'uv handles the virtual env automatically.\n\n'
'## GitHub issue search\n\n'
'The GitHub toolset runs in gh-proxy mode: there are NO `mcp__github__*` '
'tools, and the /search/issues endpoint (`gh issue list --search`, '
'`gh search issues`) returns HTTP 403 via the AWF firewall proxy. The '
'issue-list endpoint IS allowed, including its server-side `?labels=` '
'filter. When the sweep files under a dedicated label, prefer a narrow label '
"query (`gh api 'repos/pydantic/pydantic-ai/issues?state=open&labels=<label>&per_page=100' "
"--jq '.[] | select(.pull_request == null) | {number, title}'`); if it has no "
'dedicated label or the filter is inconclusive, widen to a full open-issue scan '
"(`gh api --paginate 'repos/pydantic/pydantic-ai/issues?state=open&per_page=100' "
"--jq '.[] | select(.pull_request == null) | {number, title, labels: [.labels[].name]}'`). "
'`select(.pull_request == null)` drops PRs, which the issues endpoint also returns.'
)
# The real task spec rides in `instructions=`; the user message is a trigger.
RUN_TRIGGER = 'Begin the task per the instructions above.'
SUBAGENT_INSTRUCTIONS = (
'You are a focused, read-only sub-agent. You can read files, search the '
'codebase, and fetch web content, but you cannot modify the workspace or '
'shell out. Investigate the task you were given and return a concise, '
'evidence-grounded answer to your caller — do not try to act on it.'
)
# History compaction (pydantic-ai `ProcessHistory` capability). Two stages
# inside one callback: a cheap dedup+truncate trim, then an LLM summary as
# fallback. `Agent(instructions=...)` is re-applied on every request, so
# the workflow prompt is never in the message list and never compacted.
# ~100k tokens at 4 chars/token = half of a 200k window.
COMPACTION_TRIGGER_CHARS = 400_000
COMPACTION_KEEP_RECENT = 10
TOOL_RESULT_HEAD_TAIL_CHARS = 4_000
TOOL_RESULT_TRIM_THRESHOLD = 10_000
COMPACTION_TRANSCRIPT_MAX_CHARS = 80_000
COMPACTION_SUMMARY_INSTRUCTIONS = (
'Summarise the agent transcript below for resumption in a fresh '
'context window. Produce a structured brief, not free prose. Use this '
'exact section layout, omitting any section that is empty:\n\n'
'## Goal\n'
'One short paragraph: what the agent was asked to do.\n\n'
'## Files inspected\n'
'- `<full/path>`: one-line note on what was found there.\n\n'
'## Commands run\n'
'- `<command>`: outcome in one line.\n\n'
'## Errors encountered\n'
'Verbatim error messages or unexpected behaviour, with the file or '
'command that triggered each.\n\n'
'## Decisions and approaches\n'
'- Concrete decisions with reasoning. Include approaches already tried '
'that did **not** work, so they are not re-attempted.\n\n'
'## Open questions\n'
'- Anything still unresolved.\n\n'
'## Next step\n'
'The single most likely next action.\n\n'
'Preserve specifics (paths, identifiers, exact strings) over prose. '
'Respond with text only — do not call any tools.'
)
def _part_text(part: MessagePart) -> str:
"""Best-effort text rendering of any pydantic-ai message part."""
if isinstance(part, (ToolCallPart, NativeToolCallPart, ToolSearchCallPart, NativeToolSearchCallPart)):
return f'{part.tool_name}({part.args_as_dict()!r})'
return str(part.content)
def _render_messages_for_summary(messages: list[ModelMessage]) -> str:
"""Render a slice of pydantic-ai messages into a compact transcript."""
out: list[str] = []
for m in messages:
kind = 'user' if isinstance(m, ModelRequest) else 'assistant'
for part in m.parts:
out.append(f'[{kind}/{type(part).__name__}] {_part_text(part)[:1500]}')
return '\n'.join(out)
def _history_size_chars(messages: list[ModelMessage]) -> int:
"""Char-count proxy for token cost — used as the compaction trigger."""
return sum(len(_part_text(part)) for m in messages for part in m.parts)
def _head_tail(text: str, side: int) -> str:
"""Keep the first and last `side` chars, mark the elided middle."""
skipped = len(text) - side * 2
return f'{text[:side]}\n…[trimmed {skipped} chars]…\n{text[-side:]}'
def _superseded_read_calls(messages: list[ModelMessage]) -> tuple[set[str], dict[str, str]]:
"""For each `Read` call, key on (path, offset, limit); older calls with the same key are superseded."""
label_by_call_id: dict[str, str] = {}
latest_for_args: dict[tuple[str, object, object], str] = {}
superseded: set[str] = set()
for m in messages:
for p in m.parts:
if not (isinstance(p, ToolCallPart) and p.tool_name == 'Read'):
continue
args = p.args_as_dict()
if not isinstance(args, dict):
continue
path = args.get('file_path')
if not isinstance(path, str):
continue
offset, limit = args.get('offset'), args.get('limit')
label = path if offset is None and limit is None else f'{path}[offset={offset!r}, limit={limit!r}]'
label_by_call_id[p.tool_call_id] = label
key = (path, offset, limit)
prior = latest_for_args.get(key)
if prior is not None:
superseded.add(prior)
latest_for_args[key] = p.tool_call_id
return superseded, label_by_call_id
def _trim_tool_results(messages: list[ModelMessage]) -> list[ModelMessage]:
"""Dedupe re-reads of the same file slice and head/tail-truncate oversized older tool returns."""
if len(messages) <= COMPACTION_KEEP_RECENT:
return messages
superseded, label_by_call_id = _superseded_read_calls(messages)
tail_start = len(messages) - COMPACTION_KEEP_RECENT
out: list[ModelMessage] = []
dedup_count = truncate_count = bytes_saved = 0
def _rewrite(part: ModelRequestPart | ModelResponsePart) -> ModelRequestPart | ModelResponsePart:
nonlocal dedup_count, truncate_count, bytes_saved
if not isinstance(part, ToolReturnPart):
return part
if part.tool_call_id in superseded:
new_content = f'[superseded read: {label_by_call_id[part.tool_call_id]} — see later read with same args]'
bytes_saved += len(str(part.content)) - len(new_content)
dedup_count += 1
return dataclasses.replace(part, content=new_content)
content = str(part.content)
if len(content) > TOOL_RESULT_TRIM_THRESHOLD:
new_content = _head_tail(content, TOOL_RESULT_HEAD_TAIL_CHARS)
bytes_saved += len(content) - len(new_content)
truncate_count += 1
return dataclasses.replace(part, content=new_content)
return part
for idx, m in enumerate(messages):
if idx >= tail_start:
out.append(m)
continue
new_parts = [_rewrite(p) for p in m.parts]
out.append(dataclasses.replace(m, parts=new_parts) if new_parts != list(m.parts) else m)
if dedup_count or truncate_count:
logger.info(
'trim: deduped %d superseded read(s), truncated %d oversized result(s), saved %d chars',
dedup_count,
truncate_count,
bytes_saved,
)
emit(
{
'type': 'system',
'subtype': 'compaction_trim',
'deduped_reads': dedup_count,
'truncated_results': truncate_count,
'chars_saved': bytes_saved,
}
)
return out
return messages
_SYNTHETIC_SUMMARY_TAG = '[compacted history]'
def _is_synthetic_summary(message: ModelMessage) -> bool:
"""A `ModelRequest` we synthesised in a prior `_compact_history` round."""
if not isinstance(message, ModelRequest):
return False
parts = message.parts
return (
len(parts) == 1
and isinstance(parts[0], UserPromptPart)
and str(parts[0].content).startswith(_SYNTHETIC_SUMMARY_TAG)
)
async def _compact_history(ctx: RunContext[object], messages: list[ModelMessage]) -> list[ModelMessage]:
"""Cheap trim first; LLM-summarise the middle as fallback if still over budget."""
if len(messages) <= COMPACTION_KEEP_RECENT:
return messages
trimmed = _trim_tool_results(messages)
size = _history_size_chars(trimmed)
if size < COMPACTION_TRIGGER_CHARS:
return trimmed
middle = trimmed[:-COMPACTION_KEEP_RECENT]
tail = trimmed[-COMPACTION_KEEP_RECENT:]
transcript = _render_messages_for_summary(middle)
logger.info(
'compaction summary firing: %d chars / %d messages -> summarising %d middle, keeping last %d',
size,
len(trimmed),
len(middle),
COMPACTION_KEEP_RECENT,
)
emit(
{
'type': 'system',
'subtype': 'compaction_summary_start',
'history_chars': size,
'history_messages': len(trimmed),
'middle_messages': len(middle),
'keep_recent': COMPACTION_KEEP_RECENT,
}
)
# Preserve any earlier-round synthetic at the head of the middle so a
# fallback (`return [prior_synthetic, *tail]`) doesn't silently forget
# the entire run's compacted history.
prior_synthetic = middle[0] if middle and _is_synthetic_summary(middle[0]) else None
# Fresh `RunUsage` so `request_limit=2` bounds the summariser, not
# (parent + summariser). Merge the totals back regardless of outcome.
sub_usage = RunUsage()
try:
r = await asyncio.wait_for(
Agent(ctx.model, instructions=COMPACTION_SUMMARY_INSTRUCTIONS).run(
f'Transcript to summarise:\n\n{transcript[:COMPACTION_TRANSCRIPT_MAX_CHARS]}',
usage_limits=UsageLimits(request_limit=2),
usage=sub_usage,
),
timeout=COMPACTION_TIMEOUT_SECS,
)
summary = str(r.output or '').strip() or '(empty summary)'
except Exception as exc:
# Well-handled fallback: the run continues on the trimmed history, so the
# stack is kept available (`exc_info`) without escalating the log level.
# A bare `TimeoutError` stringifies to '' — fall back to the type name so
# the emitted `error` is never empty.
ctx.usage.incr(sub_usage)
detail = f'{type(exc).__name__}: {exc}' if str(exc) else type(exc).__name__
logger.warning('compaction summarisation failed (%s); falling back', detail, exc_info=True)
emit({'type': 'system', 'subtype': 'compaction_summary_failed', 'error': detail})
return [prior_synthetic, *tail] if prior_synthetic else tail
ctx.usage.incr(sub_usage)
# If the summariser produces output larger than the middle it's replacing,
# the next compaction round would trip on the same too-large synthetic
# and never converge — fall back to the prior synthetic + tail.
middle_size = _history_size_chars(middle)
if len(summary) >= middle_size:
logger.info('compaction summary discarded (%d >= %d chars); falling back', len(summary), middle_size)
emit(
{
'type': 'system',
'subtype': 'compaction_summary_discarded',
'summary_chars': len(summary),
'middle_chars': middle_size,
}
)
return [prior_synthetic, *tail] if prior_synthetic else tail
logger.info(
'compaction summary done: %d middle messages (%d chars) -> %d-char summary',
len(middle),
middle_size,
len(summary),
)
emit(
{
'type': 'system',
'subtype': 'compaction_summary_done',
'middle_messages': len(middle),
'middle_chars': middle_size,
'summary_chars': len(summary),
'input_tokens': sub_usage.input_tokens,
'output_tokens': sub_usage.output_tokens,
}
)
synthetic = ModelRequest(parts=[UserPromptPart(content=f'{_SYNTHETIC_SUMMARY_TAG}\n{summary}')])
return [synthetic, *tail]
@dataclass(slots=True)
class Args:
"""The subset of Claude Code's CLI surface the shim acts on."""
model: str | None = None
mcp_config: str | None = None
prompt_file: str | None = None
prompt_positional: str | None = None
# None = flag absent (local/dev: no restriction). A set = enforce it.
allowed_tools: frozenset[str] | None = None
permission_mode: str | None = None
def _split_allowed_tools(value: str | None) -> frozenset[str] | None:
"""Parse Claude's `--allowed-tools` CSV into base tool names.
Entries may carry a permission scope, e.g. `Edit(/tmp/*)` or
`Bash(git:*)` — only the base name gates availability here, so the
parenthesised scope is stripped. Returns `None` when the flag is absent
so non-gh-aw/local runs keep every tool.
"""
if value is None:
return None
names: set[str] = set()
for raw in value.split(','):
entry = raw.strip()
if not entry:
continue
names.add(entry.split('(', 1)[0].strip())
return frozenset(names)
def parse_args(argv: Sequence[str]) -> Args:
"""Parse Claude Code's CLI surface into `Args`, tolerating unknown flags so a future Claude flag never breaks the engine."""
p = argparse.ArgumentParser(add_help=False)
p.add_argument('--model')
p.add_argument('--mcp-config')
p.add_argument('--prompt-file')
p.add_argument('--output-format', default='stream-json')
p.add_argument('--allowed-tools')
p.add_argument('--permission-mode')
p.add_argument('--debug-file')
for flag in ('--print', '--no-chrome', '--verbose', '--continue'):
p.add_argument(flag, action='store_true')
known, unknown = p.parse_known_args(list(argv))
# gh-aw appends the rendered prompt as the trailing positional argument.
positionals = [a for a in unknown if not a.startswith('-')]
return Args(
model=known.model,
mcp_config=known.mcp_config,
prompt_file=known.prompt_file,
prompt_positional=positionals[-1] if positionals else None,
allowed_tools=_split_allowed_tools(known.allowed_tools),
permission_mode=known.permission_mode,
)
def resolve_prompt(args: Args) -> str:
"""Prompt precedence: trailing positional -> --prompt-file -> $GH_AW_PROMPT."""
if args.prompt_positional:
return args.prompt_positional
path = args.prompt_file or os.environ.get('GH_AW_PROMPT')
if path and os.path.isfile(path):
return pathlib.Path(path).read_text(encoding='utf-8')
return ''
def build_model(args: Args) -> tuple[Model, str]:
"""Build the `pydantic-ai` model and a human-readable label.
Anthropic-only — the shim behaves like the stock Claude Code CLI:
gh-aw sets `ANTHROPIC_BASE_URL` (its in-cluster transparent proxy)
and the AWF api-proxy injects the real key on outgoing requests.
**Why we construct `AsyncAnthropic` ourselves** instead of letting
`pydantic-ai`'s `AnthropicProvider` auto-configure: gh-aw runs the
agent step in a sandbox that excludes `ANTHROPIC_API_KEY` from the
container env (`awf --exclude-env ANTHROPIC_API_KEY` — a security
measure so the real key never reaches the agent). `pydantic-ai`'s
auto-config requires that env var to be present, so it errors out
under gh-aw. The explicit `AsyncAnthropic(auth_token=...)` path
sends a placeholder bearer that the AWF api-proxy swaps for the
real key on the wire — the same dance the Claude Code CLI does.
This is a gh-aw constraint, not a pydantic-ai one; upstream gh-aw
could lift it by allowing the agent to read the key directly, but
that would break the credential-isolation guarantee.
Model name resolution (in priority order):
1. `--model X` argv flag (from Claude Code's CLI surface).
2. `ANTHROPIC_MODEL` env var (standard Anthropic SDK convention;
gh-aw populates this from the workflow's `engine.model:` field).
3. Fallback default `claude-sonnet-4-6`.
"""
model_name = args.model or os.environ.get('ANTHROPIC_MODEL') or 'claude-sonnet-4-6'
anthropic_base = os.environ.get('ANTHROPIC_BASE_URL')
auth_token = (
os.environ.get('ANTHROPIC_AUTH_TOKEN') or os.environ.get('ANTHROPIC_API_KEY') or PROXY_BEARER_PLACEHOLDER
)
logger.info('anthropic model=%s base_url=%s', model_name, anthropic_base or '(default)')
client = AsyncAnthropic(
auth_token=auth_token,
base_url=anthropic_base,
timeout=_LLM_TIMEOUT,
max_retries=_LLM_MAX_RETRIES,
)
return (
AnthropicModel(model_name, provider=AnthropicProvider(anthropic_client=client)),
f'anthropic:{model_name}',
)
def configure_logging() -> None:
"""Configure stderr logging once, at CLI entry."""
logging.basicConfig(
level=logging.INFO,
format='[pydantic-ai-gh-aw-shim] %(message)s',
stream=sys.stderr,
)
def configure_observability() -> None:
"""Wire pydantic-ai + httpx + mcp instrumentation to Logfire/OTLP if configured."""
write_token = os.environ.get('LOGFIRE_WRITE_TOKEN') or os.environ.get('LOGFIRE_TOKEN')
if not (os.environ.get('OTEL_EXPORTER_OTLP_ENDPOINT') or os.environ.get('GH_AW_OTLP_ENDPOINTS') or write_token):
return
try:
logfire.configure(
service_name=os.environ.get('OTEL_SERVICE_NAME', 'gh-aw'),
send_to_logfire='if-token-present',
console=False,
token=write_token or None,
)
logfire.instrument_pydantic_ai(include_content=True, include_binary_content=True)
logfire.instrument_httpx(capture_all=True)
logfire.instrument_mcp()
logger.info('Logfire/OTLP instrumentation enabled (pydantic_ai + httpx + mcp)')
except Exception as exc:
logger.warning('observability disabled: %r', exc)
def _mcp_tool_allowed(server: str, allowed: frozenset[str]) -> ToolPredicate:
"""Allow-list predicate matching gh-aw's `mcp__<server>__<tool>` form (or `mcp__<server>` wildcard)."""
server_wildcard = f'mcp__{server}' in allowed
def predicate(_ctx: RunContext[object], tool_def: ToolDefinition) -> bool:
return server_wildcard or tool_def.name in allowed
return predicate
def _apply_claude_mcp_prefix(entry: AbstractToolset[object]) -> AbstractToolset[object]:
"""Swap the default `<server>_<tool>` prefix for Claude Code's `mcp__<server>__<tool>` wire form.
The trailing `_` combines with `PrefixedToolset`'s `_` separator to
yield the doubled underscores gh-aw and Claude were trained on.
"""
if not isinstance(entry, PrefixedToolset):
return entry
return dataclasses.replace(entry, prefix=f'mcp__{entry.prefix}_')
def build_mcp_servers(args: Args) -> list[AbstractToolset[object]]:
"""Load gh-aw's MCP config, re-prefix to Claude Code wire format, and apply the allow-list filter."""
path = args.mcp_config or os.environ.get('GH_AW_MCP_CONFIG')
if not path or not os.path.isfile(path):
logger.info('no MCP config present — running without external tools')
return []
try:
loaded = load_mcp_toolsets(path)
# `repr` is sufficient diagnostically here (a `ValidationError` already
# enumerates the bad fields, `FileNotFoundError` names the path), so no
# traceback — but returning `[]` drops the *entire* GitHub/safeoutputs tool
# surface, a drastic behaviour change, so log it at `error` to make a run
# that silently lost its tools obvious in the artifact.
except FileNotFoundError as exc:
logger.error('MCP config %r missing (%r) — agent will run with NO external tools', path, exc)
return []
except (ValidationError, ValueError) as exc:
logger.error('MCP config %r is malformed (%r) — agent will run with NO external tools', path, exc)
return []
servers: list[AbstractToolset[object]] = []
for entry in loaded:
name = (entry.wrapped.id if isinstance(entry, PrefixedToolset) else entry.id) or '<unnamed>'
toolset = _apply_claude_mcp_prefix(cast('AbstractToolset[object]', entry))
if args.allowed_tools is not None:
toolset = toolset.filtered(_mcp_tool_allowed(name, args.allowed_tools))
logger.info('registered MCP server %r (allow-list filtered)', name)
else:
logger.info('registered MCP server %r (no allow-list)', name)
servers.append(toolset)
return servers
def _claude_code_tool_predicate(allowed: frozenset[str] | None, permission_mode: str | None) -> ToolPredicate:
"""Allow-list + `plan`-mode filter for the Claude Code toolset."""
plan = permission_mode == 'plan'
def predicate(_ctx: RunContext[object], tool_def: ToolDefinition) -> bool:
name = tool_def.name
if allowed is not None and name not in allowed:
return False
if plan and name in MUTATING_TOOLS:
return False
return True
return predicate
def select_claude_code_toolset(
allowed: frozenset[str] | None,
permission_mode: str | None,
*,
task: TaskCallable | None,
) -> AbstractToolset[object]:
"""Build the Claude Code toolset; `task=None` for sub-agents so they can't recurse."""
return build_claude_code_toolset(task=task).filtered(_claude_code_tool_predicate(allowed, permission_mode))
# --------------------------------------------------------------------------- #
# Claude-compatible stream-json output
# --------------------------------------------------------------------------- #
def emit(obj: Mapping[str, object]) -> None:
"""Write one Claude-style stream-json line to stdout."""
sys.stdout.write(json.dumps(obj) + '\n')
sys.stdout.flush()
def emit_result(
text: str,
usage: RunUsage | None,
session_id: str,
is_error: bool = False,
num_turns: int = 1,
duration_ms: int = 0,
) -> None:
"""Emit the Claude Code stream-json `result` line gh-aw parses for success + token totals."""
if usage is None:
token_usage = {
'input_tokens': 0,
'output_tokens': 0,
'cache_creation_input_tokens': 0,
'cache_read_input_tokens': 0,
}
else:
token_usage = {
'input_tokens': usage.input_tokens,
'output_tokens': usage.output_tokens,
'cache_creation_input_tokens': usage.cache_write_tokens,
'cache_read_input_tokens': usage.cache_read_tokens,
}
emit(
{
'type': 'result',
'subtype': 'error' if is_error else 'success',
'is_error': is_error,
'result': text,
'session_id': session_id,
'num_turns': num_turns,
'duration_ms': duration_ms,
'total_cost_usd': 0,
'usage': token_usage,
}
)
# Live tool-call / tool-result streaming for gh-aw's log parser. Result
# content is truncated for the stream view only — the model sees the full
# result via the message history.
MAX_LIVE_TOOL_RESULT_CHARS = 100
async def _stream_events(_ctx: RunContext[object], events: AsyncIterable[AgentStreamEvent]) -> None:
"""Emit tool_use / tool_result stream-json as events fire."""
async for event in events:
if isinstance(event, ToolCallEvent):
emit(
{
'type': 'assistant',
'message': {
'role': 'assistant',
'content': [
{
'type': 'tool_use',
'id': event.part.tool_call_id,
'name': event.part.tool_name,
'input': event.part.args_as_dict(),
}
],
},
}
)
logger.info('tool_use: %s', event.part.tool_name)
elif isinstance(event, ToolResultEvent):
# `event.part` is `ToolReturnPart | RetryPromptPart`; the latter
# means the tool result failed validation and pydantic-ai is
# asking the model to retry. Tag it so gh-aw doesn't read it as
# success.
is_retry = isinstance(event.part, RetryPromptPart)
content = str(event.part.content)
if len(content) > MAX_LIVE_TOOL_RESULT_CHARS:
content = (
content[:MAX_LIVE_TOOL_RESULT_CHARS] + f'…[+{len(content) - MAX_LIVE_TOOL_RESULT_CHARS} chars]'
)
emit(
{
'type': 'user',
'message': {
'role': 'user',
'content': [
{
'type': 'tool_result',
'tool_use_id': event.part.tool_call_id,
'content': content,
'is_error': is_retry,
}
],
},
}
)
def count_tool_calls(messages: Sequence[ModelMessage]) -> int:
"""Tally tool calls in the final message history (for the end-of-run log)."""
return sum(1 for m in messages for p in m.parts if isinstance(p, ToolCallPart))
def log_safe_outputs_state() -> None:
"""Log whether anything reached the gh-aw safe-outputs sink."""
path = os.environ.get('GH_AW_SAFE_OUTPUTS')
if not path:
logger.info('GH_AW_SAFE_OUTPUTS not set')
return
try:
data = pathlib.Path(path).read_text(encoding='utf-8')
except OSError as exc:
logger.info('GH_AW_SAFE_OUTPUTS unreadable (%s): %r', path, exc)
return
lines = [ln for ln in data.splitlines() if ln.strip()]
logger.info('GH_AW_SAFE_OUTPUTS=%s entries=%d bytes=%d', path, len(lines), len(data))
for ln in lines[:5]:
logger.info(' safe-output: %s', ln[:300])
async def task(ctx: RunContext[object], description: str, prompt: str) -> str:
"""Claude's `Task` tool: spawn a read-only sub-agent on `ctx.model`."""
logger.info('Task spawn: %s', description[:120])
# Fresh dedupe set per sub-agent — otherwise inheriting the parent's
# `seen` AGENTS.md set would silently hide context the sub-agent needs.
reset_context_state()
sub_toolset = select_claude_code_toolset(READ_ONLY_SUBAGENT_TOOLS, permission_mode=None, task=None)
sub = Agent(
ctx.model,
instructions=[INSTRUCTIONS, SUBAGENT_INSTRUCTIONS, prompt],
toolsets=[sub_toolset],
capabilities=[
*_anthropic_native_capabilities(),
ProcessEventStream(_stream_events),
],
)
# Fresh `RunUsage` so `SUBAGENT_REQUEST_LIMIT` bounds the sub-agent, not
# (parent + sub). Merge the deltas back regardless of success/failure.
sub_usage = RunUsage()
try:
result = await asyncio.wait_for(
sub.run(RUN_TRIGGER, usage_limits=UsageLimits(request_limit=SUBAGENT_REQUEST_LIMIT), usage=sub_usage),
timeout=SUBAGENT_TIMEOUT_SECS,
)
except asyncio.TimeoutError:
# A bare `TimeoutError` stringifies to '' — without an explicit message
# the model (and the log) would see `sub-agent failed:` with no payload.
ctx.usage.incr(sub_usage)
logger.error('sub-agent timed out after %.0f min: %s', SUBAGENT_TIMEOUT_SECS / 60, description[:120])
return f'error: sub-agent timed out after {SUBAGENT_TIMEOUT_SECS // 60}min'
except Exception as exc:
# The parent agent reacts to the returned string, but a sub-agent can hit
# the same nested `ExceptionGroup`/`McpError` as the main run — log the
# full stack so the failure isn't reduced to a one-line repr in the logs.
ctx.usage.incr(sub_usage)
logger.exception('sub-agent failed: %s', description[:120])
return f'error: sub-agent failed: {exc}'
ctx.usage.incr(sub_usage)
logger.info('Task done: +%d sub-requests (run total now %d)', sub_usage.requests, ctx.usage.requests)
return str(result.output or '')
async def _run_with_timeout(
prompt: str,
model: Model,
label: str,
claude_code_toolset: AbstractToolset[object],
mcp_servers: list[AbstractToolset[object]],
session_id: str,
) -> int:
"""Wrap `run()` with the global wall-clock cap and emit a clean result on timeout."""
try:
return await asyncio.wait_for(
run(prompt, model, label, claude_code_toolset, mcp_servers, session_id),
timeout=RUN_TIMEOUT_SECS,
)
except asyncio.TimeoutError:
logger.error('run timed out after %.0f min', RUN_TIMEOUT_SECS / 60)
emit_result(
f'run timed out after {RUN_TIMEOUT_SECS // 60}min',
usage=None,
session_id=session_id,
is_error=True,
)
return 1
async def run(
prompt: str,
model: Model,
label: str,
claude_code_toolset: AbstractToolset[object],
mcp_servers: list[AbstractToolset[object]],
session_id: str,
) -> int:
"""Run one agent turn and emit Claude-shape stream-json. Always emits a `result` line."""
reset_context_state()
agent: Agent[object, str] = Agent(
model,
instructions=[INSTRUCTIONS, prompt],
toolsets=[claude_code_toolset, *mcp_servers],
capabilities=[
*_anthropic_native_capabilities(),
ProcessHistory(_compact_history),
ProcessEventStream(_stream_events),
],
)
limits = UsageLimits(request_limit=REQUEST_LIMIT)
emit({'type': 'system', 'subtype': 'init', 'session_id': session_id, 'model': label})
started = time.perf_counter()
try:
async with agent:
result = await agent.run(RUN_TRIGGER, usage_limits=limits)
except Exception as exc:
# `%r` on an `ExceptionGroup` (e.g. the MCP `TaskGroup` failures seen in
# CI) discards every frame and every nested sub-exception's stack, which
# is what made the original incident so hard to root-cause. `exception()`
# renders the full traceback — and, on 3.11+, each group leaf's stack —
# to stderr, which gh-aw captures into the uploaded `agent-stdio.log`,
# so the run is self-explaining without a re-run. The `result` text
# stays a one-liner because gh-aw parses it.
logger.exception('agent run failed')
emit_result(
f'agent run failed: {exc}',
usage=None,
session_id=session_id,
is_error=True,
duration_ms=round((time.perf_counter() - started) * 1000),
)
return 1
duration_ms = round((time.perf_counter() - started) * 1000)
messages = result.all_messages()
tool_calls = count_tool_calls(messages)
num_turns = sum(isinstance(m, ModelResponse) for m in messages)
logger.info('tool calls observed: %d, turns: %d', tool_calls, num_turns)
text = str(result.output or '')
emit({'type': 'assistant', 'message': {'role': 'assistant', 'content': text}})
emit_result(text, result.usage, session_id, num_turns=num_turns, duration_ms=duration_ms)
log_safe_outputs_state()
return 0
def main() -> int:
"""Entry point. Every failure produces a stream-json `result` line so gh-aw never sees an empty log."""
configure_logging()
session_id = (os.environ.get('GITHUB_RUN_ID') or 'local') + '-' + uuid.uuid4().hex[:8]
try:
args = parse_args(sys.argv[1:])
configure_observability()
prompt = resolve_prompt(args)
if not prompt.strip():
logger.info('empty prompt — nothing to do')
emit_result('empty prompt', usage=None, session_id=session_id, is_error=True)
return 1
model, label = build_model(args)
claude_code_toolset = select_claude_code_toolset(args.allowed_tools, args.permission_mode, task=task)
mcp_servers = build_mcp_servers(args)
logger.info(
'model=%s permission_mode=%s request_limit=%d claude_code_tool_names=%s mcp_servers=%d prompt_chars=%d',
label,
args.permission_mode or '(none)',
REQUEST_LIMIT,
list(CLAUDE_CODE_TOOL_NAMES),
len(mcp_servers),
len(prompt),
)
started = time.time()
rc = asyncio.run(_run_with_timeout(prompt, model, label, claude_code_toolset, mcp_servers, session_id))
logger.info('done in %.1fs rc=%d', time.time() - started, rc)
return rc
except SystemExit as exc:
# `argparse` raises `SystemExit` (not `Exception`) on unknown-flag
# rejection — an expected, clean exit, so a traceback would be noise.
# gh-aw still needs a structured result line.
logger.error('FATAL startup error: %r', exc)
emit_result(f'shim startup failed: {exc}', usage=None, session_id=session_id, is_error=True)
return 1
except Exception as exc:
# A real crash before the agent run (model build, MCP load, …) — dump the
# full stack so a blind FATAL doesn't cost another long investigation.
logger.exception('FATAL startup error')
emit_result(f'shim startup failed: {exc}', usage=None, session_id=session_id, is_error=True)
return 1
@@ -0,0 +1,52 @@
"""Claude's `Edit` tool -- replace a string in a workspace file.
The single-occurrence case is backed by pydantic-ai-harness's
`FileSystemToolset.edit_file`, which requires `old_string` to match exactly once
-- the same uniqueness rule Claude Code's own `Edit` enforces. `replace_all=True`
has no harness equivalent, so it keeps the prior in-place read/replace/write.
"""
from pydantic_ai.exceptions import ModelRetry
from ._backends import filesystem
from .shared import attach_context, resolve
async def edit_file(file_path: str, old_string: str, new_string: str, replace_all: bool = False) -> str:
"""Replace `old_string` with `new_string` in a workspace file.
Replaces the single (unique) occurrence, or every occurrence when `replace_all`.
"""
if replace_all:
return await _replace_all(file_path, old_string, new_string)
try:
result = await filesystem().edit_file(file_path, old_string, new_string)
except (ModelRetry, OSError) as exc:
# The harness only converts a fixed set of errors to `ModelRetry`; a bare
# `OSError` (e.g. `ENAMETOOLONG` while resolving the path) would otherwise
# escape and abort the whole run, where the old tool returned an error.
return f'error: {exc}'
return attach_context(file_path) + result
async def _replace_all(file_path: str, old_string: str, new_string: str) -> str:
"""Replace every occurrence of `old_string`.
The harness `edit_file` rejects non-unique matches, so replace-all stays an
in-place rewrite (as the tool did before it was harness-backed). Workspace
containment is still enforced by preflighting the path through the filesystem
capability's `file_info` -- the same check `Grep` uses -- so this branch
can't escape the workspace root while the single-edit branch can't.
"""
try:
await filesystem().file_info(file_path) # rejects a path that escapes the workspace
p = resolve(file_path)
text = p.read_text(encoding='utf-8')
if old_string not in text:
return 'error: `old_string` not found'
p.write_text(text.replace(old_string, new_string, -1), encoding='utf-8')
except ModelRetry as exc:
return f'error: {exc}'
except OSError as exc:
return f'error: {exc}'
return attach_context(file_path) + f'edited {p}'
@@ -0,0 +1,14 @@
"""Claude's `ExitPlanMode` tool — acknowledge the end of planning."""
from .shared import logger
def exit_plan_mode(plan: str) -> str:
"""Acknowledge the end of planning.
The shim has no interactive plan
review, so this is just a structured ack — the agent continues execution
against the same workspace it was already operating on.
"""
logger.info('ExitPlanMode: %s', plan[:200])
return 'Plan acknowledged — proceeding with execution.'
@@ -0,0 +1,51 @@
"""Claude's `Glob` tool -- list workspace paths matching a glob pattern.
Containment comes from a pydantic-ai-harness `FileSystemToolset.file_info`
preflight on the search `path` (the same check `Grep`/`LS` use) plus a
per-match resolved-path check, but the glob itself is hand-rolled with the stdlib
rather than delegated to `FileSystemToolset.find_files`: the harness walker hides
every dot-prefixed path, which would make `.github/**` -- where gh-aw's own
workflows live -- unmatchable. The Claude `Glob` signature is preserved, matches
are returned relative to the workspace root, and the directory-scoped AGENTS.md /
CLAUDE.md context blocks are still prepended.
"""
import glob as globlib
import os
import pathlib
from pydantic_ai.exceptions import ModelRetry
from ._backends import filesystem
from .shared import attach_context, clip, resolve, workspace
async def glob_search(pattern: str, path: str = '.') -> str:
"""Return workspace paths matching a glob `pattern` (supports `**`)."""
# Claude's `Glob` takes a relative pattern plus a separate `path`; an absolute
# pattern would be joined as-is and could escape the search root, so reject it.
if os.path.isabs(pattern):
return 'error: glob pattern must be relative to the search path'
try:
# `file_info` contains the search `path` (a clear error if it escapes); the
# per-match resolve() + is_relative_to() below then contains the matches,
# dropping anything a `..` pattern or an in-workspace symlink points to
# outside the root (a purely lexical `relative_to` would not catch those).
await filesystem().file_info(path)
base = resolve(path)
ws = pathlib.Path(workspace())
root = ws.resolve()
matches: list[str] = []
for match in globlib.glob(str(base / pattern), recursive=True):
# Resolve only to *decide* containment (collapse `..`, follow symlinks);
# the returned path is the matched name relative to the workspace, so a
# symlink that matched (e.g. `CLAUDE.md` -> `AGENTS.md`) is reported as
# itself rather than silently rewritten to its target.
if pathlib.Path(match).resolve().is_relative_to(root):
matches.append(os.path.relpath(match, ws))
except (ModelRetry, OSError, ValueError) as exc:
# ModelRetry: the containment preflight rejected `path`. OSError (e.g.
# `ENAMETOOLONG`) / ValueError: not recoverable in the harness, so catch
# them here rather than let them abort the whole run.
return f'error: {exc}'
return clip(attach_context(path) + ('\n'.join(sorted(set(matches))) or 'No matches found.'))
@@ -0,0 +1,83 @@
r"""Claude's `Grep` tool -- recursively regex-search workspace files.
Runs ripgrep through pydantic-ai-harness's `ShellToolset` -- the same shell
capability that backs `Bash` -- instead of a hand-rolled subprocess: the harness
owns process execution, the sandbox PATH, output truncation, and the timeout,
while ripgrep keeps its speed and `.gitignore` filtering (a poor fit for the
harness's own `FileSystemToolset.search_files`, which walks every non-dotfile
including vendored/ignored trees and matches with Python `re`). The
directory-scoped AGENTS.md / CLAUDE.md context blocks are still prepended.
Two adapters bridge a shell command back into a grep tool:
- Containment. `ShellToolset` roots the cwd at the workspace but, unlike the
harness `FileSystemToolset`, does not validate the `path` argument, so a bare
`rg -- ../..` could escape `$GITHUB_WORKSPACE`. `path` is preflighted through
the filesystem capability -- the same containment `Read`/`Glob`/`LS` enforce.
- Framing. `run_command` frames output as `[stdout]` / `[stderr]` blocks and
appends `\n[exit code: N]` only on a non-zero exit. ripgrep exits 1 on "no
matches" (not an error) and 2+ on a real error, so the exit code -- not the
presence of `[stdout]` -- is what the adapter keys off, unwrapping the generic
framing back into a grep-shaped result.
"""
import re
import shlex
from pydantic_ai.exceptions import ModelRetry
from ._backends import filesystem, shell
from .shared import attach_context, clip
GREP_TIMEOUT = 60.0
# `run_command` appends this only on a non-zero exit; ripgrep's exit code rides
# at the very end of the framed output, after any tail-truncation of the body.
_EXIT_CODE_RE = re.compile(r'\n\[exit code: (\d+)\]\Z')
# The harness tail-truncates an oversized body and prepends this marker, which
# elides the leading `[stdout]` header; tolerate it so a large match set is
# never mistaken for an error.
_TRUNCATION_PREFIX = '[... output truncated'
# `search_files` (the harness's own content search) returns this on no match;
# grep mirrors it even though it's ripgrep-backed, so the two search tools agree.
_NO_MATCHES = 'No matches found.'
def _split_exit_code(out: str) -> tuple[str, int]:
"""Split a `run_command` result into its body and the command's exit code (0 if absent)."""
match = _EXIT_CODE_RE.search(out)
if match is None:
return out, 0
return out[: match.start()], int(match.group(1))
async def grep(pattern: str, path: str = '.') -> str:
"""Recursively regex-search workspace files via ripgrep, returning `file:line:text` matches."""
# `file_info` accepts '' as the workspace root, but `rg -- ''` errors on the
# empty path argument, so normalize to the default search root up front.
path = path or '.'
command = f'rg --line-number --no-heading --color never -e {shlex.quote(pattern)} -- {shlex.quote(path)}'
# Preflight `path` through the filesystem containment check (an escape or a
# missing path comes back as `ModelRetry`) before handing it to the shell.
try:
await filesystem().file_info(path)
out = await shell().run_command(command, timeout_seconds=GREP_TIMEOUT)
except (ModelRetry, OSError) as exc:
# The harness converts only a fixed set of errors to `ModelRetry`; a bare
# `OSError` (e.g. `ENAMETOOLONG` from the `file_info` preflight resolving
# an over-long path) would otherwise escape and abort the whole run.
return f'error: {exc}'
if out.startswith('[Command timed out'):
return f'error: {out}'
body, exit_code = _split_exit_code(out)
if exit_code >= 2: # bad pattern, unreadable path, ripgrep absent (127), ...
return f'error: {out}'
if exit_code == 1: # ripgrep's "nothing matched"
return clip(attach_context(path) + _NO_MATCHES)
# exit 0: matches. Strip the truncation marker first (it precedes and elides
# the `[stdout]` header), then the header itself.
if body.startswith(_TRUNCATION_PREFIX):
body = body.split('\n', 1)[1] if '\n' in body else ''
body = body.removeprefix('[stdout]\n')
return clip(attach_context(path) + (body or _NO_MATCHES))
@@ -0,0 +1,30 @@
"""Claude's `LS` tool -- list a workspace directory's entries.
Containment comes from a pydantic-ai-harness `FileSystemToolset.file_info`
preflight (the same check `Grep` uses), but the listing itself is hand-rolled
rather than delegated to `FileSystemToolset.list_directory`: the harness walker
hides every dot-prefixed entry, which would make `.github/` -- where gh-aw's own
workflows live -- invisible to the agent. The Claude `LS` signature is preserved
and the directory-scoped AGENTS.md / CLAUDE.md context blocks are still prepended.
"""
from pydantic_ai.exceptions import ModelRetry
from ._backends import filesystem
from .shared import attach_context, clip, resolve
async def list_dir(path: str = '.') -> str:
"""List a workspace directory's entries (directories marked with `/`)."""
try:
# `file_info` enforces workspace containment and rejects a missing path;
# the enumeration then keeps the dot-prefixed entries the harness drops.
await filesystem().file_info(path)
entries = resolve(path).iterdir()
listing = '\n'.join(sorted(e.name + ('/' if e.is_dir() else '') for e in entries)) or '(empty)'
except (ModelRetry, OSError) as exc:
# ModelRetry: the containment preflight rejected the path. OSError (e.g.
# `NotADirectoryError` on a file, or `ENAMETOOLONG`): not recoverable in the
# harness, so catch it here rather than let it abort the whole run.
return f'error: {exc}'
return clip(attach_context(path) + listing)
@@ -0,0 +1,41 @@
"""Claude's `MultiEdit` tool — apply multiple string replacements to one file atomically."""
# pydantic requires typing_extensions.TypedDict (not typing.TypedDict) for
# schema generation on Python < 3.12; typing_extensions ships with pydantic.
from typing_extensions import NotRequired, TypedDict
from .shared import attach_context, resolve
class EditOp(TypedDict):
"""One replacement for `MultiEdit` (Claude's edit schema)."""
old_string: str
new_string: str
replace_all: NotRequired[bool]
def multi_edit(file_path: str, edits: list[EditOp]) -> str:
"""Apply a sequence of string replacements to one workspace file atomically.
Each edit replaces the first occurrence of `old_string` (or every
occurrence when `replace_all`). If any `old_string` is missing, nothing is
written — the file is left untouched.
"""
try:
p = resolve(file_path)
text = original = p.read_text(encoding='utf-8')
except OSError as exc:
return f'error: {exc}'
for i, e in enumerate(edits):
old = e.get('old_string', '')
if not old or old not in text:
return f'error: edit #{i + 1} `old_string` not found (no changes written)'
text = text.replace(old, e.get('new_string', ''), -1 if e.get('replace_all') else 1)
if text == original:
return 'no changes'
try:
p.write_text(text, encoding='utf-8')
except OSError as exc:
return f'error: {exc}'
return attach_context(file_path) + f'applied {len(edits)} edit(s) to {p}'
@@ -0,0 +1,88 @@
"""Claude's `Read` tool -- read a UTF-8 text file.
Backed by pydantic-ai-harness's `FileSystemToolset.read_file`: path containment,
symlink resolution, and binary-file detection come from the harness. The Claude
`Read` signature (1-based `offset`, `limit`) is preserved, and the
directory-scoped AGENTS.md / CLAUDE.md context blocks are still prepended.
"""
import re
from pydantic_ai.exceptions import ModelRetry
from ._backends import filesystem
from .shared import MAX_TOOL_OUTPUT, attach_context, clip
# When the harness truncates, it appends a continuation hint carrying *its* 0-based
# offset: `... (N more lines. Use offset=M to continue reading.)`. This tool exposes
# a 1-based offset (mirroring Claude's `Read`), so the advertised value must be
# bumped by one or a model that follows the hint literally re-reads the boundary
# line. The harness writes the hint at the start of its own line, while every
# content line is line-number-prefixed (`{n:>6}\t...`); anchoring to `^` (with
# `MULTILINE`) matches only the real hint, never a file whose own contents happen
# to reproduce the hint text.
_CONTINUE_HINT_RE = re.compile(r'^(\.\.\. \(\d+ more lines\. Use offset=)(\d+)( to continue reading\.\))', re.MULTILINE)
# The harness numbers each content line as `{lineno:>6}\t{text}` (1-based). A
# char-budget truncation reads that leading number back to advertise an exact
# continuation offset for the first line it dropped.
_LINE_NO_RE = re.compile(r' *(\d+)\t')
def _hint_to_one_based(body: str) -> str:
"""Rewrite the harness's 0-based continuation offset into this tool's 1-based one."""
return _CONTINUE_HINT_RE.sub(lambda m: f'{m[1]}{int(m[2]) + 1}{m[3]}', body)
def _fit_to_output_budget(prefix: str, body: str) -> str:
"""Return `prefix + body`, truncated to the output cap on a whole-line boundary.
`clip()` keeps the head and drops the tail, but the harness's continuation hint
rides at the tail; a chunk well over the char cap (e.g. 2000 lines of code) would
lose it, leaving the model with no next offset. Instead keep whole numbered lines
that fit and re-advertise the 1-based offset of the first dropped line, so the
Read round-trip stays exact even when the per-line cap and the per-char cap
disagree.
"""
out = prefix + body
if len(out) <= MAX_TOOL_OUTPUT:
return out
size = len(prefix)
kept: list[str] = []
last_lineno: int | None = None
for line in body.split('\n'):
size += len(line) + 1
if size > MAX_TOOL_OUTPUT and kept:
break
kept.append(line)
numbered = _LINE_NO_RE.match(line)
if numbered:
last_lineno = int(numbered.group(1))
if last_lineno is None:
# Not even one whole numbered line fit (e.g. a single enormous line); fall
# back to a plain head clip so the model still sees partial content.
return clip(out)
tail = f'... (truncated to fit the output limit. Use offset={last_lineno + 1} to continue reading.)'
return prefix + '\n'.join(kept).rstrip('\n') + '\n' + tail
async def read_file(file_path: str, offset: int | None = None, limit: int | None = None) -> str:
"""Read a UTF-8 text file. Relative paths resolve under the workspace.
Optional 1-based line `offset` and line `limit` mirror Claude's Read tool.
"""
# Claude's `offset` is a 1-based line number; the harness uses a 0-based offset.
zero_based = max((offset or 1) - 1, 0)
# A non-positive `limit` is degenerate: passed straight through, `limit=0` makes
# the harness read zero lines and emit a same-offset hint that loops forever.
# Normalize it to `None` so it behaves exactly like an omitted `limit` (the
# harness applies its default line cap) rather than reading nothing.
effective_limit = limit if limit and limit > 0 else None
try:
body = await filesystem().read_file(file_path, offset=zero_based, limit=effective_limit)
except (ModelRetry, OSError) as exc:
# The harness only converts a fixed set of errors to `ModelRetry`; a bare
# `OSError` (e.g. `ENAMETOOLONG` while resolving the path) would otherwise
# escape and abort the whole run, where the old tool returned an error.
return f'error: {exc}'
return _fit_to_output_budget(attach_context(file_path), _hint_to_one_based(body))
@@ -0,0 +1,122 @@
"""Shared helpers used by the native tools in `pydantic_ai_gh_aw_shim/`.
Stdlib-only (apart from the package's own modules). Anything
pydantic-ai-specific belongs in the CLI module, not here — each tool
module should import only `shared` and be otherwise independent so it can
be swapped out cleanly.
"""
import contextvars
import logging
import os
import pathlib
MAX_TOOL_OUTPUT = 50_000
"""Cap on any native tool's stringified result. Larger outputs are clipped with
a `…[truncated N chars]` suffix so the model knows it didn't see everything.
50 000 chars (~500 lines of typical Python) lets most files be read in one shot
without requiring the model to loop over small offset/limit chunks."""
CONTEXT_FILE_NAMES = ('AGENTS.md', 'CLAUDE.md')
MAX_CONTEXT_FILE_CHARS = 8000
# Configured for output by `cli.configure_logging()` (called from `main()`);
# at import time the logger only has whatever handlers the embedding
# application has set up. Library code never calls `logging.basicConfig`.
logger = logging.getLogger('pydantic_ai_gh_aw_shim')
def workspace() -> str:
"""Resolve the workspace root live.
Reading `GITHUB_WORKSPACE` lazily
(rather than capturing at import time) means tests can set it via
`monkeypatch.setenv` instead of patching a module-level constant.
"""
return os.environ.get('GITHUB_WORKSPACE') or os.getcwd()
def resolve(path: str) -> pathlib.Path:
"""Resolve a relative path against the current workspace; absolute paths pass through."""
p = pathlib.Path(path)
return p if p.is_absolute() else pathlib.Path(workspace()) / p
def clip(text: str) -> str:
"""Truncate a tool result string to `MAX_TOOL_OUTPUT` chars."""
if len(text) <= MAX_TOOL_OUTPUT:
return text
return text[:MAX_TOOL_OUTPUT] + f'\n…[truncated {len(text) - MAX_TOOL_OUTPUT} chars]'
# --------------------------------------------------------------------------- #
# Directory-scoped AGENTS.md / CLAUDE.md auto-loading
# When the agent touches a file, the shim transparently surfaces the
# directory's `AGENTS.md` / `CLAUDE.md` the first time it's relevant (walking
# up to the workspace root). Per-run state ensures each file is shown at most
# once. Held in a `ContextVar` so each `run()` (and each test) gets its own
# set without test-ordering leaks, while `Task` sub-agents naturally inherit
# the parent's set via the async context tree (mutations to the shared object
# propagate both ways).
# --------------------------------------------------------------------------- #
_seen_context_files_var: contextvars.ContextVar[set[pathlib.Path] | None] = contextvars.ContextVar(
'_seen_context_files', default=None
)
def _seen_context_files() -> set[pathlib.Path]:
"""Return the current run's dedupe set, installing one lazily if a tool ran outside `run()`."""
s = _seen_context_files_var.get()
if s is None:
s = set[pathlib.Path]()
_seen_context_files_var.set(s)
return s
def reset_context_state() -> None:
"""Install a fresh dedupe set in the current context.
Called once at the
start of each `run()` (and from tests that want a clean slate).
"""
_seen_context_files_var.set(set())
def attach_context(path_arg: str | None) -> str:
"""Return any newly-discovered `AGENTS.md` / `CLAUDE.md` blocks to prepend to a path-taking tool result.
Walks up from the target's directory to the
workspace root, surfacing each file at most once per run.
"""
if not path_arg:
return ''
try:
ws = pathlib.Path(workspace()).resolve()
target = resolve(path_arg).resolve()
except OSError:
return ''
seen = _seen_context_files()
cur = target if target.is_dir() else target.parent
blocks: list[str] = []
while cur.is_relative_to(ws):
for name in CONTEXT_FILE_NAMES:
candidate = cur / name
if not candidate.is_file() or candidate in seen:
continue
seen.add(candidate)
try:
content = candidate.read_text(encoding='utf-8', errors='replace')
except OSError:
continue
rel = candidate.relative_to(ws)
blocks.append(
f'--- context: {rel} (auto-loaded; shown once per run) ---\n{content[:MAX_CONTEXT_FILE_CHARS]}\n'
)
if cur == ws or cur.parent == cur:
break
cur = cur.parent
if not blocks:
return ''
return '\n'.join(blocks) + '\n'
@@ -0,0 +1,52 @@
"""Claude's `TodoWrite` tool -- record the agent's task checklist.
Backed by pydantic-ai-harness's `experimental.planning` capability: the adapter
maps Claude's todo schema onto the harness `PlanItem` list and calls the same
`write_plan` the capability exposes, so the checklist is rendered (with the
harness's advisory note when more than one step is `in_progress`) instead of a
hand-rolled ack.
`planning` is experimental -- its API may change without a deprecation period --
which is acceptable here; the warning is silenced at import so it doesn't leak
into the agent's stdout. The Claude `TodoWrite` signature (`content` / `status` /
`activeForm` items) is preserved; `activeForm` is the present-tense label Claude
shows while a step runs and has no harness equivalent, so it's dropped (the
headless shim renders nothing live anyway).
"""
import warnings
from pydantic_ai_harness.experimental import HarnessExperimentalWarning
from typing_extensions import TypedDict
with warnings.catch_warnings():
warnings.simplefilter('ignore', HarnessExperimentalWarning)
from pydantic_ai_harness.experimental.planning import PlanItem, Planning, PlanningToolset, TaskStatus
class TodoItem(TypedDict):
"""One entry for `TodoWrite` (Claude's todo schema)."""
content: str
status: str
activeForm: str
def _to_status(value: str) -> TaskStatus:
"""Map a Claude todo status onto a harness `TaskStatus`, defaulting to `pending`."""
try:
return TaskStatus(value)
except ValueError:
return TaskStatus.pending
async def todo_write(todos: list[TodoItem]) -> str:
"""Record the agent's task checklist."""
items = [PlanItem(content=t.get('content', ''), status=_to_status(t.get('status', ''))) for t in todos]
# A fresh capability per call gives a fresh plan state; Claude resends the
# full list every time, so no cross-call state needs to be retained.
# `get_toolset()` is typed `AgentToolset | None` but always returns the
# planning toolset, so narrow to reach `write_plan` without a private import.
toolset = Planning[None]().get_toolset()
assert isinstance(toolset, PlanningToolset)
return await toolset.write_plan(items)
@@ -0,0 +1,32 @@
"""Claude's `Write` tool -- create or overwrite a workspace text file.
Backed by pydantic-ai-harness's `FileSystemToolset.write_file` (path
containment, symlink resolution). Claude's `Write` creates missing parent
directories, and the harness `write_file` requires the parent to exist, so the
adapter calls `create_directory` first to keep that behavior.
"""
import os
from pydantic_ai.exceptions import ModelRetry
from ._backends import filesystem
from .shared import attach_context
async def write_file(file_path: str, content: str) -> str:
"""Create or overwrite a UTF-8 text file under the workspace."""
fs = filesystem()
parent = os.path.dirname(file_path)
try:
if parent:
await fs.create_directory(parent)
result = await fs.write_file(file_path, content)
except (ModelRetry, OSError) as exc:
# The harness converts its own recoverable errors to `ModelRetry`, but
# `create_directory` -> `Path.mkdir(exist_ok=True)` still raises a bare
# `FileExistsError` when a path segment is an existing file. The old
# hand-rolled `Write` caught `OSError`, so keep doing that: a bad path is
# a returned error, not a run-aborting exception.
return f'error: {exc}'
return attach_context(file_path) + result
+179
View File
@@ -0,0 +1,179 @@
from __future__ import annotations
import sys
import urllib.error
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
import ci_duration
def test_normalize_matrix_job_signature():
job = ci_duration.normalize_job(
{
'id': 123,
'name': 'test on 3.10 (all-extras)',
'status': 'completed',
'conclusion': 'success',
'started_at': '2026-06-13T17:15:03Z',
'completed_at': '2026-06-13T17:24:05Z',
'runner_name': 'GitHub Actions 1001364942',
'runner_group_name': 'GitHub Actions',
'html_url': 'https://github.com/pydantic/pydantic-ai/actions/runs/1/job/123',
'steps': [],
}
)
assert job.job_family == 'test'
assert job.matrix_python == '3.10'
assert job.matrix_extra == 'all-extras'
assert job.runner_class == 'github-hosted'
assert job.job_signature == 'job=test / runner=github-hosted / py=3.10 / extra=all-extras'
assert job.duration_seconds == 542
assert ci_duration.is_tracked_test_job(job)
def test_non_test_jobs_are_not_tracked():
jobs = [
ci_duration.normalize_job(
{
'id': 123,
'name': name,
'status': 'completed',
'conclusion': 'success',
'started_at': '2026-06-13T17:15:03Z',
'completed_at': '2026-06-13T17:16:03Z',
'runner_name': 'GitHub Actions 1001364942',
'runner_group_name': 'GitHub Actions',
'html_url': 'https://github.com/pydantic/pydantic-ai/actions/runs/1/job/123',
'steps': [],
}
)
for name in ['lint', 'test examples on 3.13']
]
assert [ci_duration.is_tracked_test_job(job) for job in jobs] == [False, False]
def test_classify_slow_job_requires_relative_and_absolute_delta():
baseline = ci_duration.compute_baseline([360, 370, 380, 390, 400, 410, 420, 430, 440, 450])
job = ci_duration.JobRecord(
job_id=123,
raw_name='test on 3.10 (all-extras)',
job_family='test',
job_signature='job=test / runner=github-hosted / py=3.10 / extra=all-extras',
matrix_python='3.10',
matrix_extra='all-extras',
conclusion='success',
status='completed',
started_at='2026-06-13T17:15:03Z',
completed_at='2026-06-13T17:24:05Z',
duration_seconds=600,
runner_name='GitHub Actions 1001364942',
runner_group_name='GitHub Actions',
runner_class='github-hosted',
html_url='https://github.com/pydantic/pydantic-ai/actions/runs/1/job/123',
steps=[],
)
row = ci_duration.classify_job(job, baseline)
assert row.status == 'slow'
assert row.delta_seconds == 195
def test_render_report_uses_sticky_marker_and_threshold_context():
workflow: ci_duration.JsonObject = {
'duration_seconds': 840,
'html_url': 'https://github.com/pydantic/pydantic-ai/actions/runs/1',
}
row = ci_duration.ReportRow(
job_name='test on 3.10 (all-extras)',
job_signature='job=test / runner=github-hosted / py=3.10 / extra=all-extras',
duration_seconds=600,
baseline=ci_duration.compute_baseline([360, 370, 380, 390, 400, 410, 420, 430, 440, 450]),
delta_seconds=195,
delta_percent=48,
status='slow',
)
report = ci_duration.render_report(123, 'abcdef1234567890', workflow, [row])
assert report.startswith('<!-- ci-duration-report -->\n## CI Duration Report')
assert 'Tracked test jobs: 1' in report
assert 'Total tracked test job duration: 10m 00s' in report
assert 'Baseline: up to 30 successful `main` CI runs and 60 successful PR CI runs' in report
assert 'Minimum baseline sample: 10 successful matching jobs' in report
assert '| test on 3.10 (all-extras) | 10m 00s | 6m 45s | 7m 08s | +3m 15s (+48%) | slow |' in report
assert 'trigger:ci-duration-report' in report
def test_collect_baselines_skips_unavailable_historical_run():
class StubGitHubClient(ci_duration.GitHubClient):
def request_paginated(self, path: str, *, max_items: int | None = None) -> list[ci_duration.JsonObject]:
if path == 'actions/workflows/ci.yml/runs?branch=main&event=push&status=success':
return [
{
'id': run_id,
'run_attempt': 1,
'head_sha': f'baseline-{run_id}',
}
for run_id in range(11)
]
if path == 'actions/workflows/ci.yml/runs?event=pull_request&status=success':
return []
if path == 'actions/runs/0/attempts/1/jobs':
raise urllib.error.URLError('timed out')
if path.startswith('actions/runs/') and path.endswith('/attempts/1/jobs'):
return [
{
'id': 123,
'name': 'test on 3.10 (all-extras)',
'status': 'completed',
'conclusion': 'success',
'started_at': '2026-06-13T17:15:03Z',
'completed_at': '2026-06-13T17:24:05Z',
'runner_name': 'GitHub Actions 1001364942',
'runner_group_name': 'GitHub Actions',
'html_url': 'https://github.com/pydantic/pydantic-ai/actions/runs/1/job/123',
'steps': [],
}
]
raise RuntimeError(f'Unexpected path: {path}')
baselines = ci_duration.collect_baselines(StubGitHubClient('pydantic/pydantic-ai', 'token'), 'current-sha')
assert baselines['job=test / runner=github-hosted / py=3.10 / extra=all-extras'].sample_size == 10
def test_collect_baselines_stops_after_time_budget():
class StubGitHubClient(ci_duration.GitHubClient):
def request_paginated(self, path: str, *, max_items: int | None = None) -> list[ci_duration.JsonObject]:
if path == 'actions/workflows/ci.yml/runs?branch=main&event=push&status=success':
return [
{
'id': 1,
'run_attempt': 1,
'head_sha': 'baseline-1',
}
]
if path == 'actions/workflows/ci.yml/runs?event=pull_request&status=success':
return []
raise RuntimeError(f'Unexpected path: {path}')
monotonic_values = [0.0, ci_duration.BASELINE_COLLECTION_MAX_SECONDS]
original_monotonic = ci_duration.time.monotonic
def monotonic() -> float:
if monotonic_values:
return monotonic_values.pop(0)
return ci_duration.BASELINE_COLLECTION_MAX_SECONDS
ci_duration.time.monotonic = monotonic
try:
baselines = ci_duration.collect_baselines(StubGitHubClient('pydantic/pydantic-ai', 'token'), 'current-sha')
finally:
ci_duration.time.monotonic = original_monotonic
assert baselines == {}
File diff suppressed because it is too large Load Diff