Files
pydantic--pydantic-ai/.github/scripts/pydantic_ai_gh_aw_shim/cli.py
T
wehub-resource-sync 9201ef759e
CI / lint (push) Waiting to run
CI / mypy (push) Waiting to run
CI / docs (push) Waiting to run
CI / test on 3.10 (standard) (push) Waiting to run
CI / test on 3.11 (standard) (push) Waiting to run
CI / test on 3.12 (standard) (push) Waiting to run
CI / test on 3.10 (all-extras) (push) Waiting to run
CI / test on 3.11 (all-extras) (push) Waiting to run
CI / test on 3.12 (all-extras) (push) Waiting to run
CI / test on 3.13 (all-extras) (push) Waiting to run
CI / test on 3.14 (pydantic-evals) (push) Waiting to run
Harness Compat / harness compat (push) Waiting to run
CI / test on 3.13 (standard) (push) Waiting to run
CI / test on 3.14 (standard) (push) Waiting to run
CI / test on 3.14 (all-extras) (push) Waiting to run
CI / test on 3.10 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.11 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.12 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.13 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.14 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.10 (pydantic-evals) (push) Waiting to run
CI / test on 3.11 (pydantic-evals) (push) Waiting to run
CI / test on 3.12 (pydantic-evals) (push) Waiting to run
CI / test on 3.13 (pydantic-evals) (push) Waiting to run
CI / test on 3.10 (lowest-versions) (push) Waiting to run
CI / test on 3.11 (lowest-versions) (push) Waiting to run
CI / test on 3.12 (lowest-versions) (push) Waiting to run
CI / test on 3.13 (lowest-versions) (push) Waiting to run
CI / test on 3.14 (lowest-versions) (push) Waiting to run
CI / test examples on 3.11 (push) Waiting to run
CI / test examples on 3.12 (push) Waiting to run
CI / test examples on 3.13 (push) Waiting to run
CI / test examples on 3.14 (push) Waiting to run
CI / coverage (push) Blocked by required conditions
CI / check (push) Blocked by required conditions
CI / deploy-docs (push) Blocked by required conditions
CI / deploy-docs-preview (push) Blocked by required conditions
CI / build release artifacts (push) Blocked by required conditions
CI / publish to PyPI (push) Blocked by required conditions
CI / Send tweet (push) Blocked by required conditions
chore: import upstream snapshot with attribution
2026-07-13 13:27:52 +08:00

