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
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:
@@ -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
|
||||
Executable
+41
@@ -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"
|
||||
@@ -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
@@ -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"
|
||||
Executable
+30
@@ -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__")
|
||||
Executable
+42
@@ -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 30–80 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
|
||||
@@ -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
Reference in New Issue
Block a user