Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
metadata cannot abort a run.
- SDK evolution example: inspecting candidate SDK versions (which pip-installs
and imports freshly downloaded upstream code) is now opt-in via
`--inspect-candidates` and runs in a credential-scrubbed environment, and
`--draft-pr` no longer fails when the report directory is gitignored.
`--inspect-candidates` and runs in a credential-scrubbed environment — and
the behavior probes now honor the same gate and scrub instead of installing
candidates unconditionally with the caller's full environment (skipped
candidates are recorded explicitly). `--draft-pr` no longer fails when the
report directory is gitignored.
- SDK evolution example: release-note links extracted from fetched pages are
only followed when they resolve to `https://github.com` (a protocol-relative
href in user-generated discussion markup could otherwise trigger an off-site
request), and a configured-but-failing GitHub GraphQL token now surfaces on
the source instead of silently downgrading to the unauthenticated scrape.

## 0.2.0 - 2026-06-23

Expand Down
9 changes: 6 additions & 3 deletions docs/sdk-evolution-agent-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,12 @@ python -m examples.sdk_evolution_agent \
--package google-antigravity
```

`--inspect-candidates` should be effectively always on. The CLI can keep the
flag for compatibility, but update candidates without candidate API snapshots
are not actionable.
`--inspect-candidates` was originally envisioned as effectively always on,
since update candidates without candidate API snapshots are not actionable.
The shipped behavior deliberately inverts that default: candidate inspection
installs and imports freshly downloaded upstream code, so it is opt-in and
runs in a credential-scrubbed environment, with explicit `skip` records when
it is off.

Implementation mode should remain explicitly gated:

Expand Down
15 changes: 9 additions & 6 deletions docs/sdk-evolution-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,15 @@ contains a different installed version, the agent inspects the locked baseline
in a temporary isolated virtualenv instead of trusting the drifted environment.
When a refresh preview is available, package update candidates come from the
resolver's `uv lock --dry-run -P ...` output, not only from PyPI's `latest`
metadata. For each resolver update candidate, the agent installs the target
version in a temporary isolated virtualenv and writes an API snapshot plus
`api_diffs.json` entry. This avoids false downgrade diffs for packages whose
locked prerelease is newer than PyPI's stable latest field. Candidate inspection
is always enabled for update candidates; `--inspect-candidates` remains accepted
only for CLI compatibility.
metadata. With `--inspect-candidates`, the agent installs each resolver update
candidate in a temporary isolated virtualenv — with a credential-scrubbed
environment (throwaway `HOME`, `PATH` only) — and writes an API snapshot plus
`api_diffs.json` entry, and runs the behavior probes against the candidate the
same way. This avoids false downgrade diffs for packages whose locked
prerelease is newer than PyPI's stable latest field. Candidate inspection is
opt-in because it executes freshly downloaded upstream code; without the flag,
candidates are recorded as explicit `skip` entries rather than silently
missing evidence.

If `uv lock --dry-run -P ...` reports an SDK update but the run cannot produce a
candidate-version API diff for that package, implementation is blocked and the
Expand Down
10 changes: 6 additions & 4 deletions examples/run_same_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import asyncio

from agent_runtime_kit import AgentTask, create_default_registry
from agent_runtime_kit import AgentTask, create_default_registry, runtime_kind_value
from agent_runtime_kit.adapters import register_adapters


Expand All @@ -14,15 +14,17 @@ async def main() -> None:

task = AgentTask(goal="Summarize this repository in one paragraph.")
for kind in registry.kinds():
if kind.value == "fake":
# runtime_kind_value: kinds may be enum members or namespaced strings.
label = runtime_kind_value(kind)
if label == "fake":
continue
runtime = registry.resolve(kind)
diagnostic = runtime.availability()
if not diagnostic.available:
print(f"{kind.value}: unavailable - {diagnostic.message}")
print(f"{label}: unavailable - {diagnostic.message}")
continue
result = await runtime.run(task)
print(f"{kind.value}: {result.output}")
print(f"{label}: {result.output}")


if __name__ == "__main__":
Expand Down
55 changes: 50 additions & 5 deletions examples/sdk_evolution_agent/behavior.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,24 @@
from typing import Any

from examples.sdk_evolution_agent.models import BehaviorDiff, BehaviorProbeResult
from examples.sdk_evolution_agent.snapshots import DEFAULT_MODULES
from examples.sdk_evolution_agent.snapshots import DEFAULT_MODULES, isolated_env


def collect_behavior_evidence(
packages: Sequence[Mapping[str, object]],
update_versions: Mapping[str, str],
*,
inspect_candidates: bool = False,
) -> dict[str, Any]:
"""Collect current/candidate behavior probes and compare them."""
"""Collect current/candidate behavior probes and compare them.

Probing a version that is not installed means pip-installing and importing
freshly downloaded upstream code, so those venv probes run only when the
caller opted in via ``inspect_candidates`` (the ``--inspect-candidates`` CLI
flag) — the same gate the API snapshots use. Without the opt-in, candidates
get an explicit ``skip`` record instead of a silent evidence hole, and a
drifted lockfile baseline falls back to probing the installed environment.
"""

results: list[BehaviorProbeResult] = []
for package in packages:
Expand All @@ -32,13 +42,21 @@ def collect_behavior_evidence(
locked_version = _string_or_none(package.get("locked_version"))
installed_version = _string_or_none(package.get("installed_version"))
current_version = locked_version or installed_version
if locked_version and installed_version and locked_version != installed_version:
if (
inspect_candidates
and locked_version
and installed_version
and locked_version != installed_version
):
results.extend(probe_candidate_in_venv(name, locked_version, scope="current-baseline"))
else:
results.extend(probe_current_package(name, version=current_version))
candidate = update_versions.get(name)
if candidate:
results.extend(probe_candidate_in_venv(name, candidate, scope="candidate"))
if inspect_candidates:
results.extend(probe_candidate_in_venv(name, candidate, scope="candidate"))
else:
results.append(_skipped_candidate_probe(name, candidate))
diffs = diff_behavior_results(results)
return {
"results": [result for result in results],
Expand Down Expand Up @@ -69,7 +87,12 @@ def probe_candidate_in_venv(

with tempfile.TemporaryDirectory(prefix="ark-sdk-behavior-") as directory:
venv = Path(directory) / ".venv"
subprocess.run((python, "-m", "venv", str(venv)), check=True, timeout=timeout)
# Scrub the environment for every subprocess that touches freshly
# downloaded upstream code (same scrub as the API snapshots): a
# throwaway HOME and only PATH, so a malicious or buggy candidate
# package cannot read the caller's credentials/config.
env = isolated_env(Path(directory))
subprocess.run((python, "-m", "venv", str(venv)), check=True, timeout=timeout, env=env)
bin_dir = "Scripts" if sys.platform == "win32" else "bin"
venv_python = venv / bin_dir / "python"
subprocess.run(
Expand All @@ -78,13 +101,15 @@ def probe_candidate_in_venv(
text=True,
capture_output=True,
timeout=timeout,
env=env,
)
completed = subprocess.run(
(str(venv_python), "-c", _PROBE_SCRIPT, package, version, scope),
check=True,
text=True,
capture_output=True,
timeout=timeout,
env=env,
)
raw = json.loads(completed.stdout)
return tuple(BehaviorProbeResult(**item) for item in raw)
Expand All @@ -102,6 +127,10 @@ def diff_behavior_results(results: Sequence[BehaviorProbeResult]) -> tuple[Behav
after = scopes.get("candidate") or scopes.get("isolated-venv")
if before is None or after is None:
continue
if (before.status == "skip") != (after.status == "skip"):
# One side was not probed (candidate installs are opt-in): absence
# of evidence is not a behavior change and must not read as one.
continue
if before.status == after.status and _contract_details(before) == _contract_details(after):
severity = "none"
summary = "No behavior contract difference detected."
Expand Down Expand Up @@ -347,6 +376,22 @@ def _failed(
)


def _skipped_candidate_probe(package: str, version: str) -> BehaviorProbeResult:
return BehaviorProbeResult(
package=package,
version=version,
scope="candidate",
probe="adapter-contract",
status="skip",
summary=(
"Candidate behavior probe skipped: probing a candidate installs and "
"imports freshly downloaded upstream code, which is opt-in. Rerun "
"with --inspect-candidates to collect this evidence."
),
details={"reason": "candidate installs are opt-in (--inspect-candidates)"},
)


def _string_or_none(value: object) -> str | None:
if value is None:
return None
Expand Down
55 changes: 43 additions & 12 deletions examples/sdk_evolution_agent/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

import argparse
import re
from dataclasses import replace
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
Expand Down Expand Up @@ -98,10 +97,12 @@ def parse_args(argv: list[str] | None = None) -> RunOptions:
parser.add_argument(
"--inspect-candidates",
action="store_true",
default=True,
default=False,
help=(
"Inspect latest candidate SDK versions in temporary virtualenvs. "
"Always enabled for update candidates; accepted for compatibility."
"Opt in to inspecting candidate SDK versions by pip-installing and "
"importing them in a temporary, credential-scrubbed virtualenv. This "
"executes freshly downloaded upstream code, so it is OFF by default; "
"without it, snapshots use the already-installed versions only."
),
)
parser.add_argument("--create-branch", action="store_true", help="Create a local branch first.")
Expand Down Expand Up @@ -146,7 +147,6 @@ async def run_agent(
) -> Path:
"""Run the full local SDK evolution workflow."""

options = replace(options, inspect_candidates=True)
run_id = datetime.now(tz=timezone.utc).strftime("%Y%m%dT%H%M%SZ")
report_root = (options.workspace / options.report_dir / run_id).resolve()
event_log_path = report_root / "events.jsonl"
Expand Down Expand Up @@ -184,14 +184,18 @@ async def run_agent(
command_runner=command_runner,
)
update_versions = _refresh_update_versions(evidence)
snapshots = _collect_snapshots(evidence)
snapshots = _collect_snapshots(evidence, inspect_candidates=options.inspect_candidates)
api_diffs = [to_jsonable(diff) for diff in diff_snapshot_groups(snapshots)]
release_notes = [
to_jsonable(item)
for item in collect_release_notes(evidence.get("packages", []), update_versions)
]
behavior = to_jsonable(
collect_behavior_evidence(evidence.get("packages", []), update_versions)
collect_behavior_evidence(
evidence.get("packages", []),
update_versions,
inspect_candidates=options.inspect_candidates,
)
)
direction, architecture, review = await run_analysis_pipeline(
selected_runtime,
Expand Down Expand Up @@ -412,8 +416,10 @@ def _create_autonomous_pr(
) -> list[dict[str, Any]]:
branch_name = options.branch_name or _current_branch(root, command_runner=command_runner)
body = build_draft_pr_body(report_path.read_text(encoding="utf-8"))
relative_report = _relative_path(root, report_path.parent)
paths = ("uv.lock", relative_report)
# Only stage the lockfile. The report lives under the gitignored default
# report dir, so `git add`-ing it returned rc=1 and previously broke the
# --draft-pr flow; its content is already embedded in the PR body above.
paths = ("uv.lock",)
results = [
to_jsonable(stage_paths(root, paths, command_runner=command_runner)),
to_jsonable(
Expand Down Expand Up @@ -452,6 +458,12 @@ def _commit_final_autonomous_pr_report(
) -> None:
branch_name = options.branch_name or _current_branch(root, command_runner=command_runner)
relative_report = _relative_path(root, report_path.parent)
if not _report_dir_committable(root, relative_report, command_runner=command_runner):
# Skipped when the report dir is gitignored (the default location) or
# outside the repository entirely: `git add` would fail on either, and
# the report content is already embedded in the draft PR body, so there
# is nothing that must be committed.
return
results = [
stage_paths(root, (relative_report,), command_runner=command_runner),
commit_staged(
Expand All @@ -468,6 +480,20 @@ def _commit_final_autonomous_pr_report(
raise RuntimeError(f"failed to commit final autonomous PR report: {detail}")


def _report_dir_committable(
root: Path, path: str, *, command_runner: CommandRunner | None
) -> bool:
runner = command_runner
if runner is None:
from examples.sdk_evolution_agent.collectors import run_command

runner = run_command
# `git check-ignore` exits 0 when the path is ignored, 1 when it is not
# ignored, and 128 when it cannot judge it (e.g. the path lies outside the
# repository). Only a definitively not-ignored path can be staged.
return runner(("git", "check-ignore", path), cwd=root).returncode == 1


def _current_branch(root: Path, *, command_runner: CommandRunner | None) -> str:
runner = command_runner or None
if runner is None:
Expand All @@ -485,8 +511,11 @@ def _relative_path(root: Path, path: Path) -> str:
return str(path)


def _collect_snapshots(evidence: dict[str, Any], *, inspect_candidates: bool = True) -> list[Any]:
del inspect_candidates # Candidate inspection is mandatory for update candidates.
def _collect_snapshots(evidence: dict[str, Any], *, inspect_candidates: bool = False) -> list[Any]:
# Candidate inspection pip-installs and imports freshly downloaded upstream
# code, so it only runs when explicitly opted in (--inspect-candidates). When
# off, every snapshot uses the already-installed version via snapshot_current_api,
# which imports nothing new.
snapshots = []
update_versions = _refresh_update_versions(evidence)
refresh_preview_seen = evidence.get("refresh_preview") is not None
Expand All @@ -497,10 +526,12 @@ def _collect_snapshots(evidence: dict[str, Any], *, inspect_candidates: bool = T
locked = package.get("locked_version")
installed = package.get("installed_version")
baseline = locked or installed
if locked and installed and locked != installed:
if inspect_candidates and locked and installed and locked != installed:
snapshots.append(snapshot_candidate_in_venv(name, str(locked)))
else:
snapshots.append(snapshot_current_api(name, version=baseline))
if not inspect_candidates:
continue
candidate = update_versions.get(name)
if candidate is None and not refresh_preview_seen:
latest = package.get("latest_version")
Expand Down
4 changes: 3 additions & 1 deletion examples/sdk_evolution_agent/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,9 @@ class RunOptions:
report_dir: Path = Path("reports/sdk-evolution")
implementation_enabled: bool = False
refresh_preview: bool = False
inspect_candidates: bool = True
# Off by default: candidate inspection pip-installs and imports freshly
# downloaded upstream code (see _collect_snapshots / --inspect-candidates).
inspect_candidates: bool = False
create_branch: bool = False
branch_name: str | None = None
draft_pr: bool = False
Expand Down
32 changes: 23 additions & 9 deletions examples/sdk_evolution_agent/release_notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,19 +247,25 @@ def _fetch_source_text(
use_github_graphql: bool,
) -> str:
if use_github_graphql and source.kind == "github-discussions" and source.url:
try:
return _fetch_github_discussions_index(source.url)
except Exception:
pass
token = _github_token()
if token:
# With a token configured, a GraphQL failure (expired/insufficient
# credentials, API errors) must surface — the caller records it on
# the source — instead of silently downgrading to the
# unauthenticated HTML scrape, which would hide a broken token
# behind lower-quality evidence. Without a token, the HTML path is
# simply the tokenless mode of operation.
return _fetch_github_discussions_index(source.url, token=token)
if not source.url:
return ""
return fetcher(source.url)


def _fetch_github_discussions_index(url: str) -> str:
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
if not token:
raise RuntimeError("GITHUB_TOKEN or GH_TOKEN is required for GitHub Discussions GraphQL")
def _github_token() -> str | None:
return os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")


def _fetch_github_discussions_index(url: str, *, token: str) -> str:
owner, repo, category_slug = _parse_github_discussion_category_url(url)
query = """
query($owner: String!, $repo: String!) {
Expand Down Expand Up @@ -384,7 +390,15 @@ def _release_note_links_for_version(source_url: str, text: str, to_version: str)
window = text[match.end() : match.end() + 1200]
if not any(marker in window for marker in markers):
continue
links.append(urllib.parse.urljoin(source_url, match.group("path")))
resolved = urllib.parse.urljoin(source_url, match.group("path"))
parsed = urllib.parse.urlparse(resolved)
# These hrefs come from fetched content that can carry user-generated
# markup (discussion bodies). Only follow https URLs on github.com
# itself: a protocol-relative path ("//evil.example/x/discussions/1")
# would otherwise urljoin into an off-site request.
if parsed.scheme != "https" or parsed.netloc != "github.com":
continue
links.append(resolved)
return tuple(_dedupe(links))


Expand Down
Loading
Loading