945 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
r"""Pydantic AI gh-aw shim — Claude Code CLI compatibility for gh-aw.
gh-aw runs the agent engine like the Claude Code CLI:
<command> --print --no-chrome --allowed-tools '<csv>' --debug-file <path> \\
--verbose --permission-mode <mode> --output-format stream-json \\
--mcp-config <mcp-servers.json> --prompt-file <prompt.txt> \\
[--model <model>] "<rendered prompt>"
With `engine.command` set, `<command>` is this shim. It speaks Claude
Code's argv, recovers the prompt, builds a `pydantic-ai` agent backed by
the gh-aw-injected Anthropic-compatible proxy, exposes Claude-named
tools plus gh-aw's MCP servers (GitHub + the `safeoutputs` write-sink),
enforces gh-aw's `--allowed-tools` allow-list, and emits Claude-compatible
`stream-json` so gh-aw's log parser and token accounting keep working.
Like Claude Code itself, the shim only talks to Anthropic-shape APIs
(`ANTHROPIC_BASE_URL` → real Anthropic, MiniMax's Anthropic-compatible
endpoint, etc.). No OpenAI path — the workflow's `engine.id: claude`
contract is Anthropic-shape end to end.
Credentials note: under gh-aw the real API key is *excluded* from the
agent container (`awf --exclude-env ANTHROPIC_API_KEY`). The AWF
api-proxy injects it transparently; the shim only ever sends a
placeholder bearer to the proxy base URL — never a real upstream key.
This module is loaded as the `pydantic_ai_gh_aw_shim.cli` submodule;
`__main__.py` is a 3-line entry stub that calls `cli.main()`. Tests
import this module directly (`from pydantic_ai_gh_aw_shim import cli`),
which is why the runner stub doesn't live in `__main__.py` — running it
under `runpy.run_module(..., run_name="__main__")` plus PEP-563
annotations breaks pydantic-ai's `takes_run_context` detection.
"""
import argparse
import asyncio
import dataclasses
import json
import logging
import os
import pathlib
import sys
import time
import uuid
from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass
from typing import TypeAlias, cast
import httpx
import logfire
from anthropic import AsyncAnthropic
from pydantic import ValidationError
from pydantic_ai import Agent, RunContext
from pydantic_ai.capabilities import NativeTool, ProcessEventStream, ProcessHistory
from pydantic_ai.mcp import load_mcp_toolsets
from pydantic_ai.messages import (
AgentStreamEvent,
ModelMessage,
ModelRequest,
ModelRequestPart,
ModelResponse,
ModelResponsePart,
NativeToolCallPart,
NativeToolSearchCallPart,
RetryPromptPart,
ToolCallEvent,
ToolCallPart,
ToolResultEvent,
ToolReturnPart,
ToolSearchCallPart,
UserPromptPart,
)
from pydantic_ai.models import Model
from pydantic_ai.models.anthropic import AnthropicModel
from pydantic_ai.native_tools import WebFetchTool
from pydantic_ai.providers.anthropic import AnthropicProvider
from pydantic_ai.tools import ToolDefinition
from pydantic_ai.toolsets import AbstractToolset, PrefixedToolset
from pydantic_ai.usage import RunUsage, UsageLimits
from . import (
CLAUDE_CODE_TOOL_NAMES,
MUTATING_TOOLS,
READ_ONLY_SUBAGENT_TOOLS,
build_claude_code_toolset,
)
from .shared import logger, reset_context_state
# Type aliases for the public surface — the shim runs `None`-deps agents
# throughout, so every `RunContext` is concretely `RunContext[object]`.
MessagePart: TypeAlias = ModelRequestPart | ModelResponsePart
ToolPredicate: TypeAlias = Callable[[RunContext[object], ToolDefinition], bool | Awaitable[bool]]
TaskCallable: TypeAlias = Callable[[RunContext[object], str, str], Awaitable[str]]
# Placeholder bearer token sent to the AWF api-proxy. The proxy strips this
# header and injects the real `ANTHROPIC_API_KEY` on the outbound wire — so
# the agent container never sees the real key. Sent verbatim only when no
# `ANTHROPIC_AUTH_TOKEN` / `ANTHROPIC_API_KEY` env is provided locally.
PROXY_BEARER_PLACEHOLDER = 'gh-aw-proxy-injected'
def _anthropic_native_capabilities() -> list[NativeTool]:
"""`NativeTool(WebFetchTool())` for real Anthropic only.
Anthropic-compatible endpoints (MiniMax, etc.) reject the
`web_fetch_20250910` server-side tool with `invalid_request_error
(2013)` because they don't implement Anthropic's server-side tool
types. Detect via `ANTHROPIC_BASE_URL` — empty/unset means the
Anthropic SDK default (real Anthropic).
"""
base_url = os.environ.get('ANTHROPIC_BASE_URL', '')
if not base_url or 'api.anthropic.com' in base_url:
return [NativeTool(WebFetchTool())]
return []
# pydantic-ai's built-in request_limit default of 50 is too low for the
# deep multi-step workflows here; gh-aw's api-proxy still caps the run.
REQUEST_LIMIT = 200
SUBAGENT_REQUEST_LIMIT = 75
# Per-request HTTP timeout for every LLM call. The read timeout is the
# critical one: MiniMax's proxy can hold a streaming connection open without
# sending data. 5 min is generous enough for large generations but prevents
# indefinite hangs. SDK-level retries cover transient 429/5xx before raising.
_LLM_TIMEOUT = httpx.Timeout(timeout=120.0, connect=10.0)
_LLM_MAX_RETRIES = 4
# Wall-clock caps (seconds). These are last-resort guards on top of the
# per-request timeout so a burst of slow requests can't accumulate forever.
RUN_TIMEOUT_SECS = 28 * 60 # 28 min — just under the 30 min gh-aw job cap
SUBAGENT_TIMEOUT_SECS = 15 * 60 # 15 min per Task sub-agent
COMPACTION_TIMEOUT_SECS = 120 # 2 min for the compaction summariser call
# Static prefix for `Agent(instructions=[INSTRUCTIONS, prompt])`. Sequence
# form lets Anthropic's prompt-prefix cache hit `INSTRUCTIONS` across runs.
INSTRUCTIONS = (
'## Parallel tool calls\n\n'
'The model supports parallel tool calls. When multiple reads, searches, or '
"lookups are independent — meaning one doesn't need another's result — "
'issue them all in the same response. They execute concurrently. Only '
'chain sequentially when one call genuinely needs a previous result.\n\n'
'## File reading\n\n'
'Read files in large ranges (500+ lines per call). MAX_TOOL_OUTPUT is '
'50 000 chars so most Python source files fit in one or two reads. '
'Avoid reading 3080 lines at a time.\n\n'
'## Search tools\n\n'
'Use the native Grep and Glob tools for codebase search. '
'`rg` and `uv` are also available as plain commands via Bash.\n\n'
'## Dev environment\n\n'
'The repo is checked out at $GITHUB_WORKSPACE. Dev dependencies are NOT '
'pre-installed — run `make install` once before using pytest, ruff, or '
'pyright. Prefer `uv run pytest <test_file>` over a bare `pytest` call; '
'uv handles the virtual env automatically.\n\n'
'## GitHub issue search\n\n'
'The GitHub toolset runs in gh-proxy mode: there are NO `mcp__github__*` '
'tools, and the /search/issues endpoint (`gh issue list --search`, '
'`gh search issues`) returns HTTP 403 via the AWF firewall proxy. The '
'issue-list endpoint IS allowed, including its server-side `?labels=` '
'filter. When the sweep files under a dedicated label, prefer a narrow label '
"query (`gh api 'repos/pydantic/pydantic-ai/issues?state=open&labels=<label>&per_page=100' "
"--jq '.[] | select(.pull_request == null) | {number, title}'`); if it has no "
'dedicated label or the filter is inconclusive, widen to a full open-issue scan '
"(`gh api --paginate 'repos/pydantic/pydantic-ai/issues?state=open&per_page=100' "
"--jq '.[] | select(.pull_request == null) | {number, title, labels: [.labels[].name]}'`). "
'`select(.pull_request == null)` drops PRs, which the issues endpoint also returns.'
)
# The real task spec rides in `instructions=`; the user message is a trigger.
RUN_TRIGGER = 'Begin the task per the instructions above.'
SUBAGENT_INSTRUCTIONS = (
'You are a focused, read-only sub-agent. You can read files, search the '
'codebase, and fetch web content, but you cannot modify the workspace or '
'shell out. Investigate the task you were given and return a concise, '
'evidence-grounded answer to your caller — do not try to act on it.'
)
# History compaction (pydantic-ai `ProcessHistory` capability). Two stages
# inside one callback: a cheap dedup+truncate trim, then an LLM summary as
# fallback. `Agent(instructions=...)` is re-applied on every request, so
# the workflow prompt is never in the message list and never compacted.
# ~100k tokens at 4 chars/token = half of a 200k window.
COMPACTION_TRIGGER_CHARS = 400_000
COMPACTION_KEEP_RECENT = 10
TOOL_RESULT_HEAD_TAIL_CHARS = 4_000
TOOL_RESULT_TRIM_THRESHOLD = 10_000
COMPACTION_TRANSCRIPT_MAX_CHARS = 80_000
COMPACTION_SUMMARY_INSTRUCTIONS = (
'Summarise the agent transcript below for resumption in a fresh '
'context window. Produce a structured brief, not free prose. Use this '
'exact section layout, omitting any section that is empty:\n\n'
'## Goal\n'
'One short paragraph: what the agent was asked to do.\n\n'
'## Files inspected\n'
'- `<full/path>`: one-line note on what was found there.\n\n'
'## Commands run\n'
'- `<command>`: outcome in one line.\n\n'
'## Errors encountered\n'
'Verbatim error messages or unexpected behaviour, with the file or '
'command that triggered each.\n\n'
'## Decisions and approaches\n'
'- Concrete decisions with reasoning. Include approaches already tried '
'that did **not** work, so they are not re-attempted.\n\n'
'## Open questions\n'
'- Anything still unresolved.\n\n'
'## Next step\n'
'The single most likely next action.\n\n'
'Preserve specifics (paths, identifiers, exact strings) over prose. '
'Respond with text only — do not call any tools.'
)
def _part_text(part: MessagePart) -> str:
"""Best-effort text rendering of any pydantic-ai message part."""
if isinstance(part, (ToolCallPart, NativeToolCallPart, ToolSearchCallPart, NativeToolSearchCallPart)):
return f'{part.tool_name}({part.args_as_dict()!r})'
return str(part.content)
def _render_messages_for_summary(messages: list[ModelMessage]) -> str:
"""Render a slice of pydantic-ai messages into a compact transcript."""
out: list[str] = []
for m in messages:
kind = 'user' if isinstance(m, ModelRequest) else 'assistant'
for part in m.parts:
out.append(f'[{kind}/{type(part).__name__}] {_part_text(part)[:1500]}')
return '\n'.join(out)
def _history_size_chars(messages: list[ModelMessage]) -> int:
"""Char-count proxy for token cost — used as the compaction trigger."""
return sum(len(_part_text(part)) for m in messages for part in m.parts)
def _head_tail(text: str, side: int) -> str:
"""Keep the first and last `side` chars, mark the elided middle."""
skipped = len(text) - side * 2
return f'{text[:side]}\n…[trimmed {skipped} chars]…\n{text[-side:]}'
def _superseded_read_calls(messages: list[ModelMessage]) -> tuple[set[str], dict[str, str]]:
"""For each `Read` call, key on (path, offset, limit); older calls with the same key are superseded."""
label_by_call_id: dict[str, str] = {}
latest_for_args: dict[tuple[str, object, object], str] = {}
superseded: set[str] = set()
for m in messages:
for p in m.parts:
if not (isinstance(p, ToolCallPart) and p.tool_name == 'Read'):
continue
args = p.args_as_dict()
if not isinstance(args, dict):
continue
path = args.get('file_path')
if not isinstance(path, str):
continue
offset, limit = args.get('offset'), args.get('limit')
label = path if offset is None and limit is None else f'{path}[offset={offset!r}, limit={limit!r}]'
label_by_call_id[p.tool_call_id] = label
key = (path, offset, limit)
prior = latest_for_args.get(key)
if prior is not None:
superseded.add(prior)
latest_for_args[key] = p.tool_call_id
return superseded, label_by_call_id
def _trim_tool_results(messages: list[ModelMessage]) -> list[ModelMessage]:
"""Dedupe re-reads of the same file slice and head/tail-truncate oversized older tool returns."""
if len(messages) <= COMPACTION_KEEP_RECENT:
return messages
superseded, label_by_call_id = _superseded_read_calls(messages)
tail_start = len(messages) - COMPACTION_KEEP_RECENT
out: list[ModelMessage] = []
dedup_count = truncate_count = bytes_saved = 0
def _rewrite(part: ModelRequestPart | ModelResponsePart) -> ModelRequestPart | ModelResponsePart:
nonlocal dedup_count, truncate_count, bytes_saved
if not isinstance(part, ToolReturnPart):
return part
if part.tool_call_id in superseded:
new_content = f'[superseded read: {label_by_call_id[part.tool_call_id]} — see later read with same args]'
bytes_saved += len(str(part.content)) - len(new_content)
dedup_count += 1
return dataclasses.replace(part, content=new_content)
content = str(part.content)
if len(content) > TOOL_RESULT_TRIM_THRESHOLD:
new_content = _head_tail(content, TOOL_RESULT_HEAD_TAIL_CHARS)
bytes_saved += len(content) - len(new_content)
truncate_count += 1
return dataclasses.replace(part, content=new_content)
return part
for idx, m in enumerate(messages):
if idx >= tail_start:
out.append(m)
continue
new_parts = [_rewrite(p) for p in m.parts]
out.append(dataclasses.replace(m, parts=new_parts) if new_parts != list(m.parts) else m)
if dedup_count or truncate_count:
logger.info(
'trim: deduped %d superseded read(s), truncated %d oversized result(s), saved %d chars',
dedup_count,
truncate_count,
bytes_saved,
)
emit(
{
'type': 'system',
'subtype': 'compaction_trim',
'deduped_reads': dedup_count,
'truncated_results': truncate_count,
'chars_saved': bytes_saved,
}
)
return out
return messages
_SYNTHETIC_SUMMARY_TAG = '[compacted history]'
def _is_synthetic_summary(message: ModelMessage) -> bool:
"""A `ModelRequest` we synthesised in a prior `_compact_history` round."""
if not isinstance(message, ModelRequest):
return False
parts = message.parts
return (
len(parts) == 1
and isinstance(parts[0], UserPromptPart)
and str(parts[0].content).startswith(_SYNTHETIC_SUMMARY_TAG)
)
async def _compact_history(ctx: RunContext[object], messages: list[ModelMessage]) -> list[ModelMessage]:
"""Cheap trim first; LLM-summarise the middle as fallback if still over budget."""
if len(messages) <= COMPACTION_KEEP_RECENT:
return messages
trimmed = _trim_tool_results(messages)
size = _history_size_chars(trimmed)
if size < COMPACTION_TRIGGER_CHARS:
return trimmed
middle = trimmed[:-COMPACTION_KEEP_RECENT]
tail = trimmed[-COMPACTION_KEEP_RECENT:]
transcript = _render_messages_for_summary(middle)
logger.info(
'compaction summary firing: %d chars / %d messages -> summarising %d middle, keeping last %d',
size,
len(trimmed),
len(middle),
COMPACTION_KEEP_RECENT,
)
emit(
{
'type': 'system',
'subtype': 'compaction_summary_start',
'history_chars': size,
'history_messages': len(trimmed),
'middle_messages': len(middle),
'keep_recent': COMPACTION_KEEP_RECENT,
}
)
# Preserve any earlier-round synthetic at the head of the middle so a
# fallback (`return [prior_synthetic, *tail]`) doesn't silently forget
# the entire run's compacted history.
prior_synthetic = middle[0] if middle and _is_synthetic_summary(middle[0]) else None
# Fresh `RunUsage` so `request_limit=2` bounds the summariser, not
# (parent + summariser). Merge the totals back regardless of outcome.
sub_usage = RunUsage()
try:
r = await asyncio.wait_for(
Agent(ctx.model, instructions=COMPACTION_SUMMARY_INSTRUCTIONS).run(
f'Transcript to summarise:\n\n{transcript[:COMPACTION_TRANSCRIPT_MAX_CHARS]}',
usage_limits=UsageLimits(request_limit=2),
usage=sub_usage,
),
timeout=COMPACTION_TIMEOUT_SECS,
)
summary = str(r.output or '').strip() or '(empty summary)'
except Exception as exc:
# Well-handled fallback: the run continues on the trimmed history, so the
# stack is kept available (`exc_info`) without escalating the log level.
# A bare `TimeoutError` stringifies to '' — fall back to the type name so
# the emitted `error` is never empty.
ctx.usage.incr(sub_usage)
detail = f'{type(exc).__name__}: {exc}' if str(exc) else type(exc).__name__
logger.warning('compaction summarisation failed (%s); falling back', detail, exc_info=True)
emit({'type': 'system', 'subtype': 'compaction_summary_failed', 'error': detail})
return [prior_synthetic, *tail] if prior_synthetic else tail
ctx.usage.incr(sub_usage)
# If the summariser produces output larger than the middle it's replacing,
# the next compaction round would trip on the same too-large synthetic
# and never converge — fall back to the prior synthetic + tail.
middle_size = _history_size_chars(middle)
if len(summary) >= middle_size:
logger.info('compaction summary discarded (%d >= %d chars); falling back', len(summary), middle_size)
emit(
{
'type': 'system',
'subtype': 'compaction_summary_discarded',
'summary_chars': len(summary),
'middle_chars': middle_size,
}
)
return [prior_synthetic, *tail] if prior_synthetic else tail
logger.info(
'compaction summary done: %d middle messages (%d chars) -> %d-char summary',
len(middle),
middle_size,
len(summary),
)
emit(
{
'type': 'system',
'subtype': 'compaction_summary_done',
'middle_messages': len(middle),
'middle_chars': middle_size,
'summary_chars': len(summary),
'input_tokens': sub_usage.input_tokens,
'output_tokens': sub_usage.output_tokens,
}
)
synthetic = ModelRequest(parts=[UserPromptPart(content=f'{_SYNTHETIC_SUMMARY_TAG}\n{summary}')])
return [synthetic, *tail]
@dataclass(slots=True)
class Args:
"""The subset of Claude Code's CLI surface the shim acts on."""
model: str | None = None
mcp_config: str | None = None
prompt_file: str | None = None
prompt_positional: str | None = None
# None = flag absent (local/dev: no restriction). A set = enforce it.
allowed_tools: frozenset[str] | None = None
permission_mode: str | None = None
def _split_allowed_tools(value: str | None) -> frozenset[str] | None:
"""Parse Claude's `--allowed-tools` CSV into base tool names.
Entries may carry a permission scope, e.g. `Edit(/tmp/*)` or
`Bash(git:*)` — only the base name gates availability here, so the
parenthesised scope is stripped. Returns `None` when the flag is absent
so non-gh-aw/local runs keep every tool.
"""
if value is None:
return None
names: set[str] = set()
for raw in value.split(','):
entry = raw.strip()
if not entry:
continue
names.add(entry.split('(', 1)[0].strip())
return frozenset(names)
def parse_args(argv: Sequence[str]) -> Args:
"""Parse Claude Code's CLI surface into `Args`, tolerating unknown flags so a future Claude flag never breaks the engine."""
p = argparse.ArgumentParser(add_help=False)
p.add_argument('--model')
p.add_argument('--mcp-config')
p.add_argument('--prompt-file')
p.add_argument('--output-format', default='stream-json')
p.add_argument('--allowed-tools')
p.add_argument('--permission-mode')
p.add_argument('--debug-file')
for flag in ('--print', '--no-chrome', '--verbose', '--continue'):
p.add_argument(flag, action='store_true')
known, unknown = p.parse_known_args(list(argv))
# gh-aw appends the rendered prompt as the trailing positional argument.
positionals = [a for a in unknown if not a.startswith('-')]
return Args(
model=known.model,
mcp_config=known.mcp_config,
prompt_file=known.prompt_file,
prompt_positional=positionals[-1] if positionals else None,
allowed_tools=_split_allowed_tools(known.allowed_tools),
permission_mode=known.permission_mode,
)
def resolve_prompt(args: Args) -> str:
"""Prompt precedence: trailing positional -> --prompt-file -> $GH_AW_PROMPT."""
if args.prompt_positional:
return args.prompt_positional
path = args.prompt_file or os.environ.get('GH_AW_PROMPT')
if path and os.path.isfile(path):
return pathlib.Path(path).read_text(encoding='utf-8')
return ''
def build_model(args: Args) -> tuple[Model, str]:
"""Build the `pydantic-ai` model and a human-readable label.
Anthropic-only — the shim behaves like the stock Claude Code CLI:
gh-aw sets `ANTHROPIC_BASE_URL` (its in-cluster transparent proxy)
and the AWF api-proxy injects the real key on outgoing requests.
**Why we construct `AsyncAnthropic` ourselves** instead of letting
`pydantic-ai`'s `AnthropicProvider` auto-configure: gh-aw runs the
agent step in a sandbox that excludes `ANTHROPIC_API_KEY` from the
container env (`awf --exclude-env ANTHROPIC_API_KEY` — a security
measure so the real key never reaches the agent). `pydantic-ai`'s
auto-config requires that env var to be present, so it errors out
under gh-aw. The explicit `AsyncAnthropic(auth_token=...)` path
sends a placeholder bearer that the AWF api-proxy swaps for the
real key on the wire — the same dance the Claude Code CLI does.
This is a gh-aw constraint, not a pydantic-ai one; upstream gh-aw
could lift it by allowing the agent to read the key directly, but
that would break the credential-isolation guarantee.
Model name resolution (in priority order):
1. `--model X` argv flag (from Claude Code's CLI surface).
2. `ANTHROPIC_MODEL` env var (standard Anthropic SDK convention;
gh-aw populates this from the workflow's `engine.model:` field).
3. Fallback default `claude-sonnet-4-6`.
"""
model_name = args.model or os.environ.get('ANTHROPIC_MODEL') or 'claude-sonnet-4-6'
anthropic_base = os.environ.get('ANTHROPIC_BASE_URL')
auth_token = (
os.environ.get('ANTHROPIC_AUTH_TOKEN') or os.environ.get('ANTHROPIC_API_KEY') or PROXY_BEARER_PLACEHOLDER
)
logger.info('anthropic model=%s base_url=%s', model_name, anthropic_base or '(default)')
client = AsyncAnthropic(
auth_token=auth_token,
base_url=anthropic_base,
timeout=_LLM_TIMEOUT,
max_retries=_LLM_MAX_RETRIES,
)
return (
AnthropicModel(model_name, provider=AnthropicProvider(anthropic_client=client)),
f'anthropic:{model_name}',
)
def configure_logging() -> None:
"""Configure stderr logging once, at CLI entry."""
logging.basicConfig(
level=logging.INFO,
format='[pydantic-ai-gh-aw-shim] %(message)s',
stream=sys.stderr,
)
def configure_observability() -> None:
"""Wire pydantic-ai + httpx + mcp instrumentation to Logfire/OTLP if configured."""
write_token = os.environ.get('LOGFIRE_WRITE_TOKEN') or os.environ.get('LOGFIRE_TOKEN')
if not (os.environ.get('OTEL_EXPORTER_OTLP_ENDPOINT') or os.environ.get('GH_AW_OTLP_ENDPOINTS') or write_token):
return
try:
logfire.configure(
service_name=os.environ.get('OTEL_SERVICE_NAME', 'gh-aw'),
send_to_logfire='if-token-present',
console=False,
token=write_token or None,
)
logfire.instrument_pydantic_ai(include_content=True, include_binary_content=True)
logfire.instrument_httpx(capture_all=True)
logfire.instrument_mcp()
logger.info('Logfire/OTLP instrumentation enabled (pydantic_ai + httpx + mcp)')
except Exception as exc:
logger.warning('observability disabled: %r', exc)
def _mcp_tool_allowed(server: str, allowed: frozenset[str]) -> ToolPredicate:
"""Allow-list predicate matching gh-aw's `mcp__<server>__<tool>` form (or `mcp__<server>` wildcard)."""
server_wildcard = f'mcp__{server}' in allowed
def predicate(_ctx: RunContext[object], tool_def: ToolDefinition) -> bool:
return server_wildcard or tool_def.name in allowed
return predicate
def _apply_claude_mcp_prefix(entry: AbstractToolset[object]) -> AbstractToolset[object]:
"""Swap the default `<server>_<tool>` prefix for Claude Code's `mcp__<server>__<tool>` wire form.
The trailing `_` combines with `PrefixedToolset`'s `_` separator to
yield the doubled underscores gh-aw and Claude were trained on.
"""
if not isinstance(entry, PrefixedToolset):
return entry
return dataclasses.replace(entry, prefix=f'mcp__{entry.prefix}_')
def build_mcp_servers(args: Args) -> list[AbstractToolset[object]]:
"""Load gh-aw's MCP config, re-prefix to Claude Code wire format, and apply the allow-list filter."""
path = args.mcp_config or os.environ.get('GH_AW_MCP_CONFIG')
if not path or not os.path.isfile(path):
logger.info('no MCP config present — running without external tools')
return []
try:
loaded = load_mcp_toolsets(path)
# `repr` is sufficient diagnostically here (a `ValidationError` already
# enumerates the bad fields, `FileNotFoundError` names the path), so no
# traceback — but returning `[]` drops the *entire* GitHub/safeoutputs tool
# surface, a drastic behaviour change, so log it at `error` to make a run
# that silently lost its tools obvious in the artifact.
except FileNotFoundError as exc:
logger.error('MCP config %r missing (%r) — agent will run with NO external tools', path, exc)
return []
except (ValidationError, ValueError) as exc:
logger.error('MCP config %r is malformed (%r) — agent will run with NO external tools', path, exc)
return []
servers: list[AbstractToolset[object]] = []
for entry in loaded:
name = (entry.wrapped.id if isinstance(entry, PrefixedToolset) else entry.id) or '<unnamed>'
toolset = _apply_claude_mcp_prefix(cast('AbstractToolset[object]', entry))
if args.allowed_tools is not None:
toolset = toolset.filtered(_mcp_tool_allowed(name, args.allowed_tools))
logger.info('registered MCP server %r (allow-list filtered)', name)
else:
logger.info('registered MCP server %r (no allow-list)', name)
servers.append(toolset)
return servers
def _claude_code_tool_predicate(allowed: frozenset[str] | None, permission_mode: str | None) -> ToolPredicate:
"""Allow-list + `plan`-mode filter for the Claude Code toolset."""
plan = permission_mode == 'plan'
def predicate(_ctx: RunContext[object], tool_def: ToolDefinition) -> bool:
name = tool_def.name
if allowed is not None and name not in allowed:
return False
if plan and name in MUTATING_TOOLS:
return False
return True
return predicate
def select_claude_code_toolset(
allowed: frozenset[str] | None,
permission_mode: str | None,
*,
task: TaskCallable | None,
) -> AbstractToolset[object]:
"""Build the Claude Code toolset; `task=None` for sub-agents so they can't recurse."""
return build_claude_code_toolset(task=task).filtered(_claude_code_tool_predicate(allowed, permission_mode))
# --------------------------------------------------------------------------- #
# Claude-compatible stream-json output
# --------------------------------------------------------------------------- #
def emit(obj: Mapping[str, object]) -> None:
"""Write one Claude-style stream-json line to stdout."""
sys.stdout.write(json.dumps(obj) + '\n')
sys.stdout.flush()
def emit_result(
text: str,
usage: RunUsage | None,
session_id: str,
is_error: bool = False,
num_turns: int = 1,
duration_ms: int = 0,
) -> None:
"""Emit the Claude Code stream-json `result` line gh-aw parses for success + token totals."""
if usage is None:
token_usage = {
'input_tokens': 0,
'output_tokens': 0,
'cache_creation_input_tokens': 0,
'cache_read_input_tokens': 0,
}
else:
token_usage = {
'input_tokens': usage.input_tokens,
'output_tokens': usage.output_tokens,
'cache_creation_input_tokens': usage.cache_write_tokens,
'cache_read_input_tokens': usage.cache_read_tokens,
}
emit(
{
'type': 'result',
'subtype': 'error' if is_error else 'success',
'is_error': is_error,
'result': text,
'session_id': session_id,
'num_turns': num_turns,
'duration_ms': duration_ms,
'total_cost_usd': 0,
'usage': token_usage,
}
)
# Live tool-call / tool-result streaming for gh-aw's log parser. Result
# content is truncated for the stream view only — the model sees the full
# result via the message history.
MAX_LIVE_TOOL_RESULT_CHARS = 100
async def _stream_events(_ctx: RunContext[object], events: AsyncIterable[AgentStreamEvent]) -> None:
"""Emit tool_use / tool_result stream-json as events fire."""
async for event in events:
if isinstance(event, ToolCallEvent):
emit(
{
'type': 'assistant',
'message': {
'role': 'assistant',
'content': [
{
'type': 'tool_use',
'id': event.part.tool_call_id,
'name': event.part.tool_name,
'input': event.part.args_as_dict(),
}
],
},
}
)
logger.info('tool_use: %s', event.part.tool_name)
elif isinstance(event, ToolResultEvent):
# `event.part` is `ToolReturnPart | RetryPromptPart`; the latter
# means the tool result failed validation and pydantic-ai is
# asking the model to retry. Tag it so gh-aw doesn't read it as
# success.
is_retry = isinstance(event.part, RetryPromptPart)
content = str(event.part.content)
if len(content) > MAX_LIVE_TOOL_RESULT_CHARS:
content = (
content[:MAX_LIVE_TOOL_RESULT_CHARS] + f'…[+{len(content) - MAX_LIVE_TOOL_RESULT_CHARS} chars]'
)
emit(
{
'type': 'user',
'message': {
'role': 'user',
'content': [
{
'type': 'tool_result',
'tool_use_id': event.part.tool_call_id,
'content': content,
'is_error': is_retry,
}
],
},
}
)
def count_tool_calls(messages: Sequence[ModelMessage]) -> int:
"""Tally tool calls in the final message history (for the end-of-run log)."""
return sum(1 for m in messages for p in m.parts if isinstance(p, ToolCallPart))
def log_safe_outputs_state() -> None:
"""Log whether anything reached the gh-aw safe-outputs sink."""
path = os.environ.get('GH_AW_SAFE_OUTPUTS')
if not path:
logger.info('GH_AW_SAFE_OUTPUTS not set')
return
try:
data = pathlib.Path(path).read_text(encoding='utf-8')
except OSError as exc:
logger.info('GH_AW_SAFE_OUTPUTS unreadable (%s): %r', path, exc)
return
lines = [ln for ln in data.splitlines() if ln.strip()]
logger.info('GH_AW_SAFE_OUTPUTS=%s entries=%d bytes=%d', path, len(lines), len(data))
for ln in lines[:5]:
logger.info(' safe-output: %s', ln[:300])
async def task(ctx: RunContext[object], description: str, prompt: str) -> str:
"""Claude's `Task` tool: spawn a read-only sub-agent on `ctx.model`."""
logger.info('Task spawn: %s', description[:120])
# Fresh dedupe set per sub-agent — otherwise inheriting the parent's
# `seen` AGENTS.md set would silently hide context the sub-agent needs.
reset_context_state()
sub_toolset = select_claude_code_toolset(READ_ONLY_SUBAGENT_TOOLS, permission_mode=None, task=None)
sub = Agent(
ctx.model,
instructions=[INSTRUCTIONS, SUBAGENT_INSTRUCTIONS, prompt],
toolsets=[sub_toolset],
capabilities=[
*_anthropic_native_capabilities(),
ProcessEventStream(_stream_events),
],
)
# Fresh `RunUsage` so `SUBAGENT_REQUEST_LIMIT` bounds the sub-agent, not
# (parent + sub). Merge the deltas back regardless of success/failure.
sub_usage = RunUsage()
try:
result = await asyncio.wait_for(
sub.run(RUN_TRIGGER, usage_limits=UsageLimits(request_limit=SUBAGENT_REQUEST_LIMIT), usage=sub_usage),
timeout=SUBAGENT_TIMEOUT_SECS,
)
except asyncio.TimeoutError:
# A bare `TimeoutError` stringifies to '' — without an explicit message
# the model (and the log) would see `sub-agent failed:` with no payload.
ctx.usage.incr(sub_usage)
logger.error('sub-agent timed out after %.0f min: %s', SUBAGENT_TIMEOUT_SECS / 60, description[:120])
return f'error: sub-agent timed out after {SUBAGENT_TIMEOUT_SECS // 60}min'
except Exception as exc:
# The parent agent reacts to the returned string, but a sub-agent can hit
# the same nested `ExceptionGroup`/`McpError` as the main run — log the
# full stack so the failure isn't reduced to a one-line repr in the logs.
ctx.usage.incr(sub_usage)
logger.exception('sub-agent failed: %s', description[:120])
return f'error: sub-agent failed: {exc}'
ctx.usage.incr(sub_usage)
logger.info('Task done: +%d sub-requests (run total now %d)', sub_usage.requests, ctx.usage.requests)
return str(result.output or '')
async def _run_with_timeout(
prompt: str,
model: Model,
label: str,
claude_code_toolset: AbstractToolset[object],
mcp_servers: list[AbstractToolset[object]],
session_id: str,
) -> int:
"""Wrap `run()` with the global wall-clock cap and emit a clean result on timeout."""
try:
return await asyncio.wait_for(
run(prompt, model, label, claude_code_toolset, mcp_servers, session_id),
timeout=RUN_TIMEOUT_SECS,
)
except asyncio.TimeoutError:
logger.error('run timed out after %.0f min', RUN_TIMEOUT_SECS / 60)
emit_result(
f'run timed out after {RUN_TIMEOUT_SECS // 60}min',
usage=None,
session_id=session_id,
is_error=True,
)
return 1
async def run(
prompt: str,
model: Model,
label: str,
claude_code_toolset: AbstractToolset[object],
mcp_servers: list[AbstractToolset[object]],
session_id: str,
) -> int:
"""Run one agent turn and emit Claude-shape stream-json. Always emits a `result` line."""
reset_context_state()
agent: Agent[object, str] = Agent(
model,
instructions=[INSTRUCTIONS, prompt],
toolsets=[claude_code_toolset, *mcp_servers],
capabilities=[
*_anthropic_native_capabilities(),
ProcessHistory(_compact_history),
ProcessEventStream(_stream_events),
],
)
limits = UsageLimits(request_limit=REQUEST_LIMIT)
emit({'type': 'system', 'subtype': 'init', 'session_id': session_id, 'model': label})
started = time.perf_counter()
try:
async with agent:
result = await agent.run(RUN_TRIGGER, usage_limits=limits)
except Exception as exc:
# `%r` on an `ExceptionGroup` (e.g. the MCP `TaskGroup` failures seen in
# CI) discards every frame and every nested sub-exception's stack, which
# is what made the original incident so hard to root-cause. `exception()`
# renders the full traceback — and, on 3.11+, each group leaf's stack —
# to stderr, which gh-aw captures into the uploaded `agent-stdio.log`,
# so the run is self-explaining without a re-run. The `result` text
# stays a one-liner because gh-aw parses it.
logger.exception('agent run failed')
emit_result(
f'agent run failed: {exc}',
usage=None,
session_id=session_id,
is_error=True,
duration_ms=round((time.perf_counter() - started) * 1000),
)
return 1
duration_ms = round((time.perf_counter() - started) * 1000)
messages = result.all_messages()
tool_calls = count_tool_calls(messages)
num_turns = sum(isinstance(m, ModelResponse) for m in messages)
logger.info('tool calls observed: %d, turns: %d', tool_calls, num_turns)
text = str(result.output or '')
emit({'type': 'assistant', 'message': {'role': 'assistant', 'content': text}})
emit_result(text, result.usage, session_id, num_turns=num_turns, duration_ms=duration_ms)
log_safe_outputs_state()
return 0
def main() -> int:
"""Entry point. Every failure produces a stream-json `result` line so gh-aw never sees an empty log."""
configure_logging()
session_id = (os.environ.get('GITHUB_RUN_ID') or 'local') + '-' + uuid.uuid4().hex[:8]
try:
args = parse_args(sys.argv[1:])
configure_observability()
prompt = resolve_prompt(args)
if not prompt.strip():
logger.info('empty prompt — nothing to do')
emit_result('empty prompt', usage=None, session_id=session_id, is_error=True)
return 1
model, label = build_model(args)
claude_code_toolset = select_claude_code_toolset(args.allowed_tools, args.permission_mode, task=task)
mcp_servers = build_mcp_servers(args)
logger.info(
'model=%s permission_mode=%s request_limit=%d claude_code_tool_names=%s mcp_servers=%d prompt_chars=%d',
label,
args.permission_mode or '(none)',
REQUEST_LIMIT,
list(CLAUDE_CODE_TOOL_NAMES),
len(mcp_servers),
len(prompt),
)
started = time.time()
rc = asyncio.run(_run_with_timeout(prompt, model, label, claude_code_toolset, mcp_servers, session_id))
logger.info('done in %.1fs rc=%d', time.time() - started, rc)
return rc
except SystemExit as exc:
# `argparse` raises `SystemExit` (not `Exception`) on unknown-flag
# rejection — an expected, clean exit, so a traceback would be noise.
# gh-aw still needs a structured result line.
logger.error('FATAL startup error: %r', exc)
emit_result(f'shim startup failed: {exc}', usage=None, session_id=session_id, is_error=True)
return 1
except Exception as exc:
# A real crash before the agent run (model build, MCP load, …) — dump the
# full stack so a blind FATAL doesn't cost another long investigation.
logger.exception('FATAL startup error')
emit_result(f'shim startup failed: {exc}', usage=None, session_id=session_id, is_error=True)
return 1