Validators steer, evals grade: picking a model on cost per pass
What this post covers#
The big changes in this post are:
- Add more static validators, like
tflintandcheckov - Add a structured output type that nudges the agent towards self-reporting
- Build an eval set and use
pydantic-evalsto run it
For any agentic system the cheapest way to get better results is if you can add inexpensive static validators the agent can run itself, and that we re-run on submission. This is particularly true for this agent that produces files on disk. The tools the agent runs and the tools that grade its submission are the same set. We just layer deterministic formatting on top.
How strict the validators should be is a trade-off. checkov ships policies that are far stricter than many projects
need, and that context would need to be configured, which we do not want to add right now. So making it slightly
less strict is a good way to manage cost (default settings ballooned cost for some models by 100x).
pydantic-evals is the foundation for anything we add later, and eval cases for a real (non-blog example) scenario
would need to significantly extend it and add cases that start from an existing project. Currently we have five
simple test cases, which does not give us a lot of confidence statistically. Take the results presented with a pinch
of salt: they are directionally useful and enough to rule a model out, but they do not predict performance on new and
more complex cases.
The work in this post turned up a few gotchas that forced changes to earlier code. With more validation tools the
total run size increased, eventually breaching Firehose’s record cap (we use it to store audit traces). So we had to
add batching for the audit copy. We also cap the model’s max_tokens and the run’s request_limit, so the message
history cannot grow without a ceiling. That does not bound what a tool returns, though MAX_REPORT_CHARS caps the one
that mattered, checkov’s report. Beyond that we would have to trim or offload the history itself.
From validators to evals#
The additional validators we added in this post for the agent answer whether the terraform code is correct, reasonably secure according to static rules, and in line with our lint style. They say nothing about whether it did the task it was assigned. For example if it was tasked to create an AWS S3 bucket and did not, then every one of those checks would still pass.
That is where the evals come in. The structure in pydantic-evals is a small vocabulary. A case is a fixed input plus the
metadata that says what should come out of it. The agent runs and results are passed to evaluators. These can be
boolean, which gives a pass rate, categorical, which classifies runs and gives a ratio per label, or numeric, which
averages into a score. Evaluators can also use OTEL span information meaning even tool calls can be evaluated if tool
usage is expected. In addition, token usage and pydantic-ai’s genai-prices integration, which we extend with prices it
has not shipped yet, give us run cost and cost per pass.
Building an eval harness is the only real way to decide on a model or make an informed change to the setup (like system prompts or tooling). LLM agents are non-deterministic, so without a way to evaluate the results we cannot make any decisions. Collecting stats like token usage, pass rate, and cost is also what lets you run a model to a budget. If your budget is $0.20 per successful run, then you need to know how often you land under it. There is no 100% safety as your evals do not cover everything, which is why you should also do live monitoring for this - the initial premise of our series.
Our eval-case structure is currently basic. We have the inputs (the task, what the agent should do) and some metadata which we use to configure the validators (for example expected terraform resources).
cases: - name: logs-bucket inputs: >- Create an S3 bucket for storing application logs. Make sure the configuration blocks public access. metadata: expected_resources: - type: aws_s3_bucket - type: aws_s3_bucket_public_access_blockThe result#
We did run the eval harness against a few models:
- GLM 5.2 (
glm5p2), Z.ai’s 743B mixture-of-experts model with a 1M-token context, built for long-horizon coding. We reach it through Fireworks. - Mistral Medium (
mistral-medium), Mistral’s mid-tier generalist, which their own docs position for agentic and coding work. - Haiku 4.5 (
haiku), Anthropic’s fastest model, and the only Bedrock entry in our registry. - Mistral Large (
mistral-large), Mistral’s largest general-purpose model. - Codestral (
codestral), Mistral’s code-completion specialist (not a good choice for the task, which you will see).
Now plotting pass rate against cost per task gives us a decent model selection picture. First of all haiku drops out
on cost: it passes as often as mistral-medium and costs more than five times as much per task. glm5p2 is the only model that
passed all fifteen runs, but mistral-medium is cheaper per task and its single miss was a run that hit the request
limit rather than bad Terraform, so it still comes out ahead on cost per pass. I would expect this to change once we
add more complex validation checks and cases, but for now with the eval data we have mistral-medium would be the pick
if you can absorb the occasional failure with a retry, and glm5p2 if you want the consistency without one.
The fuller numbers (n=15: 5 cases x 3 repeats), including the tool-error score:
| Model | Pass rate | Errored | Eff $/M tok | Cost / task | Cost / pass | Tool-error |
|---|---|---|---|---|---|---|
| glm5p2 | 100% | 0 | $0.68 | $0.036 | $0.036 | 0.83 |
| mistral-medium | 93% | 1 | $0.45 | $0.028 | $0.030 | 0.66 |
| haiku | 93% | 1 | $1.25 | $0.156 | $0.167 | 0.60 |
| mistral-large | 87% | 1 | $2.16 | $0.084 | $0.097 | 0.61 |
| codestral | 33% | 10 | $0.33 | $0.008 | $0.025 | 0.68 |
Tool-error is a score between 0 and 1, not a boolean like the evaluators behind the pass rate. It starts at 1 and for
each tool call that needs to be retried the score is reduced by 0.1. So you can see that mistral-medium has more tool
errors than glm5p2. The causes differ, and some would yield to better instructions. For example in a particular eval
case for the log bucket the agent ran terraform init as instructed, then wrote a file adding the first aws provider
resource, and then failed on terraform validate with Error: Missing required provider. An instruction covering this
edge case would probably have caught it (in real scenarios we should have already aws provider resources in the
project). Others are just plain mechanical validations like tflint’s
terraform "required_version" attribute is required (terraform_required_version). glm5p2 avoids most of these, while
mistral-medium got confused enough in one run that it never finished inside the request limit.
What is nice if logfire is configured is the ability to drill into a particular eval case and look at the spans which include the agent conversation and the tool retry errors reducing our score.
We only have one case where an assertion failed, meaning the model did not fully meet the case’s expectations. Most other failures are mechanical, runs that never finished. The 50-request limit is what stops a confused run from spiralling in cost.
Difficulty is not spread evenly either. queue-with-dlq and sessions-table account for most of the misses, while
logs-bucket and lambda-exec-role were passed by every model except codestral, which missed one run of each.
The code#
Prerequisites (one-time setup for the series)
Tooling and AWS access common to every post in this series.
Tooling
- Terraform 1.x (install). Every post provisions infrastructure with Terraform.
- uv for Python project management (install). Each post ships a runnable script you can invoke with
uv run. - direnv (install) so
terraform,uv run, andawspick up AWS credentials automatically oncd. The project scaffold ships an.envrcthat sources a gitignored.envrc.local. - (Optional) A coding agent such as Claude Code, Cursor, Codex, or Gemini CLI to consume the
AgentPromptblocks throughout the series. Not required (each prompt has a manual equivalent shown alongside it), but it skips the boilerplate.
Agent prompt: Check and install missing tooling
You are helping set up tooling for a tutorial project.
For each of `terraform`, `uv`, and `direnv`, run `command -v` to
check whether it is installed. If present, print the version and
continue.
For missing tools, detect the system package manager in this order:
`command -v brew`, `command -v dnf`, `command -v apt-get`. Use the
first one available:
- Terraform: `brew tap hashicorp/tap && brew install hashicorp/tap/terraform`,
dnf via the HashiCorp RPM repo, or apt via the HashiCorp deb repo.
- uv: `brew install uv`, or the official installer
`curl -LsSf https://astral.sh/uv/install.sh | sh`.
- direnv: `brew install direnv`, `dnf install direnv`, or
`apt-get install direnv`.
If no package manager is available or the install fails, stop and
link the manual install page so the developer can finish by hand:
- Terraform: https://developer.hashicorp.com/terraform/install
- uv: https://docs.astral.sh/uv/getting-started/installation/
- direnv: https://direnv.net/docs/installation.html
After installing direnv, do not modify any shell rc files. Print the
hook line for the developer's shell (bash, zsh, or fish) and the path
to the relevant rc file, then wait for them to apply it themselves.
Report which tools were already present, which you installed, and
which need manual follow-up.
AWS access
- A sandbox, test, or personal AWS account with permission to create, modify, and delete the resources discussed in each post. If you don’t have one, follow the official Create Your AWS Account walkthrough (about ten minutes; requires a credit card and a phone number for verification). Treat it as disposable - you can close it from the billing console after the series.
- AWS credentials available locally via
aws configure sso,aws configure, or whichever method matches your setup. You wire them into the project through.envrc.localin the next section, not your shell rc.
Anthropic First Time Use
Bedrock requires a one-time use-case form per account (or per AWS Organization management account) before Anthropic models can be invoked. Easiest path: open any Claude model in the Bedrock console playground and submit the form. Auto-subscription on first invoke can take up to 15 minutes to settle, so it is worth clearing this before post 1.
CLI alternative and verification
Programmatic equivalent (requires AWS CLI 2.27.42 or later):
aws bedrock put-use-case-for-model-access \ --form-data "$(printf '{"companyName":"...","companyWebsite":"...","intendedUsers":"1","industryOption":"...","otherIndustryOption":"","useCases":"..."}' | base64)"Verify:
aws bedrock get-foundation-model-availability \ --model-id anthropic.claude-haiku-4-5-20251001-v1:0 \ --region eu-west-1Look for agreementAvailability.status: AVAILABLE. Expected output:
{ "modelId": "anthropic.claude-haiku-4-5-20251001-v1", "agreementAvailability": { "status": "AVAILABLE" }, "authorizationStatus": "AUTHORIZED", "entitlementAvailability": "AVAILABLE", "regionAvailability": "AVAILABLE"}If the form has not been submitted, only agreementAvailability.status flips to NOT_AVAILABLE. The other three fields stay green even when invocation would fail, so do not rely on them.
The final tree. + is new in post 5, ~ extends a post 4 file, blank carries unchanged. Click any changed or new file
to read it; the download below fast-forwards to this state if you want to walk through the post against the finished
code.
The following files are new at this checkpoint:
| File | What it does |
|---|---|
agent/tools/ | Tools become a package: filesystem.py tracks changed files, validators.py adds tflint and checkov |
evals/ | The offline harness: cases.yaml and its generated schema, evaluators.py, run.py. Never ships in the image |
agent/prices.py | The glm5p2 price genai-prices has not shipped yet. In agent/, so a live run is priced like a sweep |
tests/test_evaluators.py, tests/fixtures/plan.json | The plan-comparison logic against a canned plan document |
tests/test_run.py | The harness points model resolution at the eval registry |
These carry forward from post 4 with changes:
| File | What changed |
|---|---|
agent/core.py | TaskResult output type and output validator; execute() takes an optional workspace, and the run states its own ceilings |
agent/lambda_entry.py | The response output becomes the serialized TaskResult |
agent/models.py | Resolves whichever registry MODELS_PARAMETER names, and silences the Mistral SDK’s duplicate chat spans |
agent/observability.py | One Firehose record per span, EMF metrics matched on span scope as well as the metadata attribute, and the price registered |
tests/ | test_core.py, test_lambda_entry.py, test_tools.py, test_observability.py and test_validators.py cover the above |
Build and infrastructure:
| File | What changed |
|---|---|
infra/models.tf | A second registry for sweeps, whose Bedrock entry points at its own profile tagged Purpose=eval |
infra/lambda.tf | The Lambda role can read the Fireworks key, so a live run can reach glm5p2 and not just an eval can |
infra/variables.tf | default_model moves to mistral-medium, the model the sweep below argues for |
Dockerfile | Two more pinned stages: the tflint binary, checksum-verified, and a checkov venv |
pyproject.toml | The dev group gains pydantic-evals and typer |
Two rows in those tables have a story behind them. The audit copy from post 2 shipped a whole trace as one Firehose record, which a long validation run outgrew, so it now ships one record per span, and the run states its own ceilings so no single span can outgrow the cap either.
The other is cost. An uncapped checkov report is not paid for once: it joins the message history and is charged again on
every later turn, so validators.py caps what a failed check hands back to the model.
"""Tool surface, re-exported so callers import from ``agent.tools``."""
from agent.tools.filesystem import ( WorkspaceDeps, delete_file, edit_file, list_files, read_file, write_file,)from agent.tools.validators import ( checkov, terraform_init, terraform_validate, tflint, validate_workspace,)
__all__ = [ "WorkspaceDeps", "checkov", "delete_file", "edit_file", "list_files", "read_file", "terraform_init", "terraform_validate", "tflint", "validate_workspace", "write_file",]"""Filesystem tools: list, read, write, edit, delete inside the workspace.
All tools take ``RunContext[WorkspaceDeps]`` so the workspace root isthreaded through ``ctx.deps.root`` instead of read from a module global.That keeps tests, evals, and multi-tenant runs from sharing state."""
from pathlib import Path
from pydantic import BaseModel, ConfigDict, Fieldfrom pydantic_ai import ModelRetry, RunContext
class WorkspaceDeps(BaseModel): """Per-run dependencies threaded through ``RunContext``.
``root`` is the directory the agent is allowed to read, write, and validate inside. Tools resolve every path relative to it and reject anything that escapes the root, so the agent cannot reach outside the workspace via ``..`` or absolute paths. """
model_config = ConfigDict(arbitrary_types_allowed=True)
root: Path files_read: set[Path] = Field(default_factory=set) # Mutations are tracked here rather than diffed from the filesystem, so the # no-op check in core.py keeps working once runs start from a seeded # project instead of an empty workspace. files_changed: set[Path] = Field(default_factory=set)
def list_files(ctx: RunContext[WorkspaceDeps], path: str = ".") -> list[str]: """List files under ``path`` relative to the workspace root.""" return [ str(p.relative_to(ctx.deps.root)) for p in _resolve_absolute_folder(ctx, path).iterdir() ]
def read_file(ctx: RunContext[WorkspaceDeps], path: str) -> str: """Read the file at ``path`` and return its contents.""" file = _resolve_absolute_file(ctx, path) with file.open() as f: ctx.deps.files_read.add(file) return f.read()
def write_file(ctx: RunContext[WorkspaceDeps], path: str, contents: str) -> None: """Create or overwrite the file at ``path`` with ``contents``.""" file = _resolve_absolute_path(ctx, path) if file.exists() and file not in ctx.deps.files_read: raise ModelRetry( f"File {path} already exists. If you want to overwrite it, then delete it first." ) with file.open("w") as f: # agent wrote it and knows the content ctx.deps.files_read.add(file) ctx.deps.files_changed.add(file) f.write(contents)
def edit_file( ctx: RunContext[WorkspaceDeps], path: str, old_string: str, new_string: str,) -> None: """Replace ``old_string`` with ``new_string`` in the file at ``path``.""" file = _resolve_absolute_file(ctx, path) if file not in ctx.deps.files_read: raise ModelRetry(f"File {path} was not read. If you want to edit it, then read it first.") with file.open("r+") as f: contents = f.read() if old_string not in contents: raise ModelRetry(f"String {old_string} not found in file {path}.") if contents.count(old_string) > 1: raise ModelRetry(f"String {old_string} found more than once in file {path}.") f.seek(0) f.write(contents.replace(old_string, new_string)) f.truncate() ctx.deps.files_changed.add(file)
def delete_file(ctx: RunContext[WorkspaceDeps], path: str) -> None: """Delete the file at ``path``.""" file = _resolve_absolute_file(ctx, path) if file not in ctx.deps.files_read: raise ModelRetry(f"File {path} was not read. If you want to delete it, then read it first.") file.unlink() ctx.deps.files_changed.add(file)
def _resolve_absolute_folder(ctx: RunContext[WorkspaceDeps], path: str): if not (absolute_path := _resolve_absolute_path(ctx, path)).is_dir(): raise ModelRetry(f"Path {path} must be a directory.") return absolute_path
def _resolve_absolute_file(ctx: RunContext[WorkspaceDeps], path: str): absolute_path = _resolve_absolute_path(ctx, path) if not absolute_path.exists(): raise ModelRetry(f"File {path} does not exist.") if not absolute_path.is_file(): raise ModelRetry(f"Path {path} must be a file.") return absolute_path
def _resolve_absolute_path(ctx: RunContext[WorkspaceDeps], path: str): root = ctx.deps.root.resolve() absolute_path = (root / Path(path)).resolve() if not absolute_path.is_relative_to(root): raise ModelRetry( f"Path {path} must be relative to the workspace root, " f"it cannot be absolute or walk up the directory tree." ) return absolute_path"""Validation tools: the checks the agent runs on the workspace."""
import subprocessimport threadingfrom pathlib import Path
from agent.memory import track_memoryfrom agent.tools.filesystem import WorkspaceDepsfrom pydantic import BaseModelfrom pydantic_ai import ModelRetry, RunContext
# Every failure report becomes a tool result the model carries for the rest of# the run, so an uncapped one is paid for on every later turn and lands in the# audit record. checkov's report is the offender; its head carries the# failures that matter, and the agent re-runs the check anyway.MAX_REPORT_CHARS = 8_000
def _capped(text: str) -> str: if len(text) <= MAX_REPORT_CHARS: return text return f"{text[:MAX_REPORT_CHARS]}\n[{len(text) - MAX_REPORT_CHARS} more characters cut]"
# terraform's provider cache is not concurrency safe: with several inits# installing at once the installer's behaviour is undefined, and a workspace# that init'd against a half-written cache fails its next command with# "Failed to obtain provider schema". One run is alone in its Lambda container,# so this only ever contends in an eval sweep, where cases run in parallel.# https://developer.hashicorp.com/terraform/cli/config/config-file_INSTALL_LOCK = threading.Lock()
class CommandResult(BaseModel): success: bool stdout: str stderr: str
def format_error_for_agent(self) -> str: """The failure as the model sees it, capped. Callers keep the full output.""" if not self.success: return f"failed:\n{_capped(self.stdout)}\n{_capped(self.stderr)}" raise RuntimeError("CommandResult.format_error_for_agent() called on success")
class Command(BaseModel): name: str commands: list[str]
@property def installs_providers(self) -> bool: return "init" in self.commands
def run(self, path: Path) -> CommandResult: if self.installs_providers: with _INSTALL_LOCK: return self._run(path) return self._run(path)
def _run(self, path: Path) -> CommandResult: # Inside the lock, so the span measures the command and not the wait. with track_memory(self.name): result = subprocess.run(self.commands, cwd=path, capture_output=True, text=True) return CommandResult( success=result.returncode == 0, stdout=result.stdout, stderr=result.stderr )
def run_root(self, ctx: RunContext[WorkspaceDeps]) -> CommandResult: return self.run(ctx.deps.root)
def run_in_tool(self, path: Path) -> str: result = self.run(path) if not result.success: raise ModelRetry(f"{self.name} {result.format_error_for_agent()}") return f"OK: {self.name} passed."
TERRAFORM_INIT = Command( name="terraform_init", commands=["terraform", "init", "-backend=false", "-input=false", "-no-color"],)
TERRAFORM_VALIDATE = Command( name="terraform_validate", commands=["terraform", "validate", "-no-color"],)
TERRAFORM_FMT = Command( name="terraform_fmt", commands=["terraform", "fmt", "-recursive"],)
TFLINT = Command( name="tflint", commands=["tflint", "--format", "compact"],)
# Checks skipped by default: cost or architecture posture decisions, not# security baselines, and routinely disabled in real projects. Genuine# baselines (public access blocks, encryption at rest, IAM wildcards) stay on._CHECKOV_DEFAULT_SKIPS = [ "CKV_AWS_18", # S3 access logging on every bucket "CKV_AWS_144", # S3 cross-region replication "CKV_AWS_145", # S3 must use KMS; SSE-S3 (CKV_AWS_19) still enforced "CKV2_AWS_61", # S3 lifecycle configuration on every bucket "CKV2_AWS_62", # S3 event notifications on every bucket "CKV_AWS_50", # Lambda X-Ray tracing "CKV_AWS_115", # Lambda reserved concurrency "CKV_AWS_116", # Lambda dead-letter queue "CKV_AWS_117", # Lambda attached to a VPC "CKV_AWS_272", # Lambda code signing "CKV_AWS_338", # CloudWatch log retention of at least a year]
def _checkov_command(root: Path) -> Command: """Default skips apply only when the workspace brings no config of its own: checkov auto-discovers .checkov.yaml in the scanned directory, and a project that states its policy wins over ours. """ commands = ["checkov", "-d", ".", "--quiet", "--compact"] if not any((root / name).exists() for name in (".checkov.yaml", ".checkov.yml")): commands += ["--skip-check", ",".join(_CHECKOV_DEFAULT_SKIPS)] return Command(name="checkov", commands=commands)
def terraform_init(ctx: RunContext[WorkspaceDeps]) -> str: """Run ``terraform init`` in the workspace.
Required once before the first ``terraform_validate`` and again after provider or module requirements change. """ return TERRAFORM_INIT.run_in_tool(ctx.deps.root)
def terraform_validate(ctx: RunContext[WorkspaceDeps]) -> str: """Run ``terraform validate`` in the workspace and return its output.""" return TERRAFORM_VALIDATE.run_in_tool(ctx.deps.root)
def tflint(ctx: RunContext[WorkspaceDeps]) -> str: return TFLINT.run_in_tool(ctx.deps.root)
def checkov(ctx: RunContext[WorkspaceDeps]) -> str: return _checkov_command(ctx.deps.root).run_in_tool(ctx.deps.root)
def validate_workspace(path: Path) -> CommandResult: # Normalize formatting before gating; a fmt failure (unparsable HCL) is # ignored here because terraform validate reports it better one step later. TERRAFORM_FMT.run(path) for command in [TERRAFORM_VALIDATE, TFLINT, _checkov_command(path)]: result = command.run(path) if not result.success: break return result"""Define the agent and execute one run.
Runtime-agnostic: a fresh /tmp workspace threaded through the file and validate8 collapsed lines
tools, one run id that joins the audit trace, the parked artifacts, and theresult. The telemetry pipeline lives in observability.py, model construction inmodels.py, and run persistence plus the provider re-lock in runs.py; the Lambdaenvelope and INIT wiring live in lambda_entry.py. This module knows nothingabout Lambda events."""
import uuidfrom pathlib import Pathfrom tempfile import TemporaryDirectory
from pydantic import BaseModel, ConfigDictfrom pydantic_ai import Agent
from agent.env import require_envfrom agent.models import _build_modelfrom agent.runs import _persist_run, _relock_providersfrom agent.tools import ( WorkspaceDeps, checkov, delete_file, edit_file, list_files, read_file, terraform_init, terraform_validate, tflint, validate_workspace, write_file,)from pydantic import BaseModel, ConfigDictfrom pydantic_ai import Agent, ModelRetry, RunContextfrom pydantic_ai.settings import ModelSettingsfrom pydantic_ai.usage import UsageLimits
# A soft reproducibility nudge following the standard Terraform pattern: a# version constraint in the config, the exact version and checksums in the lock20 collapsed lines
# file. The tool-call spans in the trace are the ground truth for what the agent# actually wrote._PROVIDER_PIN_RULE = ( "When you add the AWS provider, give it a version constraint such as " '"~> 6.0" rather than leaving it unconstrained. terraform init records ' "the exact resolved version and checksums in .terraform.lock.hcl, " "which travels with the workspace and is the reproducibility record, " "so the constraint does not need to be an exact pin. Pin an exact " "version only when the user asks for one. Example:\n" "\n" "terraform {\n" " required_providers {\n" " aws = {\n" ' source = "hashicorp/aws"\n' ' version = "~> 6.0"\n' " }\n" " }\n" "}")
SYSTEM_PROMPT = ( "You are the terraform-pr-agent. You operate on a Terraform workspace " "through file tools (list_files, read_file, write_file, edit_file, " "delete_file) and two terraform tools. Use the file tools to explore, " "write, and edit. Run terraform_init before your first validate and " "again whenever you add or change provider or module requirements. " "Call terraform_validate after you write or change files to confirm " "the workspace still parses; treat its output as feedback and edit " "until it is clean.\n\n" + _PROVIDER_PIN_RULE "delete_file) and a set of terraform and validation tools. Use the file " "tools to explore, write, and edit. Run terraform_init before your first " "validate and again whenever you add or change provider or module " "requirements. Call terraform_validate after you write or change files to " "confirm the workspace still parses; treat its output as feedback and edit " "until it is clean. When the workspace is done, report your work through " "the final result.\n\n" "Give every variable you declare a default value, so the workspace plans " "without prompting for input.\n\n" "Run tflint and checkov and clear all findings before reporting done.\n\n" + _PROVIDER_PIN_RULE)
class TaskResult(BaseModel): """The agent's structured self-report on a finished run.
Each required field pushes the agent to consider that dimension of its work; the tool-call spans in the trace remain the ground truth that exposes any embellishment. Because this is the agent's output type, ending a run now requires calling the final_result output tool, so a text-only reply can no longer end a run silently. """
model_config = ConfigDict(frozen=True)
summary: str """One line on what was done.""" solution_description: str """How the problem was solved, including architectural choices.""" validations_run: list[str] """Which validation tools were invoked during the run.""" issues_addressed: list[str] """Security or correctness problems identified and fixed.""" known_limitations: list[str] """What was not handled; surfaces for human review.""" ready_for_review: bool """The agent's self-assessment that the workspace is PR-ready."""
# One Agent instance is reused across invocations: the tools reach the workspace# through RunContext.deps, so each run_sync scopes them to a fresh WorkspaceDeps.# The model carries no default; it is built from the registry at INVOKE and# passed per run, so switching DEFAULT_MODEL needs no code change.## With TaskResult as the output type, plain text is not an allowed way to end# the run, so pydantic-ai asks providers to force tool use on every turn# (Bedrock toolChoice "any"). Note Bedrock rejects that combination once# extended thinking is enabled on a model; nothing in the registry enables it.agent = Agent( deps_type=WorkspaceDeps, output_type=TaskResult, system_prompt=SYSTEM_PROMPT, tools=[ list_files,3 collapsed lines
read_file, write_file, edit_file, delete_file, terraform_init, terraform_validate, tflint, checkov, ], # Tools raise ModelRetry on failure; pydantic-ai ends the run once one tool # fails more than `retries` times in a row (a success resets the count). The # default of 1 would end the run on the second straight failing validate, # which is a normal part of the write-validate-edit loop, so the budget is # raised well past anything a converging run produces. The per-run turn cap # stays the runaway guard. retries=10, retries={"tools": 3, "output": 3},)
# The two ceilings on how long one run's message history can get, and with it# the biggest audit record the trace produces (see observability._span_record).# request_limit is pydantic-ai's own default, set here so the bound is ours;# max_tokens caps what each turn adds. The third term, tool output, is capped# in tools/validators.py.RUN_LIMITS = UsageLimits(request_limit=50)RUN_SETTINGS = ModelSettings(max_tokens=4096)
# Caller-side backstop budget: how many follow-up runs to spend trying to get a# clean validate after the agent reports done. The per-tool `retries` above# guards the loop inside one run; this guards the run as a whole._MAX_VALIDATE_RETRIES = 3
_RETRY_PROMPT = ( "terraform validate still reports errors after you finished. " "terraform validate still reports errors after you reported done. " "Fix them and validate again.\n\n{output}")
class ValidateDidNotConverge(RuntimeError): """terraform validate still failed after the caller-side retry budget."""@agent.output_validatordef _validate_final_workspace(ctx: RunContext[WorkspaceDeps], output: TaskResult) -> TaskResult: # The deliverable is the workspace, not this object: a run that changed # nothing produced nothing, however plausible its self-report reads. if not ctx.deps.files_changed: raise ModelRetry( "You reported done but made no changes to the workspace. " "Use the file tools to implement the request, validate, then report done again." ) validation_result = validate_workspace(ctx.deps.root) if not validation_result.success: raise ModelRetry(_RETRY_PROMPT.format(output=validation_result.format_error_for_agent())) return output
class RunResult(BaseModel): model_config = ConfigDict(frozen=True)
run_id: str model: str output: str output: TaskResult input_tokens: int output_tokens: int
def execute(prompt: str, model: str | None = None) -> RunResult:def execute(prompt: str, model: str | None = None, workspace: Path | None = None) -> RunResult: """Run the agent once and return the result.
The run id is the trace's gen_ai.conversation.id and the runs/<run_id>/ prefix, so one identifier joins trace, artifacts, and result. After the run the caller re-validates the workspace and feeds any failure back as a follow-up turn; a run that still fails after the retry budget raises, so the workspace ships under status error for debugging. ``model`` overrides DEFAULT_MODEL when given. prefix, so one identifier joins trace, artifacts, and result. The output validator re-checks the workspace every time the agent reports done, so a returned run is a validated run; one that exhausts the output budget raises UnexpectedModelBehavior and the workspace ships under status error for debugging. ``model`` overrides DEFAULT_MODEL when given.
``workspace`` runs the agent in that directory instead of a throwaway tempdir, and hands its lifetime to the caller: a run that owns its own directory parks the finished tree in S3 before deleting it, one that does not leaves the files where the caller can still read them. The eval harness passes a workspace for exactly that reason, so a sweep needs neither the runs bucket nor the provider re-lock the review flow wants. """ model_name = model or require_env("DEFAULT_MODEL") run_id = str(uuid.uuid4()) with TemporaryDirectory(dir="/tmp") as workspace: root = Path(workspace) deps = WorkspaceDeps(root=root) try: built = _build_model(model_name) result = agent.run_sync( prompt, deps=deps, conversation_id=run_id, model=built, metadata={"model": model_name}, ) # The agent can report done while terraform validate still fails. # Re-validate ourselves and feed any error back as a follow-up turn, # reusing the run id and message history so each retry is an # invoke_agent span under the one invocation trace. Give up after the # budget and raise so the failure is honest rather than a clean run # over a broken workspace. ok, output = validate_workspace(root) attempts = 0 while not ok and attempts < _MAX_VALIDATE_RETRIES: attempts += 1 result = agent.run_sync( _RETRY_PROMPT.format(output=output), deps=deps, conversation_id=run_id, model=built, message_history=result.all_messages(), metadata={"model": model_name}, ) ok, output = validate_workspace(root) if not ok: raise ValidateDidNotConverge(output) except Exception as error: if workspace is not None: return _execute_in(prompt, model_name, run_id, workspace, persist=False) with TemporaryDirectory(dir="/tmp") as tmp: return _execute_in(prompt, model_name, run_id, Path(tmp), persist=True)
def _execute_in(prompt: str, model_name: str, run_id: str, root: Path, persist: bool) -> RunResult: deps = WorkspaceDeps(root=root) try: built = _build_model(model_name) result = agent.run_sync( prompt, deps=deps, conversation_id=run_id, model=built, metadata={"model": model_name}, usage_limits=RUN_LIMITS, model_settings=RUN_SETTINGS, ) except Exception as error: if persist: _persist_run(run_id, root, status="error", error=repr(error)) raise raise if persist: _relock_providers(root) _persist_run(run_id, root, status="ok") return RunResult(run_id=run_id, model=model_name, output=str(result.output)) usage = result.usage return RunResult( run_id=run_id, model=model_name, output=result.output, input_tokens=usage.input_tokens, output_tokens=usage.output_tokens, )"""Define the agent and execute one run.
Runtime-agnostic: a fresh /tmp workspace threaded through the file and validatetools, one run id that joins the audit trace, the parked artifacts, and theresult. The telemetry pipeline lives in observability.py, model construction inmodels.py, and run persistence plus the provider re-lock in runs.py; the Lambdaenvelope and INIT wiring live in lambda_entry.py. This module knows nothingabout Lambda events."""
import uuidfrom pathlib import Pathfrom tempfile import TemporaryDirectory
from agent.env import require_envfrom agent.models import _build_modelfrom agent.runs import _persist_run, _relock_providersfrom agent.tools import ( WorkspaceDeps, checkov, delete_file, edit_file, list_files, read_file, terraform_init, terraform_validate, tflint, validate_workspace, write_file,)from pydantic import BaseModel, ConfigDictfrom pydantic_ai import Agent, ModelRetry, RunContextfrom pydantic_ai.settings import ModelSettingsfrom pydantic_ai.usage import UsageLimits
# A soft reproducibility nudge following the standard Terraform pattern: a# version constraint in the config, the exact version and checksums in the lock# file. The tool-call spans in the trace are the ground truth for what the agent# actually wrote._PROVIDER_PIN_RULE = ( "When you add the AWS provider, give it a version constraint such as " '"~> 6.0" rather than leaving it unconstrained. terraform init records ' "the exact resolved version and checksums in .terraform.lock.hcl, " "which travels with the workspace and is the reproducibility record, " "so the constraint does not need to be an exact pin. Pin an exact " "version only when the user asks for one. Example:\n" "\n" "terraform {\n" " required_providers {\n" " aws = {\n" ' source = "hashicorp/aws"\n' ' version = "~> 6.0"\n' " }\n" " }\n" "}")
SYSTEM_PROMPT = ( "You are the terraform-pr-agent. You operate on a Terraform workspace " "through file tools (list_files, read_file, write_file, edit_file, " "delete_file) and a set of terraform and validation tools. Use the file " "tools to explore, write, and edit. Run terraform_init before your first " "validate and again whenever you add or change provider or module " "requirements. Call terraform_validate after you write or change files to " "confirm the workspace still parses; treat its output as feedback and edit " "until it is clean. When the workspace is done, report your work through " "the final result.\n\n" "Give every variable you declare a default value, so the workspace plans " "without prompting for input.\n\n" "Run tflint and checkov and clear all findings before reporting done.\n\n" + _PROVIDER_PIN_RULE)
class TaskResult(BaseModel): """The agent's structured self-report on a finished run.
Each required field pushes the agent to consider that dimension of its work; the tool-call spans in the trace remain the ground truth that exposes any embellishment. Because this is the agent's output type, ending a run now requires calling the final_result output tool, so a text-only reply can no longer end a run silently. """
model_config = ConfigDict(frozen=True)
summary: str """One line on what was done.""" solution_description: str """How the problem was solved, including architectural choices.""" validations_run: list[str] """Which validation tools were invoked during the run.""" issues_addressed: list[str] """Security or correctness problems identified and fixed.""" known_limitations: list[str] """What was not handled; surfaces for human review.""" ready_for_review: bool """The agent's self-assessment that the workspace is PR-ready."""
# One Agent instance is reused across invocations: the tools reach the workspace# through RunContext.deps, so each run_sync scopes them to a fresh WorkspaceDeps.# The model carries no default; it is built from the registry at INVOKE and# passed per run, so switching DEFAULT_MODEL needs no code change.## With TaskResult as the output type, plain text is not an allowed way to end# the run, so pydantic-ai asks providers to force tool use on every turn# (Bedrock toolChoice "any"). Note Bedrock rejects that combination once# extended thinking is enabled on a model; nothing in the registry enables it.agent = Agent( deps_type=WorkspaceDeps, output_type=TaskResult, system_prompt=SYSTEM_PROMPT, tools=[ list_files, read_file, write_file, edit_file, delete_file, terraform_init, terraform_validate, tflint, checkov, ], retries={"tools": 3, "output": 3},)
# The two ceilings on how long one run's message history can get, and with it# the biggest audit record the trace produces (see observability._span_record).# request_limit is pydantic-ai's own default, set here so the bound is ours;# max_tokens caps what each turn adds. The third term, tool output, is capped# in tools/validators.py.RUN_LIMITS = UsageLimits(request_limit=50)RUN_SETTINGS = ModelSettings(max_tokens=4096)
_RETRY_PROMPT = ( "terraform validate still reports errors after you reported done. " "Fix them and validate again.\n\n{output}")
@agent.output_validatordef _validate_final_workspace(ctx: RunContext[WorkspaceDeps], output: TaskResult) -> TaskResult: # The deliverable is the workspace, not this object: a run that changed # nothing produced nothing, however plausible its self-report reads. if not ctx.deps.files_changed: raise ModelRetry( "You reported done but made no changes to the workspace. " "Use the file tools to implement the request, validate, then report done again." ) validation_result = validate_workspace(ctx.deps.root) if not validation_result.success: raise ModelRetry(_RETRY_PROMPT.format(output=validation_result.format_error_for_agent())) return output
class RunResult(BaseModel): model_config = ConfigDict(frozen=True)
run_id: str model: str output: TaskResult input_tokens: int output_tokens: int
def execute(prompt: str, model: str | None = None, workspace: Path | None = None) -> RunResult: """Run the agent once and return the result.
The run id is the trace's gen_ai.conversation.id and the runs/<run_id>/ prefix, so one identifier joins trace, artifacts, and result. The output validator re-checks the workspace every time the agent reports done, so a returned run is a validated run; one that exhausts the output budget raises UnexpectedModelBehavior and the workspace ships under status error for debugging. ``model`` overrides DEFAULT_MODEL when given.
``workspace`` runs the agent in that directory instead of a throwaway tempdir, and hands its lifetime to the caller: a run that owns its own directory parks the finished tree in S3 before deleting it, one that does not leaves the files where the caller can still read them. The eval harness passes a workspace for exactly that reason, so a sweep needs neither the runs bucket nor the provider re-lock the review flow wants. """ model_name = model or require_env("DEFAULT_MODEL") run_id = str(uuid.uuid4()) if workspace is not None: return _execute_in(prompt, model_name, run_id, workspace, persist=False) with TemporaryDirectory(dir="/tmp") as tmp: return _execute_in(prompt, model_name, run_id, Path(tmp), persist=True)
def _execute_in(prompt: str, model_name: str, run_id: str, root: Path, persist: bool) -> RunResult: deps = WorkspaceDeps(root=root) try: built = _build_model(model_name) result = agent.run_sync( prompt, deps=deps, conversation_id=run_id, model=built, metadata={"model": model_name}, usage_limits=RUN_LIMITS, model_settings=RUN_SETTINGS, ) except Exception as error: if persist: _persist_run(run_id, root, status="error", error=repr(error)) raise if persist: _relock_providers(root) _persist_run(run_id, root, status="ok") usage = result.usage return RunResult( run_id=run_id, model=model_name, output=result.output, input_tokens=usage.input_tokens, output_tokens=usage.output_tokens, )"""Read a required environment variable, failing loudly when it is missing."""
import os
def require_env(name: str) -> str: """Return the value of `name`, or raise if it is unset or empty.
A missing required variable is a deployment fault, not a runtime branch, so we surface it the same way everywhere instead of degrading into a silent no-op. Empty is treated as unset: a blank value is never a real config. """ value = os.environ.get(name) if not value: raise RuntimeError(f"required environment variable {name} is unset") return value"""The Lambda boundary: parse the event, run the agent, shape the response.
This module also owns the INIT wiring. The container CMD targets3 collapsed lines
agent.lambda_entry.handler, so a unit test that imports agent.core neverconfigures logfire or registers the Firehose audit processor. EverythingLambda-specific lives here, off the core, which is why no runtime-detectioncheck is needed to keep it out of tests."""
from typing import NotRequired, TypedDictfrom typing import Any, NotRequired, TypedDict
import logfire
from agent import observabilityfrom agent.core import execute
7 collapsed lines
class HandlerEvent(TypedDict): prompt: str model: NotRequired[str]
class HandlerResponse(TypedDict): status: str run_id: str model: str output: str output: dict[str, Any]
def handler(event: HandlerEvent, context: object) -> HandlerResponse:2 collapsed lines
"""Lambda entry point: require a prompt, run the agent, wrap the result.
``prompt`` is required; an event without one is a caller error and fails fast rather than running a default. ``model`` overrides DEFAULT_MODEL when given. A run that does not converge raises, so the Lambda reports 5xx and the workspace ships under status error for debugging. the workspace ships under status error for debugging. ``output`` is the agent's TaskResult self-report, serialized for the JSON response. """ prompt = event.get("prompt") if not prompt:3 collapsed lines
raise ValueError("event missing required 'prompt'") result = execute(prompt, event.get("model")) return { "status": "ok", "run_id": result.run_id, "model": result.model, "output": result.output, "output": result.output.model_dump(), }
9 collapsed lines
def bootstrap() -> None: """Stand up telemetry, then attach the Lambda runtime adapter.
configure() first so the tracer provider exists when the handler is wrapped. instrument_aws_lambda wraps the target named by _HANDLER (agent.lambda_entry.handler) in place, so each invocation becomes one trace. """ observability.configure() logfire.instrument_aws_lambda(handler)
bootstrap()"""The Lambda boundary: parse the event, run the agent, shape the response.
This module also owns the INIT wiring. The container CMD targetsagent.lambda_entry.handler, so a unit test that imports agent.core neverconfigures logfire or registers the Firehose audit processor. EverythingLambda-specific lives here, off the core, which is why no runtime-detectioncheck is needed to keep it out of tests."""
from typing import Any, NotRequired, TypedDict
import logfirefrom agent import observabilityfrom agent.core import execute
class HandlerEvent(TypedDict): prompt: str model: NotRequired[str]
class HandlerResponse(TypedDict): status: str run_id: str model: str output: dict[str, Any]
def handler(event: HandlerEvent, context: object) -> HandlerResponse: """Lambda entry point: require a prompt, run the agent, wrap the result.
``prompt`` is required; an event without one is a caller error and fails fast rather than running a default. ``model`` overrides DEFAULT_MODEL when given. A run that does not converge raises, so the Lambda reports 5xx and the workspace ships under status error for debugging. ``output`` is the agent's TaskResult self-report, serialized for the JSON response. """ prompt = event.get("prompt") if not prompt: raise ValueError("event missing required 'prompt'") result = execute(prompt, event.get("model")) return { "status": "ok", "run_id": result.run_id, "model": result.model, "output": result.output.model_dump(), }
def bootstrap() -> None: """Stand up telemetry, then attach the Lambda runtime adapter.
configure() first so the tracer provider exists when the handler is wrapped. instrument_aws_lambda wraps the target named by _HANDLER (agent.lambda_entry.handler) in place, so each invocation becomes one trace. """ observability.configure() logfire.instrument_aws_lambda(handler)
bootstrap()"""Memory probes for the terraform steps.
Lambda's Max Memory Used is misleading for this function. The terraform stepsdownload and unpack the AWS provider (~800 MB) into /tmp, and that file IOfills the kernel page cache, which the cgroup-based billed figure counts butwhich the kernel reclaims under pressure, so it is not OOM risk. ``track_memory``tags a logfire span with three numbers so a run's trace separates real demandfrom cache: the sandbox's used memory (MemTotal - MemAvailable, thenon-reclaimable memory that actually risks OOM), the reclaimable page cache(Cached + Buffers, the bulk of the billed peak), and the peak resident size ofthe largest terraform subprocess. Read together they show real demand staysunder ~1 GB while the billed peak runs to ~2 GB of reclaimable cache."""
import resourcefrom collections.abc import Iteratorfrom contextlib import contextmanagerfrom pathlib import Path
import logfire
# /proc/meminfo is always present in the Lambda sandbox; the cgroup memory# files are not (neither the v2 /sys/fs/cgroup/memory.current nor the v1# /sys/fs/cgroup/memory/memory.usage_in_bytes is readable there, so reading# them silently returned None). Values are in kibibytes._MEMINFO = Path("/proc/meminfo")
def _memory_snapshot() -> tuple[int, int] | None: """(used, cache) bytes for the whole sandbox, or None when /proc/meminfo is absent.
``used`` is MemTotal - MemAvailable, the non-reclaimable memory in use (the Python runtime plus any live subprocess), which is what actually risks OOM. ``cache`` is Cached + Buffers, the reclaimable page cache that file IO on /tmp fills; Lambda's Max Memory Used counts it but real demand does not, so recording both shows why the billed peak overstates what the function needs. Absent locally (macOS) and in tests, where the caller leaves the attributes unset. """ try: fields = dict(line.split(":", 1) for line in _MEMINFO.read_text().splitlines()) used_kib = int(fields["MemTotal"].split()[0]) - int(fields["MemAvailable"].split()[0]) cache_kib = int(fields["Cached"].split()[0]) + int(fields["Buffers"].split()[0]) except (OSError, KeyError, ValueError, IndexError): return None return used_kib * 1024, cache_kib * 1024
@contextmanagerdef track_memory(step: str) -> Iterator[None]: """Run a block in a logfire span tagged with its memory cost.
On exit, records the sandbox's used memory and reclaimable page cache, and the peak resident size of the largest subprocess waited for so far. ru_maxrss is reported in kilobytes on Linux, so it is scaled to bytes; it is a monotonic high-water mark across all children, not a per-call delta, so compare it between steps to see which one grew the subprocess most. Built with contextmanager, so it doubles as a decorator: ``@track_memory("relock")``. """ # _span_name forces the OTel span name to the interpolated value, so the # trace reads "memory.terraform_validate". Without it logfire keeps the # low-cardinality template "memory.{step}" as the name (its f-string magic # reconstructs the template), leaving the per-step value only in the attribute. with logfire.span("memory.{step}", _span_name=f"memory.{step}", step=step) as span: try: yield finally: if (snapshot := _memory_snapshot()) is not None: used, cache = snapshot span.set_attribute("mem.used_bytes", used) span.set_attribute("mem.cache_bytes", cache) child_peak_kb = resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss span.set_attribute("mem.child_max_rss_bytes", child_peak_kb * 1024)"""Build a pydantic-ai model from the SSM-backed registry, per invocation."""
import jsonimport osfrom functools import cache
from agent.ssm import fetch_parameterfrom httpx import AsyncClient, HTTPStatusError, Responsefrom mistralai.client import Mistralfrom mistralai.extra.observability.telemetry import configure_telemetryfrom opentelemetry.trace import NoOpTracerProviderfrom pydantic_ai.models import Modelfrom pydantic_ai.models.bedrock import BedrockConverseModelfrom pydantic_ai.models.mistral import MistralModelfrom pydantic_ai.models.openai import OpenAIChatModelfrom pydantic_ai.providers.fireworks import FireworksProviderfrom pydantic_ai.providers.mistral import MistralProviderfrom pydantic_ai.retries import AsyncTenacityTransport, RetryConfig, wait_retry_afterfrom tenacity import retry_if_exception_type, stop_after_attempt, wait_exponential
from agent.env import require_envfrom agent.ssm import fetch_parameter
# Rate limit and transient gateway errors are worth retrying; auth and bad# request fail fast so a real problem is not retried five times._RETRYABLE_STATUS = frozenset({429, 502, 503, 504})30 collapsed lines
def _raise_for_retryable(response: Response) -> None: if response.status_code in _RETRYABLE_STATUS: response.raise_for_status()
def _retrying_http_client() -> AsyncClient: """An httpx client that retries rate-limit and transient errors.
pydantic-ai does not retry transport errors itself, so a rate-limited Mistral call (the write/init/validate loop can burst past the free-tier per-second cap) would fail the whole run. wait_retry_after honours the Retry-After header Mistral sends on a 429, falling back to exponential backoff. Bedrock uses boto3 with its own retry config, so this is the Mistral client only. """ transport = AsyncTenacityTransport( config=RetryConfig( retry=retry_if_exception_type(HTTPStatusError), wait=wait_retry_after( fallback_strategy=wait_exponential(multiplier=1, max=60), max_wait=300, ), stop=stop_after_attempt(5), reraise=True, ), validate_response=_raise_for_retryable, ) return AsyncClient(transport=transport)
@cachedef _registry() -> dict[str, dict[str, str]]: """The model registry, one GetParameter per container.
An SSM String parameter at a terraform-owned path: each entry names a provider and model id, and Bedrock entries carry the inference-profile ARN. MODELS_PARAMETER selects a different registry, which is how the eval harness reaches its own copy. """ parameter = os.environ.get("MODELS_PARAMETER", "/terraform-pr-agent/models") return json.loads(fetch_parameter(parameter))
def _build_model(name: str) -> Model: """Build the pydantic-ai model registered under ``name``.
The registry is an SSM String parameter (MODELS_PARAMETER): each entry names a provider and model id, and Bedrock entries carry the inference-profile ARN. Bedrock authenticates via the Lambda role; Mistral reads an API key from a SecureString. Memoised per name, so the lookup is one GetParameter per container. Bedrock authenticates via the Lambda role; Mistral and Fireworks read an API key from a SecureString. Deliberately not memoised: the Mistral client owns an httpx connection pool whose primitives belong to the event loop that first used them, and an eval sweep runs concurrent cases on one loop per worker thread. Only the registry lookup, the part worth caching, is. """ registry = json.loads(fetch_parameter(require_env("MODELS_PARAMETER"))) config = registry[name] config = _registry()[name] provider = config["provider"] model_id = config["model_id"] if provider == "bedrock": return BedrockConverseModel( config["model_id"], model_id, settings={"bedrock_inference_profile": config["inference_profile_arn"]}, ) if provider == "mistral": key_param = os.environ.get("MISTRAL_API_KEY_PARAMETER") elif provider == "fireworks": key_param = os.environ.get( "FIREWORKS_API_KEY_PARAMETER", "/terraform-pr-agent/fireworks-api-key" ) if not key_param: raise RuntimeError( f"model {name!r} uses the Fireworks API, but FIREWORKS_API_KEY_PARAMETER " "is not set. Set FIREWORKS_API_KEY and re-apply so the key is wired, or " "select a Bedrock model via DEFAULT_MODEL or the event's model field." ) return OpenAIChatModel( model_id, provider=FireworksProvider(api_key=fetch_parameter(key_param)) ) elif provider == "mistral": key_param = os.environ.get( "MISTRAL_API_KEY_PARAMETER", "/terraform-pr-agent/mistral-api-key" ) if not key_param: raise RuntimeError( f"model {name!r} uses the Mistral API, but MISTRAL_API_KEY_PARAMETER " "is not set. Set MISTRAL_API_KEY and re-apply so the key is wired, or " "select a Bedrock model via DEFAULT_MODEL or the event's model field." ) return MistralModel( config["model_id"], provider=MistralProvider( api_key=fetch_parameter(key_param), http_client=_retrying_http_client(), ), client = Mistral( api_key=fetch_parameter(key_param), async_client=_retrying_http_client(), ) # The Mistral SDK sends a second `chat` span per request when a global # tracer provider exists, so the audit copy and the token metrics count # twice. Its env knob stops only the SDK exporter, not the spans. configure_telemetry(client, provider=NoOpTracerProvider()) return MistralModel(config["model_id"], provider=MistralProvider(mistral_client=client)) raise ValueError(f"unknown provider {provider!r} for model {name!r}")"""Build a pydantic-ai model from the SSM-backed registry, per invocation."""
import jsonimport osfrom functools import cache
from agent.ssm import fetch_parameterfrom httpx import AsyncClient, HTTPStatusError, Responsefrom mistralai.client import Mistralfrom mistralai.extra.observability.telemetry import configure_telemetryfrom opentelemetry.trace import NoOpTracerProviderfrom pydantic_ai.models import Modelfrom pydantic_ai.models.bedrock import BedrockConverseModelfrom pydantic_ai.models.mistral import MistralModelfrom pydantic_ai.models.openai import OpenAIChatModelfrom pydantic_ai.providers.fireworks import FireworksProviderfrom pydantic_ai.providers.mistral import MistralProviderfrom pydantic_ai.retries import AsyncTenacityTransport, RetryConfig, wait_retry_afterfrom tenacity import retry_if_exception_type, stop_after_attempt, wait_exponential
# Rate limit and transient gateway errors are worth retrying; auth and bad# request fail fast so a real problem is not retried five times._RETRYABLE_STATUS = frozenset({429, 502, 503, 504})
def _raise_for_retryable(response: Response) -> None: if response.status_code in _RETRYABLE_STATUS: response.raise_for_status()
def _retrying_http_client() -> AsyncClient: """An httpx client that retries rate-limit and transient errors.
pydantic-ai does not retry transport errors itself, so a rate-limited Mistral call (the write/init/validate loop can burst past the free-tier per-second cap) would fail the whole run. wait_retry_after honours the Retry-After header Mistral sends on a 429, falling back to exponential backoff. Bedrock uses boto3 with its own retry config, so this is the Mistral client only. """ transport = AsyncTenacityTransport( config=RetryConfig( retry=retry_if_exception_type(HTTPStatusError), wait=wait_retry_after( fallback_strategy=wait_exponential(multiplier=1, max=60), max_wait=300, ), stop=stop_after_attempt(5), reraise=True, ), validate_response=_raise_for_retryable, ) return AsyncClient(transport=transport)
@cachedef _registry() -> dict[str, dict[str, str]]: """The model registry, one GetParameter per container.
An SSM String parameter at a terraform-owned path: each entry names a provider and model id, and Bedrock entries carry the inference-profile ARN. MODELS_PARAMETER selects a different registry, which is how the eval harness reaches its own copy. """ parameter = os.environ.get("MODELS_PARAMETER", "/terraform-pr-agent/models") return json.loads(fetch_parameter(parameter))
def _build_model(name: str) -> Model: """Build the pydantic-ai model registered under ``name``.
Bedrock authenticates via the Lambda role; Mistral and Fireworks read an API key from a SecureString. Deliberately not memoised: the Mistral client owns an httpx connection pool whose primitives belong to the event loop that first used them, and an eval sweep runs concurrent cases on one loop per worker thread. Only the registry lookup, the part worth caching, is. """ config = _registry()[name] provider = config["provider"] model_id = config["model_id"] if provider == "bedrock": return BedrockConverseModel( model_id, settings={"bedrock_inference_profile": config["inference_profile_arn"]}, ) elif provider == "fireworks": key_param = os.environ.get( "FIREWORKS_API_KEY_PARAMETER", "/terraform-pr-agent/fireworks-api-key" ) if not key_param: raise RuntimeError( f"model {name!r} uses the Fireworks API, but FIREWORKS_API_KEY_PARAMETER " "is not set. Set FIREWORKS_API_KEY and re-apply so the key is wired, or " "select a Bedrock model via DEFAULT_MODEL or the event's model field." ) return OpenAIChatModel( model_id, provider=FireworksProvider(api_key=fetch_parameter(key_param)) ) elif provider == "mistral": key_param = os.environ.get( "MISTRAL_API_KEY_PARAMETER", "/terraform-pr-agent/mistral-api-key" ) if not key_param: raise RuntimeError( f"model {name!r} uses the Mistral API, but MISTRAL_API_KEY_PARAMETER " "is not set. Set MISTRAL_API_KEY and re-apply so the key is wired, or " "select a Bedrock model via DEFAULT_MODEL or the event's model field." ) client = Mistral( api_key=fetch_parameter(key_param), async_client=_retrying_http_client(), ) # The Mistral SDK sends a second `chat` span per request when a global # tracer provider exists, so the audit copy and the token metrics count # twice. Its env knob stops only the SDK exporter, not the spans. configure_telemetry(client, provider=NoOpTracerProvider()) return MistralModel(config["model_id"], provider=MistralProvider(mistral_client=client)) raise ValueError(f"unknown provider {provider!r} for model {name!r}")"""Telemetry pipeline: structured logs, the per-trace audit copy, and EMF metrics.
``configure()`` runs at INIT (see handler.py) so the tracer provider and the5 collapsed lines
audit processor exist before instrument_aws_lambda opens the first invocationspan. The audit copy ships from inside the processor when the trace's root spanends, so the handler needs no flush logic."""
import jsonimport osimport threadingfrom collections.abc import Callable, Sequencefrom collections.abc import Callable, Iterator, Sequence
import boto3import logfireimport structlogfrom agent.prices import register_missing_pricesfrom agent.ssm import fetch_parameterfrom google.protobuf import json_formatfrom logfire.sampling import SamplingOptionsfrom opentelemetry.context import (8 collapsed lines
_SUPPRESS_INSTRUMENTATION_KEY, attach, detach, set_value,)from opentelemetry.exporter.otlp.proto.common._internal.trace_encoder import ( encode_spans,)from opentelemetry.sdk.trace import ReadableSpan, SpanProcessorfrom opentelemetry.trace.status import StatusCode
from agent.env import require_envfrom agent.ssm import fetch_parameter
# JSON logs to stdout, which CloudWatch Logs ingests as-is. The same stream# carries the EMF metric envelope (see _emit_emf), so one structured sink covers# both logs and metrics.68 collapsed lines
structlog.configure( processors=[ structlog.processors.add_log_level, structlog.processors.TimeStamper(fmt="iso"), structlog.processors.EventRenamer("message"), structlog.processors.JSONRenderer(), ], logger_factory=structlog.PrintLoggerFactory(), cache_logger_on_first_use=True,)log = structlog.get_logger()
class PerTraceAuditProcessor(SpanProcessor): """Buffer spans by trace_id, ship as one batch when the local root ends.
The OTel SDK has no `OnTraceComplete` hook, so this implements it against the only signal available: `on_end` fires synchronously, and a span is this process's local root when it has no parent, or a remote parent (context propagated in, e.g. from API Gateway), which `SpanContext.is_remote` marks. Late children (ended on a transport thread after the root shipped) are dropped, mirroring logfire's tail sampler. See pydantic/logfire#1034. """
def __init__( self, on_trace_complete: Callable[[Sequence[ReadableSpan]], None], ) -> None: self._on_trace_complete = on_trace_complete self._buffers: dict[int, list[ReadableSpan]] = {} self._shipped: set[int] = set() self._lock = threading.Lock()
def on_end(self, span: ReadableSpan) -> None: if not (span.context and span.context.trace_flags.sampled): return trace_id = span.context.trace_id with self._lock: if trace_id in self._shipped: return self._buffers.setdefault(trace_id, []).append(span) if span.parent is not None and not span.parent.is_remote: return spans = self._buffers.pop(trace_id) self._shipped.add(trace_id) self._ship(spans)
def force_flush(self, timeout_millis: int = 30000) -> bool: with self._lock: pending = list(self._buffers.values()) self._shipped.update(self._buffers) self._buffers.clear() for spans in pending: self._ship(spans) return True
def shutdown(self) -> None: self.force_flush()
def _ship(self, spans: Sequence[ReadableSpan]) -> None: # Suppress instrumentation around the callback so an instrumented # boto3 client inside it does not emit a span that re-enters on_end. token = attach(set_value(_SUPPRESS_INSTRUMENTATION_KEY, True)) try: self._on_trace_complete(spans) finally: detach(token)
_firehose = boto3.client("firehose")_DELIVERY_STREAM = require_env("FIREHOSE_DELIVERY_STREAM")# Lazy reads keep importing this module free of configuration.DELIVERY_STREAM = "terraform-pr-agent-audit"METRICS_NAMESPACE = "TerraformPrAgent/Models"
def _delivery_stream() -> str: return os.environ.get("FIREHOSE_DELIVERY_STREAM", DELIVERY_STREAM)
def _metrics_namespace() -> str: return os.environ.get("METRICS_NAMESPACE", METRICS_NAMESPACE)
# Firehose caps a record at 1,000 KiB, and a PutRecordBatch call at 500 records# and 4 MiB. A long run broke the record cap, so each span is now one record.# The DuckDB view unnests each NDJSON line, so the audit queries do not change._MAX_RECORD_BYTES = 1_000_000_MAX_BATCH_RECORDS = 500_MAX_BATCH_BYTES = 4_000_000
def _span_record(span: ReadableSpan) -> bytes: """One span as one OTLP-JSON NDJSON line.
The largest span is a late model turn, because it holds the full message history. The agent's own limits bound that history: `request_limit` turns, each with one capped model output and one capped tool result (see core.py and tools/validators.py). A span over the cap means those limits failed, so this raises instead of dropping the turn from the audit trail. If a correct run breaks the cap, cap the tool output more. """ payload = json_format.MessageToJson(encode_spans([span]), indent=None) + "\n" data = payload.encode("utf-8") if len(data) > _MAX_RECORD_BYTES: raise RuntimeError( f"audit span {span.name} is {len(data)} bytes, over the " f"{_MAX_RECORD_BYTES} byte Firehose record cap" ) return data
def _batches(records: list[bytes]) -> Iterator[list[bytes]]: """Greedy chunks that respect the PutRecordBatch count and byte caps.""" batch: list[bytes] = [] size = 0 for record in records: overflows = len(batch) == _MAX_BATCH_RECORDS or size + len(record) > _MAX_BATCH_BYTES if batch and overflows: yield batch batch, size = [], 0 batch.append(record) size += len(record) if batch: yield batch
def _ship_trace(spans: Sequence[ReadableSpan]) -> None: """Serialise one trace as OTLP-JSON and ship it as a single Firehose record.""" payload = json_format.MessageToJson(encode_spans(spans), indent=None) + "\n" _firehose.put_record( DeliveryStreamName=_DELIVERY_STREAM, Record={"Data": payload.encode("utf-8")}, ) """Serialise the trace as OTLP-JSON and ship it, one Firehose record per span.""" records = [_span_record(span) for span in spans] firehose = boto3.client("firehose") for batch in _batches(records): response = firehose.put_record_batch( DeliveryStreamName=_delivery_stream(), Records=[{"Data": data} for data in batch], ) # PutRecordBatch reports per-record failures in the response instead # of raising; surface them so a lost audit record is loud. if failed := response.get("FailedPutCount"): raise RuntimeError(f"audit ship failed for {failed} of {len(batch)} records")
14 collapsed lines
def _emf_record(span: ReadableSpan) -> dict: """Build the EMF envelope for one agent-run span.
pydantic-ai records gen_ai.usage.* on the agent-run span as the run total, so a single read is the correct total. The model dimension is the registry key the handler passed as run metadata, read back so a Bedrock run and a Mistral run land on one set of widgets. """ attributes = span.attributes or {} model = json.loads(attributes["metadata"]).get("model", "unknown") errored = span.status.status_code is StatusCode.ERROR return { "_aws": { "Timestamp": span.end_time // 1_000_000, "CloudWatchMetrics": [ { "Namespace": require_env("METRICS_NAMESPACE"), "Namespace": _metrics_namespace(), "Dimensions": [["Model"]], "Metrics": [ {"Name": "InputTokens", "Unit": "Count"},20 collapsed lines
{"Name": "OutputTokens", "Unit": "Count"}, {"Name": "CacheReadTokens", "Unit": "Count"}, {"Name": "CacheWriteTokens", "Unit": "Count"}, {"Name": "Latency", "Unit": "Milliseconds"}, {"Name": "Invocations", "Unit": "Count"}, {"Name": "Errors", "Unit": "Count"}, ], } ], }, "Model": model, "InputTokens": attributes.get("gen_ai.usage.input_tokens", 0), "OutputTokens": attributes.get("gen_ai.usage.output_tokens", 0), # pydantic-ai sets these only when non-zero; providers without prompt # caching (the Mistral API) never report them, so default to 0. "CacheReadTokens": attributes.get("gen_ai.usage.cache_read.input_tokens", 0), "CacheWriteTokens": attributes.get("gen_ai.usage.cache_creation.input_tokens", 0), "Latency": (span.end_time - span.start_time) / 1_000_000, "Invocations": 1, "Errors": 1 if errored else 0, }
AGENT_RUN_SCOPE = "pydantic-ai"
def _is_agent_run(span: ReadableSpan) -> bool: """An agent-run span, matched on scope as well as the `metadata` attribute.
`metadata` alone is not specific enough: pydantic_evals stamps a case's own metadata under the same key, so a sweep emitted a second, model-less metric line per case until the scope check landed. """ scope = span.instrumentation_scope return bool( scope and scope.name == AGENT_RUN_SCOPE and span.attributes and "metadata" in span.attributes )
def _emit_emf(spans: Sequence[ReadableSpan]) -> None: """Emit one EMF metric line per agent run in the trace.
4 collapsed lines
Off Bedrock there are no AWS/Bedrock metrics, so the dashboard reads these. The trace root is the Lambda invocation span, so metrics come off the agent-run spans nested under it, found by the `metadata` attribute pydantic-ai stamps on every run (a caller-side retry holds several, so one line each). CloudWatch Logs extracts the metrics from the structured line. """ for span in spans: if span.attributes and "metadata" in span.attributes: if _is_agent_run(span): log.info("trace_metrics", **_emf_record(span))
29 collapsed lines
def _on_trace_complete(spans: Sequence[ReadableSpan]) -> None: """Ship the audit copy, then emit metrics: one hook, two sinks.""" _ship_trace(spans) _emit_emf(spans)
def _logfire_token() -> str | None: """Logfire token from SSM, or None when the integration is not wired.""" name = os.environ.get("LOGFIRE_TOKEN_PARAMETER") return fetch_parameter(name) if name else None
def configure() -> None: """Wire logfire at INIT: register the audit processor and instrument pydantic-ai.
head=1.0 / tail=None because this is an audit pipeline: every trace must reach S3, and volume is low (one trace per invocation). Splitting the rates (say 1% to Logfire, 100% to S3) is possible with an extra sampler. include_content=True keeps the audit copy useful for forensics; flip it to False if prompts ever carry PII or secrets. version=5 pins the GenAI span schema so the audit copy stays stable across pydantic-ai releases. """ if token := _logfire_token(): os.environ["LOGFIRE_TOKEN"] = token logfire.configure( send_to_logfire="if-token-present", sampling=SamplingOptions(head=1.0, tail=None), additional_span_processors=[PerTraceAuditProcessor(_on_trace_complete)], ) logfire.instrument_pydantic_ai(version=5, include_content=True) register_missing_prices()"""Telemetry pipeline: structured logs, the per-trace audit copy, and EMF metrics.
``configure()`` runs at INIT (see handler.py) so the tracer provider and theaudit processor exist before instrument_aws_lambda opens the first invocationspan. The audit copy ships from inside the processor when the trace's root spanends, so the handler needs no flush logic."""
import jsonimport osimport threadingfrom collections.abc import Callable, Iterator, Sequence
import boto3import logfireimport structlogfrom agent.prices import register_missing_pricesfrom agent.ssm import fetch_parameterfrom google.protobuf import json_formatfrom logfire.sampling import SamplingOptionsfrom opentelemetry.context import ( _SUPPRESS_INSTRUMENTATION_KEY, attach, detach, set_value,)from opentelemetry.exporter.otlp.proto.common._internal.trace_encoder import ( encode_spans,)from opentelemetry.sdk.trace import ReadableSpan, SpanProcessorfrom opentelemetry.trace.status import StatusCode
# JSON logs to stdout, which CloudWatch Logs ingests as-is. The same stream# carries the EMF metric envelope (see _emit_emf), so one structured sink covers# both logs and metrics.structlog.configure( processors=[ structlog.processors.add_log_level, structlog.processors.TimeStamper(fmt="iso"), structlog.processors.EventRenamer("message"), structlog.processors.JSONRenderer(), ], logger_factory=structlog.PrintLoggerFactory(), cache_logger_on_first_use=True,)log = structlog.get_logger()
class PerTraceAuditProcessor(SpanProcessor): """Buffer spans by trace_id, ship as one batch when the local root ends.
The OTel SDK has no `OnTraceComplete` hook, so this implements it against the only signal available: `on_end` fires synchronously, and a span is this process's local root when it has no parent, or a remote parent (context propagated in, e.g. from API Gateway), which `SpanContext.is_remote` marks. Late children (ended on a transport thread after the root shipped) are dropped, mirroring logfire's tail sampler. See pydantic/logfire#1034. """
def __init__( self, on_trace_complete: Callable[[Sequence[ReadableSpan]], None], ) -> None: self._on_trace_complete = on_trace_complete self._buffers: dict[int, list[ReadableSpan]] = {} self._shipped: set[int] = set() self._lock = threading.Lock()
def on_end(self, span: ReadableSpan) -> None: if not (span.context and span.context.trace_flags.sampled): return trace_id = span.context.trace_id with self._lock: if trace_id in self._shipped: return self._buffers.setdefault(trace_id, []).append(span) if span.parent is not None and not span.parent.is_remote: return spans = self._buffers.pop(trace_id) self._shipped.add(trace_id) self._ship(spans)
def force_flush(self, timeout_millis: int = 30000) -> bool: with self._lock: pending = list(self._buffers.values()) self._shipped.update(self._buffers) self._buffers.clear() for spans in pending: self._ship(spans) return True
def shutdown(self) -> None: self.force_flush()
def _ship(self, spans: Sequence[ReadableSpan]) -> None: # Suppress instrumentation around the callback so an instrumented # boto3 client inside it does not emit a span that re-enters on_end. token = attach(set_value(_SUPPRESS_INSTRUMENTATION_KEY, True)) try: self._on_trace_complete(spans) finally: detach(token)
# Lazy reads keep importing this module free of configuration.DELIVERY_STREAM = "terraform-pr-agent-audit"METRICS_NAMESPACE = "TerraformPrAgent/Models"
def _delivery_stream() -> str: return os.environ.get("FIREHOSE_DELIVERY_STREAM", DELIVERY_STREAM)
def _metrics_namespace() -> str: return os.environ.get("METRICS_NAMESPACE", METRICS_NAMESPACE)
# Firehose caps a record at 1,000 KiB, and a PutRecordBatch call at 500 records# and 4 MiB. A long run broke the record cap, so each span is now one record.# The DuckDB view unnests each NDJSON line, so the audit queries do not change._MAX_RECORD_BYTES = 1_000_000_MAX_BATCH_RECORDS = 500_MAX_BATCH_BYTES = 4_000_000
def _span_record(span: ReadableSpan) -> bytes: """One span as one OTLP-JSON NDJSON line.
The largest span is a late model turn, because it holds the full message history. The agent's own limits bound that history: `request_limit` turns, each with one capped model output and one capped tool result (see core.py and tools/validators.py). A span over the cap means those limits failed, so this raises instead of dropping the turn from the audit trail. If a correct run breaks the cap, cap the tool output more. """ payload = json_format.MessageToJson(encode_spans([span]), indent=None) + "\n" data = payload.encode("utf-8") if len(data) > _MAX_RECORD_BYTES: raise RuntimeError( f"audit span {span.name} is {len(data)} bytes, over the " f"{_MAX_RECORD_BYTES} byte Firehose record cap" ) return data
def _batches(records: list[bytes]) -> Iterator[list[bytes]]: """Greedy chunks that respect the PutRecordBatch count and byte caps.""" batch: list[bytes] = [] size = 0 for record in records: overflows = len(batch) == _MAX_BATCH_RECORDS or size + len(record) > _MAX_BATCH_BYTES if batch and overflows: yield batch batch, size = [], 0 batch.append(record) size += len(record) if batch: yield batch
def _ship_trace(spans: Sequence[ReadableSpan]) -> None: """Serialise the trace as OTLP-JSON and ship it, one Firehose record per span.""" records = [_span_record(span) for span in spans] firehose = boto3.client("firehose") for batch in _batches(records): response = firehose.put_record_batch( DeliveryStreamName=_delivery_stream(), Records=[{"Data": data} for data in batch], ) # PutRecordBatch reports per-record failures in the response instead # of raising; surface them so a lost audit record is loud. if failed := response.get("FailedPutCount"): raise RuntimeError(f"audit ship failed for {failed} of {len(batch)} records")
def _emf_record(span: ReadableSpan) -> dict: """Build the EMF envelope for one agent-run span.
pydantic-ai records gen_ai.usage.* on the agent-run span as the run total, so a single read is the correct total. The model dimension is the registry key the handler passed as run metadata, read back so a Bedrock run and a Mistral run land on one set of widgets. """ attributes = span.attributes or {} model = json.loads(attributes["metadata"]).get("model", "unknown") errored = span.status.status_code is StatusCode.ERROR return { "_aws": { "Timestamp": span.end_time // 1_000_000, "CloudWatchMetrics": [ { "Namespace": _metrics_namespace(), "Dimensions": [["Model"]], "Metrics": [ {"Name": "InputTokens", "Unit": "Count"}, {"Name": "OutputTokens", "Unit": "Count"}, {"Name": "CacheReadTokens", "Unit": "Count"}, {"Name": "CacheWriteTokens", "Unit": "Count"}, {"Name": "Latency", "Unit": "Milliseconds"}, {"Name": "Invocations", "Unit": "Count"}, {"Name": "Errors", "Unit": "Count"}, ], } ], }, "Model": model, "InputTokens": attributes.get("gen_ai.usage.input_tokens", 0), "OutputTokens": attributes.get("gen_ai.usage.output_tokens", 0), # pydantic-ai sets these only when non-zero; providers without prompt # caching (the Mistral API) never report them, so default to 0. "CacheReadTokens": attributes.get("gen_ai.usage.cache_read.input_tokens", 0), "CacheWriteTokens": attributes.get("gen_ai.usage.cache_creation.input_tokens", 0), "Latency": (span.end_time - span.start_time) / 1_000_000, "Invocations": 1, "Errors": 1 if errored else 0, }
AGENT_RUN_SCOPE = "pydantic-ai"
def _is_agent_run(span: ReadableSpan) -> bool: """An agent-run span, matched on scope as well as the `metadata` attribute.
`metadata` alone is not specific enough: pydantic_evals stamps a case's own metadata under the same key, so a sweep emitted a second, model-less metric line per case until the scope check landed. """ scope = span.instrumentation_scope return bool( scope and scope.name == AGENT_RUN_SCOPE and span.attributes and "metadata" in span.attributes )
def _emit_emf(spans: Sequence[ReadableSpan]) -> None: """Emit one EMF metric line per agent run in the trace.
Off Bedrock there are no AWS/Bedrock metrics, so the dashboard reads these. The trace root is the Lambda invocation span, so metrics come off the agent-run spans nested under it, found by the `metadata` attribute pydantic-ai stamps on every run (a caller-side retry holds several, so one line each). CloudWatch Logs extracts the metrics from the structured line. """ for span in spans: if _is_agent_run(span): log.info("trace_metrics", **_emf_record(span))
def _on_trace_complete(spans: Sequence[ReadableSpan]) -> None: """Ship the audit copy, then emit metrics: one hook, two sinks.""" _ship_trace(spans) _emit_emf(spans)
def _logfire_token() -> str | None: """Logfire token from SSM, or None when the integration is not wired.""" name = os.environ.get("LOGFIRE_TOKEN_PARAMETER") return fetch_parameter(name) if name else None
def configure() -> None: """Wire logfire at INIT: register the audit processor and instrument pydantic-ai.
head=1.0 / tail=None because this is an audit pipeline: every trace must reach S3, and volume is low (one trace per invocation). Splitting the rates (say 1% to Logfire, 100% to S3) is possible with an extra sampler. include_content=True keeps the audit copy useful for forensics; flip it to False if prompts ever carry PII or secrets. version=5 pins the GenAI span schema so the audit copy stays stable across pydantic-ai releases. """ if token := _logfire_token(): os.environ["LOGFIRE_TOKEN"] = token logfire.configure( send_to_logfire="if-token-present", sampling=SamplingOptions(head=1.0, tail=None), additional_span_processors=[PerTraceAuditProcessor(_on_trace_complete)], ) logfire.instrument_pydantic_ai(version=5, include_content=True) register_missing_prices()"""Prices for models genai-prices has not shipped yet.
pydantic-ai prices each chat span into an `operation.cost` attribute withgenai-prices. GLM-5.2 on Fireworks is not in the catalog, so its spans carry nocost, and both the eval report and the live cost views read empty. configure()calls this, so a Lambda run is priced like a sweep. When genai-prices shipsglm-5p2, the catalog entry wins and the guard makes this a no-op."""
from decimal import Decimal
from genai_prices.data_snapshot import get_snapshot, set_custom_snapshotfrom genai_prices.types import ClauseEquals, ModelInfo, ModelPrice
# https://fireworks.ai/models/fireworks/glm-5p2, USD per 1M tokens._GLM_5P2 = "accounts/fireworks/models/glm-5p2"_GLM_5P2_PRICE = ModelPrice( input_mtok=Decimal("1.40"), cache_read_mtok=Decimal("0.14"), output_mtok=Decimal("4.40"),)
def register_missing_prices() -> None: snapshot = get_snapshot() fireworks = next(p for p in snapshot.providers if p.id == "fireworks") already_priced = any( isinstance(model.match, ClauseEquals) and model.match.equals == _GLM_5P2 for model in fireworks.models ) if already_priced: return fireworks.models.append( ModelInfo( id="glm-5p2", match=ClauseEquals(equals=_GLM_5P2), name="GLM-5.2", prices=_GLM_5P2_PRICE, ) ) set_custom_snapshot(snapshot)"""Park a run's workspace in S3, and re-lock providers before it ships."""
import jsonimport subprocessfrom collections.abc import Iteratorfrom pathlib import Path
import boto3
from agent.env import require_envfrom agent.memory import track_memoryfrom agent.observability import log
def _persist_run(run_id: str, workspace: Path, status: str, error: str | None = None) -> None: """Park the workspace and a minimal result marker under runs/<run_id>/.
result.json carries only what the audit trace does not: prompt, output, and messages already live in the trace under the same conversation id, so copying them here would create a second source of truth. """ bucket = require_env("RUNS_BUCKET") s3 = boto3.client("s3") for file in _workspace_files(workspace): key = f"runs/{run_id}/workspace/{file.relative_to(workspace)}" s3.put_object(Bucket=bucket, Key=key, Body=file.read_bytes()) result = {"status": status} | ({"error": error} if error else {}) s3.put_object( Bucket=bucket, Key=f"runs/{run_id}/result.json", Body=json.dumps(result).encode(), )
def _workspace_files(workspace: Path) -> Iterator[Path]: """Every file except .terraform/, which is init scratch plus the provider downloaded into /tmp, gigabytes of noise per run. The top-level .terraform.lock.hcl is the reproducibility record and stays. """ for path in sorted(workspace.rglob("*")): if ".terraform" in path.relative_to(workspace).parts: continue if path.is_file(): yield path
# init inside the arm64 Lambda locks only linux_arm64, so a reviewer or CI on# another platform hits a checksum error. Re-lock the platforms they are likely# to run before the workspace ships. One call per platform rather than one with# three -platform flags: `providers lock` is additive, so each call merges its# platform and leaves the others, and a fresh process per platform keeps the# peak to one platform's footprint. Best-effort and independent: a failure on# one is logged and the rest still run._LOCK_PLATFORMS = ("linux_amd64", "linux_arm64", "darwin_arm64")
def _relock_providers(workspace: Path) -> None: if not (workspace / ".terraform.lock.hcl").exists(): return for platform in _LOCK_PLATFORMS: with track_memory(f"relock.{platform}"): result = subprocess.run( ["terraform", "providers", "lock", "-no-color", f"-platform={platform}"], cwd=workspace, capture_output=True, text=True, ) if result.returncode != 0: log.warning( "provider re-lock failed", platform=platform, stdout=result.stdout, stderr=result.stderr, )"""SSM Parameter Store access, shared by the model registry and the Logfire token fetch."""
import boto3
def fetch_parameter(name: str) -> str: """Read one parameter from SSM Parameter Store.
WithDecryption is a no-op on a plain String, so this covers String and SecureString alike. A direct GetParameter call, because container-image Lambdas cannot attach the Parameters and Secrets extension layer the zip package used. The client is built per call so moto can intercept it in tests. """ response = boto3.client("ssm").get_parameter(Name=name, WithDecryption=True) return response["Parameter"]["Value"]{ "$defs": { "Case": { "additionalProperties": false, "properties": { "name": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null, "title": "Name" }, "inputs": { "title": "Inputs", "type": "string" }, "metadata": { "anyOf": [ { "$ref": "#/$defs/CaseMetadata" }, { "type": "null" } ], "default": null }, "expected_output": { "anyOf": [ { "$ref": "#/$defs/EvalOutput" }, { "type": "null" } ], "default": null }, "evaluators": { "default": [], "items": { "anyOf": [ { "const": "WorkspaceValidates", "type": "string" }, { "const": "PlanMatchesGraph", "type": "string" }, { "const": "SelfReportAccurate", "type": "string" }, { "const": "ToolErrorMetricEvaluator", "type": "string" }, { "$ref": "#/$defs/short_spec_Equals" }, { "$ref": "#/$defs/spec_Equals" }, { "const": "EqualsExpected", "type": "string" }, { "$ref": "#/$defs/short_spec_EqualsExpected" }, { "$ref": "#/$defs/short_spec_Contains" }, { "$ref": "#/$defs/spec_Contains" }, { "$ref": "#/$defs/short_spec_IsInstance" }, { "$ref": "#/$defs/spec_IsInstance" }, { "$ref": "#/$defs/short_spec_MaxDuration" }, { "$ref": "#/$defs/short_spec_LLMJudge" }, { "$ref": "#/$defs/spec_LLMJudge" }, { "$ref": "#/$defs/short_spec_HasMatchingSpan" }, { "$ref": "#/$defs/spec_HasMatchingSpan" } ] }, "title": "Evaluators", "type": "array" } }, "required": ["inputs"], "title": "Case", "type": "object" }, "CaseMetadata": { "description": "Per-case expectations. The expected graph lives here rather than in\n``expected_output`` because pydantic_evals types expected_output as the\ntask's output type (EvalOutput), which is not what a case author writes.", "properties": { "expected_resources": { "items": { "$ref": "#/$defs/ExpectedResource" }, "title": "Expected Resources", "type": "array" } }, "required": ["expected_resources"], "title": "CaseMetadata", "type": "object" }, "EvalOutput": { "description": "What the eval task hands the evaluators: the self-report plus the\nfinished workspace on disk, and the run id that joins the Logfire trace,\nthe S3 audit copy, and the parked artifacts.", "properties": { "run_id": { "title": "Run Id", "type": "string" }, "model": { "title": "Model", "type": "string" }, "workspace": { "format": "path", "title": "Workspace", "type": "string" }, "result": { "$ref": "#/$defs/TaskResult" }, "input_tokens": { "title": "Input Tokens", "type": "integer" }, "output_tokens": { "title": "Output Tokens", "type": "integer" } }, "required": [ "run_id", "model", "workspace", "result", "input_tokens", "output_tokens" ], "title": "EvalOutput", "type": "object" }, "ExpectedResource": { "description": "One entry of a case's expected resource graph.\n\n``attrs`` lists only the attributes worth asserting; generated names and\nordering never appear, which is the nondeterminism answer. Extra planned\nresources are fine by design: checkov baselines legitimately force\nsupporting resources (public access blocks, encryption config) the case\ndoes not enumerate.", "properties": { "type": { "title": "Type", "type": "string" }, "count": { "default": 1, "title": "Count", "type": "integer" }, "attrs": { "additionalProperties": true, "title": "Attrs", "type": "object" } }, "required": ["type"], "title": "ExpectedResource", "type": "object" }, "KnownModelName": { "enum": [ "anthropic:claude-3-haiku-20240307", "anthropic:claude-fable-5", "anthropic:claude-haiku-4-5-20251001", "anthropic:claude-mythos-5", "anthropic:claude-mythos-preview", "anthropic:claude-haiku-4-5", "anthropic:claude-opus-4-0", "anthropic:claude-opus-4-1", "anthropic:claude-opus-4-1-20250805", "anthropic:claude-opus-4-20250514", "anthropic:claude-opus-4-5-20251101", "anthropic:claude-opus-4-5", "anthropic:claude-opus-4-6", "anthropic:claude-opus-4-7", "anthropic:claude-opus-4-8", "anthropic:claude-sonnet-4-0", "anthropic:claude-sonnet-4-20250514", "anthropic:claude-sonnet-4-5-20250929", "anthropic:claude-sonnet-4-5", "anthropic:claude-sonnet-4-6", "bedrock:amazon.titan-text-express-v1", "bedrock:amazon.titan-text-lite-v1", "bedrock:amazon.titan-tg1-large", "bedrock:anthropic.claude-3-5-haiku-20241022-v1:0", "bedrock:anthropic.claude-3-5-sonnet-20240620-v1:0", "bedrock:anthropic.claude-3-5-sonnet-20241022-v2:0", "bedrock:anthropic.claude-3-7-sonnet-20250219-v1:0", "bedrock:anthropic.claude-3-haiku-20240307-v1:0", "bedrock:anthropic.claude-3-opus-20240229-v1:0", "bedrock:anthropic.claude-3-sonnet-20240229-v1:0", "bedrock:anthropic.claude-haiku-4-5-20251001-v1:0", "bedrock:anthropic.claude-instant-v1", "bedrock:anthropic.claude-opus-4-20250514-v1:0", "bedrock:anthropic.claude-sonnet-4-20250514-v1:0", "bedrock:anthropic.claude-sonnet-4-5-20250929-v1:0", "bedrock:anthropic.claude-sonnet-4-6", "bedrock:anthropic.claude-v2:1", "bedrock:anthropic.claude-v2", "bedrock:cohere.command-light-text-v14", "bedrock:cohere.command-r-plus-v1:0", "bedrock:cohere.command-r-v1:0", "bedrock:cohere.command-text-v14", "bedrock:eu.anthropic.claude-haiku-4-5-20251001-v1:0", "bedrock:eu.anthropic.claude-sonnet-4-20250514-v1:0", "bedrock:eu.anthropic.claude-sonnet-4-5-20250929-v1:0", "bedrock:eu.anthropic.claude-sonnet-4-6", "bedrock:global.anthropic.claude-opus-4-5-20251101-v1:0", "bedrock:meta.llama3-1-405b-instruct-v1:0", "bedrock:meta.llama3-1-70b-instruct-v1:0", "bedrock:meta.llama3-1-8b-instruct-v1:0", "bedrock:meta.llama3-70b-instruct-v1:0", "bedrock:meta.llama3-8b-instruct-v1:0", "bedrock:mistral.mistral-7b-instruct-v0:2", "bedrock:mistral.mistral-large-2402-v1:0", "bedrock:mistral.mistral-large-2407-v1:0", "bedrock:mistral.mixtral-8x7b-instruct-v0:1", "bedrock:us.amazon.nova-2-lite-v1:0", "bedrock:us.amazon.nova-lite-v1:0", "bedrock:us.amazon.nova-micro-v1:0", "bedrock:us.amazon.nova-pro-v1:0", "bedrock:us.anthropic.claude-3-5-haiku-20241022-v1:0", "bedrock:us.anthropic.claude-3-5-sonnet-20240620-v1:0", "bedrock:us.anthropic.claude-3-5-sonnet-20241022-v2:0", "bedrock:us.anthropic.claude-3-7-sonnet-20250219-v1:0", "bedrock:us.anthropic.claude-3-haiku-20240307-v1:0", "bedrock:us.anthropic.claude-3-opus-20240229-v1:0", "bedrock:us.anthropic.claude-3-sonnet-20240229-v1:0", "bedrock:us.anthropic.claude-haiku-4-5-20251001-v1:0", "bedrock:us.anthropic.claude-opus-4-20250514-v1:0", "bedrock:us.anthropic.claude-sonnet-4-20250514-v1:0", "bedrock:us.anthropic.claude-sonnet-4-5-20250929-v1:0", "bedrock:us.anthropic.claude-sonnet-4-6", "bedrock:us.meta.llama3-1-70b-instruct-v1:0", "bedrock:us.meta.llama3-1-8b-instruct-v1:0", "bedrock:us.meta.llama3-2-11b-instruct-v1:0", "bedrock:us.meta.llama3-2-1b-instruct-v1:0", "bedrock:us.meta.llama3-2-3b-instruct-v1:0", "bedrock:us.meta.llama3-2-90b-instruct-v1:0", "bedrock:us.meta.llama3-3-70b-instruct-v1:0", "cerebras:gpt-oss-120b", "cerebras:llama3.1-8b", "cerebras:qwen-3-235b-a22b-instruct-2507", "cerebras:zai-glm-4.7", "cohere:c4ai-aya-expanse-32b", "cohere:c4ai-aya-expanse-8b", "cohere:command-nightly", "cohere:command-r-08-2024", "cohere:command-r-plus-08-2024", "cohere:command-r7b-12-2024", "deepseek:deepseek-chat", "deepseek:deepseek-reasoner", "deepseek:deepseek-v4-flash", "deepseek:deepseek-v4-pro", "gateway/anthropic:claude-3-haiku-20240307", "gateway/anthropic:claude-fable-5", "gateway/anthropic:claude-haiku-4-5-20251001", "gateway/anthropic:claude-mythos-5", "gateway/anthropic:claude-mythos-preview", "gateway/anthropic:claude-haiku-4-5", "gateway/anthropic:claude-opus-4-0", "gateway/anthropic:claude-opus-4-1", "gateway/anthropic:claude-opus-4-1-20250805", "gateway/anthropic:claude-opus-4-20250514", "gateway/anthropic:claude-opus-4-5-20251101", "gateway/anthropic:claude-opus-4-5", "gateway/anthropic:claude-opus-4-6", "gateway/anthropic:claude-opus-4-7", "gateway/anthropic:claude-opus-4-8", "gateway/anthropic:claude-sonnet-4-0", "gateway/anthropic:claude-sonnet-4-20250514", "gateway/anthropic:claude-sonnet-4-5-20250929", "gateway/anthropic:claude-sonnet-4-5", "gateway/anthropic:claude-sonnet-4-6", "gateway/bedrock:anthropic.claude-3-5-sonnet-20240620-v1:0", "gateway/bedrock:anthropic.claude-3-haiku-20240307-v1:0", "gateway/bedrock:eu.anthropic.claude-haiku-4-5-20251001-v1:0", "gateway/bedrock:eu.anthropic.claude-sonnet-4-20250514-v1:0", "gateway/bedrock:eu.anthropic.claude-sonnet-4-5-20250929-v1:0", "gateway/bedrock:eu.anthropic.claude-sonnet-4-6", "gateway/bedrock:global.anthropic.claude-opus-4-5-20251101-v1:0", "gateway/google-cloud:gemini-2.5-flash-image", "gateway/google-cloud:gemini-2.5-flash-lite-preview-09-2025", "gateway/google-cloud:gemini-2.5-flash-lite", "gateway/google-cloud:gemini-2.5-flash", "gateway/google-cloud:gemini-2.5-pro", "gateway/google-cloud:gemini-3-flash-preview", "gateway/google-cloud:gemini-3-pro-image-preview", "gateway/google-cloud:gemini-3.1-flash-image-preview", "gateway/google-cloud:gemini-3.1-flash-lite-preview", "gateway/google-cloud:gemini-3.1-pro-preview", "gateway/google-cloud:gemini-3.5-flash", "gateway/groq:llama-3.1-8b-instant", "gateway/groq:llama-3.3-70b-versatile", "gateway/groq:meta-llama/llama-4-scout-17b-16e-instruct", "gateway/groq:moonshotai/kimi-k2-instruct-0905", "gateway/groq:openai/gpt-oss-120b", "gateway/groq:openai/gpt-oss-20b", "gateway/groq:openai/gpt-oss-safeguard-20b", "gateway/openai:gpt-3.5-turbo-0125", "gateway/openai:gpt-3.5-turbo-1106", "gateway/openai:gpt-3.5-turbo-16k", "gateway/openai:gpt-3.5-turbo", "gateway/openai:gpt-4-0613", "gateway/openai:gpt-4-turbo-2024-04-09", "gateway/openai:gpt-4-turbo", "gateway/openai:gpt-4.1-2025-04-14", "gateway/openai:gpt-4.1-mini-2025-04-14", "gateway/openai:gpt-4.1-mini", "gateway/openai:gpt-4.1-nano-2025-04-14", "gateway/openai:gpt-4.1-nano", "gateway/openai:gpt-4.1", "gateway/openai:gpt-4", "gateway/openai:gpt-4o-2024-05-13", "gateway/openai:gpt-4o-2024-08-06", "gateway/openai:gpt-4o-2024-11-20", "gateway/openai:gpt-4o-mini-2024-07-18", "gateway/openai:gpt-4o-mini-search-preview-2025-03-11", "gateway/openai:gpt-4o-mini-search-preview", "gateway/openai:gpt-4o-mini", "gateway/openai:gpt-4o-search-preview-2025-03-11", "gateway/openai:gpt-4o-search-preview", "gateway/openai:gpt-4o", "gateway/openai:gpt-5-2025-08-07", "gateway/openai:gpt-5-chat-latest", "gateway/openai:gpt-5-mini-2025-08-07", "gateway/openai:gpt-5-mini", "gateway/openai:gpt-5-nano-2025-08-07", "gateway/openai:gpt-5-nano", "gateway/openai:gpt-5.1-2025-11-13", "gateway/openai:gpt-5.1-chat-latest", "gateway/openai:gpt-5.1", "gateway/openai:gpt-5.2-2025-12-11", "gateway/openai:gpt-5.2-chat-latest", "gateway/openai:gpt-5.2", "gateway/openai:gpt-5.4-mini-2026-03-17", "gateway/openai:gpt-5.4-mini", "gateway/openai:gpt-5.4-nano-2026-03-17", "gateway/openai:gpt-5.4-nano", "gateway/openai:gpt-5.4", "gateway/openai:gpt-5", "gateway/openai:o1-2024-12-17", "gateway/openai:o1", "gateway/openai:o3-2025-04-16", "gateway/openai:o3-mini-2025-01-31", "gateway/openai:o3-mini", "gateway/openai:o3", "gateway/openai:o4-mini-2025-04-16", "gateway/openai:o4-mini", "google-cloud:gemini-2.0-flash-lite", "google-cloud:gemini-2.0-flash", "google-cloud:gemini-2.5-flash-image", "google-cloud:gemini-2.5-flash-lite-preview-09-2025", "google-cloud:gemini-2.5-flash-lite", "google-cloud:gemini-2.5-flash-preview-09-2025", "google-cloud:gemini-2.5-flash", "google-cloud:gemini-2.5-pro", "google-cloud:gemini-3-flash-preview", "google-cloud:gemini-3-pro-image-preview", "google-cloud:gemini-3-pro-preview", "google-cloud:gemini-3.1-flash-image-preview", "google-cloud:gemini-3.1-flash-lite-preview", "google-cloud:gemini-3.1-pro-preview", "google-cloud:gemini-3.5-flash", "google-cloud:gemini-flash-latest", "google-cloud:gemini-flash-lite-latest", "google:gemini-2.0-flash-lite", "google:gemini-2.0-flash", "google:gemini-2.5-flash-image", "google:gemini-2.5-flash-lite-preview-09-2025", "google:gemini-2.5-flash-lite", "google:gemini-2.5-flash-preview-09-2025", "google:gemini-2.5-flash", "google:gemini-2.5-pro", "google:gemini-3-flash-preview", "google:gemini-3-pro-image-preview", "google:gemini-3-pro-preview", "google:gemini-3.1-flash-image-preview", "google:gemini-3.1-flash-lite-preview", "google:gemini-3.1-pro-preview", "google:gemini-3.5-flash", "google:gemini-flash-latest", "google:gemini-flash-lite-latest", "grok:grok-2-image-1212", "grok:grok-2-vision-1212", "grok:grok-3-fast", "grok:grok-3-mini-fast", "grok:grok-3-mini", "grok:grok-3", "grok:grok-4-0709", "grok:grok-4.3", "grok:grok-4.3-latest", "grok:grok-4.20", "grok:grok-4.20-0309", "grok:grok-4.20-0309-non-reasoning", "grok:grok-4.20-0309-reasoning", "grok:grok-4.20-multi-agent", "grok:grok-4.20-multi-agent-0309", "grok:grok-4.20-multi-agent-latest", "grok:grok-4.20-non-reasoning", "grok:grok-4.20-non-reasoning-latest", "grok:grok-4.20-reasoning", "grok:grok-4.20-reasoning-latest", "grok:grok-latest", "grok:grok-4-latest", "grok:grok-4-1-fast-non-reasoning", "grok:grok-4-1-fast-reasoning", "grok:grok-4-1-fast", "grok:grok-4-fast-non-reasoning", "grok:grok-4-fast-reasoning", "grok:grok-4-fast", "grok:grok-4", "grok:grok-build-0.1", "grok:grok-code-fast-1", "xai:grok-3", "xai:grok-3-fast", "xai:grok-3-fast-latest", "xai:grok-3-latest", "xai:grok-3-mini", "xai:grok-3-mini-fast", "xai:grok-3-mini-fast-latest", "xai:grok-4", "xai:grok-4-0709", "xai:grok-4.20", "xai:grok-4.20-0309", "xai:grok-4.20-0309-non-reasoning", "xai:grok-4.20-0309-reasoning", "xai:grok-4.20-multi-agent", "xai:grok-4.20-multi-agent-0309", "xai:grok-4.20-multi-agent-latest", "xai:grok-4.20-non-reasoning", "xai:grok-4.20-non-reasoning-latest", "xai:grok-4.20-reasoning-latest", "xai:grok-4.3", "xai:grok-4.3-latest", "xai:grok-4-1-fast", "xai:grok-4-1-fast-non-reasoning", "xai:grok-4-1-fast-non-reasoning-latest", "xai:grok-4-1-fast-reasoning", "xai:grok-4-1-fast-reasoning-latest", "xai:grok-4-fast", "xai:grok-4-fast-non-reasoning", "xai:grok-4-fast-non-reasoning-latest", "xai:grok-4-fast-reasoning", "xai:grok-4-fast-reasoning-latest", "xai:grok-4-latest", "xai:grok-code-fast-1", "groq:llama-3.1-8b-instant", "groq:llama-3.3-70b-versatile", "groq:meta-llama/llama-guard-4-12b", "groq:openai/gpt-oss-120b", "groq:openai/gpt-oss-20b", "groq:whisper-large-v3", "groq:whisper-large-v3-turbo", "groq:meta-llama/llama-4-maverick-17b-128e-instruct", "groq:meta-llama/llama-4-scout-17b-16e-instruct", "groq:meta-llama/llama-prompt-guard-2-22m", "groq:meta-llama/llama-prompt-guard-2-86m", "groq:moonshotai/kimi-k2-instruct-0905", "groq:openai/gpt-oss-safeguard-20b", "groq:playai-tts", "groq:playai-tts-arabic", "groq:qwen/qwen-3-32b", "heroku:claude-3-5-haiku", "heroku:claude-3-5-sonnet-latest", "heroku:claude-3-7-sonnet", "heroku:claude-3-haiku", "heroku:claude-4-5-haiku", "heroku:claude-4-5-sonnet", "heroku:claude-4-6-sonnet", "heroku:claude-4-sonnet", "heroku:claude-opus-4-5", "heroku:claude-opus-4-6", "heroku:deepseek-v3-2", "heroku:glm-4-7", "heroku:glm-4-7-flash", "heroku:gpt-oss-120b", "heroku:kimi-k2-5", "heroku:kimi-k2-thinking", "heroku:minimax-m2", "heroku:minimax-m2-1", "heroku:qwen3-235b", "heroku:qwen3-coder-480b", "heroku:nova-2-lite", "heroku:nova-lite", "heroku:nova-pro", "huggingface:deepseek-ai/DeepSeek-R1", "huggingface:meta-llama/Llama-3.3-70B-Instruct", "huggingface:meta-llama/Llama-4-Maverick-17B-128E-Instruct", "huggingface:meta-llama/Llama-4-Scout-17B-16E-Instruct", "huggingface:Qwen/Qwen2.5-72B-Instruct", "huggingface:Qwen/Qwen3-235B-A22B", "huggingface:Qwen/Qwen3-32B", "huggingface:Qwen/QwQ-32B", "mistral:codestral-latest", "mistral:mistral-large-latest", "mistral:mistral-moderation-latest", "mistral:mistral-small-latest", "moonshotai:kimi-k2-0711-preview", "moonshotai:kimi-latest", "moonshotai:kimi-thinking-preview", "moonshotai:moonshot-v1-128k-vision-preview", "moonshotai:moonshot-v1-128k", "moonshotai:moonshot-v1-32k-vision-preview", "moonshotai:moonshot-v1-32k", "moonshotai:moonshot-v1-8k-vision-preview", "moonshotai:moonshot-v1-8k", "openai:computer-use-preview-2025-03-11", "openai:computer-use-preview", "openai:gpt-3.5-turbo-0125", "openai:gpt-3.5-turbo-0301", "openai:gpt-3.5-turbo-0613", "openai:gpt-3.5-turbo-1106", "openai:gpt-3.5-turbo-16k-0613", "openai:gpt-3.5-turbo-16k", "openai:gpt-3.5-turbo", "openai:gpt-4-0314", "openai:gpt-4-0613", "openai:gpt-4-turbo-2024-04-09", "openai:gpt-4-turbo", "openai:gpt-4.1-2025-04-14", "openai:gpt-4.1-mini-2025-04-14", "openai:gpt-4.1-mini", "openai:gpt-4.1-nano-2025-04-14", "openai:gpt-4.1-nano", "openai:gpt-4.1", "openai:gpt-4", "openai:gpt-4o-2024-05-13", "openai:gpt-4o-2024-08-06", "openai:gpt-4o-2024-11-20", "openai:gpt-4o-audio-preview-2024-12-17", "openai:gpt-4o-audio-preview-2025-06-03", "openai:gpt-4o-audio-preview", "openai:gpt-4o-mini-2024-07-18", "openai:gpt-4o-mini-audio-preview-2024-12-17", "openai:gpt-4o-mini-audio-preview", "openai:gpt-4o-mini-search-preview-2025-03-11", "openai:gpt-4o-mini-search-preview", "openai:gpt-4o-mini", "openai:gpt-4o-search-preview-2025-03-11", "openai:gpt-4o-search-preview", "openai:gpt-4o", "openai:gpt-5-2025-08-07", "openai:gpt-5-chat-latest", "openai:gpt-5-codex", "openai:gpt-5-mini-2025-08-07", "openai:gpt-5-mini", "openai:gpt-5-nano-2025-08-07", "openai:gpt-5-nano", "openai:gpt-5-pro-2025-10-06", "openai:gpt-5-pro", "openai:gpt-5.1-2025-11-13", "openai:gpt-5.1-chat-latest", "openai:gpt-5.1-codex-max", "openai:gpt-5.1-codex", "openai:gpt-5.1", "openai:gpt-5.2-2025-12-11", "openai:gpt-5.2-chat-latest", "openai:gpt-5.2-pro-2025-12-11", "openai:gpt-5.2-pro", "openai:gpt-5.2", "openai:gpt-5.3-chat-latest", "openai:gpt-5.4-mini-2026-03-17", "openai:gpt-5.4-mini", "openai:gpt-5.4-nano-2026-03-17", "openai:gpt-5.4-nano", "openai:gpt-5.4", "openai:gpt-5", "openai:o1-2024-12-17", "openai:o1-pro-2025-03-19", "openai:o1-pro", "openai:o1", "openai:o3-2025-04-16", "openai:o3-deep-research-2025-06-26", "openai:o3-deep-research", "openai:o3-mini-2025-01-31", "openai:o3-mini", "openai:o3-pro-2025-06-10", "openai:o3-pro", "openai:o3", "openai:o4-mini-2025-04-16", "openai:o4-mini-deep-research-2025-06-26", "openai:o4-mini-deep-research", "openai:o4-mini", "openai-chat:computer-use-preview-2025-03-11", "openai-chat:computer-use-preview", "openai-chat:gpt-3.5-turbo-0125", "openai-chat:gpt-3.5-turbo-0301", "openai-chat:gpt-3.5-turbo-0613", "openai-chat:gpt-3.5-turbo-1106", "openai-chat:gpt-3.5-turbo-16k-0613", "openai-chat:gpt-3.5-turbo-16k", "openai-chat:gpt-3.5-turbo", "openai-chat:gpt-4-0314", "openai-chat:gpt-4-0613", "openai-chat:gpt-4-turbo-2024-04-09", "openai-chat:gpt-4-turbo", "openai-chat:gpt-4.1-2025-04-14", "openai-chat:gpt-4.1-mini-2025-04-14", "openai-chat:gpt-4.1-mini", "openai-chat:gpt-4.1-nano-2025-04-14", "openai-chat:gpt-4.1-nano", "openai-chat:gpt-4.1", "openai-chat:gpt-4", "openai-chat:gpt-4o-2024-05-13", "openai-chat:gpt-4o-2024-08-06", "openai-chat:gpt-4o-2024-11-20", "openai-chat:gpt-4o-audio-preview-2024-12-17", "openai-chat:gpt-4o-audio-preview-2025-06-03", "openai-chat:gpt-4o-audio-preview", "openai-chat:gpt-4o-mini-2024-07-18", "openai-chat:gpt-4o-mini-audio-preview-2024-12-17", "openai-chat:gpt-4o-mini-audio-preview", "openai-chat:gpt-4o-mini-search-preview-2025-03-11", "openai-chat:gpt-4o-mini-search-preview", "openai-chat:gpt-4o-mini", "openai-chat:gpt-4o-search-preview-2025-03-11", "openai-chat:gpt-4o-search-preview", "openai-chat:gpt-4o", "openai-chat:gpt-5-2025-08-07", "openai-chat:gpt-5-chat-latest", "openai-chat:gpt-5-codex", "openai-chat:gpt-5-mini-2025-08-07", "openai-chat:gpt-5-mini", "openai-chat:gpt-5-nano-2025-08-07", "openai-chat:gpt-5-nano", "openai-chat:gpt-5-pro-2025-10-06", "openai-chat:gpt-5-pro", "openai-chat:gpt-5.1-2025-11-13", "openai-chat:gpt-5.1-chat-latest", "openai-chat:gpt-5.1-codex-max", "openai-chat:gpt-5.1-codex", "openai-chat:gpt-5.1", "openai-chat:gpt-5.2-2025-12-11", "openai-chat:gpt-5.2-chat-latest", "openai-chat:gpt-5.2-pro-2025-12-11", "openai-chat:gpt-5.2-pro", "openai-chat:gpt-5.2", "openai-chat:gpt-5.3-chat-latest", "openai-chat:gpt-5.4-mini-2026-03-17", "openai-chat:gpt-5.4-mini", "openai-chat:gpt-5.4-nano-2026-03-17", "openai-chat:gpt-5.4-nano", "openai-chat:gpt-5.4", "openai-chat:gpt-5", "openai-chat:o1-2024-12-17", "openai-chat:o1-pro-2025-03-19", "openai-chat:o1-pro", "openai-chat:o1", "openai-chat:o3-2025-04-16", "openai-chat:o3-deep-research-2025-06-26", "openai-chat:o3-deep-research", "openai-chat:o3-mini-2025-01-31", "openai-chat:o3-mini", "openai-chat:o3-pro-2025-06-10", "openai-chat:o3-pro", "openai-chat:o3", "openai-chat:o4-mini-2025-04-16", "openai-chat:o4-mini-deep-research-2025-06-26", "openai-chat:o4-mini-deep-research", "openai-chat:o4-mini", "test" ], "type": "string" }, "ModelSettings": { "additionalProperties": false, "description": "Settings to configure an LLM.\n\nIncludes only settings which apply to multiple models / model providers,\nthough not all of these settings are supported by all models.\n\nAll types must be serializable using Pydantic.", "properties": { "max_tokens": { "title": "Max Tokens", "type": "integer" }, "temperature": { "title": "Temperature", "type": "number" }, "top_p": { "title": "Top P", "type": "number" }, "top_k": { "title": "Top K", "type": "integer" }, "timeout": { "title": "Timeout", "type": "number" }, "parallel_tool_calls": { "title": "Parallel Tool Calls", "type": "boolean" }, "tool_choice": { "anyOf": [ { "enum": ["none", "required", "auto"], "type": "string" }, { "items": { "type": "string" }, "type": "array" }, { "$ref": "#/$defs/ToolOrOutput" }, { "type": "null" } ], "title": "Tool Choice" }, "seed": { "title": "Seed", "type": "integer" }, "presence_penalty": { "title": "Presence Penalty", "type": "number" }, "frequency_penalty": { "title": "Frequency Penalty", "type": "number" }, "logit_bias": { "additionalProperties": { "type": "integer" }, "title": "Logit Bias", "type": "object" }, "stop_sequences": { "items": { "type": "string" }, "title": "Stop Sequences", "type": "array" }, "extra_headers": { "additionalProperties": { "type": "string" }, "title": "Extra Headers", "type": "object" }, "thinking": { "anyOf": [ { "type": "boolean" }, { "enum": ["minimal", "low", "medium", "high", "xhigh"], "type": "string" } ], "title": "Thinking" }, "service_tier": { "enum": ["auto", "default", "flex", "priority"], "title": "Service Tier", "type": "string" }, "extra_body": { "title": "Extra Body" } }, "title": "ModelSettings", "type": "object" }, "OutputConfig": { "additionalProperties": false, "description": "Configuration for the score and assertion outputs of the LLMJudge evaluator.", "properties": { "evaluation_name": { "title": "Evaluation Name", "type": "string" }, "include_reason": { "title": "Include Reason", "type": "boolean" } }, "title": "OutputConfig", "type": "object" }, "SpanQuery": { "additionalProperties": false, "description": "A serializable query for filtering SpanNodes based on various conditions.\n\nAll fields are optional and combined with AND logic by default.", "properties": { "name_equals": { "title": "Name Equals", "type": "string" }, "name_contains": { "title": "Name Contains", "type": "string" }, "name_matches_regex": { "title": "Name Matches Regex", "type": "string" }, "has_attributes": { "additionalProperties": true, "title": "Has Attributes", "type": "object" }, "has_attribute_keys": { "items": { "type": "string" }, "title": "Has Attribute Keys", "type": "array" }, "min_duration": { "anyOf": [ { "format": "duration", "type": "string" }, { "type": "number" } ], "title": "Min Duration" }, "max_duration": { "anyOf": [ { "format": "duration", "type": "string" }, { "type": "number" } ], "title": "Max Duration" }, "not_": { "$ref": "#/$defs/SpanQuery" }, "and_": { "items": { "$ref": "#/$defs/SpanQuery" }, "title": "And", "type": "array" }, "or_": { "items": { "$ref": "#/$defs/SpanQuery" }, "title": "Or", "type": "array" }, "min_child_count": { "title": "Min Child Count", "type": "integer" }, "max_child_count": { "title": "Max Child Count", "type": "integer" }, "some_child_has": { "$ref": "#/$defs/SpanQuery" }, "all_children_have": { "$ref": "#/$defs/SpanQuery" }, "no_child_has": { "$ref": "#/$defs/SpanQuery" }, "stop_recursing_when": { "$ref": "#/$defs/SpanQuery" }, "min_descendant_count": { "title": "Min Descendant Count", "type": "integer" }, "max_descendant_count": { "title": "Max Descendant Count", "type": "integer" }, "some_descendant_has": { "$ref": "#/$defs/SpanQuery" }, "all_descendants_have": { "$ref": "#/$defs/SpanQuery" }, "no_descendant_has": { "$ref": "#/$defs/SpanQuery" }, "min_depth": { "title": "Min Depth", "type": "integer" }, "max_depth": { "title": "Max Depth", "type": "integer" }, "some_ancestor_has": { "$ref": "#/$defs/SpanQuery" }, "all_ancestors_have": { "$ref": "#/$defs/SpanQuery" }, "no_ancestor_has": { "$ref": "#/$defs/SpanQuery" } }, "title": "SpanQuery", "type": "object" }, "TaskResult": { "description": "The agent's structured self-report on a finished run.\n\nEach required field pushes the agent to consider that dimension of its\nwork; the tool-call spans in the trace remain the ground truth that\nexposes any embellishment. Because this is the agent's output type,\nending a run now requires calling the final_result output tool, so a\ntext-only reply can no longer end a run silently.", "properties": { "summary": { "title": "Summary", "type": "string" }, "solution_description": { "title": "Solution Description", "type": "string" }, "validations_run": { "items": { "type": "string" }, "title": "Validations Run", "type": "array" }, "issues_addressed": { "items": { "type": "string" }, "title": "Issues Addressed", "type": "array" }, "known_limitations": { "items": { "type": "string" }, "title": "Known Limitations", "type": "array" }, "ready_for_review": { "title": "Ready For Review", "type": "boolean" } }, "required": [ "summary", "solution_description", "validations_run", "issues_addressed", "known_limitations", "ready_for_review" ], "title": "TaskResult", "type": "object" }, "ToolOrOutput": { "description": "Restricts function tools while keeping output tools and direct text/image output available.\n\nUse this when you want to control which function tools the model can use\nin an agent run while still allowing the agent to complete with structured output,\ntext, or images.\n\nSee the [Tool Choice guide](../tools-advanced.md#tool-choice) for examples.", "properties": { "function_tools": { "items": { "type": "string" }, "title": "Function Tools", "type": "array" } }, "required": ["function_tools"], "title": "ToolOrOutput", "type": "object" }, "short_spec_Contains": { "additionalProperties": false, "properties": { "Contains": { "title": "Contains" } }, "required": ["Contains"], "title": "short_spec_Contains", "type": "object" }, "short_spec_Equals": { "additionalProperties": false, "properties": { "Equals": { "title": "Equals" } }, "required": ["Equals"], "title": "short_spec_Equals", "type": "object" }, "short_spec_EqualsExpected": { "additionalProperties": false, "properties": { "EqualsExpected": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "title": "Equalsexpected" } }, "title": "short_spec_EqualsExpected", "type": "object" }, "short_spec_HasMatchingSpan": { "additionalProperties": false, "properties": { "HasMatchingSpan": { "$ref": "#/$defs/SpanQuery" } }, "required": ["HasMatchingSpan"], "title": "short_spec_HasMatchingSpan", "type": "object" }, "short_spec_IsInstance": { "additionalProperties": false, "properties": { "IsInstance": { "title": "Isinstance", "type": "string" } }, "required": ["IsInstance"], "title": "short_spec_IsInstance", "type": "object" }, "short_spec_LLMJudge": { "additionalProperties": false, "properties": { "LLMJudge": { "title": "Llmjudge", "type": "string" } }, "required": ["LLMJudge"], "title": "short_spec_LLMJudge", "type": "object" }, "short_spec_MaxDuration": { "additionalProperties": false, "properties": { "MaxDuration": { "anyOf": [ { "type": "number" }, { "format": "duration", "type": "string" } ], "title": "Maxduration" } }, "required": ["MaxDuration"], "title": "short_spec_MaxDuration", "type": "object" }, "spec_ConfusionMatrixEvaluator": { "additionalProperties": false, "properties": { "ConfusionMatrixEvaluator": { "$ref": "#/$defs/spec_params_ConfusionMatrixEvaluator" } }, "required": ["ConfusionMatrixEvaluator"], "title": "spec_ConfusionMatrixEvaluator", "type": "object" }, "spec_Contains": { "additionalProperties": false, "properties": { "Contains": { "$ref": "#/$defs/spec_params_Contains" } }, "required": ["Contains"], "title": "spec_Contains", "type": "object" }, "spec_Equals": { "additionalProperties": false, "properties": { "Equals": { "$ref": "#/$defs/spec_params_Equals" } }, "required": ["Equals"], "title": "spec_Equals", "type": "object" }, "spec_HasMatchingSpan": { "additionalProperties": false, "properties": { "HasMatchingSpan": { "$ref": "#/$defs/spec_params_HasMatchingSpan" } }, "required": ["HasMatchingSpan"], "title": "spec_HasMatchingSpan", "type": "object" }, "spec_IsInstance": { "additionalProperties": false, "properties": { "IsInstance": { "$ref": "#/$defs/spec_params_IsInstance" } }, "required": ["IsInstance"], "title": "spec_IsInstance", "type": "object" }, "spec_KolmogorovSmirnovEvaluator": { "additionalProperties": false, "properties": { "KolmogorovSmirnovEvaluator": { "$ref": "#/$defs/spec_params_KolmogorovSmirnovEvaluator" } }, "required": ["KolmogorovSmirnovEvaluator"], "title": "spec_KolmogorovSmirnovEvaluator", "type": "object" }, "spec_LLMJudge": { "additionalProperties": false, "properties": { "LLMJudge": { "$ref": "#/$defs/spec_params_LLMJudge" } }, "required": ["LLMJudge"], "title": "spec_LLMJudge", "type": "object" }, "spec_PrecisionRecallEvaluator": { "additionalProperties": false, "properties": { "PrecisionRecallEvaluator": { "$ref": "#/$defs/spec_params_PrecisionRecallEvaluator" } }, "required": ["PrecisionRecallEvaluator"], "title": "spec_PrecisionRecallEvaluator", "type": "object" }, "spec_ROCAUCEvaluator": { "additionalProperties": false, "properties": { "ROCAUCEvaluator": { "$ref": "#/$defs/spec_params_ROCAUCEvaluator" } }, "required": ["ROCAUCEvaluator"], "title": "spec_ROCAUCEvaluator", "type": "object" }, "spec_params_ConfusionMatrixEvaluator": { "additionalProperties": false, "properties": { "predicted_from": { "enum": ["expected_output", "output", "metadata", "labels"], "title": "Predicted From", "type": "string" }, "predicted_key": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "title": "Predicted Key" }, "expected_from": { "enum": ["expected_output", "output", "metadata", "labels"], "title": "Expected From", "type": "string" }, "expected_key": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "title": "Expected Key" }, "title": { "title": "Title", "type": "string" } }, "title": "spec_params_ConfusionMatrixEvaluator", "type": "object" }, "spec_params_Contains": { "additionalProperties": false, "properties": { "value": { "title": "Value" }, "case_sensitive": { "title": "Case Sensitive", "type": "boolean" }, "as_strings": { "title": "As Strings", "type": "boolean" }, "evaluation_name": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "title": "Evaluation Name" } }, "required": ["value"], "title": "spec_params_Contains", "type": "object" }, "spec_params_Equals": { "additionalProperties": false, "properties": { "value": { "title": "Value" }, "evaluation_name": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "title": "Evaluation Name" } }, "required": ["value"], "title": "spec_params_Equals", "type": "object" }, "spec_params_HasMatchingSpan": { "additionalProperties": false, "properties": { "query": { "$ref": "#/$defs/SpanQuery" }, "evaluation_name": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "title": "Evaluation Name" } }, "required": ["query"], "title": "spec_params_HasMatchingSpan", "type": "object" }, "spec_params_IsInstance": { "additionalProperties": false, "properties": { "type_name": { "title": "Type Name", "type": "string" }, "evaluation_name": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "title": "Evaluation Name" } }, "required": ["type_name"], "title": "spec_params_IsInstance", "type": "object" }, "spec_params_KolmogorovSmirnovEvaluator": { "additionalProperties": false, "properties": { "score_key": { "title": "Score Key", "type": "string" }, "positive_from": { "enum": ["expected_output", "assertions", "labels"], "title": "Positive From", "type": "string" }, "positive_key": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "title": "Positive Key" }, "score_from": { "enum": ["scores", "metrics"], "title": "Score From", "type": "string" }, "title": { "title": "Title", "type": "string" }, "n_thresholds": { "title": "N Thresholds", "type": "integer" } }, "required": ["score_key", "positive_from"], "title": "spec_params_KolmogorovSmirnovEvaluator", "type": "object" }, "spec_params_LLMJudge": { "additionalProperties": false, "properties": { "rubric": { "title": "Rubric", "type": "string" }, "model": { "anyOf": [ { "$ref": "#/$defs/KnownModelName" }, { "type": "string" }, { "type": "null" } ], "title": "Model" }, "include_input": { "title": "Include Input", "type": "boolean" }, "include_expected_output": { "title": "Include Expected Output", "type": "boolean" }, "model_settings": { "anyOf": [ { "$ref": "#/$defs/ModelSettings" }, { "type": "null" } ] }, "score": { "anyOf": [ { "$ref": "#/$defs/OutputConfig" }, { "const": false, "type": "boolean" } ], "title": "Score" }, "assertion": { "anyOf": [ { "$ref": "#/$defs/OutputConfig" }, { "const": false, "type": "boolean" } ], "title": "Assertion" } }, "required": ["rubric"], "title": "spec_params_LLMJudge", "type": "object" }, "spec_params_PrecisionRecallEvaluator": { "additionalProperties": false, "properties": { "score_key": { "title": "Score Key", "type": "string" }, "positive_from": { "enum": ["expected_output", "assertions", "labels"], "title": "Positive From", "type": "string" }, "positive_key": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "title": "Positive Key" }, "score_from": { "enum": ["scores", "metrics"], "title": "Score From", "type": "string" }, "title": { "title": "Title", "type": "string" }, "n_thresholds": { "title": "N Thresholds", "type": "integer" } }, "required": ["score_key", "positive_from"], "title": "spec_params_PrecisionRecallEvaluator", "type": "object" }, "spec_params_ROCAUCEvaluator": { "additionalProperties": false, "properties": { "score_key": { "title": "Score Key", "type": "string" }, "positive_from": { "enum": ["expected_output", "assertions", "labels"], "title": "Positive From", "type": "string" }, "positive_key": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "title": "Positive Key" }, "score_from": { "enum": ["scores", "metrics"], "title": "Score From", "type": "string" }, "title": { "title": "Title", "type": "string" }, "n_thresholds": { "title": "N Thresholds", "type": "integer" } }, "required": ["score_key", "positive_from"], "title": "spec_params_ROCAUCEvaluator", "type": "object" } }, "additionalProperties": false, "properties": { "name": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null, "title": "Name" }, "cases": { "items": { "$ref": "#/$defs/Case" }, "title": "Cases", "type": "array" }, "evaluators": { "default": [], "items": { "anyOf": [ { "const": "WorkspaceValidates", "type": "string" }, { "const": "PlanMatchesGraph", "type": "string" }, { "const": "SelfReportAccurate", "type": "string" }, { "const": "ToolErrorMetricEvaluator", "type": "string" }, { "$ref": "#/$defs/short_spec_Equals" }, { "$ref": "#/$defs/spec_Equals" }, { "const": "EqualsExpected", "type": "string" }, { "$ref": "#/$defs/short_spec_EqualsExpected" }, { "$ref": "#/$defs/short_spec_Contains" }, { "$ref": "#/$defs/spec_Contains" }, { "$ref": "#/$defs/short_spec_IsInstance" }, { "$ref": "#/$defs/spec_IsInstance" }, { "$ref": "#/$defs/short_spec_MaxDuration" }, { "$ref": "#/$defs/short_spec_LLMJudge" }, { "$ref": "#/$defs/spec_LLMJudge" }, { "$ref": "#/$defs/short_spec_HasMatchingSpan" }, { "$ref": "#/$defs/spec_HasMatchingSpan" } ] }, "title": "Evaluators", "type": "array" }, "report_evaluators": { "default": [], "items": { "anyOf": [ { "const": "ConfusionMatrixEvaluator", "type": "string" }, { "$ref": "#/$defs/spec_ConfusionMatrixEvaluator" }, { "$ref": "#/$defs/spec_KolmogorovSmirnovEvaluator" }, { "$ref": "#/$defs/spec_PrecisionRecallEvaluator" }, { "$ref": "#/$defs/spec_ROCAUCEvaluator" } ] }, "title": "Report Evaluators", "type": "array" }, "$schema": { "type": "string" } }, "required": ["cases"], "title": "Dataset", "type": "object"}# yaml-language-server: $schema=cases_schema.jsonname: terraform-pr-agentcases: - name: logs-bucket inputs: >- Create an S3 bucket for storing application logs. Make sure the configuration blocks public access. metadata: expected_resources: - type: aws_s3_bucket - type: aws_s3_bucket_public_access_block - name: lambda-exec-role inputs: >- Create an IAM role that AWS Lambda functions can assume as their execution role. metadata: expected_resources: - type: aws_iam_role - name: sessions-table inputs: >- Create a DynamoDB table for user sessions with a string hash key named session_id and on-demand billing. metadata: expected_resources: - type: aws_dynamodb_table attrs: billing_mode: PAY_PER_REQUEST hash_key: session_id - name: secrets-key inputs: >- Create a KMS key for encrypting application secrets, with key rotation enabled. metadata: expected_resources: - type: aws_kms_key attrs: enable_key_rotation: true - name: queue-with-dlq inputs: >- Create an SQS queue for background jobs with a dead-letter queue wired up via a redrive policy. metadata: expected_resources: - type: aws_sqs_queue count: 2evaluators: - WorkspaceValidates - PlanMatchesGraph - SelfReportAccurate - ToolErrorMetricEvaluator"""The eval's pass conditions: the agent's own validators, plus the plan.
There is no second rulebook here. WorkspaceValidates calls the samevalidate_workspace() gate as the agent's output validator, so a case passes forthe same reason the agent stopped. The plan evaluator asserts on terraform's ownview, with variables, locals, count/for_each and functions resolved, so theassertions hold for any coding style that a raw HCL parse could not read. Theplan runs with no input, so the agent must give each variable a default, and asubmission that does not is a plan failure."""
import jsonfrom dataclasses import dataclassfrom pathlib import Pathfrom shutil import copytree, ignore_patternsfrom tempfile import TemporaryDirectoryfrom typing import Any
from agent.core import TaskResultfrom agent.tools.validators import Command, validate_workspacefrom pydantic import BaseModel, ConfigDict, Fieldfrom pydantic_evals.evaluators import ( EvaluationReason, Evaluator, EvaluatorContext, EvaluatorOutput,)from pydantic_evals.otel import SpanQuery
class EvalOutput(BaseModel): """What the eval task gives the evaluators: the self-report, the finished workspace on disk, and the run id that joins the Logfire trace, the S3 audit copy and the parked artifacts."""
model_config = ConfigDict(frozen=True)
run_id: str model: str workspace: Path result: TaskResult input_tokens: int output_tokens: int
class ExpectedResource(BaseModel): """One entry of a case's expected resource graph.
``attrs`` lists only the attributes worth an assertion. Generated names and order never appear, which is the answer to nondeterminism. Extra planned resources are correct: a checkov baseline forces supporting resources, such as public access blocks, that the case does not list. """
type: str count: int = 1 attrs: dict[str, Any] = Field(default_factory=dict)
class CaseMetadata(BaseModel): """Per-case expectations. The expected graph goes here, not in ``expected_output``, because pydantic_evals types expected_output as the task output type (EvalOutput), which is not what a case author writes."""
expected_resources: list[ExpectedResource]
TASK_INPUTS = str"""Case inputs: the English request handed to the agent."""
@dataclassclass WorkspaceValidates(Evaluator[TASK_INPUTS, EvalOutput, CaseMetadata]): """fmt + terraform validate + tflint + checkov, verbatim from the agent."""
def evaluate( self, ctx: EvaluatorContext[TASK_INPUTS, EvalOutput, CaseMetadata] ) -> EvaluationReason: result = validate_workspace(ctx.output.workspace) if result.success: return EvaluationReason(value=True) return EvaluationReason(value=False, reason=result.format_error_for_agent())
_TERRAFORM_SHOW = Command( name="terraform_show", commands=["terraform", "show", "-json", "tfplan"],)
_TERRAFORM_PLAN = Command( name="terraform_plan", commands=["terraform", "plan", "-out=tfplan", "-input=false", "-no-color"],)
# init -upgrade repairs the lock before the plan, so a stale lock from the# shared plugin cache cannot fail a valid config._TERRAFORM_INIT = Command( name="terraform_init", commands=["terraform", "init", "-upgrade", "-backend=false", "-input=false", "-no-color"],)
# Terraform merges a *_override.tf file into the config, so this reaches the# provider whether or not the agent declared one._PROVIDER_OVERRIDE = """\provider "aws" { access_key = "mock" secret_key = "mock" region = "eu-west-1" skip_credentials_validation = true skip_requesting_account_id = true skip_metadata_api_check = true}"""
def planned_resources(workspace: Path) -> tuple[list[dict[str, Any]], str | None]: """The plan document's resources, root module and child modules flattened.
Configuring the AWS provider authenticates against STS, so a plan needs real credentials. The plan runs on a copy of the workspace with a provider override that supplies mock ones, which keeps the eval offline and leaves the graded workspace untouched. A workspace that does not plan returns its error as the second element, which the evaluator scores instead of raising. """ with TemporaryDirectory(prefix="plan-") as tmp: # .terraform holds the downloaded provider; the plugin cache refills it. planned = Path(tmp) / "workspace" copytree(workspace, planned, ignore=ignore_patterns(".terraform")) (planned / "eval_override.tf").write_text(_PROVIDER_OVERRIDE) for command in (_TERRAFORM_INIT, _TERRAFORM_PLAN, _TERRAFORM_SHOW): result = command.run(planned) if not result.success: return [], f"{command.name} failed: {result.format_error_for_agent()}" module = json.loads(result.stdout)["planned_values"]["root_module"] return _module_resources(module), None
def _module_resources(module: dict[str, Any]) -> list[dict[str, Any]]: resources = list(module.get("resources", [])) for child in module.get("child_modules", []): resources.extend(_module_resources(child)) return resources
def compare_resources(planned: list[dict[str, Any]], expected: list[ExpectedResource]) -> list[str]: """Mismatches between the plan and the expected graph; empty means pass.
For each expected entry, the number of planned instances of that type must agree. If the entry gives ``attrs``, one instance must hold each listed attribute with exactly that value. """ problems: list[str] = [] for entry in expected: instances = [r for r in planned if r["type"] == entry.type] if len(instances) != entry.count: problems.append(f"{entry.type}: expected {entry.count}, planned {len(instances)}") continue if entry.attrs and not any( all(instance["values"].get(key) == value for key, value in entry.attrs.items()) for instance in instances ): problems.append(f"{entry.type}: no instance matches attrs {entry.attrs}") return problems
@dataclassclass PlanMatchesGraph(Evaluator[TASK_INPUTS, EvalOutput, CaseMetadata]): """terraform's evaluated view of the workspace matches the expected graph."""
def evaluate( self, ctx: EvaluatorContext[TASK_INPUTS, EvalOutput, CaseMetadata] ) -> EvaluationReason: assert ctx.metadata is not None planned, error = planned_resources(ctx.output.workspace) if error is not None: return EvaluationReason(value=False, reason=error) problems = compare_resources(planned, ctx.metadata.expected_resources) return EvaluationReason(value=not problems, reason="; ".join(problems) or None)
@dataclassclass SelfReportAccurate(Evaluator[TASK_INPUTS, EvalOutput, CaseMetadata]): """The TaskResult agrees with what the case knows.
The tool-call spans in the trace stay the ground truth. This checks only the self-report claims that a case can verify cheaply. """
def evaluate( self, ctx: EvaluatorContext[TASK_INPUTS, EvalOutput, CaseMetadata] ) -> dict[str, EvaluatorOutput]: report = ctx.output.result validations = " ".join(report.validations_run).lower() tool_spans = ctx.span_tree.find( predicate=SpanQuery( name_contains="execute_tool", has_attribute_keys=["gen_ai.tool.name"] ) ) actual_tools = {span.attributes.get("gen_ai.tool.name") for span in tool_spans} return { "ready_for_review": EvaluationReason( value=report.ready_for_review, reason="Agent should mark ready for review.", ), "reported_linters": EvaluationReason( value=( "tflint" in validations and "checkov" in validations and "tflint" in actual_tools and "checkov" in actual_tools ), reason=( f"Mismatch in validations: {validations} and {actual_tools}, " "should both contain tflint and checkov." ), ), }
@dataclassclass ToolErrorMetricEvaluator(Evaluator[TASK_INPUTS, EvalOutput, CaseMetadata]): """Score from the tool errors: 0 errors -> 1.0, 10 or more errors -> 0.0"""
def evaluate(self, ctx: EvaluatorContext[TASK_INPUTS, EvalOutput, CaseMetadata]): tool_spans = ctx.span_tree.find( predicate=SpanQuery( name_contains="execute_tool", has_attribute_keys=["gen_ai.tool.name"] ) ) error_spans = [ span for span in tool_spans if ( isinstance(result := span.attributes.get("gen_ai.tool.call.result"), str) and result.strip().endswith("Fix the errors and try again.") ) ] n_tool_errors = len(error_spans) return { "tool_error_score": EvaluationReason( value=max(0.0, 1.0 - (0.1 * n_tool_errors)), reason="Full score 1.0 if there are no errors 0.1 deducted for each error.", ) }
EVALUATOR_TYPES = [ WorkspaceValidates, PlanMatchesGraph, SelfReportAccurate, ToolErrorMetricEvaluator,]"""Run the offline eval suite: every case against each model.
Needs the Lambda's own environment, so the eval runs production code againsteval-scoped sinks. Each dataset.evaluate_sync() call makes one named Logfireexperiment per model, comparable in the Evals UI."""
import osfrom datetime import datetimefrom pathlib import Pathfrom tempfile import mkdtempfrom typing import Annotated
import typerfrom agent import observabilityfrom agent.core import executefrom agent.env import require_envfrom pydantic_evals import Dataset
from evals.evaluators import EVALUATOR_TYPES, CaseMetadata, EvalOutput
CASES = Path(__file__).parent / "cases.yaml"
EVAL_MODELS_PARAMETER = "/terraform-pr-agent/models-evals"EVAL_ENVIRONMENT = "evals"
def _wire_eval_run() -> None: os.environ["MODELS_PARAMETER"] = EVAL_MODELS_PARAMETER os.environ["LOGFIRE_ENVIRONMENT"] = EVAL_ENVIRONMENT
def load_dataset(names: list[str]) -> Dataset[str, EvalOutput, CaseMetadata]: dataset = Dataset[str, EvalOutput, CaseMetadata].from_file( CASES, custom_evaluator_types=EVALUATOR_TYPES ) if names: if unknown := set(names) - {case.name for case in dataset.cases}: raise typer.BadParameter(f"unknown case names: {sorted(unknown)}") dataset.cases = [case for case in dataset.cases if case.name in names] return dataset
def _task(model: str, runs_dir: Path): def run_case(prompt: str) -> EvalOutput: workspace = Path(mkdtemp(prefix="case-", dir=runs_dir)) run = execute(prompt, model=model, workspace=workspace) return EvalOutput( run_id=run.run_id, model=run.model, workspace=workspace, result=run.output, input_tokens=run.input_tokens, output_tokens=run.output_tokens, )
return run_case
def main( model: Annotated[ list[str] | None, typer.Option(help="Registry keys to sweep; defaults to DEFAULT_MODEL.") ] = None, case: Annotated[ list[str] | None, typer.Option(help="Run only the named cases (smoke runs).") ] = None, concurrency: Annotated[ int, typer.Option(help="Concurrent cases per experiment; mind Bedrock quotas.") ] = 2, repeat: Annotated[int, typer.Option(help="Repeat experiment n times to get better stats.")] = 1,) -> None: """Evaluate the agent offline; one Logfire experiment per model.""" _wire_eval_run() observability.configure() eval_runs = Path(".evals-runs") _set_up_terraform_plugin_cache(eval_runs) models = model or [require_env("DEFAULT_MODEL")] dataset = load_dataset(case or []) stamp = datetime.now().strftime("%Y%m%d-%H%M%S") for name in models: runs_dir = eval_runs / stamp / name runs_dir.mkdir(parents=True, exist_ok=True) report = dataset.evaluate_sync( _task(name, runs_dir), name=f"terraform-pr-agent-{name}", max_concurrency=concurrency, repeat=repeat, ) report.print() # The pass rate from report.print() averages only the cases with output. # A sweep that loses half its cases to errors still shows 100%. expected = len(dataset.cases) * repeat print(f"{name}: {len(report.cases)}/{expected} scored, {len(report.failures)} errored")
def _set_up_terraform_plugin_cache(eval_runs): plugin_cache = (eval_runs / ".terraform/plugin-cache").resolve() plugin_cache.mkdir(parents=True, exist_ok=True) os.environ["TF_PLUGIN_CACHE_DIR"] = str(plugin_cache)
if __name__ == "__main__": typer.run(main)FROM public.ecr.aws/lambda/python:3.13
COPY handler.py ${LAMBDA_TASK_ROOT}/CMD ["handler.handler"]def handler(event, context): return { "status": "placeholder", "note": "run scripts/build-lambda.sh to build and deploy the real image", }# alias/aws/sns is the AWS-managed SNS key; a CMK is unnecessary for an# operational alarm topic that publishes alarm-fired events, not audit data.#trivy:ignore:avd-aws-0136resource "aws_sns_topic" "agent_alerts" { name = "terraform-pr-agent-alerts" kms_master_key_id = "alias/aws/sns"}
# The subscription stays PENDING until the recipient clicks the AWS# confirmation email. Alarms fire either way, but no email goes out until the# subscription is confirmed.resource "aws_sns_topic_subscription" "agent_alerts_email" { topic_arn = aws_sns_topic.agent_alerts.arn protocol = "email" endpoint = var.alert_email}
# Reads the EMF tokens the handler emits (Model dimension), not AWS/Bedrock,# so the budget guard works whichever provider DEFAULT_MODEL points at. Scoped# to the active model.resource "aws_cloudwatch_metric_alarm" "daily_tokens" { alarm_name = "terraform-pr-agent-daily-tokens" alarm_description = "Daily input + output token usage for the active model exceeded the configured threshold." comparison_operator = "GreaterThanThreshold" evaluation_periods = 1 threshold = var.daily_token_alarm_threshold treat_missing_data = "notBreaching"
metric_query { id = "total" expression = "input + output" label = "Total tokens (input + output)" return_data = true }
metric_query { id = "input" metric { namespace = local.metrics_namespace metric_name = "InputTokens" dimensions = { Model = var.default_model } stat = "Sum" period = 86400 } }
metric_query { id = "output" metric { namespace = local.metrics_namespace metric_name = "OutputTokens" dimensions = { Model = var.default_model } stat = "Sum" period = 86400 } }
alarm_actions = [aws_sns_topic.agent_alerts.arn]}locals { audit_bucket_name = "terraform-pr-agent-audit-${data.aws_caller_identity.current.account_id}-${data.aws_region.current.region}"}
# Access logging would require a second bucket and is out of scope: the audit# copy here is the system of record for what the agent did, not for who read it.#trivy:ignore:avd-aws-0089resource "aws_s3_bucket" "audit" { bucket = local.audit_bucket_name object_lock_enabled = true}
resource "aws_s3_bucket_versioning" "audit" { bucket = aws_s3_bucket.audit.id
versioning_configuration { status = "Enabled" }}
resource "aws_s3_bucket_public_access_block" "audit" { bucket = aws_s3_bucket.audit.id block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true}
resource "aws_s3_bucket_server_side_encryption_configuration" "audit" { bucket = aws_s3_bucket.audit.id
rule { apply_server_side_encryption_by_default { sse_algorithm = "aws:kms" kms_master_key_id = aws_kms_key.audit.arn }
bucket_key_enabled = true }}
data "aws_iam_policy_document" "audit_bucket_resource_policy" { statement { sid = "DenyInsecureTransport" effect = "Deny" actions = ["s3:*"] resources = [ aws_s3_bucket.audit.arn, "${aws_s3_bucket.audit.arn}/*", ] principals { type = "*" identifiers = ["*"] } condition { test = "Bool" variable = "aws:SecureTransport" values = ["false"] } }}
resource "aws_s3_bucket_policy" "audit" { bucket = aws_s3_bucket.audit.id policy = data.aws_iam_policy_document.audit_bucket_resource_policy.json}
# GOVERNANCE leaves the bucket deletable for the tutorial; switch to "COMPLIANCE" in production.resource "aws_s3_bucket_object_lock_configuration" "audit" { bucket = aws_s3_bucket.audit.id
rule { default_retention { mode = "GOVERNANCE" days = var.audit_retention_days } }}
resource "aws_s3_bucket_lifecycle_configuration" "audit" { bucket = aws_s3_bucket.audit.id
rule { id = "transition-to-glacier-ir" status = "Enabled"
filter {}
transition { days = 90 storage_class = "GLACIER_IR" }
noncurrent_version_transition { noncurrent_days = 90 storage_class = "GLACIER_IR" } }
rule { id = "expire-after-retention" status = "Enabled"
filter {}
expiration { days = var.audit_retention_days }
noncurrent_version_expiration { noncurrent_days = 1 } }}
output "audit_bucket_name" { value = aws_s3_bucket.audit.id}
output "audit_bucket_arn" { value = aws_s3_bucket.audit.arn}locals { bedrock_model_id = "anthropic.claude-haiku-4-5-20251001-v1:0" bedrock_cross_region_prefix = "eu"
system_inference_profile_arn = format( "arn:aws:bedrock:%s:%s:inference-profile/%s.%s", data.aws_region.current.region, data.aws_caller_identity.current.account_id, local.bedrock_cross_region_prefix, local.bedrock_model_id, )}
resource "aws_bedrock_inference_profile" "agent" { name = "terraform-pr-agent" description = "Application inference profile for the Terraform PR Agent series."
model_source { copy_from = local.system_inference_profile_arn }}
output "inference_profile_arn" { value = aws_bedrock_inference_profile.agent.arn}locals { cloudwatch_region = data.aws_region.current.region dashboard_name = "terraform-pr-agent" lambda_name = aws_lambda_function.agent.function_name
# The model widgets read the EMF metrics the handler emits (namespace # local.metrics_namespace, dimensioned by Model), not AWS/Bedrock, so a # Bedrock model and a Mistral-API model land in the same widgets. One line # per registry model, built by iterating keys(local.models). model_keys = keys(local.models)}
resource "aws_cloudwatch_dashboard" "agent" { dashboard_name = local.dashboard_name dashboard_body = jsonencode({ widgets = [ { type = "text" x = 0 y = 0 width = 24 height = 2 properties = { markdown = "## Lambda\nContainer-image function health: invocations and errors, end to end duration, cold start init duration (Lambda Insights emits `init_duration` only on a cold start), and the memory and `/tmp` footprint behind the `memory_size` and `ephemeral_storage` sizing." } }, { type = "metric" x = 0 y = 2 width = 12 height = 6 properties = { title = "Lambda invocations and errors" region = local.cloudwatch_region view = "timeSeries" stat = "Sum" period = 60 metrics = [ ["AWS/Lambda", "Invocations", "FunctionName", local.lambda_name, { label = "${local.lambda_name} / invocations" }], [".", "Errors", ".", ".", { label = "${local.lambda_name} / errors" }], [".", "Throttles", ".", ".", { label = "${local.lambda_name} / throttles" }], ] } }, { type = "metric" x = 12 y = 2 width = 12 height = 6 properties = { title = "Lambda duration (ms)" region = local.cloudwatch_region view = "timeSeries" period = 60 metrics = [ ["AWS/Lambda", "Duration", "FunctionName", local.lambda_name, { label = "${local.lambda_name} / avg", stat = "Average" }], [".", ".", ".", ".", { label = "${local.lambda_name} / p99", stat = "p99" }], ] } }, { type = "metric" x = 0 y = 8 width = 12 height = 6 properties = { title = "Cold start init duration (ms)" region = local.cloudwatch_region view = "timeSeries" period = 60 metrics = [ # Insights reports init_duration only when an init phase happened, # so points appear only on cold starts. ["LambdaInsights", "init_duration", "function_name", local.lambda_name, { label = "${local.lambda_name} / init avg (ms)", stat = "Average" }], [".", ".", ".", ".", { label = "${local.lambda_name} / init max (ms)", stat = "Maximum" }], ] } }, { type = "metric" x = 12 y = 8 width = 12 height = 6 properties = { title = "Memory used (MB)" region = local.cloudwatch_region view = "timeSeries" period = 60 metrics = [ # used_memory_max is the cgroup figure (Max Memory Used), which # counts the reclaimable /tmp page cache and so reads ~2 GB while # real demand is under 1 GB. Backs the memory_size comment in # lambda.tf. ["LambdaInsights", "used_memory_max", "function_name", local.lambda_name, { label = "${local.lambda_name} / memory max (MB)", stat = "Maximum" }], ] } }, { type = "metric" x = 0 y = 14 width = 12 height = 6 properties = { title = "/tmp used (bytes)" region = local.cloudwatch_region view = "timeSeries" period = 60 metrics = [ # tmp_used tracks the ~800 MB provider download into /tmp, behind # the 4 GB ephemeral_storage sizing in lambda.tf. ["LambdaInsights", "tmp_used", "function_name", local.lambda_name, { label = "${local.lambda_name} / tmp used (B)", stat = "Maximum" }], ] } }, { type = "text" x = 0 y = 20 width = 24 height = 2 properties = { markdown = "## Model\nPer-model usage from the handler's EMF metrics (namespace `local.metrics_namespace`, dimensioned by `Model`), so Bedrock and Mistral-API models share one set of widgets: tokens, invocations and errors, latency, and cache reads and writes." } }, { type = "metric" x = 0 y = 22 width = 12 height = 6 properties = { title = "Tokens" region = local.cloudwatch_region view = "timeSeries" stat = "Sum" period = 60 metrics = [ for m in flatten([ for key in local.model_keys : [ { metric = "InputTokens", label = "${key} / input", key = key }, { metric = "OutputTokens", label = "${key} / output", key = key }, ] ]) : [local.metrics_namespace, m.metric, "Model", m.key, { label = m.label }] ] } }, { type = "metric" x = 12 y = 22 width = 12 height = 6 properties = { title = "Invocations and errors" region = local.cloudwatch_region view = "timeSeries" stat = "Sum" period = 60 metrics = [ for m in flatten([ for key in local.model_keys : [ { metric = "Invocations", label = "${key} / invocations", key = key }, { metric = "Errors", label = "${key} / errors", key = key }, ] ]) : [local.metrics_namespace, m.metric, "Model", m.key, { label = m.label }] ] } }, { type = "metric" x = 0 y = 28 width = 12 height = 6 properties = { title = "Latency (ms)" region = local.cloudwatch_region view = "timeSeries" period = 60 metrics = [ for m in flatten([ for key in local.model_keys : [ { label = "${key} / avg", key = key, stat = "Average" }, { label = "${key} / p99", key = key, stat = "p99" }, ] ]) : [local.metrics_namespace, "Latency", "Model", m.key, { label = m.label, stat = m.stat }] ] } }, { type = "metric" x = 12 y = 28 width = 12 height = 6 properties = { title = "Cache tokens" region = local.cloudwatch_region view = "timeSeries" stat = "Sum" period = 60 metrics = [ for m in flatten([ for key in local.model_keys : [ { metric = "CacheReadTokens", label = "${key} / cache read", key = key }, { metric = "CacheWriteTokens", label = "${key} / cache write", key = key }, ] ]) : [local.metrics_namespace, m.metric, "Model", m.key, { label = m.label }] ] } }, ] })}
output "cloudwatch_dashboard_url" { value = format( "https://%s.console.aws.amazon.com/cloudwatch/home?region=%s#dashboards/dashboard/%s", local.cloudwatch_region, local.cloudwatch_region, aws_cloudwatch_dashboard.agent.dashboard_name, )}resource "aws_ecr_repository" "agent" { name = "terraform-pr-agent"
# Deploys re-push the :latest and :placeholder tags in place, the same # out-of-band code-ship pattern as the zip flow this replaces. Immutable # tags would force a fresh tag per build. #trivy:ignore:avd-aws-0031 image_tag_mutability = "MUTABLE"
# Tutorial teardown: terraform destroy must succeed while images exist. force_delete = true
image_scanning_configuration { scan_on_push = true }
# The AWS-managed AES256 key is enough here: the image holds no secret # material, and a CMK adds cost and key-policy surface for nothing. #trivy:ignore:avd-aws-0033 encryption_configuration { encryption_type = "AES256" }}
# Container twin of the zip flow's archive_file placeholder: the function# resource needs a pullable image at create time, so terraform seeds a# minimal one. Create-only (input never changes), so scripts/build-lambda.sh# owns every push after this.resource "terraform_data" "placeholder_image" { input = aws_ecr_repository.agent.repository_url
# Needs docker and the aws cli on the machine running apply; both are # already prerequisites for the series. provisioner "local-exec" { command = <<-EOT aws ecr get-login-password --region ${data.aws_region.current.region} | docker login --username AWS --password-stdin ${split("/", aws_ecr_repository.agent.repository_url)[0]} docker buildx build --platform linux/arm64 --provenance=false \ -t ${aws_ecr_repository.agent.repository_url}:placeholder \ --push ${path.module}/placeholder EOT }}data "aws_iam_policy_document" "firehose_assume" { statement { actions = ["sts:AssumeRole"] principals { type = "Service" identifiers = ["firehose.amazonaws.com"] } }}
resource "aws_iam_role" "firehose" { name = "terraform-pr-agent-firehose" assume_role_policy = data.aws_iam_policy_document.firehose_assume.json}
data "aws_iam_policy_document" "firehose_permissions" { statement { actions = [ "s3:PutObject", "s3:PutObjectRetention", "s3:GetBucketLocation", "s3:ListBucket", ] # /* is how S3 IAM grants object-scoped actions; bounded to the audit bucket. #trivy:ignore:avd-aws-0057 resources = [ aws_s3_bucket.audit.arn, "${aws_s3_bucket.audit.arn}/*", ] }
statement { actions = [ "kms:GenerateDataKey", "kms:Decrypt", ] resources = [aws_kms_key.audit.arn] }
statement { actions = ["logs:PutLogEvents"] # :* covers log streams inside the named Firehose log group only. #trivy:ignore:avd-aws-0057 resources = ["${aws_cloudwatch_log_group.firehose.arn}:*"] }}
resource "aws_iam_role_policy" "firehose_permissions" { name = "terraform-pr-agent-firehose-permissions" role = aws_iam_role.firehose.id policy = data.aws_iam_policy_document.firehose_permissions.json}
# Operational delivery log for Firehose; carries error metadata, not audit# payload. The audit copy in S3 is the protected artefact and already uses a CMK.#trivy:ignore:avd-aws-0017resource "aws_cloudwatch_log_group" "firehose" { name = "/aws/kinesisfirehose/terraform-pr-agent-audit" retention_in_days = 7}
resource "aws_cloudwatch_log_stream" "firehose_s3" { name = "S3Delivery" log_group_name = aws_cloudwatch_log_group.firehose.name}
resource "aws_kinesis_firehose_delivery_stream" "audit" { name = "terraform-pr-agent-audit" destination = "extended_s3"
extended_s3_configuration { role_arn = aws_iam_role.firehose.arn bucket_arn = aws_s3_bucket.audit.arn prefix = "traces/year=!{timestamp:yyyy}/month=!{timestamp:MM}/day=!{timestamp:dd}/hour=!{timestamp:HH}/" error_output_prefix = "errors/!{firehose:error-output-type}/year=!{timestamp:yyyy}/month=!{timestamp:MM}/day=!{timestamp:dd}/" buffering_size = 5 buffering_interval = 60 compression_format = "GZIP"
cloudwatch_logging_options { enabled = true log_group_name = aws_cloudwatch_log_group.firehose.name log_stream_name = aws_cloudwatch_log_stream.firehose_s3.name } }}
output "firehose_stream_name" { value = aws_kinesis_firehose_delivery_stream.audit.name}
output "firehose_stream_arn" { value = aws_kinesis_firehose_delivery_stream.audit.arn}data "aws_iam_policy_document" "bedrock_invoke" { statement { actions = [ "bedrock:Converse", "bedrock:ConverseStream", "bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream", ] # Bedrock foundation-model ARNs do not pin to the caller region; the # inference profile fans out cross-region, so the * region segment is required. #trivy:ignore:avd-aws-0057 resources = [ aws_bedrock_inference_profile.agent.arn, local.system_inference_profile_arn, "arn:aws:bedrock:*::foundation-model/${local.bedrock_model_id}", ] }
# Anthropic models on Bedrock are distributed via AWS Marketplace. On the # first invocation from a new account, Bedrock auto-subscribes the account # to the model product, which requires the invoking principal to hold these # Marketplace actions. Once the subscription is active these calls become # no-ops, but the principal still needs ViewSubscriptions on every call so # Bedrock can confirm the subscription is in place. statement { actions = [ "aws-marketplace:Subscribe", "aws-marketplace:Unsubscribe", "aws-marketplace:ViewSubscriptions", ] # Marketplace subscription actions are global by design. #trivy:ignore:avd-aws-0057 resources = ["*"] }}
resource "aws_iam_policy" "bedrock_invoke" { name = "terraform-pr-agent-bedrock-invoke" description = "Invoke Claude via the terraform-pr-agent inference profile." policy = data.aws_iam_policy_document.bedrock_invoke.json}
data "aws_iam_policy_document" "agent_assume" { statement { actions = ["sts:AssumeRole"] principals { type = "AWS" identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"] } }}
resource "aws_iam_role" "agent" { name = "terraform-pr-agent" assume_role_policy = data.aws_iam_policy_document.agent_assume.json}
resource "aws_iam_role_policy_attachment" "agent_bedrock_invoke" { role = aws_iam_role.agent.name policy_arn = aws_iam_policy.bedrock_invoke.arn}
output "agent_role_arn" { value = aws_iam_role.agent.arn}resource "aws_kms_key" "audit" { description = "Encrypts the terraform-pr-agent audit bucket." enable_key_rotation = true deletion_window_in_days = 7 policy = data.aws_iam_policy_document.audit_kms_key_resource_policy.json}
resource "aws_kms_alias" "audit" { name = "alias/terraform-pr-agent-audit" target_key_id = aws_kms_key.audit.key_id}
data "aws_iam_policy_document" "audit_kms_key_resource_policy" { statement { sid = "EnableIAMUserPermissions" actions = ["kms:*"] resources = ["*"] principals { type = "AWS" identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"] } }}
output "audit_kms_key_arn" { value = aws_kms_key.audit.arn}data "aws_iam_policy_document" "lambda_assume" { statement { actions = ["sts:AssumeRole"]64 collapsed lines
principals { type = "Service" identifiers = ["lambda.amazonaws.com"] } }}
# trivy:ignore:avd-aws-0057# Bedrock foundation-model ARNs do not pin to the caller region (the inference# profile fans out cross-region), and Marketplace subscription actions are# global by design.data "aws_iam_policy_document" "lambda_permissions" { # Bedrock invocation. Same shape as iam.tf's bedrock_invoke; copied # here so the Lambda role is self-contained and does not require # the user-role policy to also be attached to the Lambda role. statement { actions = [ "bedrock:Converse", "bedrock:ConverseStream", "bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream", ] # Bedrock foundation-model ARNs do not pin to the caller region; the # inference profile fans out cross-region, so the * region segment is required. #trivy:ignore:avd-aws-0057 resources = [ aws_bedrock_inference_profile.agent.arn, local.system_inference_profile_arn, "arn:aws:bedrock:*::foundation-model/${local.bedrock_model_id}", ] }
statement { actions = [ "aws-marketplace:Subscribe", "aws-marketplace:Unsubscribe", "aws-marketplace:ViewSubscriptions", ] # Marketplace subscription actions are global by design. #trivy:ignore:avd-aws-0057 resources = ["*"] }
# Model registry read. A plain String parameter, so no KMS is involved. statement { actions = ["ssm:GetParameter"] resources = [aws_ssm_parameter.models.arn] }
# SSM SecureString read for the Logfire token, only when wired. dynamic "statement" { for_each = local.logfire_token_wired ? [1] : [] content { actions = ["ssm:GetParameter"] resources = [aws_ssm_parameter.logfire_token[0].arn] } }
# SSM SecureString read for the Mistral API key, only when wired. dynamic "statement" { for_each = local.mistral_key_wired ? [1] : [] content { actions = ["ssm:GetParameter"] resources = [aws_ssm_parameter.mistral_api_key[0].arn] } }
# SSM SecureString read for the Fireworks API key, only when wired. dynamic "statement" { for_each = local.fireworks_key_wired ? [1] : [] content { actions = ["ssm:GetParameter"] resources = [aws_ssm_parameter.fireworks_api_key[0].arn] } }
# KMS Decrypt on the AWS-managed SSM key, required to decrypt any # SecureString read via GetParameter. Present when either secret is wired. # SecureString read via GetParameter. Present when any secret is wired. dynamic "statement" { for_each = local.logfire_token_wired || local.mistral_key_wired ? [1] : [] for_each = local.logfire_token_wired || local.mistral_key_wired || local.fireworks_key_wired ? [1] : [] content { actions = ["kms:Decrypt"] resources = [122 collapsed lines
"arn:aws:kms:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:alias/aws/ssm", ] } }
statement { actions = [ "firehose:PutRecord", "firehose:PutRecordBatch", ] resources = [aws_kinesis_firehose_delivery_stream.audit.arn] }
# Write-only: the handler parks run artifacts and never reads them back, # so there is no GetObject or ListBucket. Scoped to the runs/ prefix the # handler writes under. statement { actions = ["s3:PutObject"] resources = ["${aws_s3_bucket.runs.arn}/runs/*"] }}
resource "aws_iam_role" "lambda" { name = "terraform-pr-agent-lambda" assume_role_policy = data.aws_iam_policy_document.lambda_assume.json}
resource "aws_iam_role_policy_attachment" "lambda_basic_execution" { role = aws_iam_role.lambda.name policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"}
resource "aws_iam_role_policy" "lambda_permissions" { name = "terraform-pr-agent-lambda-permissions" role = aws_iam_role.lambda.id policy = data.aws_iam_policy_document.lambda_permissions.json}
# The Lambda Insights extension baked into the image ships its metrics# through its own /aws/lambda-insights log group; the AWS-managed policy# grants exactly that write path.resource "aws_iam_role_policy_attachment" "lambda_insights" { role = aws_iam_role.lambda.name policy_arn = "arn:aws:iam::aws:policy/CloudWatchLambdaInsightsExecutionRolePolicy"}
resource "aws_lambda_function" "agent" { function_name = "terraform-pr-agent" role = aws_iam_role.lambda.arn architectures = ["arm64"]
# The handler entry point comes from the image's CMD; the runtime, # handler, and layers attributes only apply to zip packages. package_type = "Image" image_uri = "${aws_ecr_repository.agent.repository_url}:placeholder"
# Max Memory Used overstates what this function needs. It runs to ~2 GB on a # heavy run, but the track_memory spans (see agent/memory.py) show the real, # non-reclaimable demand stays under ~1 GB: ~315 MB resident for the Python # runtime plus a transient ~420 MB while terraform validate loads the # provider schema. The rest is reclaimable page cache from the ~800 MB # provider download and re-lock unpacks doing file IO on /tmp, which the # cgroup-based billed figure counts but the kernel drops under pressure, so # it is not OOM risk. Memory is therefore not the binding constraint here. # 3008 is set for the vCPU it buys, not the RAM: above 1769 MB Lambda gives a # full core (3008 is ~1.7), which speeds the run. Drop it toward ~1769 if # latency matters less than cost; do not raise it for memory headroom. memory_size = 3008
# The per-run tool budget is the runaway guard; the timeout only has to # accommodate several model turns with init + validate rounds in between. timeout = 300
# Two things land in /tmp: terraform init downloads the AWS provider # (~800 MB) into the workspace, and the post-run re-lock unpacks the # provider for three platforms (another ~2 GB) to write a portable lock # file. 4 GB covers both with headroom; the default 512 MB would not. ephemeral_storage { size = 4096 }
tracing_config { mode = "Active" }
environment { variables = merge( { MODELS_PARAMETER = aws_ssm_parameter.models.name DEFAULT_MODEL = var.default_model METRICS_NAMESPACE = local.metrics_namespace FIREHOSE_DELIVERY_STREAM = aws_kinesis_firehose_delivery_stream.audit.name RUNS_BUCKET = aws_s3_bucket.runs.bucket }, local.logfire_token_wired ? { LOGFIRE_TOKEN_PARAMETER = local.logfire_token_parameter_name } : {}, local.mistral_key_wired ? { MISTRAL_API_KEY_PARAMETER = aws_ssm_parameter.mistral_api_key[0].name } : {}, ) }
# Code ships out of band: scripts/build-lambda.sh pushes a new image and # calls update-function-code, so terraform must not flip the function # back to the placeholder on the next apply. lifecycle { ignore_changes = [image_uri] }
depends_on = [ aws_iam_role_policy_attachment.lambda_basic_execution, aws_iam_role_policy_attachment.lambda_insights, aws_iam_role_policy.lambda_permissions, terraform_data.placeholder_image, ]}
output "lambda_function_name" { value = aws_lambda_function.agent.function_name}
output "lambda_function_arn" { value = aws_lambda_function.agent.arn}data "aws_iam_policy_document" "lambda_assume" { statement { actions = ["sts:AssumeRole"] principals { type = "Service" identifiers = ["lambda.amazonaws.com"] } }}
# trivy:ignore:avd-aws-0057# Bedrock foundation-model ARNs do not pin to the caller region (the inference# profile fans out cross-region), and Marketplace subscription actions are# global by design.data "aws_iam_policy_document" "lambda_permissions" { # Bedrock invocation. Same shape as iam.tf's bedrock_invoke; copied # here so the Lambda role is self-contained and does not require # the user-role policy to also be attached to the Lambda role. statement { actions = [ "bedrock:Converse", "bedrock:ConverseStream", "bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream", ] # Bedrock foundation-model ARNs do not pin to the caller region; the # inference profile fans out cross-region, so the * region segment is required. #trivy:ignore:avd-aws-0057 resources = [ aws_bedrock_inference_profile.agent.arn, local.system_inference_profile_arn, "arn:aws:bedrock:*::foundation-model/${local.bedrock_model_id}", ] }
statement { actions = [ "aws-marketplace:Subscribe", "aws-marketplace:Unsubscribe", "aws-marketplace:ViewSubscriptions", ] # Marketplace subscription actions are global by design. #trivy:ignore:avd-aws-0057 resources = ["*"] }
# Model registry read. A plain String parameter, so no KMS is involved. statement { actions = ["ssm:GetParameter"] resources = [aws_ssm_parameter.models.arn] }
# SSM SecureString read for the Logfire token, only when wired. dynamic "statement" { for_each = local.logfire_token_wired ? [1] : [] content { actions = ["ssm:GetParameter"] resources = [aws_ssm_parameter.logfire_token[0].arn] } }
# SSM SecureString read for the Mistral API key, only when wired. dynamic "statement" { for_each = local.mistral_key_wired ? [1] : [] content { actions = ["ssm:GetParameter"] resources = [aws_ssm_parameter.mistral_api_key[0].arn] } }
# SSM SecureString read for the Fireworks API key, only when wired. dynamic "statement" { for_each = local.fireworks_key_wired ? [1] : [] content { actions = ["ssm:GetParameter"] resources = [aws_ssm_parameter.fireworks_api_key[0].arn] } }
# KMS Decrypt on the AWS-managed SSM key, required to decrypt any # SecureString read via GetParameter. Present when any secret is wired. dynamic "statement" { for_each = local.logfire_token_wired || local.mistral_key_wired || local.fireworks_key_wired ? [1] : [] content { actions = ["kms:Decrypt"] resources = [ "arn:aws:kms:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:alias/aws/ssm", ] } }
statement { actions = [ "firehose:PutRecord", "firehose:PutRecordBatch", ] resources = [aws_kinesis_firehose_delivery_stream.audit.arn] }
# Write-only: the handler parks run artifacts and never reads them back, # so there is no GetObject or ListBucket. Scoped to the runs/ prefix the # handler writes under. statement { actions = ["s3:PutObject"] resources = ["${aws_s3_bucket.runs.arn}/runs/*"] }}
resource "aws_iam_role" "lambda" { name = "terraform-pr-agent-lambda" assume_role_policy = data.aws_iam_policy_document.lambda_assume.json}
resource "aws_iam_role_policy_attachment" "lambda_basic_execution" { role = aws_iam_role.lambda.name policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"}
resource "aws_iam_role_policy" "lambda_permissions" { name = "terraform-pr-agent-lambda-permissions" role = aws_iam_role.lambda.id policy = data.aws_iam_policy_document.lambda_permissions.json}
# The Lambda Insights extension baked into the image ships its metrics# through its own /aws/lambda-insights log group; the AWS-managed policy# grants exactly that write path.resource "aws_iam_role_policy_attachment" "lambda_insights" { role = aws_iam_role.lambda.name policy_arn = "arn:aws:iam::aws:policy/CloudWatchLambdaInsightsExecutionRolePolicy"}
resource "aws_lambda_function" "agent" { function_name = "terraform-pr-agent" role = aws_iam_role.lambda.arn architectures = ["arm64"]
# The handler entry point comes from the image's CMD; the runtime, # handler, and layers attributes only apply to zip packages. package_type = "Image" image_uri = "${aws_ecr_repository.agent.repository_url}:placeholder"
# Max Memory Used overstates what this function needs. It runs to ~2 GB on a # heavy run, but the track_memory spans (see agent/memory.py) show the real, # non-reclaimable demand stays under ~1 GB: ~315 MB resident for the Python # runtime plus a transient ~420 MB while terraform validate loads the # provider schema. The rest is reclaimable page cache from the ~800 MB # provider download and re-lock unpacks doing file IO on /tmp, which the # cgroup-based billed figure counts but the kernel drops under pressure, so # it is not OOM risk. Memory is therefore not the binding constraint here. # 3008 is set for the vCPU it buys, not the RAM: above 1769 MB Lambda gives a # full core (3008 is ~1.7), which speeds the run. Drop it toward ~1769 if # latency matters less than cost; do not raise it for memory headroom. memory_size = 3008
# The per-run tool budget is the runaway guard; the timeout only has to # accommodate several model turns with init + validate rounds in between. timeout = 300
# Two things land in /tmp: terraform init downloads the AWS provider # (~800 MB) into the workspace, and the post-run re-lock unpacks the # provider for three platforms (another ~2 GB) to write a portable lock # file. 4 GB covers both with headroom; the default 512 MB would not. ephemeral_storage { size = 4096 }
tracing_config { mode = "Active" }
environment { variables = merge( { MODELS_PARAMETER = aws_ssm_parameter.models.name DEFAULT_MODEL = var.default_model METRICS_NAMESPACE = local.metrics_namespace FIREHOSE_DELIVERY_STREAM = aws_kinesis_firehose_delivery_stream.audit.name RUNS_BUCKET = aws_s3_bucket.runs.bucket }, local.logfire_token_wired ? { LOGFIRE_TOKEN_PARAMETER = local.logfire_token_parameter_name } : {}, local.mistral_key_wired ? { MISTRAL_API_KEY_PARAMETER = aws_ssm_parameter.mistral_api_key[0].name } : {}, ) }
# Code ships out of band: scripts/build-lambda.sh pushes a new image and # calls update-function-code, so terraform must not flip the function # back to the placeholder on the next apply. lifecycle { ignore_changes = [image_uri] }
depends_on = [ aws_iam_role_policy_attachment.lambda_basic_execution, aws_iam_role_policy_attachment.lambda_insights, aws_iam_role_policy.lambda_permissions, terraform_data.placeholder_image, ]}
output "lambda_function_name" { value = aws_lambda_function.agent.function_name}
output "lambda_function_arn" { value = aws_lambda_function.agent.arn}locals { logfire_token_parameter_name = "/terraform-pr-agent/logfire-token" logfire_token_wired = var.logfire_token != ""}
resource "aws_ssm_parameter" "logfire_token" { count = local.logfire_token_wired ? 1 : 0
name = local.logfire_token_parameter_name description = "Logfire write token. Consumed by the terraform-pr-agent Lambda." type = "SecureString" value = var.logfire_token}
output "logfire_token_parameter_arn" { # nonsensitive: the ARN is just a resource identifier, but Terraform's # taint analysis propagates the sensitive marker from var.logfire_token # via the resource. Explicit unmark so the ARN renders in `terraform # output` (the value itself never leaks the token). value = nonsensitive(local.logfire_token_wired ? aws_ssm_parameter.logfire_token[0].arn : "") description = "ARN of the SSM SecureString; empty when LOGFIRE_TOKEN is not set."}terraform { required_providers { aws = { source = "hashicorp/aws" version = "6.17.0" } }}
provider "aws" {}
data "aws_caller_identity" "current" {}data "aws_region" "current" {}# The model registry: Terraform owns it, renders it to JSON, and parks it in# an SSM String parameter the handler reads at startup. Each entry names a# provider and a model id; Bedrock entries also carry the inference-profile# ARN. DEFAULT_MODEL (set on the Lambda) selects the active one, so switching# the agent's model is a parameter change, not a code change.locals { metrics_namespace = "TerraformPrAgent/Models"
7 collapsed lines
models = { haiku = { provider = "bedrock" model_id = local.bedrock_model_id inference_profile_arn = aws_bedrock_inference_profile.agent.arn } "mistral-large" = { provider = "mistral" model_id = "mistral-large-latest" } "devstral-small" = { "mistral-medium" = { provider = "mistral" model_id = "devstral-small-2507" model_id = "mistral-medium-latest" } "codestral" = { provider = "mistral" model_id = "codestral-latest" } "glm5p2" = { provider = "fireworks" model_id = "accounts/fireworks/models/glm-5p2" } }
mistral_key_wired = var.mistral_api_key != "" mistral_key_wired = var.mistral_api_key != "" fireworks_key_wired = var.fireworks_api_key != ""}
resource "aws_ssm_parameter" "models" {3 collapsed lines
name = "/terraform-pr-agent/models" description = "Model registry for the terraform-pr-agent Lambda (provider + model id per entry)." type = "String" value = jsonencode(local.models)}
# A second registry, identical except that the Bedrock entry points at an# eval-scoped inference profile. Eval sweeps reuse the production code path,# so isolation has to come from configuration: the eval runner points# MODELS_PARAMETER here and its Haiku traffic bills separately.resource "aws_bedrock_inference_profile" "agent_evals" { name = "terraform-pr-agent-evals" description = "Application inference profile for offline eval sweeps."
model_source { copy_from = local.system_inference_profile_arn }
# Cost attribution only. Nothing resolves models by tag: the four # Mistral and Fireworks entries have no AWS resource to query, so the SSM # registry stays the single lookup path for every provider. tags = { Purpose = "eval" }}
locals { models_evals = merge(local.models, { haiku = merge(local.models.haiku, { inference_profile_arn = aws_bedrock_inference_profile.agent_evals.arn }) })}
resource "aws_ssm_parameter" "models_evals" { name = "/terraform-pr-agent/models-evals" description = "Model registry for offline eval sweeps (eval-scoped Bedrock inference profile)." type = "String" value = jsonencode(local.models_evals)}
# The Mistral API key, SecureString, fetched by the handler through the same# Parameters and Secrets extension path as the Logfire token. Only created# when TF_VAR_mistral_api_key is set, mirroring the Logfire token wiring; with8 collapsed lines
# it unset the Mistral providers are simply unreachable and a Bedrock default# still works.resource "aws_ssm_parameter" "mistral_api_key" { count = local.mistral_key_wired ? 1 : 0
name = "/terraform-pr-agent/mistral-api-key" description = "Mistral API key. Consumed by the terraform-pr-agent Lambda." type = "SecureString" value = var.mistral_api_key}
resource "aws_ssm_parameter" "fireworks_api_key" { count = local.fireworks_key_wired ? 1 : 0
name = "/terraform-pr-agent/fireworks-api-key" description = "Fireworks API key. Consumed by the terraform-pr-agent Lambda." type = "SecureString" value = var.fireworks_api_key}
output "models_parameter_name" { value = aws_ssm_parameter.models.name}
output "models_evals_parameter_name" { value = aws_ssm_parameter.models_evals.name}locals { metrics_namespace = "TerraformPrAgent/Models"
models = { haiku = { provider = "bedrock" model_id = local.bedrock_model_id inference_profile_arn = aws_bedrock_inference_profile.agent.arn } "mistral-large" = { provider = "mistral" model_id = "mistral-large-latest" } "mistral-medium" = { provider = "mistral" model_id = "mistral-medium-latest" } "codestral" = { provider = "mistral" model_id = "codestral-latest" } "glm5p2" = { provider = "fireworks" model_id = "accounts/fireworks/models/glm-5p2" } }
mistral_key_wired = var.mistral_api_key != "" fireworks_key_wired = var.fireworks_api_key != ""}
resource "aws_ssm_parameter" "models" { name = "/terraform-pr-agent/models" description = "Model registry for the terraform-pr-agent Lambda (provider + model id per entry)." type = "String" value = jsonencode(local.models)}
# A second registry, identical except that the Bedrock entry points at an# eval-scoped inference profile. Eval sweeps reuse the production code path,# so isolation has to come from configuration: the eval runner points# MODELS_PARAMETER here and its Haiku traffic bills separately.resource "aws_bedrock_inference_profile" "agent_evals" { name = "terraform-pr-agent-evals" description = "Application inference profile for offline eval sweeps."
model_source { copy_from = local.system_inference_profile_arn }
# Cost attribution only. Nothing resolves models by tag: the four # Mistral and Fireworks entries have no AWS resource to query, so the SSM # registry stays the single lookup path for every provider. tags = { Purpose = "eval" }}
locals { models_evals = merge(local.models, { haiku = merge(local.models.haiku, { inference_profile_arn = aws_bedrock_inference_profile.agent_evals.arn }) })}
resource "aws_ssm_parameter" "models_evals" { name = "/terraform-pr-agent/models-evals" description = "Model registry for offline eval sweeps (eval-scoped Bedrock inference profile)." type = "String" value = jsonencode(local.models_evals)}
# The Mistral API key, SecureString, fetched by the handler through the same# Parameters and Secrets extension path as the Logfire token. Only created# when TF_VAR_mistral_api_key is set, mirroring the Logfire token wiring; with# it unset the Mistral providers are simply unreachable and a Bedrock default# still works.resource "aws_ssm_parameter" "mistral_api_key" { count = local.mistral_key_wired ? 1 : 0
name = "/terraform-pr-agent/mistral-api-key" description = "Mistral API key. Consumed by the terraform-pr-agent Lambda." type = "SecureString" value = var.mistral_api_key}
resource "aws_ssm_parameter" "fireworks_api_key" { count = local.fireworks_key_wired ? 1 : 0
name = "/terraform-pr-agent/fireworks-api-key" description = "Fireworks API key. Consumed by the terraform-pr-agent Lambda." type = "SecureString" value = var.fireworks_api_key}
output "models_parameter_name" { value = aws_ssm_parameter.models.name}
output "models_evals_parameter_name" { value = aws_ssm_parameter.models_evals.name}locals { runs_bucket_name = "terraform-pr-agent-runs-${data.aws_caller_identity.current.account_id}-${data.aws_region.current.region}"}
# Stopgap output sink until the GitHub PR flow lands in a later post: the# agent's /tmp workspace dies with the invocation, so each run parks its# files and a minimal result marker under runs/<run_id>/ here. Nothing in# this bucket is a system of record (the Object Lock audit bucket is), so# it takes the opposite posture of audit-bucket.tf: force_destroy so# terraform destroy empties and removes the bucket, no versioning, and# SSE-S3 instead of a KMS key that would outlive the bucket's purpose.# Access logging would require a second bucket, which transient run# outputs are not worth.#trivy:ignore:avd-aws-0089#trivy:ignore:avd-aws-0090resource "aws_s3_bucket" "runs" { bucket = local.runs_bucket_name force_destroy = true}
resource "aws_s3_bucket_public_access_block" "runs" { bucket = aws_s3_bucket.runs.id block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true}
# Agent-written HCL, not secrets; SSE-S3 keeps the temporary bucket free# of a CMK lifecycle.#trivy:ignore:avd-aws-0132resource "aws_s3_bucket_server_side_encryption_configuration" "runs" { bucket = aws_s3_bucket.runs.id
rule { apply_server_side_encryption_by_default { sse_algorithm = "AES256" } }}variable "alert_email" { description = "Email address subscribed to the agent alerts SNS topic. Set via TF_VAR_alert_email." type = string11 collapsed lines
}
variable "daily_token_alarm_threshold" { description = "Daily combined input + output token threshold. Crossing it sends an email via SNS." type = number default = 1000000}
variable "audit_retention_days" { type = number description = "Object Lock default retention in days. Tutorial default is 7 so the bucket is easy to clean up; production audit horizons are typically years (e.g. 2555 for SOX-style controls)." default = 7}
# arm64 build of the AWS Parameters and Secrets Lambda Extension, pinned to eu-west-1.variable "secrets_extension_layer_arn" { type = string description = "ARN of the AWS Parameters and Secrets Lambda Extension layer." default = "arn:aws:lambda:eu-west-1:015030872274:layer:AWS-Parameters-and-Secrets-Lambda-Extension-Arm64:87"}
variable "logfire_token" { type = string description = "Logfire write token. Leave empty to skip the Logfire integration. Set via TF_VAR_logfire_token in .envrc.local."3 collapsed lines
default = "" sensitive = true}
variable "mistral_api_key" { type = string description = "Mistral API key. Leave empty to skip the Mistral providers (Bedrock models still work). Set via TF_VAR_mistral_api_key in .envrc.local." description = "Mistral API key. Leave empty to skip the Mistral provider (Bedrock models still work). Set via TF_VAR_mistral_api_key in .envrc.local." default = "" sensitive = true}
variable "fireworks_api_key" { type = string description = "Fireworks API key. Leave empty to skip the Fireworks provider (Bedrock models still work). Set via TF_VAR_mistral_api_key in .envrc.local." default = "" sensitive = true}
variable "default_model" { type = string description = "Registry key of the model the agent runs with (see models.tf). One of: haiku, mistral-large, devstral-small." default = "mistral-large" description = "Registry key of the model the agent runs with (see models.tf). One of: haiku, mistral-large, mistral-medium, codestral, glm5p2." default = "mistral-medium"}variable "alert_email" { description = "Email address subscribed to the agent alerts SNS topic. Set via TF_VAR_alert_email." type = string}
variable "daily_token_alarm_threshold" { description = "Daily combined input + output token threshold. Crossing it sends an email via SNS." type = number default = 1000000}
variable "audit_retention_days" { type = number description = "Object Lock default retention in days. Tutorial default is 7 so the bucket is easy to clean up; production audit horizons are typically years (e.g. 2555 for SOX-style controls)." default = 7}
# arm64 build of the AWS Parameters and Secrets Lambda Extension, pinned to eu-west-1.variable "secrets_extension_layer_arn" { type = string description = "ARN of the AWS Parameters and Secrets Lambda Extension layer." default = "arn:aws:lambda:eu-west-1:015030872274:layer:AWS-Parameters-and-Secrets-Lambda-Extension-Arm64:87"}
variable "logfire_token" { type = string description = "Logfire write token. Leave empty to skip the Logfire integration. Set via TF_VAR_logfire_token in .envrc.local." default = "" sensitive = true}
variable "mistral_api_key" { type = string description = "Mistral API key. Leave empty to skip the Mistral provider (Bedrock models still work). Set via TF_VAR_mistral_api_key in .envrc.local." default = "" sensitive = true}
variable "fireworks_api_key" { type = string description = "Fireworks API key. Leave empty to skip the Fireworks provider (Bedrock models still work). Set via TF_VAR_mistral_api_key in .envrc.local." default = "" sensitive = true}
variable "default_model" { type = string description = "Registry key of the model the agent runs with (see models.tf). One of: haiku, mistral-large, mistral-medium, codestral, glm5p2." default = "mistral-medium"}#!/usr/bin/env bash# Build and push the terraform-pr-agent container image, then point the# Lambda at it. Terraform owns the ECR repo and the function (infra/);# this script only ships code, the same split as the zip flow it# replaces.## Runs from anywhere; cd's to the project root (the dir holding the# Dockerfile).set -euo pipefail
cd "$(dirname "$0")/.."
# Make sure the lock matches pyproject.toml before the Dockerfile copies# it into the build.uv sync --quiet
# Terraform created the repo; asking AWS for the URI keeps the account# id and region out of this script.repo_uri="$(aws ecr describe-repositories \ --repository-names terraform-pr-agent \ --query 'repositories[0].repositoryUri' --output text)"registry="${repo_uri%%/*}"
aws ecr get-login-password | docker login --username AWS --password-stdin "$registry"
# --provenance=false: buildx otherwise wraps the image in an OCI image# index for the provenance attestation, which Lambda rejects.docker buildx build --platform linux/arm64 --provenance=false \ -t "$repo_uri:latest" --push .
# update-function-code resolves :latest to a digest at call time, so a# re-pushed tag rolls the function forward. No --publish needed:# invocations hit $LATEST.aws lambda update-function-code \ --function-name terraform-pr-agent \ --image-uri "$repo_uri:latest" >/dev/nullaws lambda wait function-updated --function-name terraform-pr-agent
echo "deployed: $repo_uri:latest"# /// script# requires-python = ">=3.11"# dependencies = [# "pydantic-ai-slim[bedrock]>=1.106,<2",# "boto3>=1.35,<2",# ]# ///"""Command-line chat against Claude Haiku 4.5 on Bedrock.
Reads three env vars (set them in .envrc.local; see post 1):
AGENT_ROLE_ARN IAM role to assume. Source: terraform -chdir=infra output -raw agent_role_arn BEDROCK_INFERENCE_PROFILE_ARN Application inference profile that wraps the EU CRIS profile. Source: terraform -chdir=infra output -raw inference_profile_arn AWS_REGION Region for the bedrock-runtime endpoint. Defaults to eu-west-1.
Run with `uv run scripts/chat.py` from the project root."""
import osimport sys
import boto3from botocore.exceptions import ClientErrorfrom pydantic import BaseModel, Fieldfrom pydantic_ai import Agentfrom pydantic_ai.models.bedrock import BedrockConverseModelfrom pydantic_ai.providers.bedrock import BedrockProvider
MODEL_ID = "anthropic.claude-haiku-4-5-20251001-v1:0"
SYSTEM_PROMPT = ( "You are a conversational assistant running in a small command-line chat. " "Respond naturally to the user in the `message` field. " "Set `terminate=True` only when the user explicitly asks to end the " "conversation (for example: bye, quit, exit, stop, that's all). " "When you set `terminate=True`, include a short farewell in `message`. " "In every other case set `terminate=False`.")
class Reply(BaseModel): """Structured response on every turn so the loop can decide when to stop."""
message: str = Field(description="Natural-language reply to the user.") terminate: bool = Field( description=( "True only when the user has explicitly asked to end the conversation; False otherwise." ) )
def require_env(name: str) -> str: value = os.environ.get(name) if not value: sys.stderr.write( f"error: {name} is not set. Add it to .envrc.local from " f"`terraform output`, then run `direnv reload`.\n" ) sys.exit(2) return value
def assume_role(role_arn: str) -> dict[str, str]: """Trade the local SSO identity for short-lived terraform-pr-agent creds.""" sts = boto3.client("sts") response = sts.assume_role(RoleArn=role_arn, RoleSessionName="chat-cli") return response["Credentials"]
def build_model(role_arn: str, inference_profile_arn: str, region: str) -> BedrockConverseModel: creds = assume_role(role_arn) bedrock_client = boto3.client( "bedrock-runtime", region_name=region, aws_access_key_id=creds["AccessKeyId"], aws_secret_access_key=creds["SecretAccessKey"], aws_session_token=creds["SessionToken"], ) # The application inference profile ARN goes in settings, not model_name: # pydantic-ai still uses the foundation-model id for capability detection # and routes the actual Converse call through the profile. return BedrockConverseModel( MODEL_ID, provider=BedrockProvider(bedrock_client=bedrock_client), settings={"bedrock_inference_profile": inference_profile_arn}, )
def main() -> None: role_arn = require_env("AGENT_ROLE_ARN") inference_profile_arn = require_env("BEDROCK_INFERENCE_PROFILE_ARN") region = os.environ.get("AWS_REGION", "eu-west-1")
try: model = build_model(role_arn, inference_profile_arn, region) except ClientError as err: sys.stderr.write( f"error: could not assume {role_arn}: {err}\n" f"check that `terraform apply` has run and AWS_PROFILE is set.\n" ) sys.exit(1)
agent = Agent(model, output_type=Reply, system_prompt=SYSTEM_PROMPT)
print('Chatting with Claude Haiku 4.5 via terraform-pr-agent. Type "bye" to exit.') history: list = [] while True: try: user_input = input("you> ").strip() except (EOFError, KeyboardInterrupt): print() break if not user_input: continue try: result = agent.run_sync(user_input, message_history=history) except ClientError as err: sys.stderr.write(f"bedrock error: {err}\n") continue reply = result.output print(f"agent> {reply.message}") history = result.all_messages() if reply.terminate: break
if __name__ == "__main__": main()SELECT *FROM tracesWHERE year = 2026 AND month = 6 AND day = 8ORDER BY started DESC;
-- Example output (narrowed to 7 of the view's 18 columns; SELECT * also-- returns conversation_id, system_prompt, user_prompt, assistant_response,-- input_messages, output_messages, status, batch_key, source_file,-- year/month/day):---- ┌───────────────────────────────┬──────────┬────────────────────────────────┬─────────────┬───────────┬────────────┬────────┐-- │ started │ trace │ model │ dur_ms │ in_tokens │ out_tokens │ finish │-- ├───────────────────────────────┼──────────┼────────────────────────────────┼─────────────┼───────────┼────────────┼────────┤-- │ 2026-06-08 20:31:06.044709547 │ 019ea8ee │ claude-haiku-4-5-20251001-v1:0 │ 6815.733257 │ 36 │ 59 │ stop │-- │ 2026-06-08 20:31:05.984812728 │ 019ea8ee │ claude-haiku-4-5-20251001-v1:0 │ 1459.186377 │ 36 │ 38 │ stop │-- │ 2026-06-08 20:31:05.964196348 │ 019ea8ee │ claude-haiku-4-5-20251001-v1:0 │ 2024.133412 │ 36 │ 123 │ stop │-- │ 2026-06-08 20:31:05.944446527 │ 019ea8ee │ claude-haiku-4-5-20251001-v1:0 │ 1581.984687 │ 36 │ 50 │ stop │-- │ 2026-06-08 20:31:05.926721468 │ 019ea8ee │ claude-haiku-4-5-20251001-v1:0 │ 1480.496671 │ 36 │ 43 │ stop │-- └───────────────────────────────┴──────────┴────────────────────────────────┴─────────────┴───────────┴────────────┴────────┘
SELECT day, count(*) AS runsFROM tracesWHERE year = 2026 AND month = 6GROUP BY dayORDER BY day;
-- Example output:---- ┌─────┬──────┐-- │ day │ runs │-- ├─────┼──────┤-- │ 04 │ 2 │-- │ 08 │ 11 │-- │ 09 │ 1 │-- └─────┴──────┘
SELECT day, sum(in_tokens) AS input_tokens, sum(out_tokens) AS output_tokensFROM tracesWHERE year = 2026GROUP BY dayORDER BY day;
-- Example output:---- ┌─────┬──────────────┬───────────────┐-- │ day │ input_tokens │ output_tokens │-- ├─────┼──────────────┼───────────────┤-- │ 04 │ 80 │ 43 │-- │ 08 │ 397 │ 605 │-- │ 09 │ 38 │ 145 │-- └─────┴──────────────┴───────────────┘-- OR REPLACE so re-running this script is idempotent: a plain-- CREATE PERSISTENT SECRET errors on the second run (the secret persists-- in ~/.duckdb), which aborts the script before the view below is rebuilt-- and leaves a stale `traces` view in place.CREATE OR REPLACE PERSISTENT SECRET ( TYPE s3, PROVIDER credential_chain, REFRESH auto);SET VARIABLE audit_bucket = getenv('AUDIT_BUCKET');
-- hive_partitioning = true reads year=YYYY/month=MM/day=DD/ from the-- object path as virtual columns, so the partition predicate in a-- query below prunes objects before any file is opened.CREATE OR REPLACE VIEW traces ASWITH spans AS ( -- Flatten the OTLP-JSON envelope into one row per span, with the -- common span fields lifted out as named columns so downstream -- CTEs and ad-hoc queries can work against `name`, `trace_id`, -- `dur_ms`, etc. without re-doing the struct navigation each time. SELECT year, month, day, span.name AS name, lower(hex(from_base64(span.traceId::VARCHAR))) AS trace_id, lower(hex(from_base64(span.spanId::VARCHAR))) AS span_id, lower(hex(from_base64(span.parentSpanId::VARCHAR))) AS parent_span_id, make_timestamp_ns(span.startTimeUnixNano::BIGINT) AS started, make_timestamp_ns(span.endTimeUnixNano::BIGINT) AS ended, (span.endTimeUnixNano::BIGINT - span.startTimeUnixNano::BIGINT) / 1e6 AS dur_ms, span.status.code::VARCHAR AS status_code, span.attributes AS attributes, data.filename AS source_file, -- Firehose names objects <stream>-<ver>-<YYYY-MM-DD-HH-MM-SS>-<uuid>.gz; -- stripping the trailing -<uuid>.gz collapses rows from the same flush -- batch onto a stable key for grouping. regexp_replace( split_part(data.filename, '/', -1), '-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.gz$', '' ) AS batch_key FROM read_ndjson( 's3://' || getvariable('audit_bucket') || '/traces/**/*.gz', compression = 'gzip', hive_partitioning = true, filename = true) AS data , UNNEST(data.resourceSpans) AS u1(rs) , UNNEST(rs.scopeSpans) AS u2(ss) , UNNEST(ss.spans) AS u3(span)),roots AS ( -- One row per agent run: pydantic-ai's invoke_agent span, identified by -- the GenAI operation rather than by being the parentless span. -- instrument_aws_lambda now roots each trace at the SpanKind.SERVER -- invocation span, so the agent run is a child of it, not the trace root. -- (A caller-side retry would put several invoke_agent spans under one -- invocation; the trace_id join below would then cross them, so that -- case wants each chat tied to its enclosing run instead.) SELECT * FROM spans WHERE list_filter(attributes, x -> x.key = 'gen_ai.operation.name')[1] .value.stringValue = 'invoke_agent'),chats AS ( -- Per-trace summary of the LLM call: pulls the GenAI semantic -- convention attributes off the chat span and exposes each as a -- named column. SELECT trace_id, list_filter(attributes, x -> x.key = 'gen_ai.system')[1].value.stringValue AS gen_ai_system, list_filter(attributes, x -> x.key = 'gen_ai.operation.name')[1].value.stringValue AS operation, list_filter(attributes, x -> x.key = 'gen_ai.request.model')[1].value.stringValue AS request_model, list_filter(attributes, x -> x.key = 'gen_ai.response.model')[1].value.stringValue AS response_model, list_filter(attributes, x -> x.key = 'gen_ai.usage.input_tokens')[1].value.intValue::BIGINT AS in_tokens, list_filter(attributes, x -> x.key = 'gen_ai.usage.output_tokens')[1].value.intValue::BIGINT AS out_tokens, list_filter(attributes, x -> x.key = 'gen_ai.response.finish_reasons')[1] .value.arrayValue.values[1].stringValue AS finish, list_filter(attributes, x -> x.key = 'gen_ai.conversation.id')[1].value.stringValue AS conversation_id, list_filter(attributes, x -> x.key = 'gen_ai.agent.name')[1].value.stringValue AS agent_name, list_filter(attributes, x -> x.key = 'gen_ai.agent.call.id')[1].value.stringValue AS agent_call_id, list_filter(attributes, x -> x.key = 'gen_ai.input.messages')[1].value.stringValue AS input_messages, list_filter(attributes, x -> x.key = 'gen_ai.output.messages')[1].value.stringValue AS output_messages FROM spans WHERE name LIKE 'chat %'),final_chats AS ( -- The run's closing assistant message, one row per trace: the chat span -- that ended last. Its first text part is the summary the agent returns, -- so unlike the per-row assistant_response convenience column it is not -- knocked out by the tool-call turns a multi-turn run is mostly made of. SELECT trace_id, output_messages FROM ( SELECT trace_id, list_filter(attributes, x -> x.key = 'gen_ai.output.messages')[1].value.stringValue AS output_messages, row_number() OVER (PARTITION BY trace_id ORDER BY ended DESC) AS rn FROM spans WHERE name LIKE 'chat %' ) WHERE rn = 1)SELECT roots.started, roots.year, roots.month, roots.day, roots.trace_id, substr(roots.trace_id, 1, 8) AS trace, roots.batch_key, roots.source_file, roots.dur_ms, regexp_extract(chats.request_model, '[^./]+$') AS model, chats.in_tokens, chats.out_tokens, chats.finish, chats.agent_name, chats.conversation_id, -- Convenience columns for the common single-turn shape: system at -- input[0], user at input[1], assistant at output[0]. Multi-turn -- runs invalidate the indices, so reach for input_messages and -- output_messages directly for those. json_extract_string(chats.input_messages, '$[0].parts[0].content') AS system_prompt, json_extract_string(chats.input_messages, '$[1].parts[0].content') AS user_prompt, json_extract_string(chats.output_messages, '$[0].parts[0].content') AS assistant_response, -- One reliable assistant summary per trace (the closing turn), repeated -- across the trace's fanned-out rows; see the final_chats CTE. json_extract_string(final_chats.output_messages, '$[0].parts[0].content') AS final_response, chats.input_messages, chats.output_messages, -- Per the OTel spec, instrumentation libraries leave status unset -- on success (only application code may set it to Ok). Every OTel -- backend treats unset as "no error reported"; we render the same. CASE roots.status_code WHEN 'STATUS_CODE_ERROR' THEN 'err' ELSE 'ok' END AS statusFROM rootsJOIN chats USING (trace_id)LEFT JOIN final_chats USING (trace_id);{ "format_version": "1.2", "terraform_version": "1.15.6", "planned_values": { "root_module": { "resources": [ { "address": "aws_dynamodb_table.sessions", "mode": "managed", "type": "aws_dynamodb_table", "name": "sessions", "provider_name": "registry.terraform.io/hashicorp/aws", "values": { "billing_mode": "PAY_PER_REQUEST", "hash_key": "session_id", "name": "user-sessions" } }, { "address": "aws_sqs_queue.jobs", "mode": "managed", "type": "aws_sqs_queue", "name": "jobs", "provider_name": "registry.terraform.io/hashicorp/aws", "values": { "name": "background-jobs" } } ], "child_modules": [ { "address": "module.dlq", "resources": [ { "address": "module.dlq.aws_sqs_queue.dead_letter", "mode": "managed", "type": "aws_sqs_queue", "name": "dead_letter", "provider_name": "registry.terraform.io/hashicorp/aws", "values": { "name": "background-jobs-dlq" } } ] } ] } }}terraform { required_version = ">= 1.0" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } }}
resource "aws_s3_bucket" "b" { bucket = "example" tags = { Project = "demo" Environment = "dev" Owner = "team" ManagedBy = "terraform" }}"""Shared test setup.
handler.py reads FIREHOSE_DELIVERY_STREAM at import time, so a stand-in is setbefore any test imports it. The model registry and default model are read atINVOKE time; tests stub _build_model and swap in a scripted FunctionModel viaagent.override, so no real AWS or Mistral call is ever made. The S3 upload runsagainst moto."""
import os
import logfire
# The tools and the re-lock open logfire spans via track_memory. Outside# Lambda nothing configures logfire, so pin it to local-only here to keep# spans as no-ops and off the network during the test run.logfire.configure(send_to_logfire=False)
os.environ.setdefault("AWS_DEFAULT_REGION", "eu-central-1")os.environ.setdefault("FIREHOSE_DELIVERY_STREAM", "test-stream")os.environ.setdefault("METRICS_NAMESPACE", "TerraformPrAgent/Models")os.environ.setdefault("DEFAULT_MODEL", "mistral-large")os.environ.setdefault("MODELS_PARAMETER", "/terraform-pr-agent/models")"""Core test: the real Agent, tools, and execute(), with the Bedrock modelswapped for a scripted FunctionModel so no AWS call is made. The script walksthe loop the post is about: write broken HCL, init, watch validate fail (twice,exercising the raised retry budget), fix it, validate clean, report done. Theruns-bucket upload is exercised against moto, so the real boto3 call path runswithout touching AWS."""exercising the raised retry budget), fix it, validate clean, report done viathe final_result output tool. The runs-bucket upload is exercised against moto,so the real boto3 call path runs without touching AWS."""
import jsonimport subprocess10 collapsed lines
import uuidfrom collections import dequefrom types import SimpleNamespace
import agent.core as coreimport agent.models as modelsimport agent.observability as observabilityimport agent.runs as runsimport boto3import pytestfrom moto import mock_awsfrom opentelemetry.trace import SpanContext, TraceFlagsfrom opentelemetry.trace.status import StatusCodefrom pydantic_ai.messages import ModelMessage, ModelResponse, TextPart, ToolCallPartfrom pydantic_ai import Agentfrom pydantic_ai.exceptions import UnexpectedModelBehaviorfrom pydantic_ai.messages import ( ModelMessage, ModelResponse, RetryPromptPart, TextPart, ToolCallPart,)from pydantic_ai.models.bedrock import BedrockConverseModelfrom pydantic_ai.models.function import AgentInfo, FunctionModelfrom pydantic_ai.models.mistral import MistralModel11 collapsed lines
REGISTRY = { "haiku": { "provider": "bedrock", "model_id": "anthropic.claude-haiku-4-5-20251001-v1:0", "inference_profile_arn": "arn:aws:bedrock:eu-west-1:0:inference-profile/test", }, "mistral-large": {"provider": "mistral", "model_id": "mistral-large-latest"},}
# Captured before the autouse fixture stubs core._build_model, so the# factory tests below can call the real implementation._REAL_BUILD_MODEL = core._build_model
INVALID_TF = 'output "x" { value = var.missing }\n'VALID_TF = 'output "x" { value = "fixed" }\n'# validate_workspace now runs tflint and checkov after every agent turn, so# the fixtures carry the settings block tflint's required_version rule demands._TF_SETTINGS = 'terraform { required_version = ">= 1.0" }\n'INVALID_TF = _TF_SETTINGS + 'output "x" { value = var.missing }\n'VALID_TF = _TF_SETTINGS + 'output "x" { value = "fixed" }\n'
RUNS_BUCKET = "test-runs"
31 collapsed lines
def _stub_model(name: str) -> FunctionModel: # A throwaway model so _build_model makes no SSM call. agent.override in # each test supplies the model actually used; pydantic-ai still requires a # non-None model on the call, so this stands in for that slot. return FunctionModel(lambda messages, info: ModelResponse(parts=[TextPart(content="")]))
@pytest.fixture(autouse=True)def no_observability(monkeypatch) -> None: # The logfire/instrumentation wiring lives in agent.lambda_entry, which the # tests never import, so the Firehose-backed audit processor is never # registered here. _build_model would read the registry from SSM; tests # supply the model through agent.override instead, which takes precedence # over the per-run model. monkeypatch.setattr(core, "_build_model", _stub_model)
@pytest.fixturedef runs_bucket(monkeypatch): # Static stand-in credentials so a hole in the moto mock can never # reach a real account. monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing") monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing") monkeypatch.setenv("RUNS_BUCKET", RUNS_BUCKET) with mock_aws(): s3 = boto3.client("s3") s3.create_bucket( Bucket=RUNS_BUCKET, CreateBucketConfiguration={"LocationConstraint": "eu-central-1"}, ) yield s3
def final_result_call(summary: str = "workspace validated") -> ToolCallPart: # Ending a run now means calling the final_result output tool with a full # TaskResult payload; a bare TextPart no longer ends anything. return ToolCallPart( tool_name="final_result", args={ "summary": summary, "solution_description": "wrote main.tf", "validations_run": ["terraform_validate"], "issues_addressed": [], "known_limitations": [], "ready_for_review": True, }, )
def scripted_model(steps: deque[list[ToolCallPart]]) -> FunctionModel: def call(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: if steps: return ModelResponse(parts=list(steps.popleft())) return ModelResponse(parts=[TextPart(content="workspace validated")]) return ModelResponse(parts=[final_result_call()])
return FunctionModel(call)
23 collapsed lines
def happy_path_steps() -> deque[list[ToolCallPart]]: return deque( [ [ToolCallPart(tool_name="write_file", args={"path": "main.tf", "contents": VALID_TF})], [ToolCallPart(tool_name="terraform_init", args={})], [ToolCallPart(tool_name="terraform_validate", args={})], ] )
def uploaded_keys(s3) -> list[str]: objects = s3.list_objects_v2(Bucket=RUNS_BUCKET).get("Contents", []) return [entry["Key"] for entry in objects]
def test_execute_survives_consecutive_validate_failures(runs_bucket) -> None: steps = deque( [ [ ToolCallPart( tool_name="write_file", args={"path": "main.tf", "contents": INVALID_TF} ) ], [ToolCallPart(tool_name="terraform_init", args={})], # Two failing validates in a row: the default per-tool retry # budget of 1 would kill the run here; Agent(retries=10) is # what lets the loop continue. # budget of 1 would kill the run here; the raised `tools` # budget is what lets the loop continue. [ToolCallPart(tool_name="terraform_validate", args={})], [ToolCallPart(tool_name="terraform_validate", args={})], [12 collapsed lines
ToolCallPart( tool_name="edit_file", args={ "path": "main.tf", "old_string": "var.missing", "new_string": '"fixed"', }, ) ], [ToolCallPart(tool_name="terraform_validate", args={})], ] ) with core.agent.override(model=scripted_model(steps)): result = core.execute("make me a bucket")
assert result.output == "workspace validated" assert result.output.summary == "workspace validated" uuid.UUID(result.run_id) assert not steps, "the scripted run should consume every step"
def test_caller_retry_fixes_workspace_after_agent_claims_done(runs_bucket) -> None: # Run 1 leaves an invalid but initialized workspace and claims done while # validate still fails. The caller re-validates, feeds the error back, and # run 2 edits it clean. A TextPart step ends the current run, so the deque # spans two runs rather than draining in one.def test_output_validator_feeds_validate_failure_back(runs_bucket) -> None: # The agent leaves an invalid but initialized workspace and reports done. # The output validator re-runs the validators, rejects the final_result # with ModelRetry, and the same run continues: the agent edits the file # clean and reports done again. One run, one conversation, one trace. steps = deque( [ [2 collapsed lines
ToolCallPart( tool_name="write_file", args={"path": "main.tf", "contents": INVALID_TF} ) ], [ToolCallPart(tool_name="terraform_init", args={})], [TextPart(content="done")], [final_result_call("done")], [ ToolCallPart( tool_name="edit_file", args={"path": "main.tf", "old_string": "var.missing", "new_string": '"fixed"'}, ) ], [TextPart(content="fixed it")], [final_result_call("fixed it")], ] )
def call(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: if steps: return ModelResponse(parts=list(steps.popleft())) return ModelResponse(parts=[TextPart(content="done")])
with core.agent.override(model=FunctionModel(call)): with core.agent.override(model=scripted_model(steps)): result = core.execute("make me a bucket")
assert result.output == "fixed it" assert not steps, "both the first run and the caller-side retry should be consumed" assert result.output.summary == "fixed it" assert not steps, "the rejected final_result and the fix should both be consumed"
def test_caller_retry_raises_when_not_converging(runs_bucket) -> None: # The agent sets up an invalid, initialized workspace and never fixes it. # After the retry budget execute raises (Lambda 5xx) and still parks the # broken workspace under status error for debugging.def test_output_validator_raises_when_not_converging(runs_bucket) -> None: # The agent sets up an invalid, initialized workspace and keeps reporting # done without fixing it. After the `output` retry budget the run raises # (Lambda 5xx) and still parks the broken workspace under status error # for debugging; the validator's feedback lives in the trace. setup = deque( [ [8 collapsed lines
ToolCallPart( tool_name="write_file", args={"path": "main.tf", "contents": INVALID_TF} ) ], [ToolCallPart(tool_name="terraform_init", args={})], ] )
def call(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: if setup: return ModelResponse(parts=list(setup.popleft())) return ModelResponse(parts=[TextPart(content="all done")]) return ModelResponse(parts=[final_result_call("all done")])
with core.agent.override(model=FunctionModel(call)): with pytest.raises(core.ValidateDidNotConverge): with pytest.raises(UnexpectedModelBehavior, match="maximum output retries"): core.execute("make me a bucket")
result_key = next(key for key in uploaded_keys(runs_bucket) if key.endswith("/result.json")) parked = json.loads(runs_bucket.get_object(Bucket=RUNS_BUCKET, Key=result_key)["Body"].read()) assert parked["status"] == "error" assert "ValidateDidNotConverge" in parked["error"] assert "maximum output retries" in parked["error"]
def test_no_op_run_is_rejected_and_retried(runs_bucket) -> None: # The regression this whole change exists for: a model that reports done # without a single mutating tool call. The validator's no-op guard sends # it back to work instead of letting the empty run persist as ok. steps = deque( [ [final_result_call("all done")], [ToolCallPart(tool_name="write_file", args={"path": "main.tf", "contents": VALID_TF})], [ToolCallPart(tool_name="terraform_init", args={})], [final_result_call("actually done")], ] ) retry_feedback: list[str] = []
def call(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: for part in messages[-1].parts: if isinstance(part, RetryPromptPart): retry_feedback.append(str(part.content)) return ModelResponse(parts=list(steps.popleft()))
with core.agent.override(model=FunctionModel(call)): result = core.execute("make me a bucket")
assert result.output.summary == "actually done" assert any("made no changes" in feedback for feedback in retry_feedback) assert not steps
def test_success_uploads_workspace_and_minimal_result(runs_bucket) -> None: with core.agent.override(model=scripted_model(happy_path_steps())): result = core.execute("make me a bucket")8 collapsed lines
run_id = result.run_id keys = uploaded_keys(runs_bucket) assert f"runs/{run_id}/workspace/main.tf" in keys assert f"runs/{run_id}/result.json" in keys assert not any("/.terraform/" in key for key in keys)
body = runs_bucket.get_object(Bucket=RUNS_BUCKET, Key=f"runs/{run_id}/result.json") assert json.loads(body["Body"].read()) == {"status": "ok"}
def test_execute_in_given_workspace_keeps_the_files(runs_bucket, tmp_path) -> None: # The eval harness passes its own directory so evaluators can inspect the # finished workspace after the run; the tempdir path would delete it. # Owning the directory also means owning what happens to it, so the run # skips the S3 copy the throwaway path needs. with core.agent.override(model=scripted_model(happy_path_steps())): result = core.execute("make me a bucket", workspace=tmp_path)
assert (tmp_path / "main.tf").exists() assert f"runs/{result.run_id}/workspace/main.tf" not in uploaded_keys(runs_bucket)
def test_run_result_carries_token_usage(runs_bucket) -> None: with core.agent.override(model=scripted_model(happy_path_steps())): result = core.execute("make me a bucket")
# FunctionModel estimates usage, so the exact numbers are meaningless; # what matters is that execute surfaces the run totals. assert result.input_tokens > 0 assert result.output_tokens > 0
def test_failure_still_uploads_with_error_status(runs_bucket) -> None: calls = iter( [[ToolCallPart(tool_name="write_file", args={"path": "main.tf", "contents": VALID_TF})]]37 collapsed lines
)
def call(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: step = next(calls, None) if step is None: raise RuntimeError("boom") return ModelResponse(parts=list(step))
with core.agent.override(model=FunctionModel(call)): with pytest.raises(RuntimeError, match="boom"): core.execute("make me a bucket")
keys = uploaded_keys(runs_bucket) result_key = next(key for key in keys if key.endswith("/result.json")) run_id = result_key.split("/")[1] assert f"runs/{run_id}/workspace/main.tf" in keys
body = runs_bucket.get_object(Bucket=RUNS_BUCKET, Key=result_key) assert json.loads(body["Body"].read()) == { "status": "error", "error": "RuntimeError('boom')", }
def test_persist_run_requires_bucket(monkeypatch, tmp_path) -> None: # An unset RUNS_BUCKET means the run would never be parked. That is a # deployment fault, so persisting fails fast rather than dropping it. monkeypatch.delenv("RUNS_BUCKET", raising=False) with pytest.raises(RuntimeError, match="RUNS_BUCKET"): runs._persist_run("run-1", tmp_path, status="ok")
def test_run_id_is_the_conversation_id(runs_bucket) -> None: seen: list[str | None] = [] steps = happy_path_steps()
def call(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: seen.append(messages[0].conversation_id) if steps: return ModelResponse(parts=list(steps.popleft())) return ModelResponse(parts=[TextPart(content="workspace validated")]) return ModelResponse(parts=[final_result_call()])
with core.agent.override(model=FunctionModel(call)): result = core.execute("make me a bucket")107 collapsed lines
# The same id the caller gets back is stamped on every model request, # which is what surfaces as gen_ai.conversation.id on the trace. assert seen == [result.run_id] * len(seen)
def test_workspace_files_skips_terraform_dir(tmp_path) -> None: (tmp_path / "main.tf").write_text(VALID_TF) (tmp_path / ".terraform.lock.hcl").write_text("# lock\n") (tmp_path / ".terraform" / "providers").mkdir(parents=True) (tmp_path / ".terraform" / "providers" / "x").write_text("provider blob")
files = [path.relative_to(tmp_path) for path in runs._workspace_files(tmp_path)]
assert sorted(str(path) for path in files) == [".terraform.lock.hcl", "main.tf"]
def test_system_prompt_nudges_provider_constraint_and_lock_file() -> None: assert '"~> 6.0"' in core.SYSTEM_PROMPT assert ".terraform.lock.hcl" in core.SYSTEM_PROMPT
def test_relock_providers_skips_without_lock_file(tmp_path, monkeypatch) -> None: calls: list = [] monkeypatch.setattr(runs.subprocess, "run", lambda *a, **k: calls.append(a)) runs._relock_providers(tmp_path) assert calls == []
def test_relock_providers_covers_all_platforms(tmp_path, monkeypatch) -> None: (tmp_path / ".terraform.lock.hcl").write_text("# lock\n") calls: list[list[str]] = []
def fake_run(args, **kwargs): calls.append(args) return subprocess.CompletedProcess(args, 0, "", "")
monkeypatch.setattr(runs.subprocess, "run", fake_run) runs._relock_providers(tmp_path)
# One terraform call per platform, each carrying only its own -platform flag. assert [args[-1] for args in calls] == [ f"-platform={platform}" for platform in runs._LOCK_PLATFORMS ] for args in calls: assert args[:4] == ["terraform", "providers", "lock", "-no-color"]
def test_relock_providers_continues_after_one_platform_fails(tmp_path, monkeypatch) -> None: (tmp_path / ".terraform.lock.hcl").write_text("# lock\n") calls: list[list[str]] = []
def fake_run(args, **kwargs): calls.append(args) returncode = 1 if f"-platform={runs._LOCK_PLATFORMS[0]}" in args else 0 return subprocess.CompletedProcess(args, returncode, "", "boom")
monkeypatch.setattr(runs.subprocess, "run", fake_run) runs._relock_providers(tmp_path)
assert [args[-1] for args in calls] == [ f"-platform={platform}" for platform in runs._LOCK_PLATFORMS ]
def _sampled_ctx(trace_id: int, span_id: int, *, is_remote: bool = False) -> SpanContext: return SpanContext( trace_id=trace_id, span_id=span_id, is_remote=is_remote, trace_flags=TraceFlags(TraceFlags.SAMPLED), )
def test_audit_processor_ships_on_no_parent() -> None: shipped: list = [] proc = observability.PerTraceAuditProcessor(lambda spans: shipped.append(list(spans))) root = SimpleNamespace(context=_sampled_ctx(1, 1), parent=None) proc.on_end(root) assert shipped == [[root]]
def test_audit_processor_treats_remote_parent_as_local_root() -> None: # instrument_aws_lambda can root the trace at a span propagated in from a # remote parent (API Gateway / X-Ray); is_remote marks it the local root. shipped: list = [] proc = observability.PerTraceAuditProcessor(lambda spans: shipped.append(list(spans))) span = SimpleNamespace(context=_sampled_ctx(1, 2), parent=_sampled_ctx(1, 9, is_remote=True)) proc.on_end(span) assert shipped == [[span]]
def test_audit_processor_buffers_local_child_until_root() -> None: shipped: list = [] proc = observability.PerTraceAuditProcessor(lambda spans: shipped.append(list(spans))) child = SimpleNamespace(context=_sampled_ctx(1, 2), parent=_sampled_ctx(1, 1)) root = SimpleNamespace(context=_sampled_ctx(1, 1), parent=None) proc.on_end(child) assert shipped == [] proc.on_end(root) assert shipped == [[child, root]]
def _agent_span(model: str, *, in_tokens: int = 0, out_tokens: int = 0, errored: bool = False): return SimpleNamespace( attributes={ "metadata": json.dumps({"model": model}), "gen_ai.usage.input_tokens": in_tokens, "gen_ai.usage.output_tokens": out_tokens, }, instrumentation_scope=SimpleNamespace(name=observability.AGENT_RUN_SCOPE), status=SimpleNamespace(status_code=StatusCode.ERROR if errored else StatusCode.UNSET), start_time=0, end_time=1_000_000,3 collapsed lines
)
def _non_agent_span(): return SimpleNamespace( attributes={"gen_ai.operation.name": "chat"}, instrumentation_scope=SimpleNamespace(name=observability.AGENT_RUN_SCOPE), status=SimpleNamespace(status_code=StatusCode.UNSET), start_time=0, end_time=1_000_000, )
def _eval_case_span(): """What pydantic_evals opens around a case: `metadata` under a foreign scope.""" return SimpleNamespace( attributes={"metadata": json.dumps({"expected_resources": []})}, instrumentation_scope=SimpleNamespace(name="pydantic-evals"), status=SimpleNamespace(status_code=StatusCode.UNSET), start_time=0, end_time=1_000_000, )
def test_emit_emf_one_line_per_agent_run(monkeypatch) -> None: records: list = [] monkeypatch.setattr(18 collapsed lines
observability, "log", SimpleNamespace(info=lambda event, **kw: records.append((event, kw))) ) observability._emit_emf([_non_agent_span(), _agent_span("haiku", in_tokens=10, out_tokens=3)]) assert len(records) == 1 event, kw = records[0] assert event == "trace_metrics" assert (kw["Model"], kw["InputTokens"], kw["OutputTokens"], kw["Errors"]) == ("haiku", 10, 3, 0)
def test_emit_emf_emits_per_run_for_retries(monkeypatch) -> None: records: list = [] monkeypatch.setattr( observability, "log", SimpleNamespace(info=lambda event, **kw: records.append((event, kw))) ) observability._emit_emf( [_agent_span("haiku"), _non_agent_span(), _agent_span("mistral-large", errored=True)] ) assert [kw["Model"] for _, kw in records] == ["haiku", "mistral-large"] assert [kw["Errors"] for _, kw in records] == [0, 1]
def test_emit_emf_ignores_the_eval_case_span(monkeypatch) -> None: records: list = [] monkeypatch.setattr( observability, "log", SimpleNamespace(info=lambda event, **kw: records.append((event, kw))) ) observability._emit_emf([_eval_case_span(), _agent_span("haiku")]) assert [kw["Model"] for _, kw in records] == ["haiku"]
# These exercise the real _build_model(name) rather than the _stub_model the# autouse fixture installs, guarding the factory's signature and provider# branching (the execute path stubs it, so it cannot catch a signature drift).def test_build_model_selects_bedrock(monkeypatch) -> None: _REAL_BUILD_MODEL.cache_clear() models._registry.cache_clear() monkeypatch.setattr(models, "fetch_parameter", lambda name: json.dumps(REGISTRY)) model = _REAL_BUILD_MODEL("haiku") assert isinstance(model, BedrockConverseModel) assert model.model_name == "anthropic.claude-haiku-4-5-20251001-v1:0"
def test_build_model_selects_mistral(monkeypatch) -> None: _REAL_BUILD_MODEL.cache_clear() models._registry.cache_clear() monkeypatch.setattr( models, "fetch_parameter",3 collapsed lines
lambda name: json.dumps(REGISTRY) if "models" in name else "key", ) monkeypatch.setenv("MISTRAL_API_KEY_PARAMETER", "/terraform-pr-agent/mistral-api-key") model = _REAL_BUILD_MODEL("mistral-large") assert isinstance(model, MistralModel) assert model.model_name == "mistral-large-latest"
def test_each_run_builds_its_own_model_off_one_registry_read(monkeypatch) -> None: """A shared model would bind its httpx pool to one thread's event loop.
pydantic_evals runs a sync task per worker thread and pydantic-ai's run_sync gives each thread its own loop, so a memoised client fails every concurrent case after the first. Only the registry read is cached. """ models._registry.cache_clear() reads: list[str] = [] monkeypatch.setattr( models, "fetch_parameter", lambda name: reads.append(name) or json.dumps(REGISTRY), ) monkeypatch.setenv("MISTRAL_API_KEY_PARAMETER", "/terraform-pr-agent/mistral-api-key")
first = _REAL_BUILD_MODEL("mistral-large") second = _REAL_BUILD_MODEL("mistral-large")
assert first is not second assert first.client is not second.client assert reads.count("/terraform-pr-agent/models") == 1
def test_mistral_model_emits_no_sdk_tracer_spans(monkeypatch) -> None: # The Mistral SDK traces every request to the global tracer provider once # one exists (conftest's logfire.configure creates it), duplicating # pydantic-ai's chat spans: the audit copy stored each request twice and # the eval report's span-derived token metrics double-counted. models.py # points the SDK tracer at a no-op provider; this pins that an SDK upgrade # does not quietly bring the duplicate spans back. import httpx from opentelemetry import trace as otel_trace from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
def canned(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={ "id": "cmpl-1", "object": "chat.completion", "model": "mistral-large-latest", "created": 1, "choices": [ { "index": 0, "message": {"role": "assistant", "content": "OK", "tool_calls": None}, "finish_reason": "stop", } ], "usage": {"prompt_tokens": 8, "completion_tokens": 2, "total_tokens": 10}, }, )
models._registry.cache_clear() monkeypatch.setattr( models, "fetch_parameter", lambda name: json.dumps(REGISTRY) if "models" in name else "key", ) monkeypatch.setenv("MISTRAL_API_KEY_PARAMETER", "/terraform-pr-agent/mistral-api-key") monkeypatch.setattr( models, "_retrying_http_client", lambda: httpx.AsyncClient(transport=httpx.MockTransport(canned)), ) model = _REAL_BUILD_MODEL("mistral-large")
exporter = InMemorySpanExporter() otel_trace.get_tracer_provider().add_span_processor(SimpleSpanProcessor(exporter)) # instrument=True stands in for lambda_entry's instrument_pydantic_ai, # which the tests never run; without it there is no pydantic-ai span to # prove the capture worked. Agent(instrument=True).run_sync("hi", model=model)
scopes = { span.instrumentation_scope.name for span in exporter.get_finished_spans() if span.name.startswith("chat") } # pydantic-ai's span proves the capture worked; the SDK's must be gone. assert "pydantic-ai" in scopes assert "mistralai_sdk_tracer" not in scopes"""Core test: the real Agent, tools, and execute(), with the Bedrock modelswapped for a scripted FunctionModel so no AWS call is made. The script walksthe loop the post is about: write broken HCL, init, watch validate fail (twice,exercising the raised retry budget), fix it, validate clean, report done viathe final_result output tool. The runs-bucket upload is exercised against moto,so the real boto3 call path runs without touching AWS."""
import jsonimport subprocessimport uuidfrom collections import dequefrom types import SimpleNamespace
import agent.core as coreimport agent.models as modelsimport agent.observability as observabilityimport agent.runs as runsimport boto3import pytestfrom moto import mock_awsfrom opentelemetry.trace import SpanContext, TraceFlagsfrom opentelemetry.trace.status import StatusCodefrom pydantic_ai import Agentfrom pydantic_ai.exceptions import UnexpectedModelBehaviorfrom pydantic_ai.messages import ( ModelMessage, ModelResponse, RetryPromptPart, TextPart, ToolCallPart,)from pydantic_ai.models.bedrock import BedrockConverseModelfrom pydantic_ai.models.function import AgentInfo, FunctionModelfrom pydantic_ai.models.mistral import MistralModel
REGISTRY = { "haiku": { "provider": "bedrock", "model_id": "anthropic.claude-haiku-4-5-20251001-v1:0", "inference_profile_arn": "arn:aws:bedrock:eu-west-1:0:inference-profile/test", }, "mistral-large": {"provider": "mistral", "model_id": "mistral-large-latest"},}
# Captured before the autouse fixture stubs core._build_model, so the# factory tests below can call the real implementation._REAL_BUILD_MODEL = core._build_model
# validate_workspace now runs tflint and checkov after every agent turn, so# the fixtures carry the settings block tflint's required_version rule demands._TF_SETTINGS = 'terraform { required_version = ">= 1.0" }\n'INVALID_TF = _TF_SETTINGS + 'output "x" { value = var.missing }\n'VALID_TF = _TF_SETTINGS + 'output "x" { value = "fixed" }\n'
RUNS_BUCKET = "test-runs"
def _stub_model(name: str) -> FunctionModel: # A throwaway model so _build_model makes no SSM call. agent.override in # each test supplies the model actually used; pydantic-ai still requires a # non-None model on the call, so this stands in for that slot. return FunctionModel(lambda messages, info: ModelResponse(parts=[TextPart(content="")]))
@pytest.fixture(autouse=True)def no_observability(monkeypatch) -> None: # The logfire/instrumentation wiring lives in agent.lambda_entry, which the # tests never import, so the Firehose-backed audit processor is never # registered here. _build_model would read the registry from SSM; tests # supply the model through agent.override instead, which takes precedence # over the per-run model. monkeypatch.setattr(core, "_build_model", _stub_model)
@pytest.fixturedef runs_bucket(monkeypatch): # Static stand-in credentials so a hole in the moto mock can never # reach a real account. monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing") monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing") monkeypatch.setenv("RUNS_BUCKET", RUNS_BUCKET) with mock_aws(): s3 = boto3.client("s3") s3.create_bucket( Bucket=RUNS_BUCKET, CreateBucketConfiguration={"LocationConstraint": "eu-central-1"}, ) yield s3
def final_result_call(summary: str = "workspace validated") -> ToolCallPart: # Ending a run now means calling the final_result output tool with a full # TaskResult payload; a bare TextPart no longer ends anything. return ToolCallPart( tool_name="final_result", args={ "summary": summary, "solution_description": "wrote main.tf", "validations_run": ["terraform_validate"], "issues_addressed": [], "known_limitations": [], "ready_for_review": True, }, )
def scripted_model(steps: deque[list[ToolCallPart]]) -> FunctionModel: def call(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: if steps: return ModelResponse(parts=list(steps.popleft())) return ModelResponse(parts=[final_result_call()])
return FunctionModel(call)
def happy_path_steps() -> deque[list[ToolCallPart]]: return deque( [ [ToolCallPart(tool_name="write_file", args={"path": "main.tf", "contents": VALID_TF})], [ToolCallPart(tool_name="terraform_init", args={})], [ToolCallPart(tool_name="terraform_validate", args={})], ] )
def uploaded_keys(s3) -> list[str]: objects = s3.list_objects_v2(Bucket=RUNS_BUCKET).get("Contents", []) return [entry["Key"] for entry in objects]
def test_execute_survives_consecutive_validate_failures(runs_bucket) -> None: steps = deque( [ [ ToolCallPart( tool_name="write_file", args={"path": "main.tf", "contents": INVALID_TF} ) ], [ToolCallPart(tool_name="terraform_init", args={})], # Two failing validates in a row: the default per-tool retry # budget of 1 would kill the run here; the raised `tools` # budget is what lets the loop continue. [ToolCallPart(tool_name="terraform_validate", args={})], [ToolCallPart(tool_name="terraform_validate", args={})], [ ToolCallPart( tool_name="edit_file", args={ "path": "main.tf", "old_string": "var.missing", "new_string": '"fixed"', }, ) ], [ToolCallPart(tool_name="terraform_validate", args={})], ] ) with core.agent.override(model=scripted_model(steps)): result = core.execute("make me a bucket")
assert result.output.summary == "workspace validated" uuid.UUID(result.run_id) assert not steps, "the scripted run should consume every step"
def test_output_validator_feeds_validate_failure_back(runs_bucket) -> None: # The agent leaves an invalid but initialized workspace and reports done. # The output validator re-runs the validators, rejects the final_result # with ModelRetry, and the same run continues: the agent edits the file # clean and reports done again. One run, one conversation, one trace. steps = deque( [ [ ToolCallPart( tool_name="write_file", args={"path": "main.tf", "contents": INVALID_TF} ) ], [ToolCallPart(tool_name="terraform_init", args={})], [final_result_call("done")], [ ToolCallPart( tool_name="edit_file", args={"path": "main.tf", "old_string": "var.missing", "new_string": '"fixed"'}, ) ], [final_result_call("fixed it")], ] )
with core.agent.override(model=scripted_model(steps)): result = core.execute("make me a bucket")
assert result.output.summary == "fixed it" assert not steps, "the rejected final_result and the fix should both be consumed"
def test_output_validator_raises_when_not_converging(runs_bucket) -> None: # The agent sets up an invalid, initialized workspace and keeps reporting # done without fixing it. After the `output` retry budget the run raises # (Lambda 5xx) and still parks the broken workspace under status error # for debugging; the validator's feedback lives in the trace. setup = deque( [ [ ToolCallPart( tool_name="write_file", args={"path": "main.tf", "contents": INVALID_TF} ) ], [ToolCallPart(tool_name="terraform_init", args={})], ] )
def call(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: if setup: return ModelResponse(parts=list(setup.popleft())) return ModelResponse(parts=[final_result_call("all done")])
with core.agent.override(model=FunctionModel(call)): with pytest.raises(UnexpectedModelBehavior, match="maximum output retries"): core.execute("make me a bucket")
result_key = next(key for key in uploaded_keys(runs_bucket) if key.endswith("/result.json")) parked = json.loads(runs_bucket.get_object(Bucket=RUNS_BUCKET, Key=result_key)["Body"].read()) assert parked["status"] == "error" assert "maximum output retries" in parked["error"]
def test_no_op_run_is_rejected_and_retried(runs_bucket) -> None: # The regression this whole change exists for: a model that reports done # without a single mutating tool call. The validator's no-op guard sends # it back to work instead of letting the empty run persist as ok. steps = deque( [ [final_result_call("all done")], [ToolCallPart(tool_name="write_file", args={"path": "main.tf", "contents": VALID_TF})], [ToolCallPart(tool_name="terraform_init", args={})], [final_result_call("actually done")], ] ) retry_feedback: list[str] = []
def call(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: for part in messages[-1].parts: if isinstance(part, RetryPromptPart): retry_feedback.append(str(part.content)) return ModelResponse(parts=list(steps.popleft()))
with core.agent.override(model=FunctionModel(call)): result = core.execute("make me a bucket")
assert result.output.summary == "actually done" assert any("made no changes" in feedback for feedback in retry_feedback) assert not steps
def test_success_uploads_workspace_and_minimal_result(runs_bucket) -> None: with core.agent.override(model=scripted_model(happy_path_steps())): result = core.execute("make me a bucket")
run_id = result.run_id keys = uploaded_keys(runs_bucket) assert f"runs/{run_id}/workspace/main.tf" in keys assert f"runs/{run_id}/result.json" in keys assert not any("/.terraform/" in key for key in keys)
body = runs_bucket.get_object(Bucket=RUNS_BUCKET, Key=f"runs/{run_id}/result.json") assert json.loads(body["Body"].read()) == {"status": "ok"}
def test_execute_in_given_workspace_keeps_the_files(runs_bucket, tmp_path) -> None: # The eval harness passes its own directory so evaluators can inspect the # finished workspace after the run; the tempdir path would delete it. # Owning the directory also means owning what happens to it, so the run # skips the S3 copy the throwaway path needs. with core.agent.override(model=scripted_model(happy_path_steps())): result = core.execute("make me a bucket", workspace=tmp_path)
assert (tmp_path / "main.tf").exists() assert f"runs/{result.run_id}/workspace/main.tf" not in uploaded_keys(runs_bucket)
def test_run_result_carries_token_usage(runs_bucket) -> None: with core.agent.override(model=scripted_model(happy_path_steps())): result = core.execute("make me a bucket")
# FunctionModel estimates usage, so the exact numbers are meaningless; # what matters is that execute surfaces the run totals. assert result.input_tokens > 0 assert result.output_tokens > 0
def test_failure_still_uploads_with_error_status(runs_bucket) -> None: calls = iter( [[ToolCallPart(tool_name="write_file", args={"path": "main.tf", "contents": VALID_TF})]] )
def call(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: step = next(calls, None) if step is None: raise RuntimeError("boom") return ModelResponse(parts=list(step))
with core.agent.override(model=FunctionModel(call)): with pytest.raises(RuntimeError, match="boom"): core.execute("make me a bucket")
keys = uploaded_keys(runs_bucket) result_key = next(key for key in keys if key.endswith("/result.json")) run_id = result_key.split("/")[1] assert f"runs/{run_id}/workspace/main.tf" in keys
body = runs_bucket.get_object(Bucket=RUNS_BUCKET, Key=result_key) assert json.loads(body["Body"].read()) == { "status": "error", "error": "RuntimeError('boom')", }
def test_persist_run_requires_bucket(monkeypatch, tmp_path) -> None: # An unset RUNS_BUCKET means the run would never be parked. That is a # deployment fault, so persisting fails fast rather than dropping it. monkeypatch.delenv("RUNS_BUCKET", raising=False) with pytest.raises(RuntimeError, match="RUNS_BUCKET"): runs._persist_run("run-1", tmp_path, status="ok")
def test_run_id_is_the_conversation_id(runs_bucket) -> None: seen: list[str | None] = [] steps = happy_path_steps()
def call(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: seen.append(messages[0].conversation_id) if steps: return ModelResponse(parts=list(steps.popleft())) return ModelResponse(parts=[final_result_call()])
with core.agent.override(model=FunctionModel(call)): result = core.execute("make me a bucket")
# The same id the caller gets back is stamped on every model request, # which is what surfaces as gen_ai.conversation.id on the trace. assert seen == [result.run_id] * len(seen)
def test_workspace_files_skips_terraform_dir(tmp_path) -> None: (tmp_path / "main.tf").write_text(VALID_TF) (tmp_path / ".terraform.lock.hcl").write_text("# lock\n") (tmp_path / ".terraform" / "providers").mkdir(parents=True) (tmp_path / ".terraform" / "providers" / "x").write_text("provider blob")
files = [path.relative_to(tmp_path) for path in runs._workspace_files(tmp_path)]
assert sorted(str(path) for path in files) == [".terraform.lock.hcl", "main.tf"]
def test_system_prompt_nudges_provider_constraint_and_lock_file() -> None: assert '"~> 6.0"' in core.SYSTEM_PROMPT assert ".terraform.lock.hcl" in core.SYSTEM_PROMPT
def test_relock_providers_skips_without_lock_file(tmp_path, monkeypatch) -> None: calls: list = [] monkeypatch.setattr(runs.subprocess, "run", lambda *a, **k: calls.append(a)) runs._relock_providers(tmp_path) assert calls == []
def test_relock_providers_covers_all_platforms(tmp_path, monkeypatch) -> None: (tmp_path / ".terraform.lock.hcl").write_text("# lock\n") calls: list[list[str]] = []
def fake_run(args, **kwargs): calls.append(args) return subprocess.CompletedProcess(args, 0, "", "")
monkeypatch.setattr(runs.subprocess, "run", fake_run) runs._relock_providers(tmp_path)
# One terraform call per platform, each carrying only its own -platform flag. assert [args[-1] for args in calls] == [ f"-platform={platform}" for platform in runs._LOCK_PLATFORMS ] for args in calls: assert args[:4] == ["terraform", "providers", "lock", "-no-color"]
def test_relock_providers_continues_after_one_platform_fails(tmp_path, monkeypatch) -> None: (tmp_path / ".terraform.lock.hcl").write_text("# lock\n") calls: list[list[str]] = []
def fake_run(args, **kwargs): calls.append(args) returncode = 1 if f"-platform={runs._LOCK_PLATFORMS[0]}" in args else 0 return subprocess.CompletedProcess(args, returncode, "", "boom")
monkeypatch.setattr(runs.subprocess, "run", fake_run) runs._relock_providers(tmp_path)
assert [args[-1] for args in calls] == [ f"-platform={platform}" for platform in runs._LOCK_PLATFORMS ]
def _sampled_ctx(trace_id: int, span_id: int, *, is_remote: bool = False) -> SpanContext: return SpanContext( trace_id=trace_id, span_id=span_id, is_remote=is_remote, trace_flags=TraceFlags(TraceFlags.SAMPLED), )
def test_audit_processor_ships_on_no_parent() -> None: shipped: list = [] proc = observability.PerTraceAuditProcessor(lambda spans: shipped.append(list(spans))) root = SimpleNamespace(context=_sampled_ctx(1, 1), parent=None) proc.on_end(root) assert shipped == [[root]]
def test_audit_processor_treats_remote_parent_as_local_root() -> None: # instrument_aws_lambda can root the trace at a span propagated in from a # remote parent (API Gateway / X-Ray); is_remote marks it the local root. shipped: list = [] proc = observability.PerTraceAuditProcessor(lambda spans: shipped.append(list(spans))) span = SimpleNamespace(context=_sampled_ctx(1, 2), parent=_sampled_ctx(1, 9, is_remote=True)) proc.on_end(span) assert shipped == [[span]]
def test_audit_processor_buffers_local_child_until_root() -> None: shipped: list = [] proc = observability.PerTraceAuditProcessor(lambda spans: shipped.append(list(spans))) child = SimpleNamespace(context=_sampled_ctx(1, 2), parent=_sampled_ctx(1, 1)) root = SimpleNamespace(context=_sampled_ctx(1, 1), parent=None) proc.on_end(child) assert shipped == [] proc.on_end(root) assert shipped == [[child, root]]
def _agent_span(model: str, *, in_tokens: int = 0, out_tokens: int = 0, errored: bool = False): return SimpleNamespace( attributes={ "metadata": json.dumps({"model": model}), "gen_ai.usage.input_tokens": in_tokens, "gen_ai.usage.output_tokens": out_tokens, }, instrumentation_scope=SimpleNamespace(name=observability.AGENT_RUN_SCOPE), status=SimpleNamespace(status_code=StatusCode.ERROR if errored else StatusCode.UNSET), start_time=0, end_time=1_000_000, )
def _non_agent_span(): return SimpleNamespace( attributes={"gen_ai.operation.name": "chat"}, instrumentation_scope=SimpleNamespace(name=observability.AGENT_RUN_SCOPE), status=SimpleNamespace(status_code=StatusCode.UNSET), start_time=0, end_time=1_000_000, )
def _eval_case_span(): """What pydantic_evals opens around a case: `metadata` under a foreign scope.""" return SimpleNamespace( attributes={"metadata": json.dumps({"expected_resources": []})}, instrumentation_scope=SimpleNamespace(name="pydantic-evals"), status=SimpleNamespace(status_code=StatusCode.UNSET), start_time=0, end_time=1_000_000, )
def test_emit_emf_one_line_per_agent_run(monkeypatch) -> None: records: list = [] monkeypatch.setattr( observability, "log", SimpleNamespace(info=lambda event, **kw: records.append((event, kw))) ) observability._emit_emf([_non_agent_span(), _agent_span("haiku", in_tokens=10, out_tokens=3)]) assert len(records) == 1 event, kw = records[0] assert event == "trace_metrics" assert (kw["Model"], kw["InputTokens"], kw["OutputTokens"], kw["Errors"]) == ("haiku", 10, 3, 0)
def test_emit_emf_emits_per_run_for_retries(monkeypatch) -> None: records: list = [] monkeypatch.setattr( observability, "log", SimpleNamespace(info=lambda event, **kw: records.append((event, kw))) ) observability._emit_emf( [_agent_span("haiku"), _non_agent_span(), _agent_span("mistral-large", errored=True)] ) assert [kw["Model"] for _, kw in records] == ["haiku", "mistral-large"] assert [kw["Errors"] for _, kw in records] == [0, 1]
def test_emit_emf_ignores_the_eval_case_span(monkeypatch) -> None: records: list = [] monkeypatch.setattr( observability, "log", SimpleNamespace(info=lambda event, **kw: records.append((event, kw))) ) observability._emit_emf([_eval_case_span(), _agent_span("haiku")]) assert [kw["Model"] for _, kw in records] == ["haiku"]
# These exercise the real _build_model(name) rather than the _stub_model the# autouse fixture installs, guarding the factory's signature and provider# branching (the execute path stubs it, so it cannot catch a signature drift).def test_build_model_selects_bedrock(monkeypatch) -> None: models._registry.cache_clear() monkeypatch.setattr(models, "fetch_parameter", lambda name: json.dumps(REGISTRY)) model = _REAL_BUILD_MODEL("haiku") assert isinstance(model, BedrockConverseModel) assert model.model_name == "anthropic.claude-haiku-4-5-20251001-v1:0"
def test_build_model_selects_mistral(monkeypatch) -> None: models._registry.cache_clear() monkeypatch.setattr( models, "fetch_parameter", lambda name: json.dumps(REGISTRY) if "models" in name else "key", ) monkeypatch.setenv("MISTRAL_API_KEY_PARAMETER", "/terraform-pr-agent/mistral-api-key") model = _REAL_BUILD_MODEL("mistral-large") assert isinstance(model, MistralModel) assert model.model_name == "mistral-large-latest"
def test_each_run_builds_its_own_model_off_one_registry_read(monkeypatch) -> None: """A shared model would bind its httpx pool to one thread's event loop.
pydantic_evals runs a sync task per worker thread and pydantic-ai's run_sync gives each thread its own loop, so a memoised client fails every concurrent case after the first. Only the registry read is cached. """ models._registry.cache_clear() reads: list[str] = [] monkeypatch.setattr( models, "fetch_parameter", lambda name: reads.append(name) or json.dumps(REGISTRY), ) monkeypatch.setenv("MISTRAL_API_KEY_PARAMETER", "/terraform-pr-agent/mistral-api-key")
first = _REAL_BUILD_MODEL("mistral-large") second = _REAL_BUILD_MODEL("mistral-large")
assert first is not second assert first.client is not second.client assert reads.count("/terraform-pr-agent/models") == 1
def test_mistral_model_emits_no_sdk_tracer_spans(monkeypatch) -> None: # The Mistral SDK traces every request to the global tracer provider once # one exists (conftest's logfire.configure creates it), duplicating # pydantic-ai's chat spans: the audit copy stored each request twice and # the eval report's span-derived token metrics double-counted. models.py # points the SDK tracer at a no-op provider; this pins that an SDK upgrade # does not quietly bring the duplicate spans back. import httpx from opentelemetry import trace as otel_trace from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
def canned(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={ "id": "cmpl-1", "object": "chat.completion", "model": "mistral-large-latest", "created": 1, "choices": [ { "index": 0, "message": {"role": "assistant", "content": "OK", "tool_calls": None}, "finish_reason": "stop", } ], "usage": {"prompt_tokens": 8, "completion_tokens": 2, "total_tokens": 10}, }, )
models._registry.cache_clear() monkeypatch.setattr( models, "fetch_parameter", lambda name: json.dumps(REGISTRY) if "models" in name else "key", ) monkeypatch.setenv("MISTRAL_API_KEY_PARAMETER", "/terraform-pr-agent/mistral-api-key") monkeypatch.setattr( models, "_retrying_http_client", lambda: httpx.AsyncClient(transport=httpx.MockTransport(canned)), ) model = _REAL_BUILD_MODEL("mistral-large")
exporter = InMemorySpanExporter() otel_trace.get_tracer_provider().add_span_processor(SimpleSpanProcessor(exporter)) # instrument=True stands in for lambda_entry's instrument_pydantic_ai, # which the tests never run; without it there is no pydantic-ai span to # prove the capture worked. Agent(instrument=True).run_sync("hi", model=model)
scopes = { span.instrumentation_scope.name for span in exporter.get_finished_spans() if span.name.startswith("chat") } # pydantic-ai's span proves the capture worked; the SDK's must be gone. assert "pydantic-ai" in scopes assert "mistralai_sdk_tracer" not in scopes"""require_env: the single fail-fast read for required configuration."""
import pytestfrom agent.env import require_env
def test_returns_value_when_set(monkeypatch) -> None: monkeypatch.setenv("SOME_VAR", "value") assert require_env("SOME_VAR") == "value"
def test_raises_when_unset(monkeypatch) -> None: monkeypatch.delenv("SOME_VAR", raising=False) with pytest.raises(RuntimeError, match="SOME_VAR"): require_env("SOME_VAR")
def test_treats_empty_as_unset(monkeypatch) -> None: monkeypatch.setenv("SOME_VAR", "") with pytest.raises(RuntimeError, match="SOME_VAR"): require_env("SOME_VAR")"""The plan-comparison logic behind PlanMatchesGraph, against a canned`terraform show -json` plan document. The subprocess plumbing around it reusesthe already-tested Command class, and WorkspaceValidates is a passthrough tovalidate_workspace, so this file covers the one piece of real logic theevaluators add."""
import jsonfrom pathlib import Path
from evals.evaluators import ( ExpectedResource, _module_resources, compare_resources,)
_PLAN = json.loads((Path(__file__).parent / "fixtures" / "plan.json").read_text())
def planned() -> list[dict]: return _module_resources(_PLAN["planned_values"]["root_module"])
def test_module_resources_flattens_child_modules() -> None: addresses = [resource["address"] for resource in planned()] assert "module.dlq.aws_sqs_queue.dead_letter" in addresses assert len(addresses) == 3
def test_matching_graph_reports_no_problems() -> None: expected = [ ExpectedResource(type="aws_dynamodb_table", attrs={"billing_mode": "PAY_PER_REQUEST"}), ExpectedResource(type="aws_sqs_queue", count=2), ] assert compare_resources(planned(), expected) == []
def test_count_mismatch_is_reported() -> None: problems = compare_resources(planned(), [ExpectedResource(type="aws_sqs_queue", count=1)]) assert problems == ["aws_sqs_queue: expected 1, planned 2"]
def test_missing_type_is_a_count_mismatch() -> None: problems = compare_resources(planned(), [ExpectedResource(type="aws_kms_key")]) assert problems == ["aws_kms_key: expected 1, planned 0"]
def test_attrs_must_match_one_instance() -> None: expected = [ExpectedResource(type="aws_dynamodb_table", attrs={"hash_key": "user_id"})] problems = compare_resources(planned(), expected) assert problems == ["aws_dynamodb_table: no instance matches attrs {'hash_key': 'user_id'}"]
def test_attrs_matched_by_any_instance_pass() -> None: # Two queues, only the DLQ carries the asserted name; one match is enough. expected = [ ExpectedResource(type="aws_sqs_queue", count=2, attrs={"name": "background-jobs-dlq"}) ] assert compare_resources(planned(), expected) == []
def test_extra_planned_resources_are_allowed() -> None: # The graph lists what must exist; checkov baselines legitimately add # supporting resources a case does not enumerate. assert compare_resources(planned(), [ExpectedResource(type="aws_dynamodb_table")]) == []"""Tests for the Lambda boundary: the INIT wiring and the event envelope.
Importing agent.lambda_entry runs the INIT wiring (configure then wrap), so the9 collapsed lines
side effects are stubbed before a fresh import; the tests assert the wiring andthe envelope, not real logfire configuration."""
import importlibimport sys
import agent.core as coreimport agent.observability as observabilityimport logfireimport pytest
TASK_RESULT = core.TaskResult( summary="done", solution_description="wrote main.tf", validations_run=["terraform_validate"], issues_addressed=[], known_limitations=[], ready_for_review=True,)
@pytest.fixturedef lambda_entry(monkeypatch): calls: list = []24 collapsed lines
monkeypatch.setattr(observability, "configure", lambda: calls.append("configure")) monkeypatch.setattr( logfire, "instrument_aws_lambda", lambda h, **kw: calls.append(("instrument", h)) ) sys.modules.pop("agent.lambda_entry", None) module = importlib.import_module("agent.lambda_entry") module.init_calls = calls return module
def test_configure_runs_before_the_handler_is_wrapped(lambda_entry) -> None: # The container CMD targets agent.lambda_entry.handler, so the wrap must # land on that symbol, with configure() run first. assert lambda_entry.init_calls == ["configure", ("instrument", lambda_entry.handler)]
def test_handler_requires_a_prompt(lambda_entry) -> None: with pytest.raises(ValueError, match="prompt"): lambda_entry.handler({}, None)
def test_handler_runs_the_agent_and_wraps_the_result(lambda_entry, monkeypatch) -> None: seen: dict = {}
def fake_execute(prompt, model=None): seen["prompt"] = prompt seen["model"] = model return core.RunResult(run_id="run-1", model="haiku", output="done") return core.RunResult( run_id="run-1", model="haiku", output=TASK_RESULT, input_tokens=10, output_tokens=2 )
monkeypatch.setattr(lambda_entry, "execute", fake_execute) response = lambda_entry.handler({"prompt": "make me a bucket", "model": "haiku"}, None)
assert seen == {"prompt": "make me a bucket", "model": "haiku"} # The TaskResult is dumped to a plain dict so the Lambda response stays # JSON-serializable. assert response == { "status": "ok", "run_id": "run-1", "model": "haiku", "output": "done", "output": TASK_RESULT.model_dump(), }"""Tests for the Lambda boundary: the INIT wiring and the event envelope.
Importing agent.lambda_entry runs the INIT wiring (configure then wrap), so theside effects are stubbed before a fresh import; the tests assert the wiring andthe envelope, not real logfire configuration."""
import importlibimport sys
import agent.core as coreimport agent.observability as observabilityimport logfireimport pytest
TASK_RESULT = core.TaskResult( summary="done", solution_description="wrote main.tf", validations_run=["terraform_validate"], issues_addressed=[], known_limitations=[], ready_for_review=True,)
@pytest.fixturedef lambda_entry(monkeypatch): calls: list = [] monkeypatch.setattr(observability, "configure", lambda: calls.append("configure")) monkeypatch.setattr( logfire, "instrument_aws_lambda", lambda h, **kw: calls.append(("instrument", h)) ) sys.modules.pop("agent.lambda_entry", None) module = importlib.import_module("agent.lambda_entry") module.init_calls = calls return module
def test_configure_runs_before_the_handler_is_wrapped(lambda_entry) -> None: # The container CMD targets agent.lambda_entry.handler, so the wrap must # land on that symbol, with configure() run first. assert lambda_entry.init_calls == ["configure", ("instrument", lambda_entry.handler)]
def test_handler_requires_a_prompt(lambda_entry) -> None: with pytest.raises(ValueError, match="prompt"): lambda_entry.handler({}, None)
def test_handler_runs_the_agent_and_wraps_the_result(lambda_entry, monkeypatch) -> None: seen: dict = {}
def fake_execute(prompt, model=None): seen["prompt"] = prompt seen["model"] = model return core.RunResult( run_id="run-1", model="haiku", output=TASK_RESULT, input_tokens=10, output_tokens=2 )
monkeypatch.setattr(lambda_entry, "execute", fake_execute) response = lambda_entry.handler({"prompt": "make me a bucket", "model": "haiku"}, None)
assert seen == {"prompt": "make me a bucket", "model": "haiku"} # The TaskResult is dumped to a plain dict so the Lambda response stays # JSON-serializable. assert response == { "status": "ok", "run_id": "run-1", "model": "haiku", "output": TASK_RESULT.model_dump(), }"""Tests for the per-span audit ship: our chunking arithmetic against theFirehose PutRecordBatch caps, and the record cap itself. The Firehose callitself is a single boto3 put_record_batch and not worth faking."""
import agent.observability as observabilityimport pytestfrom opentelemetry.sdk.trace import ReadableSpanfrom opentelemetry.sdk.util.instrumentation import InstrumentationScopefrom opentelemetry.trace import SpanContext, TraceFlags
def test_batches_splits_on_record_count() -> None: records = [b"x"] * (observability._MAX_BATCH_RECORDS + 1) batches = list(observability._batches(records)) assert [len(b) for b in batches] == [observability._MAX_BATCH_RECORDS, 1]
def test_batches_splits_on_byte_size() -> None: big = b"x" * (observability._MAX_BATCH_BYTES // 2) batches = list(observability._batches([big, big, big])) assert [len(b) for b in batches] == [2, 1]
def test_batches_keeps_order_and_drops_nothing() -> None: records = [bytes([i]) for i in range(5)] assert [r for batch in observability._batches(records) for r in batch] == records
def test_batches_empty() -> None: assert list(observability._batches([])) == []
def _span(**attributes: str) -> ReadableSpan: return ReadableSpan( name="chat model", context=SpanContext(trace_id=1, span_id=2, is_remote=False, trace_flags=TraceFlags(1)), instrumentation_scope=InstrumentationScope(observability.AGENT_RUN_SCOPE), attributes=attributes, start_time=0, end_time=1_000_000, )
def test_span_record_serialises_a_span_within_the_cap() -> None: record = observability._span_record(_span(**{"gen_ai.input.messages": "[]"})) assert record.endswith(b"\n") assert len(record) <= observability._MAX_RECORD_BYTES
def test_span_record_raises_over_the_record_cap() -> None: """The agent's own limits keep a span under the cap, so over it is a bug.""" span = _span(bulk="x" * (observability._MAX_RECORD_BYTES + 1)) with pytest.raises(RuntimeError, match="over the .* Firehose record cap"): observability._span_record(span)"""wire_eval_run: the sweep's redirects away from the production defaults."""
import os
from evals.run import EVAL_ENVIRONMENT, EVAL_MODELS_PARAMETER, _wire_eval_run
def test_redirects_model_resolution_and_logfire_environment(monkeypatch) -> None: monkeypatch.setenv("MODELS_PARAMETER", "/terraform-pr-agent/models") monkeypatch.delenv("LOGFIRE_ENVIRONMENT", raising=False)
_wire_eval_run()
assert os.environ["MODELS_PARAMETER"] == EVAL_MODELS_PARAMETER assert os.environ["LOGFIRE_ENVIRONMENT"] == EVAL_ENVIRONMENT"""Tests for the workspace tools: path guards, init/validate semantics,and how ModelRetry interacts with pydantic-ai's per-tool retry budget."""
3 collapsed lines
from pathlib import Pathfrom types import SimpleNamespace
import pytestfrom agent.tools import ( WorkspaceDeps, delete_file, edit_file, list_files, read_file, terraform_init, terraform_validate,17 collapsed lines
write_file,)from pydantic_ai import Agent, ModelRetryfrom pydantic_ai.exceptions import UnexpectedModelBehaviorfrom pydantic_ai.messages import ModelMessage, ModelResponse, ToolCallPartfrom pydantic_ai.models.function import AgentInfo, FunctionModel
VALID_TF = 'output "ok" { value = "ok" }\n'# Parses fine (init passes) but references an undeclared variable, so the# failure surfaces at the validate step.INVALID_TF = 'output "x" { value = var.missing }\n'
def ctx_for(root: Path) -> SimpleNamespace: # The tools only touch ctx.deps, so a namespace stands in for the # full RunContext. return SimpleNamespace(deps=WorkspaceDeps(root=root))
def test_read_missing_file_raises_model_retry(tmp_path: Path) -> None: with pytest.raises(ModelRetry, match="must be a file"): with pytest.raises(ModelRetry, match="does not exist"): read_file(ctx_for(tmp_path), "absent.tf")
5 collapsed lines
def test_path_escape_raises_model_retry(tmp_path: Path) -> None: with pytest.raises(ModelRetry, match="workspace root"): write_file(ctx_for(tmp_path), "../outside.tf", "boom")
def test_init_then_validate_passes(tmp_path: Path) -> None: ctx = ctx_for(tmp_path) (tmp_path / "main.tf").write_text(VALID_TF) assert terraform_init(ctx) == "OK: terraform init completed." assert terraform_validate(ctx) == "OK: terraform validate passed." assert terraform_init(ctx) == "OK: terraform_init passed." assert terraform_validate(ctx) == "OK: terraform_validate passed."
def test_init_failure_raises_model_retry(tmp_path: Path) -> None: (tmp_path / "main.tf").write_text("terraform {") with pytest.raises(ModelRetry, match="terraform init failed"): with pytest.raises(ModelRetry, match="terraform_init failed"): terraform_init(ctx_for(tmp_path))
def test_validate_failure_raises_model_retry(tmp_path: Path) -> None: ctx = ctx_for(tmp_path) (tmp_path / "main.tf").write_text(INVALID_TF) terraform_init(ctx) with pytest.raises(ModelRetry, match="terraform validate failed"): with pytest.raises(ModelRetry, match="terraform_validate failed"): terraform_validate(ctx)
def test_mutating_tools_record_files_changed(tmp_path: Path) -> None: """The no-op guard in core.py reads files_changed, so only the tools that modify the workspace may populate it.""" ctx = ctx_for(tmp_path) write_file(ctx, "main.tf", VALID_TF) edit_file(ctx, "main.tf", 'value = "ok"', 'value = "fine"') delete_file(ctx, "main.tf") assert ctx.deps.files_changed == {tmp_path / "main.tf"}
reader = ctx_for(tmp_path) (tmp_path / "main.tf").write_text(VALID_TF) read_file(reader, "main.tf") list_files(reader) assert reader.deps.files_changed == set()
def test_requirements_added_mid_run_recover_via_init(tmp_path: Path) -> None: """The flow the agent is prompted to follow: adding a module after the first init breaks validate until terraform_init runs again.""" ctx = ctx_for(tmp_path) (tmp_path / "main.tf").write_text(VALID_TF) terraform_init(ctx) assert terraform_validate(ctx) == "OK: terraform validate passed." assert terraform_validate(ctx) == "OK: terraform_validate passed."
mod = tmp_path / "mod" mod.mkdir()3 collapsed lines
(mod / "main.tf").write_text(VALID_TF) (tmp_path / "uses_module.tf").write_text('module "m" { source = "./mod" }\n')
with pytest.raises(ModelRetry, match="terraform init"): terraform_validate(ctx) terraform_init(ctx) assert terraform_validate(ctx) == "OK: terraform validate passed." assert terraform_validate(ctx) == "OK: terraform_validate passed."
def _always_validate_agent(retries: int | None) -> Agent:24 collapsed lines
def always_validate(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: return ModelResponse(parts=[ToolCallPart(tool_name="terraform_validate", args={})])
kwargs = {} if retries is None else {"retries": retries} return Agent( FunctionModel(always_validate), deps_type=WorkspaceDeps, tools=[terraform_init, terraform_validate], **kwargs, )
def test_default_budget_kills_run_on_second_consecutive_failure(tmp_path: Path) -> None: """pydantic-ai counts consecutive ModelRetry failures per tool against `retries` (default 1) and then raises UnexpectedModelBehavior. This is why handler.py sets a higher budget.""" (tmp_path / "main.tf").write_text("terraform {") agent = _always_validate_agent(retries=None) with pytest.raises(UnexpectedModelBehavior, match="exceeded max retries"): agent.run_sync("go", deps=WorkspaceDeps(root=tmp_path))
def test_raised_budget_tolerates_consecutive_failures(tmp_path: Path) -> None: (tmp_path / "main.tf").write_text("terraform {") agent = _always_validate_agent(retries=5) with pytest.raises(UnexpectedModelBehavior, match="exceeded max retries count of 5"): agent.run_sync("go", deps=WorkspaceDeps(root=tmp_path))"""Tests for the workspace tools: path guards, init/validate semantics,and how ModelRetry interacts with pydantic-ai's per-tool retry budget."""
from pathlib import Pathfrom types import SimpleNamespace
import pytestfrom agent.tools import ( WorkspaceDeps, delete_file, edit_file, list_files, read_file, terraform_init, terraform_validate, write_file,)from pydantic_ai import Agent, ModelRetryfrom pydantic_ai.exceptions import UnexpectedModelBehaviorfrom pydantic_ai.messages import ModelMessage, ModelResponse, ToolCallPartfrom pydantic_ai.models.function import AgentInfo, FunctionModel
VALID_TF = 'output "ok" { value = "ok" }\n'# Parses fine (init passes) but references an undeclared variable, so the# failure surfaces at the validate step.INVALID_TF = 'output "x" { value = var.missing }\n'
def ctx_for(root: Path) -> SimpleNamespace: # The tools only touch ctx.deps, so a namespace stands in for the # full RunContext. return SimpleNamespace(deps=WorkspaceDeps(root=root))
def test_read_missing_file_raises_model_retry(tmp_path: Path) -> None: with pytest.raises(ModelRetry, match="does not exist"): read_file(ctx_for(tmp_path), "absent.tf")
def test_path_escape_raises_model_retry(tmp_path: Path) -> None: with pytest.raises(ModelRetry, match="workspace root"): write_file(ctx_for(tmp_path), "../outside.tf", "boom")
def test_init_then_validate_passes(tmp_path: Path) -> None: ctx = ctx_for(tmp_path) (tmp_path / "main.tf").write_text(VALID_TF) assert terraform_init(ctx) == "OK: terraform_init passed." assert terraform_validate(ctx) == "OK: terraform_validate passed."
def test_init_failure_raises_model_retry(tmp_path: Path) -> None: (tmp_path / "main.tf").write_text("terraform {") with pytest.raises(ModelRetry, match="terraform_init failed"): terraform_init(ctx_for(tmp_path))
def test_validate_failure_raises_model_retry(tmp_path: Path) -> None: ctx = ctx_for(tmp_path) (tmp_path / "main.tf").write_text(INVALID_TF) terraform_init(ctx) with pytest.raises(ModelRetry, match="terraform_validate failed"): terraform_validate(ctx)
def test_mutating_tools_record_files_changed(tmp_path: Path) -> None: """The no-op guard in core.py reads files_changed, so only the tools that modify the workspace may populate it.""" ctx = ctx_for(tmp_path) write_file(ctx, "main.tf", VALID_TF) edit_file(ctx, "main.tf", 'value = "ok"', 'value = "fine"') delete_file(ctx, "main.tf") assert ctx.deps.files_changed == {tmp_path / "main.tf"}
reader = ctx_for(tmp_path) (tmp_path / "main.tf").write_text(VALID_TF) read_file(reader, "main.tf") list_files(reader) assert reader.deps.files_changed == set()
def test_requirements_added_mid_run_recover_via_init(tmp_path: Path) -> None: """The flow the agent is prompted to follow: adding a module after the first init breaks validate until terraform_init runs again.""" ctx = ctx_for(tmp_path) (tmp_path / "main.tf").write_text(VALID_TF) terraform_init(ctx) assert terraform_validate(ctx) == "OK: terraform_validate passed."
mod = tmp_path / "mod" mod.mkdir() (mod / "main.tf").write_text(VALID_TF) (tmp_path / "uses_module.tf").write_text('module "m" { source = "./mod" }\n')
with pytest.raises(ModelRetry, match="terraform init"): terraform_validate(ctx) terraform_init(ctx) assert terraform_validate(ctx) == "OK: terraform_validate passed."
def _always_validate_agent(retries: int | None) -> Agent: def always_validate(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: return ModelResponse(parts=[ToolCallPart(tool_name="terraform_validate", args={})])
kwargs = {} if retries is None else {"retries": retries} return Agent( FunctionModel(always_validate), deps_type=WorkspaceDeps, tools=[terraform_init, terraform_validate], **kwargs, )
def test_default_budget_kills_run_on_second_consecutive_failure(tmp_path: Path) -> None: """pydantic-ai counts consecutive ModelRetry failures per tool against `retries` (default 1) and then raises UnexpectedModelBehavior. This is why handler.py sets a higher budget.""" (tmp_path / "main.tf").write_text("terraform {") agent = _always_validate_agent(retries=None) with pytest.raises(UnexpectedModelBehavior, match="exceeded max retries"): agent.run_sync("go", deps=WorkspaceDeps(root=tmp_path))
def test_raised_budget_tolerates_consecutive_failures(tmp_path: Path) -> None: (tmp_path / "main.tf").write_text("terraform {") agent = _always_validate_agent(retries=5) with pytest.raises(UnexpectedModelBehavior, match="exceeded max retries count of 5"): agent.run_sync("go", deps=WorkspaceDeps(root=tmp_path))"""Contract tests for the validators."""
from pathlib import Pathfrom types import SimpleNamespace
import pytestfrom agent.tools import ( WorkspaceDeps, checkov, tflint, validate_workspace,)from agent.tools.validators import CommandResult, _checkov_commandfrom pydantic_ai import ModelRetry
# Unformatted and untagged, so it exercises fmt normalization and trips# tflint and checkov.UNTAGGED_TF = 'resource "aws_s3_bucket" "b" {bucket="example"}\n'CLEAN_TF = (Path(__file__).parent / "clean.tf").read_text()
def ctx_for(root: Path) -> SimpleNamespace: # The tools only touch ctx.deps, so a namespace stands in for the # full RunContext. return SimpleNamespace(deps=WorkspaceDeps(root=root))
def test_validate_workspace_formats_files_in_place(tmp_path: Path) -> None: # fmt is not an agent tool; the caller-side check normalizes formatting # deterministically before gating. (tmp_path / "main.tf").write_text(UNTAGGED_TF) validate_workspace(tmp_path) formatted = (tmp_path / "main.tf").read_text() assert formatted == 'resource "aws_s3_bucket" "b" { bucket = "example" }\n'
def test_tflint_passes_on_clean_config(tmp_path: Path) -> None: (tmp_path / "main.tf").write_text(CLEAN_TF) assert tflint(ctx_for(tmp_path)).startswith("OK:")
def test_checkov_flags_insecure_bucket(tmp_path: Path) -> None: # The default skips must not swallow real baselines: a bucket without a # public access block still fails. (tmp_path / "main.tf").write_text(UNTAGGED_TF) with pytest.raises(ModelRetry): checkov(ctx_for(tmp_path))
def test_checkov_default_skips_apply_without_project_config(tmp_path: Path) -> None: commands = _checkov_command(tmp_path).commands assert "--skip-check" in commands assert "CKV_AWS_144" in commands[commands.index("--skip-check") + 1]
def test_checkov_project_config_wins_over_default_skips(tmp_path: Path) -> None: (tmp_path / ".checkov.yaml").write_text("skip-check:\n - CKV_AWS_21\n") assert "--skip-check" not in _checkov_command(tmp_path).commands
def test_failure_report_to_the_agent_is_capped() -> None: """An uncapped checkov report rides the message history for the whole run.""" result = CommandResult(success=False, stdout="x" * 50_000, stderr="") message = result.format_error_for_agent()
assert len(message) < 50_000 assert "42000 more characters cut" in message # The full output stays on the object for the caller that wants it. assert len(result.stdout) == 50_000
def test_failure_report_under_the_cap_is_untouched() -> None: result = CommandResult(success=False, stdout="boom", stderr="stack") assert result.format_error_for_agent() == "failed:\nboom\nstack"
def test_only_provider_installing_commands_take_the_lock() -> None: """The lock exists for terraform's shared, concurrency-unsafe plugin cache.""" from agent.tools.validators import TERRAFORM_INIT, TERRAFORM_VALIDATE, TFLINT
assert TERRAFORM_INIT.installs_providers assert not TERRAFORM_VALIDATE.installs_providers assert not TFLINT.installs_providers# Keep the build context to what the Dockerfile actually copies; .envrc# files stay out because they can hold tokens..venvbuild.gitinfrascriptstests.envrc.envrc.local*.tar.gzsource_env_if_exists .envrc.local# Local AWS env for this project. Gitignored.# Set the region (shared by both auth options) and uncomment ONE of the# credential blocks below, then run `direnv allow` in this directory.
export AWS_REGION=eu-west-1
# Option A: Named profile (e.g. from `aws configure sso` or ~/.aws/credentials).# export AWS_PROFILE=your-profile-name
# Option B: Static or temporary credentials.# export AWS_ACCESS_KEY_ID=...# export AWS_SECRET_ACCESS_KEY=...# export AWS_SESSION_TOKEN=... # only if using temporary creds
# Email subscribed to the alarms SNS topic. Required by infra/alerts.tf.# Set before `terraform apply`; AWS sends a confirmation email that must be# clicked before alarms can deliver.# export [email protected]
# Fill in after `terraform apply` in post 1, then run `direnv reload`.# Values come from terraform outputs:# terraform -chdir=infra output -raw agent_role_arn# terraform -chdir=infra output -raw inference_profile_arn# export AGENT_ROLE_ARN=arn:aws:iam::<account>:role/terraform-pr-agent# export BEDROCK_INFERENCE_PROFILE_ARN=arn:aws:bedrock:eu-west-1:<account>:application-inference-profile/<id>
export LOGFIRE_TOKEN="pylf_v1_..."
# Same value as LOGFIRE_TOKEN above, but exposed as a Terraform variable# so infra/logfire.tf can put it in an SSM SecureString for the Lambda.# Leave unset to skip the SSM parameter and the matching IAM grants.# export TF_VAR_logfire_token="$LOGFIRE_TOKEN"
# API key for the Mistral direct API, used when DEFAULT_MODEL (or the per-event# model override) points at a Mistral registry entry. Get one at# https://console.mistral.ai/api-keys/.export MISTRAL_API_KEY="..."
# Same value as MISTRAL_API_KEY above, exposed as a Terraform variable so# infra/models.tf can put it in an SSM SecureString for the Lambda. Leave# unset to skip the SSM parameter, the matching IAM grants, and the Mistral# providers (a Bedrock default still works). The justfile's _tf-post-env# already derives this from MISTRAL_API_KEY, so this line is optional.# export TF_VAR_mistral_api_key="$MISTRAL_API_KEY"
# Bucket name for the audit copy. The DuckDB view in scripts/traces.sql# reads it via the CLI-only getenv() function so the SQL stays free of# account-specific values.export AUDIT_BUCKET=terraform-pr-agent-audit-$(aws sts get-caller-identity --query Account --output text)-${AWS_REGION}.envrc.local.terraform/*.tfstate*.tfstate.*.direnv/# Project conventions
Tutorial project from the Terraform PR Agent series athttps://andreaslang.dev/posts/terraform-pr-agent/
> When a post introduces a new top-level directory, ship an updated> `AGENTS.md` in that post's `scaffold/` overlay so this Layout section> stays accurate.
## Layout
- `infra/` Terraform (Bedrock, CloudWatch, DynamoDB, ...)- `agent/` Python package (pydantic-ai code)- `evals/` Golden cases + eval harness- `scripts/` Standalone single-file Python scripts with PEP 723 inline dependency metadata. Run with `uv run scripts/<name>.py`; do not move these into the `agent` package or a shared `pyproject.toml`.
## Tooling
- Python: use `uv` for dependency and script management. Run scripts with `uv run`. Single-file scripts under `scripts/` declare their deps inline via PEP 723 (`# /// script ... # ///` headers) so they stay self-contained and runnable without a project venv.- Terraform: 1.x.- AWS: credentials configured via `aws configure sso` or static keys.
## Conventions
- Run `terraform validate` after editing any `.tf` file.- Run `terraform fmt` before committing `.tf`.- Never auto-apply Terraform; print the plan first and wait for confirmation.- Don't introduce dependencies the current post hasn't covered.- AWS resources live in a sandbox sub-account; never assume a production account.# Multi-stage build: the builder stages below carry tooling (unzip, uv,# rpm metadata) that the function never needs at runtime. Only their# outputs are copied into the final stage, so the shipped image stays10 collapsed lines
# lean and pulls faster on a cold start.
FROM public.ecr.aws/lambda/python:3.13 AS terraform# Pinned + checksum-verified so the image build is reproducible and a# tampered release archive fails the build instead of shipping.ARG TERRAFORM_VERSION=1.15.6RUN dnf install -y unzip && dnf clean allRUN curl -fsSLO https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_arm64.zip \ && curl -fsSLO https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_SHA256SUMS \ && grep " terraform_${TERRAFORM_VERSION}_linux_arm64.zip\$" terraform_${TERRAFORM_VERSION}_SHA256SUMS | sha256sum -c - \ && unzip terraform_${TERRAFORM_VERSION}_linux_arm64.zip -d /usr/local/bin \ && rm terraform_${TERRAFORM_VERSION}_linux_arm64.zip terraform_${TERRAFORM_VERSION}_SHA256SUMS
# Same pinned + checksum-verified pattern as terraform, via its checksums.txt.FROM public.ecr.aws/lambda/python:3.13 AS tflintARG TFLINT_VERSION=0.63.1RUN dnf install -y unzip && dnf clean allRUN curl -fsSLO https://github.com/terraform-linters/tflint/releases/download/v${TFLINT_VERSION}/tflint_linux_arm64.zip \ && curl -fsSLO https://github.com/terraform-linters/tflint/releases/download/v${TFLINT_VERSION}/checksums.txt \ && grep " tflint_linux_arm64.zip\$" checksums.txt | sha256sum -c - \ && unzip tflint_linux_arm64.zip -d /usr/local/bin \ && rm tflint_linux_arm64.zip checksums.txt# TODO: the AWS ruleset is a plugin fetched by `tflint --init`; wire it in.
# No arm64 release binary, so install into an isolated venv, copied whole.FROM public.ecr.aws/lambda/python:3.13 AS checkovCOPY --from=ghcr.io/astral-sh/uv:0.11.21 /uv /usr/local/bin/uvARG CHECKOV_VERSION=3.3.6RUN uv venv /opt/checkov \ && VIRTUAL_ENV=/opt/checkov uv pip install "checkov==${CHECKOV_VERSION}"
# Same uv flow as the zip build this replaces (see the uv AWS Lambda# guide); the dependency set installs straight into the task root, where# the final stage picks it up.34 collapsed lines
FROM public.ecr.aws/lambda/python:3.13 AS pythonCOPY --from=ghcr.io/astral-sh/uv:0.11.21 /uv /usr/local/bin/uvWORKDIR /opt/buildCOPY pyproject.toml uv.lock ./RUN uv export --frozen --no-dev --no-editable -o requirements.txt \ && uv pip install \ --no-installer-metadata \ --no-compile-bytecode \ --target "${LAMBDA_TASK_ROOT}" \ -r requirements.txt
# Container images cannot attach layers, so the Lambda Insights extension# is baked into the image: pinned version, detached GPG signature checked# against the key fingerprint published in the Lambda Insights docs, so a# tampered rpm fails the build.FROM public.ecr.aws/lambda/python:3.13 AS insightsARG INSIGHTS_VERSION=1.0.660.0ARG INSIGHTS_BASE_URL=https://lambda-insights-extension-arm64.s3-ap-northeast-1.amazonaws.com# The downloaded key is checked against the fingerprint from the docs# before anything trusts it; gpg runs with --batch/--no-tty/--no-autostart# because the base image ships no gpg-agent.RUN curl -fsSLO ${INSIGHTS_BASE_URL}/amazon_linux/lambda-insights-extension-arm64.${INSIGHTS_VERSION}.rpm \ && curl -fsSLO ${INSIGHTS_BASE_URL}/amazon_linux/lambda-insights-extension-arm64.${INSIGHTS_VERSION}.rpm.sig \ && curl -fsSLO ${INSIGHTS_BASE_URL}/lambda-insights-extension.gpg \ && gpg --batch --no-tty --show-keys --with-colons lambda-insights-extension.gpg \ | grep -q '^fpr:::::::::E0AFFA11FFF35BD7349EE222479C97A1848ABDC8:' \ && gpg --batch --no-tty --no-autostart --import lambda-insights-extension.gpg \ && gpg --batch --no-tty --no-autostart --verify \ lambda-insights-extension-arm64.${INSIGHTS_VERSION}.rpm.sig \ lambda-insights-extension-arm64.${INSIGHTS_VERSION}.rpm \ && rpm -U lambda-insights-extension-arm64.${INSIGHTS_VERSION}.rpm \ && rm -f lambda-insights-extension-arm64.${INSIGHTS_VERSION}.rpm* lambda-insights-extension.gpg
# The shipped image: only the artifacts the function uses at runtime# cross over from the builder stages.FROM public.ecr.aws/lambda/python:3.13COPY --from=terraform /usr/local/bin/terraform /usr/local/bin/terraformCOPY --from=tflint /usr/local/bin/tflint /usr/local/bin/tflintCOPY --from=checkov /opt/checkov /opt/checkovCOPY --from=insights /opt/extensions /opt/extensionsCOPY --from=insights /opt/cloudwatch /opt/cloudwatchCOPY --from=python ${LAMBDA_TASK_ROOT} ${LAMBDA_TASK_ROOT}COPY agent ${LAMBDA_TASK_ROOT}/agentWORKDIR ${LAMBDA_TASK_ROOT}
# terraform needs a writable HOME for incidental state; /tmp is the only# writable path at runtime. CMD replaces the zip package's handler attribute# and points at lambda_entry, the instrumented entry point, not the bare# handler module.# writable path at runtime. checkov's venv bin joins PATH. CMD replaces the zip# package's handler attribute and points at lambda_entry, the instrumented# entry point, not the bare handler module.ENV HOME=/tmp \ TF_IN_AUTOMATION=true \ TF_INPUT=false TF_INPUT=false \ PATH=/opt/checkov/bin:/usr/local/bin:/usr/bin:/binCMD ["agent.lambda_entry.handler"]# Multi-stage build: the builder stages below carry tooling (unzip, uv,# rpm metadata) that the function never needs at runtime. Only their# outputs are copied into the final stage, so the shipped image stays# lean and pulls faster on a cold start.
FROM public.ecr.aws/lambda/python:3.13 AS terraform# Pinned + checksum-verified so the image build is reproducible and a# tampered release archive fails the build instead of shipping.ARG TERRAFORM_VERSION=1.15.6RUN dnf install -y unzip && dnf clean allRUN curl -fsSLO https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_arm64.zip \ && curl -fsSLO https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_SHA256SUMS \ && grep " terraform_${TERRAFORM_VERSION}_linux_arm64.zip\$" terraform_${TERRAFORM_VERSION}_SHA256SUMS | sha256sum -c - \ && unzip terraform_${TERRAFORM_VERSION}_linux_arm64.zip -d /usr/local/bin \ && rm terraform_${TERRAFORM_VERSION}_linux_arm64.zip terraform_${TERRAFORM_VERSION}_SHA256SUMS
# Same pinned + checksum-verified pattern as terraform, via its checksums.txt.FROM public.ecr.aws/lambda/python:3.13 AS tflintARG TFLINT_VERSION=0.63.1RUN dnf install -y unzip && dnf clean allRUN curl -fsSLO https://github.com/terraform-linters/tflint/releases/download/v${TFLINT_VERSION}/tflint_linux_arm64.zip \ && curl -fsSLO https://github.com/terraform-linters/tflint/releases/download/v${TFLINT_VERSION}/checksums.txt \ && grep " tflint_linux_arm64.zip\$" checksums.txt | sha256sum -c - \ && unzip tflint_linux_arm64.zip -d /usr/local/bin \ && rm tflint_linux_arm64.zip checksums.txt# TODO: the AWS ruleset is a plugin fetched by `tflint --init`; wire it in.
# No arm64 release binary, so install into an isolated venv, copied whole.FROM public.ecr.aws/lambda/python:3.13 AS checkovCOPY --from=ghcr.io/astral-sh/uv:0.11.21 /uv /usr/local/bin/uvARG CHECKOV_VERSION=3.3.6RUN uv venv /opt/checkov \ && VIRTUAL_ENV=/opt/checkov uv pip install "checkov==${CHECKOV_VERSION}"
# Same uv flow as the zip build this replaces (see the uv AWS Lambda# guide); the dependency set installs straight into the task root, where# the final stage picks it up.FROM public.ecr.aws/lambda/python:3.13 AS pythonCOPY --from=ghcr.io/astral-sh/uv:0.11.21 /uv /usr/local/bin/uvWORKDIR /opt/buildCOPY pyproject.toml uv.lock ./RUN uv export --frozen --no-dev --no-editable -o requirements.txt \ && uv pip install \ --no-installer-metadata \ --no-compile-bytecode \ --target "${LAMBDA_TASK_ROOT}" \ -r requirements.txt
# Container images cannot attach layers, so the Lambda Insights extension# is baked into the image: pinned version, detached GPG signature checked# against the key fingerprint published in the Lambda Insights docs, so a# tampered rpm fails the build.FROM public.ecr.aws/lambda/python:3.13 AS insightsARG INSIGHTS_VERSION=1.0.660.0ARG INSIGHTS_BASE_URL=https://lambda-insights-extension-arm64.s3-ap-northeast-1.amazonaws.com# The downloaded key is checked against the fingerprint from the docs# before anything trusts it; gpg runs with --batch/--no-tty/--no-autostart# because the base image ships no gpg-agent.RUN curl -fsSLO ${INSIGHTS_BASE_URL}/amazon_linux/lambda-insights-extension-arm64.${INSIGHTS_VERSION}.rpm \ && curl -fsSLO ${INSIGHTS_BASE_URL}/amazon_linux/lambda-insights-extension-arm64.${INSIGHTS_VERSION}.rpm.sig \ && curl -fsSLO ${INSIGHTS_BASE_URL}/lambda-insights-extension.gpg \ && gpg --batch --no-tty --show-keys --with-colons lambda-insights-extension.gpg \ | grep -q '^fpr:::::::::E0AFFA11FFF35BD7349EE222479C97A1848ABDC8:' \ && gpg --batch --no-tty --no-autostart --import lambda-insights-extension.gpg \ && gpg --batch --no-tty --no-autostart --verify \ lambda-insights-extension-arm64.${INSIGHTS_VERSION}.rpm.sig \ lambda-insights-extension-arm64.${INSIGHTS_VERSION}.rpm \ && rpm -U lambda-insights-extension-arm64.${INSIGHTS_VERSION}.rpm \ && rm -f lambda-insights-extension-arm64.${INSIGHTS_VERSION}.rpm* lambda-insights-extension.gpg
# The shipped image: only the artifacts the function uses at runtime# cross over from the builder stages.FROM public.ecr.aws/lambda/python:3.13COPY --from=terraform /usr/local/bin/terraform /usr/local/bin/terraformCOPY --from=tflint /usr/local/bin/tflint /usr/local/bin/tflintCOPY --from=checkov /opt/checkov /opt/checkovCOPY --from=insights /opt/extensions /opt/extensionsCOPY --from=insights /opt/cloudwatch /opt/cloudwatchCOPY --from=python ${LAMBDA_TASK_ROOT} ${LAMBDA_TASK_ROOT}COPY agent ${LAMBDA_TASK_ROOT}/agentWORKDIR ${LAMBDA_TASK_ROOT}
# terraform needs a writable HOME for incidental state; /tmp is the only# writable path at runtime. checkov's venv bin joins PATH. CMD replaces the zip# package's handler attribute and points at lambda_entry, the instrumented# entry point, not the bare handler module.ENV HOME=/tmp \ TF_IN_AUTOMATION=true \ TF_INPUT=false \ PATH=/opt/checkov/bin:/usr/local/bin:/usr/bin:/binCMD ["agent.lambda_entry.handler"][project]name = "terraform-pr-agent"version = "0.1.0"description = "The terraform-pr-agent Lambda handler."requires-python = ">=3.13"dependencies = [ "pydantic-ai-slim[bedrock,mistral,retries]>=1.106,<2", "pydantic-ai-slim[bedrock,mistral,retries,openai]>=1.106,<2", # models.py suppresses the SDK's duplicate chat spans via # mistralai.extra.observability.telemetry, which exists from 2.4.8; # pydantic-ai's own floor (>=2.0.0) would let a resolver pick older. "mistralai>=2.4.8,<3", "logfire[aws-lambda]>=4.35,<5", "structlog>=24,<27", "boto3>=1.35,<2",]
# The eval harness runs from a dev machine, never inside the Lambda image# (the Dockerfile installs with --no-dev), so its dependencies stay out of# the main set.[dependency-groups]dev = [ "pytest>=8,<10", "moto[s3]>=5,<6", "pydantic-evals>=1.106,<2", "typer>=0.16,<1",]
[tool.pytest.ini_options]testpaths = ["tests"]pythonpath = ["."][project]name = "terraform-pr-agent"version = "0.1.0"description = "The terraform-pr-agent Lambda handler."requires-python = ">=3.13"dependencies = [ "pydantic-ai-slim[bedrock,mistral,retries,openai]>=1.106,<2", # models.py suppresses the SDK's duplicate chat spans via # mistralai.extra.observability.telemetry, which exists from 2.4.8; # pydantic-ai's own floor (>=2.0.0) would let a resolver pick older. "mistralai>=2.4.8,<3", "logfire[aws-lambda]>=4.35,<5", "structlog>=24,<27", "boto3>=1.35,<2",]
# The eval harness runs from a dev machine, never inside the Lambda image# (the Dockerfile installs with --no-dev), so its dependencies stay out of# the main set.[dependency-groups]dev = [ "pytest>=8,<10", "moto[s3]>=5,<6", "pydantic-evals>=1.106,<2", "typer>=0.16,<1",]
[tool.pytest.ini_options]testpaths = ["tests"]pythonpath = ["."]# terraform-pr-agent
A pydantic-ai agent that writes and validates Terraform, running as acontainer-image AWS Lambda. This is the worked example from the **Terraform PRAgent** series; each post adds one capability.
Series: <https://andreaslang.dev/posts/terraform-pr-agent/>
At this checkpoint the agent gets a `/tmp` workspace, sandboxed file tools(list, read, write, edit, delete), and a `terraform_validate` tool it can callin a loop. It ships as a Docker-based Lambda with the terraform CLI baked in.
## Prerequisites
- [uv](https://docs.astral.sh/uv/) for Python and the single-file scripts- Terraform 1.x- Docker with buildx (the image is arm64; the placeholder is built during apply)- An AWS account you are happy to create resources in. A throwaway sandbox sub-account is assumed; never point this at production. Credentials via `aws configure sso` or static keys.- Bedrock model access enabled for the models in `infra/models.tf`, in your region- Optional: a [Logfire](https://logfire.pydantic.dev/) token for tracing, and a [Mistral API key](https://console.mistral.ai/api-keys/) for the Mistral models
## Setup
A commented `.envrc.local` template ships in the scaffold (gitignored). Fill itin and load it:
```bashdirenv allow```
At minimum set `AWS_REGION`, your AWS credentials, and `TF_VAR_alert_email`(AWS emails a confirmation for the alarm topic). The Logfire and Mistral keysare optional; leave them unset to skip those integrations. A few values(`AUDIT_BUCKET`, `AGENT_ROLE_ARN`, and friends) come from terraform outputs, soset them and run `direnv reload` after the first apply.
If you skip the Mistral key, set `TF_VAR_default_model=haiku` (a Bedrock entryin `infra/models.tf`); the default is `mistral-large`, which needs the key.
## Deploy
```bashcd infraterraform initterraform planterraform applycd .../scripts/build-lambda.sh```
`terraform apply` stands the function up on a placeholder image so everyresource is created in one pass; `build-lambda.sh` then builds the real arm64image and points the Lambda at it. Re-run the script whenever you change`agent/` or its dependencies.
Never blind-apply: read the plan first, and note the apply reads your`.envrc.local` env (the alert email and the optional tokens feed `TF_VAR_*`).
## Run the agent
Invoke the function with an empty payload (it falls back to a sample prompt) oryour own:
```bashaws lambda invoke --function-name terraform-pr-agent \ --payload '{"prompt": "Set up a new terraform project, creating a best practice s3 bucket."}' \ --cli-binary-format raw-in-base64-out --cli-read-timeout 0 out.jsoncat out.json```
`--cli-read-timeout 0` disables the CLI's default 60s read timeout: asynchronous invoke runs the whole agent, which takes tens of seconds.
A `model` field in the payload overrides the `default_model` variable. Each runwrites its workspace and a `result.json` to the runs bucket under`runs/<run_id>/`, and emits a trace (Logfire if configured, plus the S3 auditcopy). The CloudWatch dashboard `terraform-pr-agent` shows the model and Lambdametrics.
## Tests
```bashuv syncuv run pytest```
The suite is moto-backed and makes no real AWS calls.
## Layout
- `agent/` pydantic-ai handler and tools (the Lambda code)- `infra/` Terraform for all AWS resources- `scripts/` standalone PEP 723 scripts (`uv run scripts/<name>.py`) plus `build-lambda.sh`- `tests/` pytest suite
See `AGENTS.md` for the conventions used when editing this project.Fast-forward to the final code of this post
Download the cumulative checkpoint that matches the state at the end of this post. Useful for landing on the finished tree without working through every step.
mkdir -p ~/projectscd ~/projectscurl -fsSL https://andreaslang.dev/terraform-pr-agent/terraform-pr-agent-05.tar.gz | tar xzMore validation tools#
We extend the validation suite well beyond Post 4’s terraform_validate. It is nice to tell the agent “make sure the
S3 bucket you create follows security best practices”, but it is better to use checkov to check that the
bucket is encrypted and has a lifecycle policy that deletes old versions and tflint to check that nothing uses
deprecated syntax or known bad practices.
We do not want to give the agent raw access to the shell and all features of terraform, so we create a wrapper class that standardises how each shell command runs and how its output or failure is reported.
class CommandResult(BaseModel): success: bool stdout: str stderr: str
def format_error_for_agent(self) -> str: """The failure as the model sees it, capped. Callers keep the full output.""" if not self.success: return f"failed:\n{_capped(self.stdout)}\n{_capped(self.stderr)}" raise RuntimeError("CommandResult.format_error_for_agent() called on success")
class Command(BaseModel): name: str commands: list[str]
@property def installs_providers(self) -> bool: return "init" in self.commands
def run(self, path: Path) -> CommandResult: if self.installs_providers: with _INSTALL_LOCK: return self._run(path) return self._run(path)
def _run(self, path: Path) -> CommandResult: # Inside the lock, so the span measures the command and not the wait. with track_memory(self.name): result = subprocess.run(self.commands, cwd=path, capture_output=True, text=True) return CommandResult( success=result.returncode == 0, stdout=result.stdout, stderr=result.stderr )
def run_root(self, ctx: RunContext[WorkspaceDeps]) -> CommandResult: return self.run(ctx.deps.root)
def run_in_tool(self, path: Path) -> str: result = self.run(path) if not result.success: raise ModelRetry(f"{self.name} {result.format_error_for_agent()}") return f"OK: {self.name} passed."The first runs with the new tools went badly, and logfire showed it straight away. Token usage was
rising quickly and I could see a lot of failed tool calls for checkov. Failed here means the checks
did not pass, not that the tool crashed. A quick dive into the tool parameters showed that checkov
was returning a very long report where:
- All checks, even the successful ones, were listed, filling the agent’s context and confusing it
- The actual failed tests were extremely strict, stricter than you would expect in a normal setup
For example KMS over AES256 is a good rule, but irrelevant outside a compliance setting. Similarly,
every S3 bucket is told it needs its own access log bucket. We disable a short list of rules, but
only when the workspace ships no .checkov.yaml of its own.
So the fix? Add --quiet --compact to checkov, so we do not fill the agent’s context with irrelevant information.
I would rather read the short version as a human too. Together with excluding a few rules, the
results improved immediately. We went from timing out after 300s and
2M tokens input to 94s and less than 20k tokens input. Significant cost difference!
A failed check is not paid for once, though. Its output joins the message history and is billed again on every later turn, so we also cap what a failure hands back:
# Every failure report becomes a tool result the model carries for the rest of# the run, so an uncapped one is paid for on every later turn and lands in the# audit record. checkov's report is the offender; its head carries the# failures that matter, and the agent re-runs the check anyway.MAX_REPORT_CHARS = 8_000
def _capped(text: str) -> str: if len(text) <= MAX_REPORT_CHARS: return text return f"{text[:MAX_REPORT_CHARS]}\n[{len(text) - MAX_REPORT_CHARS} more characters cut]"TERRAFORM_INIT = Command( name="terraform_init", commands=["terraform", "init", "-backend=false", "-input=false", "-no-color"],)
TERRAFORM_VALIDATE = Command( name="terraform_validate", commands=["terraform", "validate", "-no-color"],)
TERRAFORM_FMT = Command( name="terraform_fmt", commands=["terraform", "fmt", "-recursive"],)
TFLINT = Command( name="tflint", commands=["tflint", "--format", "compact"],)
# Checks skipped by default: cost or architecture posture decisions, not# security baselines, and routinely disabled in real projects. Genuine# baselines (public access blocks, encryption at rest, IAM wildcards) stay on.13 collapsed lines
_CHECKOV_DEFAULT_SKIPS = [ "CKV_AWS_18", # S3 access logging on every bucket "CKV_AWS_144", # S3 cross-region replication "CKV_AWS_145", # S3 must use KMS; SSE-S3 (CKV_AWS_19) still enforced "CKV2_AWS_61", # S3 lifecycle configuration on every bucket "CKV2_AWS_62", # S3 event notifications on every bucket "CKV_AWS_50", # Lambda X-Ray tracing "CKV_AWS_115", # Lambda reserved concurrency "CKV_AWS_116", # Lambda dead-letter queue "CKV_AWS_117", # Lambda attached to a VPC "CKV_AWS_272", # Lambda code signing "CKV_AWS_338", # CloudWatch log retention of at least a year]
def _checkov_command(root: Path) -> Command: """Default skips apply only when the workspace brings no config of its own: checkov auto-discovers .checkov.yaml in the scanned directory, and a project that states its policy wins over ours. """ commands = ["checkov", "-d", ".", "--quiet", "--compact"] if not any((root / name).exists() for name in (".checkov.yaml", ".checkov.yml")): commands += ["--skip-check", ",".join(_CHECKOV_DEFAULT_SKIPS)] return Command(name="checkov", commands=commands)Wrapping them as tools is then trivial. The command objects never mutate their state, so holding them as module-level singletons carries no concurrency risk.
def terraform_init(ctx: RunContext[WorkspaceDeps]) -> str: """Run ``terraform init`` in the workspace.
Required once before the first ``terraform_validate`` and again after provider or module requirements change. """ return TERRAFORM_INIT.run_in_tool(ctx.deps.root)
def terraform_validate(ctx: RunContext[WorkspaceDeps]) -> str: """Run ``terraform validate`` in the workspace and return its output.""" return TERRAFORM_VALIDATE.run_in_tool(ctx.deps.root)
def tflint(ctx: RunContext[WorkspaceDeps]) -> str: return TFLINT.run_in_tool(ctx.deps.root)
def checkov(ctx: RunContext[WorkspaceDeps]) -> str: return _checkov_command(ctx.deps.root).run_in_tool(ctx.deps.root)validate_workspace is different: we hook it up as an output validator. In the previous post we ran this check
ourselves and passed the messages back into a new run with the retry prompt. Then I discovered pydantic-ai covers
this out of the box, which let us delete most of that code.
def validate_workspace(path: Path) -> CommandResult: # Normalize formatting before gating; a fmt failure (unparsable HCL) is # ignored here because terraform validate reports it better one step later. TERRAFORM_FMT.run(path) for command in [TERRAFORM_VALIDATE, TFLINT, _checkov_command(path)]: result = command.run(path) if not result.success: break return resultWe extend the system prompt so the agent reaches for the new validators:
Run tflint and checkov and clear all findings before reporting done.
TaskResult: structured self-report#
Making the agent declare what it did, knowing the claim gets checked, changes its behaviour more than I expected. So in this post we also added a structured output type:
class TaskResult(BaseModel): """The agent's structured self-report on a finished run.
Each required field pushes the agent to consider that dimension of its work; the tool-call spans in the trace remain the ground truth that exposes any embellishment. Because this is the agent's output type, ending a run now requires calling the final_result output tool, so a text-only reply can no longer end a run silently. """
model_config = ConfigDict(frozen=True)
summary: str """One line on what was done.""" solution_description: str """How the problem was solved, including architectural choices.""" validations_run: list[str] """Which validation tools were invoked during the run.""" issues_addressed: list[str] """Security or correctness problems identified and fixed.""" known_limitations: list[str] """What was not handled; surfaces for human review.""" ready_for_review: bool """The agent's self-assessment that the workspace is PR-ready."""Reporting done is not the same as being done, so the same validate_workspace gate runs as an output validator: the
agent only escapes the loop when the checks it is told to run actually pass.
_RETRY_PROMPT = ( "terraform validate still reports errors after you reported done. " "Fix them and validate again.\n\n{output}")
@agent.output_validatordef _validate_final_workspace(ctx: RunContext[WorkspaceDeps], output: TaskResult) -> TaskResult: # The deliverable is the workspace, not this object: a run that changed # nothing produced nothing, however plausible its self-report reads. if not ctx.deps.files_changed: raise ModelRetry( "You reported done but made no changes to the workspace. " "Use the file tools to implement the request, validate, then report done again." ) validation_result = validate_workspace(ctx.deps.root) if not validation_result.success: raise ModelRetry(_RETRY_PROMPT.format(output=validation_result.format_error_for_agent())) return outputTo be clear this does not entirely prevent the agent from making things up and filling things in it did not do, but it does push more runs the right way, because each output field forces the agent to account for that dimension.
In the eval runs I compared different models with and without the structured output. In particular
weaker ones like Haiku (vs glm5p2) did better if they were forced to justify themselves in the structured
output. glm5p2 showed no difference. For Haiku the structured output runs were cleaner
with tool calls 27.0 down to 22.4, validator calls 13.0 down to 10.8, validate-pass 93% up to 100%, errored
runs 6.7% down to 0%.
The output type itself is enforced, the agent has to call a tool with this enforced schema to end the run. We also have a max limit of turns and errors to avoid infinite unsuccessful runs.
This structured schema is also what will let us deterministically create PRs. This is what we set out to do in the first place after all.
The eval harness: the validators are the conditions#
pydantic_evals is built around a few concepts:
- Datasets: contain cases for evaluation and which Evaluators to apply - the central abstraction validation runs against
- Cases: inputs, expected outputs, and metadata
- Evaluators: Logic to evaluate which can be based on expected outputs and metadata
It offers a few built-in evaluators, but the more complex checks we need, actually inspecting the code the agent produced, required building our own.
WorkspaceValidates- uses the existingvalidate_workspaceoutput validator to check the workspace is valid. Technically a slight double validation as the agent cannot submit the result without this.PlanMatchesGraph- runsterraform plan -outandterraform show -jsonto compare the workspace to the expected graph (coming from metadata). Configuring the AWS provider authenticates against STS, so the plan would need real credentials. It runs on a copy of the workspace with a*_override.tfthat supplies mock ones, which keeps the sweep offline and leaves the graded workspace untouched.SelfReportAccurate- the agent has to say which checks it did run in itsTaskResult, we validate this against actual tool calls by going through recorded spans.ToolErrorMetricEvaluator- slightly different from the others: here we give a numeric score from 0.0 to 1.0 to indicate how many tool calls failed. Every time a tool call fails 0.1 is subtracted from the score until it reaches 0.0.
One slight practical hiccup was when the agent decided to add variables without a default, which made it difficult to create a plan. Therefore we instruct the agent to default every variable it declares and fail the evaluation if it does not.
WorkspaceValidates is the one place the policy tools and the harness meet, in eight lines: it calls the same
validate_workspace() the agent had to satisfy at runtime, so a case clears that check for the same reason the agent
was allowed to stop. The other three evaluators ask questions the validators cannot answer.
@dataclassclass WorkspaceValidates(Evaluator[TASK_INPUTS, EvalOutput, CaseMetadata]): """fmt + terraform validate + tflint + checkov, verbatim from the agent."""
def evaluate( self, ctx: EvaluatorContext[TASK_INPUTS, EvalOutput, CaseMetadata] ) -> EvaluationReason: result = validate_workspace(ctx.output.workspace) if result.success: return EvaluationReason(value=True) return EvaluationReason(value=False, reason=result.format_error_for_agent())Take SelfReportAccurate, one of the more interesting evaluators here: you can see that the context
passed to the evaluator contains a ctx.span_tree object with functions to find spans we need for evals. The
downside is that it does not carry non-genai attributes, which is a problem if you are looking for
error information that is recorded in an OTEL-specific field. Not a problem here, but was an issue for
ToolErrorMetricEvaluator as the errors were harder to identify consistently.
@dataclassclass SelfReportAccurate(Evaluator[TASK_INPUTS, EvalOutput, CaseMetadata]): """The TaskResult agrees with what the case knows.
The tool-call spans in the trace stay the ground truth. This checks only the self-report claims that a case can verify cheaply. """
def evaluate( self, ctx: EvaluatorContext[TASK_INPUTS, EvalOutput, CaseMetadata] ) -> dict[str, EvaluatorOutput]: report = ctx.output.result validations = " ".join(report.validations_run).lower() tool_spans = ctx.span_tree.find( predicate=SpanQuery( name_contains="execute_tool", has_attribute_keys=["gen_ai.tool.name"] ) ) actual_tools = {span.attributes.get("gen_ai.tool.name") for span in tool_spans} return { "ready_for_review": EvaluationReason( value=report.ready_for_review, reason="Agent should mark ready for review.", ), "reported_linters": EvaluationReason( value=( "tflint" in validations and "checkov" in validations and "tflint" in actual_tools and "checkov" in actual_tools ), reason=( f"Mismatch in validations: {validations} and {actual_tools}, " "should both contain tflint and checkov." ), ), }Running the sweep#
Now you can run all eval cases across the full suite of models. The command below will run 5 models and repeat each model three times. LLMs are non-deterministic, so three repeats give enough spread to tell a real failure from a bad roll.
uv run python -m evals.run \ --model glm5p2 --model haiku --model mistral-large \ --model mistral-medium --model codestral --repeat 3You get one experiment per model, each with its own pass rate.
Once you open a particular eval run you can see more detailed results, including our assertion/boolean validators and the tool error metric score. Further down are operational metrics like cost and average task duration.
You can also interact with the logfire MCP to create ad-hoc summary tables, or, if you will run it repeatedly, have a model write a script against the API. That is where the numbers at the top of this post come from.
End state#
In this post we gave the agent a stricter definition of correct Terraform. The same fmt, validate, tflint and
checkov steer it while it works and gate what it submits. None of that says whether it did the task it was asked to
do, which is the question the eval harness answers.
We implemented an eval harness that lets us weigh different models against each other and allows us to make a choice based on numbers instead of gut-feel.
In the next post we will integrate with github and ship the PR flow, that means human in the loop via github and deciding how we manage state across the whole PR lifecycle.