8d379168b2
* Add skill to replace hardcoded foundry project endpoint and model * Include more samples and fix migration samples part 1 * Fix migration samples * Replace Foundry hosted agent validation skill * Fix hosted agent file sample * Fix agent result format * Reorganize jobs * Update discovery heuristic for apps * Split agents into even more jobs * Add toolbox endpoint * Add more pre configured resources * Fix using deployed agent sample * Add sample status * Add playbook * Exclude hidden folder in sample discovery * Install autogen dependencies * Grant azure search RBAC role * Increase timeout for magentic * Build search resouce id deterministically * Remove grant in the workflow * Move azure cli login closer to when the sample actually runs * Refactor playbook * Fix using deployed agent sample * Actually save the playbooks * Fix action syntax error * Fix magentic sample * Address copilot comments * Fix link inspection * Address comments * Correct README * Fix playbook path * Remove trailing space
329 lines
13 KiB
Python
329 lines
13 KiB
Python
# Copyright (c) Microsoft. All rights reserved.
|
|
|
|
"""Cached validation playbooks.
|
|
|
|
A *playbook* is a small JSON recipe that captures exactly how a sample was
|
|
successfully validated so future runs can replay it deterministically without
|
|
invoking an LLM agent. The agent produces a playbook the first time it
|
|
validates a sample; subsequent runs replay it directly and only fall back to
|
|
the agent when the playbook is missing, stale (the sample changed), or the
|
|
replay fails.
|
|
|
|
The playbook payload is an agent-authored **Python validation script**. Replaying
|
|
a playbook writes that script to a temporary file and runs it with the active
|
|
interpreter, treating a zero exit code as success. A single script can validate
|
|
any sample shape: a plain sample that runs to completion, or a long-lived server
|
|
sample (e.g. a hosted agent) that must be started in the background, exercised
|
|
over HTTP, asserted, and then torn down.
|
|
"""
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from dataclasses import asdict, dataclass, field
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from sample_validation.models import RunResult, RunStatus, SampleInfo
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Hard cap on how long a replayed sample may run, regardless of the value the
|
|
# agent recorded, to protect the CI job from a runaway process.
|
|
MAX_REPLAY_TIMEOUT = 600
|
|
|
|
|
|
@dataclass
|
|
class Playbook:
|
|
"""A cached, replayable recipe for validating a single sample.
|
|
|
|
The recipe is an agent-authored Python ``script`` that reproduces a
|
|
successful validation without any AI assistance. Replaying runs the script
|
|
with the active interpreter from the Python root and maps a zero exit code to
|
|
success. The script is self-contained: it launches/exercises the sample,
|
|
performs its own assertions, bakes in any edits it needs, and (for a server
|
|
sample) starts and tears down the server itself.
|
|
"""
|
|
|
|
sample: str
|
|
sample_hash: str
|
|
script: str
|
|
timeout: int = 120
|
|
env: dict[str, str] = field(default_factory=dict) # type: ignore
|
|
expected_status: str = RunStatus.SUCCESS.value
|
|
generated_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
|
|
|
def to_dict(self) -> dict[str, object]:
|
|
"""Serialize to a JSON-friendly dict."""
|
|
return asdict(self)
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict[str, object]) -> "Playbook":
|
|
"""Deserialize from a dict, tolerating unknown/missing optional keys."""
|
|
return cls(
|
|
sample=str(data["sample"]),
|
|
sample_hash=str(data["sample_hash"]),
|
|
script=str(data.get("script") or ""),
|
|
timeout=int(data.get("timeout") or 120), # type: ignore[arg-type]
|
|
env={str(k): str(v) for k, v in (data.get("env") or {}).items()}, # type: ignore[union-attr]
|
|
expected_status=str(data.get("expected_status") or RunStatus.SUCCESS.value),
|
|
generated_at=str(data.get("generated_at") or datetime.now().isoformat()),
|
|
)
|
|
|
|
|
|
def sample_files(sample: SampleInfo) -> list[Path]:
|
|
"""Return the files that make up a sample.
|
|
|
|
For a single-file sample this is just that file. For a directory sample
|
|
(``main.py``/``app.py`` entrypoint) it is every ``.py`` file in the tree.
|
|
|
|
Note: we only consider source code files, not data files, nor deployment
|
|
artifacts (Dockerfile, requirements.txt, etc.) for now.
|
|
"""
|
|
path = sample.path
|
|
if path.is_dir():
|
|
return sorted(p for p in path.rglob("*.py") if "__pycache__" not in p.parts)
|
|
return [path]
|
|
|
|
|
|
def compute_sample_hash(sample: SampleInfo) -> str:
|
|
"""Compute a stable content hash for a sample.
|
|
|
|
For a single-file sample the hash covers that file. For a directory sample
|
|
(``main.py``/``app.py`` entrypoint) it covers every ``.py`` file in the
|
|
directory tree so any change to the sample invalidates the cached playbook.
|
|
"""
|
|
hasher = hashlib.sha256()
|
|
path = sample.path
|
|
for file in sample_files(sample):
|
|
try:
|
|
hasher.update(file.relative_to(path.parent).as_posix().encode("utf-8"))
|
|
hasher.update(b"\0")
|
|
hasher.update(file.read_bytes())
|
|
hasher.update(b"\0")
|
|
except OSError as ex: # pragma: no cover - defensive
|
|
logger.warning(f"Could not hash {file}: {ex}")
|
|
|
|
return f"sha256:{hasher.hexdigest()}"
|
|
|
|
|
|
class PlaybookStore:
|
|
"""Loads and saves per-sample playbooks under a directory.
|
|
|
|
Each sample gets one JSON file whose name is derived from the sample's
|
|
relative path (path separators replaced so it stays flat and filesystem
|
|
safe).
|
|
"""
|
|
|
|
def __init__(self, directory: Path) -> None:
|
|
self.directory = directory
|
|
|
|
@staticmethod
|
|
def _slug(relative_path: str) -> str:
|
|
return relative_path.replace("\\", "/").replace("/", "__") + ".json"
|
|
|
|
def _path_for(self, relative_path: str) -> Path:
|
|
return self.directory / self._slug(relative_path)
|
|
|
|
def load(self, sample: SampleInfo) -> Playbook | None:
|
|
"""Return the stored playbook for a sample, or ``None`` if absent/invalid."""
|
|
path = self._path_for(sample.relative_path)
|
|
if not path.exists():
|
|
return None
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
playbook = Playbook.from_dict(data)
|
|
except Exception as ex:
|
|
logger.warning(f"Ignoring unreadable playbook {path}: {ex}")
|
|
return None
|
|
return playbook
|
|
|
|
def save(self, playbook: Playbook) -> Path:
|
|
"""Persist a playbook to disk and return its path."""
|
|
self.directory.mkdir(parents=True, exist_ok=True)
|
|
path = self._path_for(playbook.sample)
|
|
path.write_text(json.dumps(playbook.to_dict(), indent=2), encoding="utf-8")
|
|
return path
|
|
|
|
def is_valid_for(self, sample: SampleInfo) -> Playbook | None:
|
|
"""Return the stored playbook only if it matches the sample's current hash."""
|
|
playbook = self.load(sample)
|
|
if playbook is None:
|
|
return None
|
|
current_hash = compute_sample_hash(sample)
|
|
if playbook.sample_hash != current_hash:
|
|
logger.info(f"Playbook for {sample.relative_path} is stale (sample changed); will re-validate.")
|
|
return None
|
|
return playbook
|
|
|
|
|
|
def _new_process_group_kwargs() -> dict[str, object]:
|
|
"""Return subprocess kwargs that launch the child in its own process group.
|
|
|
|
Isolating the replay in a new group/session lets us tear down the whole tree
|
|
(for example a background server the script spawned) even if the script exits
|
|
without cleaning up after itself.
|
|
"""
|
|
if os.name == "posix":
|
|
return {"start_new_session": True}
|
|
# Windows: a new process group so the whole tree can be terminated together.
|
|
return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
|
|
|
|
|
|
def _terminate_process_tree(proc: "asyncio.subprocess.Process", pgid: int | None) -> None:
|
|
"""Best-effort kill of a replay process and any descendants it leaked.
|
|
|
|
This always runs, even when the script itself already exited cleanly, because
|
|
the whole point of the backstop is to reap a background server the script may
|
|
have started but failed to tear down. On POSIX we signal the saved process
|
|
group id (captured at spawn time, since the leader may already be reaped);
|
|
on Windows we kill the process tree by PID.
|
|
"""
|
|
try:
|
|
if os.name == "posix":
|
|
if pgid is not None:
|
|
os.killpg(pgid, signal.SIGKILL)
|
|
elif proc.returncode is None:
|
|
proc.kill()
|
|
else:
|
|
# Kill by PID (not image name) including the whole child tree.
|
|
subprocess.run(
|
|
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
except (ProcessLookupError, OSError) as ex: # pragma: no cover - defensive
|
|
logger.debug(f"Could not terminate replay process tree {proc.pid}: {ex}")
|
|
|
|
|
|
async def replay_playbook(playbook: Playbook, python_root: Path, samples_dir: Path) -> RunResult:
|
|
"""Deterministically replay a playbook and return a ``RunResult``.
|
|
|
|
Writes the recorded Python script to a temporary file and runs it with the
|
|
active interpreter, using the Python root as the working directory. A zero
|
|
exit code maps to ``SUCCESS``; anything else yields a ``FAILURE`` so the
|
|
caller falls back to the agent.
|
|
|
|
Sample files are snapshotted and restored so a replay never leaves the
|
|
repository dirty (the script may edit the sample to make it run), and the
|
|
replay process is launched in its own process group so a background server it
|
|
starts is always torn down afterwards, even if the script fails to clean up.
|
|
"""
|
|
sample_path = playbook.sample
|
|
sample_info = SampleInfo(path=samples_dir / sample_path, relative_path=sample_path)
|
|
|
|
if not playbook.script.strip():
|
|
return RunResult(
|
|
sample=sample_info,
|
|
status=RunStatus.FAILURE,
|
|
output="",
|
|
error="Playbook has an empty validation script.",
|
|
)
|
|
|
|
# Snapshot every file that makes up the sample so we can restore it byte-exact
|
|
# no matter how we exit (the script is allowed to edit the sample in place).
|
|
snapshot: dict[Path, bytes] = {}
|
|
for file in sample_files(sample_info):
|
|
try:
|
|
snapshot[file] = file.read_bytes()
|
|
except OSError:
|
|
pass
|
|
|
|
script_file = tempfile.NamedTemporaryFile( # ruff:ignore[open-file-with-context-handler] - closed explicitly below
|
|
prefix="playbook_", suffix=".py", delete=False
|
|
)
|
|
script_path = Path(script_file.name)
|
|
proc: asyncio.subprocess.Process | None = None
|
|
pgid: int | None = None
|
|
try:
|
|
script_file.write(playbook.script.encode("utf-8"))
|
|
script_file.close()
|
|
|
|
env = {**os.environ, **playbook.env}
|
|
timeout = min(max(1, playbook.timeout), MAX_REPLAY_TIMEOUT)
|
|
|
|
start = time.perf_counter()
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
sys.executable,
|
|
str(script_path),
|
|
cwd=str(python_root),
|
|
env=env,
|
|
stdin=asyncio.subprocess.DEVNULL,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.STDOUT,
|
|
**_new_process_group_kwargs(), # type: ignore[arg-type]
|
|
)
|
|
except OSError as ex:
|
|
return RunResult(
|
|
sample=sample_info,
|
|
status=RunStatus.FAILURE,
|
|
output="",
|
|
error=f"Replay could not start the script: {ex}",
|
|
)
|
|
|
|
# Capture the process-group id now (== proc.pid, since we start a new
|
|
# session/group) so teardown can kill leaked descendants even after the
|
|
# leader process has exited and been reaped.
|
|
if os.name == "posix":
|
|
try:
|
|
pgid = os.getpgid(proc.pid)
|
|
except OSError: # pragma: no cover - defensive
|
|
pgid = None
|
|
|
|
try:
|
|
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
except (TimeoutError, asyncio.TimeoutError):
|
|
return RunResult(
|
|
sample=sample_info,
|
|
status=RunStatus.FAILURE,
|
|
output="",
|
|
error=f"Replay timed out after {timeout}s.",
|
|
)
|
|
|
|
duration = time.perf_counter() - start
|
|
output_text = stdout.decode("utf-8", errors="replace") if stdout else ""
|
|
|
|
if proc.returncode == 0:
|
|
return RunResult(
|
|
sample=sample_info,
|
|
status=RunStatus.SUCCESS,
|
|
output=f"Replayed cached playbook successfully in {duration:.1f}s.",
|
|
error="",
|
|
)
|
|
|
|
# Non-zero exit: surface a truncated tail to aid debugging; caller retries with the agent.
|
|
tail = output_text[-1000:]
|
|
return RunResult(
|
|
sample=sample_info,
|
|
status=RunStatus.FAILURE,
|
|
output="",
|
|
error=f"Replay exited with code {proc.returncode}. Output tail: {tail}",
|
|
)
|
|
finally:
|
|
# Always tear down the process group (even if the script exited cleanly)
|
|
# so a background server it leaked is killed, then reap the leader.
|
|
if proc is not None:
|
|
_terminate_process_tree(proc, pgid)
|
|
try:
|
|
await proc.wait()
|
|
except Exception: # pragma: no cover - defensive
|
|
pass
|
|
for target, original in snapshot.items():
|
|
try:
|
|
if target.read_bytes() != original:
|
|
target.write_bytes(original)
|
|
except OSError as ex: # pragma: no cover - defensive
|
|
logger.warning(f"Could not restore {target} after replay: {ex}")
|
|
try:
|
|
script_path.unlink()
|
|
except OSError: # pragma: no cover - defensive
|
|
pass
|