From f1886ffbd9c282866edb111ac34685de6797a942 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Nov 2026 06:08:33 +0000 Subject: [PATCH] Add offline and online guardrails plus scored evals The same guardrail definition checks recorded traces and live message or tool-call streams. Evals register with a decorator and run from pytest, and both results appear in the HTML report. Co-authored-by: Dor Ingber --- README.md | 181 +++++++- examples/guardrails_and_evals.py | 104 +++++ pyproject.toml | 1 + src/agent_test_kit/__init__.py | 50 ++ src/agent_test_kit/evals/__init__.py | 26 ++ src/agent_test_kit/evals/base.py | 197 ++++++++ src/agent_test_kit/evals/builtin.py | 84 ++++ src/agent_test_kit/guardrails/__init__.py | 33 ++ src/agent_test_kit/guardrails/base.py | 489 ++++++++++++++++++++ src/agent_test_kit/guardrails/builtin.py | 140 ++++++ src/agent_test_kit/models/execution.py | 46 ++ src/agent_test_kit/models/report.py | 33 ++ src/agent_test_kit/plugin/pytest_plugin.py | 161 ++++++- src/agent_test_kit/reporting/html_report.py | 62 ++- src/agent_test_kit/reporting/json_report.py | 75 ++- src/agent_test_kit_pytest_entrypoint.py | 8 + tests/test_evals.py | 229 +++++++++ tests/test_guardrail_eval_reporting.py | 232 ++++++++++ tests/test_guardrails.py | 333 +++++++++++++ tests/test_pytest_plugin.py | 9 +- 20 files changed, 2470 insertions(+), 23 deletions(-) create mode 100644 examples/guardrails_and_evals.py create mode 100644 src/agent_test_kit/evals/__init__.py create mode 100644 src/agent_test_kit/evals/base.py create mode 100644 src/agent_test_kit/evals/builtin.py create mode 100644 src/agent_test_kit/guardrails/__init__.py create mode 100644 src/agent_test_kit/guardrails/base.py create mode 100644 src/agent_test_kit/guardrails/builtin.py create mode 100644 tests/test_evals.py create mode 100644 tests/test_guardrail_eval_reporting.py create mode 100644 tests/test_guardrails.py diff --git a/README.md b/README.md index 05663e8..44f6ddb 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@

Quick start · Assertions · + Guardrails · + Evals · Reports · Full guide · E2E flow @@ -44,7 +46,7 @@ Tests run against mocks by default. Live tests are opt-in. See Quick start to te ## The report is the product -Most agent tests dump JSON into CI logs nobody reads. **agent-test-kit** generates a single self-contained HTML file — a GO / NO-GO readiness banner, tool-flow strips, MCP chips, prompt review diffs, golden guard outcomes — the same UI whether you ran mock tests or live E2E. +Most agent tests dump JSON into CI logs nobody reads. **agent-test-kit** generates a single self-contained HTML file — a GO / NO-GO readiness banner, tool-flow strips, MCP chips, prompt review diffs, golden guard outcomes, and guardrail and eval results — the same UI whether you ran mock tests or live E2E. ``` ┌─ Agent test report ─────────────────────────────────────────────┐ @@ -62,7 +64,7 @@ Most agent tests dump JSON into CI logs nobody reads. **agent-test-kit** generat │ │ get_inv… │ │ get_iss… │ │ create… │ │ │ └──────────┘ └──────────┘ └──────────┘ │ ├─────────────────────────────────────────────────────────────────┤ -│ ▾ Assertions ▾ Tool calls ▾ Timeline ▾ Side effects │ +│ ▾ Assertions ▾ Guardrails ▾ Evals ▾ Tool calls ▾ Timeline │ └─────────────────────────────────────────────────────────────────┘ ``` @@ -84,7 +86,7 @@ Redaction is on by default — tokens, credentials, and sensitive args never hit **Your agent repo owns:** test cases, prompts, fixtures, domain verifiers, cleanup hooks, and any LLM judge. -**This package provides:** HTTP/Cursor clients, normalized traces, workflow assertions, deterministic behavior scorers, goal-based regression cases, JSON/HTML reports with a GO / NO-GO readiness block, and verifier orchestration. +**This package provides:** HTTP/Cursor clients, normalized traces, workflow assertions, offline and online guardrails, evals, deterministic behavior scorers, goal-based regression cases, JSON/HTML reports with a GO / NO-GO readiness block, and verifier orchestration. The pytest plugin loads automatically when installed — no `pytest_plugins` line needed. @@ -130,6 +132,7 @@ markers = agent_integration: deployed agent, read-only agent_e2e: live HTTP (opt-in) agent_write_action: real writes (opt-in) + agent_eval: registered evals selected by name addopts = -m "not agent_e2e and not agent_write_action" ``` @@ -369,7 +372,159 @@ class GroundednessJudge: await result.assert_score(GroundednessJudge(), min_score=0.8) ``` -### Regression cases (YAML-driven) +## Guardrails + +A guardrail checks assistant output and tool flow. The same object runs offline, against a recorded trace, and online, against a live stream. Message and tool-call hooks run as each event arrives. Trace hooks, such as a required final tool, run when the run finishes. + +Subclass `Guardrail` or use `@guardrail`. Return a violation message, or `None` when the event is allowed. + +| Guardrail | What it checks | +|-----------|----------------| +| `NoProfanityGuardrail(terms=None, *, extra_terms=())` | Disallowed language in assistant output. Defaults to a small profanity list. `terms` replaces that list; `extra_terms` adds words or phrases. | +| `RequiredFinalToolGuardrail(tool_name, *, server=None)` | The last tool call is `tool_name` and succeeded. | +| `must_open_pull_request(tool_name="create_pull_request", *, server=None)` | Flow rule: the run ends by opening a pull request. | + +Failures are `AssertionError`s. They are stored on the result and rendered in the HTML report under **Guardrails**, including when the test fails. + +### Offline guardrail + +```python +import pytest +from agent_test_kit import ( + AgentExecutionResult, + NoProfanityGuardrail, + ToolCall, + must_open_pull_request, +) +from agent_test_kit.models.enums import ToolCallStatus, ToolOperationKind +from agent_test_kit.models.trace import AgentTrace + + +@pytest.mark.agent_unit +def test_recorded_run_stays_within_guardrails(agent_guardrails): + result = AgentExecutionResult( + success=True, + run_id="run_1", + response="I opened the pull request.", + trace=AgentTrace( + tool_calls=[ + ToolCall( + server="github", + name="create_pull_request", + status=ToolCallStatus.SUCCESS, + operation_kind=ToolOperationKind.WRITE, + ) + ] + ), + ) + agent_guardrails.check( + result, + [NoProfanityGuardrail(), must_open_pull_request(server="github")], + ) +``` + +`check_guardrails(result, guardrails)` is the same check without pytest. `result.assert_guardrails(...)` records it like the other assertions. + +### Online guardrail + +`GuardrailMonitor` is the streaming hook. Call `handle` (or `on_message` / `on_tool_call`) for each event. `fail_fast=True` raises `GuardrailViolationError` at the first violation so middleware can stop the run. Leave it false to collect every violation, then call `report.assert_passed()`. + +```python +from agent_test_kit import GuardrailMonitor, NoProfanityGuardrail, must_open_pull_request + + +def test_live_stream_opens_pr(agent_guardrails): + monitor = agent_guardrails.monitor( + [NoProfanityGuardrail(), must_open_pull_request(server="github")], + fail_fast=True, + ) + + def on_agent_event(event: dict) -> None: + monitor.handle(event) + + on_agent_event({"type": "message", "text": "I opened the pull request."}) + on_agent_event({ + "type": "tool_call", + "server": "github", + "name": "create_pull_request", + "status": "success", + "operation_kind": "write", + }) + monitor.finish().assert_passed() +``` + +Outside pytest, construct `GuardrailMonitor` yourself. The fixture records the online result on teardown, including when a violation aborts the test. + +### Write your own + +```python +from agent_test_kit import Guardrail, ToolCall, guardrail + + +@guardrail(phase="message") +def no_secrets(text: str, *, role: str = "assistant") -> str | None: + if role == "assistant" and "sk-" in text: + return "assistant output looks like a secret key" + return None + + +class NoDeleteGuardrail(Guardrail): + name = "no_delete" + + def on_tool_call(self, call: ToolCall) -> str | None: + if call.name.startswith("delete_"): + return f"delete tool is not allowed: {call.name}" + return None +``` + +`phase` is `"message"`, `"tool_call"`, or `"trace"`. A decorated guardrail is a `Guardrail`, so it works with `check_guardrails` and `GuardrailMonitor`. + +## Evals + +An eval scores one run from 0.0 to 1.0 and passes when the score is at least its threshold. Register it with `@eval_case`. Run it with the `agent_eval` fixture, or call `run_evals` / `result.assert_evals` directly. `@pytest.mark.agent_eval("name")` selects registered evals by name when you call `agent_eval.run(result)` without a list. + +| Eval | Score | +|------|-------| +| `RequiredTermsEval(terms, *, threshold=1.0)` | Fraction of terms present in the response | +| `RequiredToolsEval(tools, *, threshold=1.0)` | Fraction of required tools that were called. A tool is a name or `(server, name)`. | + +```python +import pytest +from agent_test_kit import AgentExecutionResult, RequiredTermsEval, eval_case, response_text + + +@eval_case(threshold=1.0) +def mentions_pull_request(result) -> float: + return 1.0 if "pull request" in response_text(result).casefold() else 0.0 + + +@pytest.mark.agent_eval("mentions_pull_request") +@pytest.mark.agent_unit +def test_mentions_pull_request(agent_eval): + result = AgentExecutionResult( + success=True, + run_id="run_1", + response="I opened the pull request.", + ) + agent_eval.run(result) + + +def test_terms_with_an_explicit_eval(agent_eval): + result = AgentExecutionResult(success=True, run_id="run_2", response="refund for order 123") + agent_eval.run(result, [RequiredTermsEval(["refund", "123"])]) +``` + +`run_evals(result, [mentions_pull_request])` is the same call outside pytest. Scores, thresholds, and pass/fail appear in the HTML report under **Evals**. A failing eval raises `AssertionError` and is still recorded. + +Subclass `Eval` and implement `score` to return a `Score`, a float, or a bool. `register_eval` adds it under `evaluation.name`. + +Runnable copies of the offline guardrail, the online guardrail, and an eval: [`examples/guardrails_and_evals.py`](examples/guardrails_and_evals.py). + +```bash +python examples/guardrails_and_evals.py +``` + +## Regression cases (YAML-driven) Load cases with `load_regression_cases(path)`; evaluate with `case.evaluate(result)`. @@ -435,7 +590,7 @@ for case in load_regression_cases("tests/data/behavior_cases.yaml"): `case.run()` executes `runs` times with one idempotency key (`regression:` by default) and checks that every scorer named in `behavior.scores` was provided before calling the agent. To score results you already have, use `await case.evaluate_runs(results, scorers=...)`. Full example: [`examples/behavior_regression_cases.yaml`](examples/behavior_regression_cases.yaml). -### Typical PR-review workflow (all together) +## Typical PR-review workflow ```python result.assert_success() @@ -559,7 +714,9 @@ See [First test](#first-test). More examples: [`examples/consumer_test_example.p | `agent_test_config` | `AgentTestConfig` from env / defaults | | `agent_client` | `AgentClient` + auto report attachment | | `cursor_agent_client` | `CursorAgentClient` + auto report attachment | -| `agent_scenario` | Attach results, verifiers, and `attach_regression_evaluation` for persona readiness | +| `agent_scenario` | Attach results, verifiers, guardrail and eval reports, and `attach_regression_evaluation` | +| `agent_guardrails` | Offline `check` and online `monitor` for guardrails | +| `agent_eval` | Run evals passed in, or named by `@pytest.mark.agent_eval` | | `agent_cleanup` | Async cleanup manager (runs on teardown) | ```python @@ -721,6 +878,7 @@ Reference examples in **this repo**: [`examples/consumer_test_example.py`](examp | `agent_regression` | Regression case files | | `agent_security` | Safety / redaction checks | | `agent_multi_mcp` | Multi-MCP orchestration | +| `agent_eval` | Selects registered evals for the `agent_eval` fixture | --- @@ -746,8 +904,12 @@ from agent_test_kit import ( AgentExecutionResult, AnyOfStep, CleanupManager, + Eval, ForbiddenTermsScorer, + Guardrail, + GuardrailMonitor, HtmlReportWriter, + NoProfanityGuardrail, JsonReportWriter, OptionalStep, PromptReviewClient, @@ -755,15 +917,22 @@ from agent_test_kit import ( RegressionRunEvaluation, RepeatedExecutionResult, RequiredFieldsScorer, + RequiredTermsEval, RequiredTermsScorer, + RequiredToolsEval, Score, Scorer, + ToolCall, ToolStep, + check_guardrails, + eval_case, evaluate_readiness, get_profile, load_regression_cases, load_report, + must_open_pull_request, response_text, + run_evals, run_repeatedly, run_verifiers, SideEffectVerifier, diff --git a/examples/guardrails_and_evals.py b/examples/guardrails_and_evals.py new file mode 100644 index 0000000..0e7f7fe --- /dev/null +++ b/examples/guardrails_and_evals.py @@ -0,0 +1,104 @@ +"""Runnable guardrail and eval examples. + +From a checkout with the package installed: + + python examples/guardrails_and_evals.py +""" + +from __future__ import annotations + +from agent_test_kit import ( + AgentExecutionResult, + EvalResult, + GuardrailMonitor, + GuardrailReport, + NoProfanityGuardrail, + RequiredTermsEval, + ToolCall, + check_guardrails, + eval_case, + must_open_pull_request, + response_text, + run_evals, +) +from agent_test_kit.models.enums import ToolCallStatus, ToolOperationKind +from agent_test_kit.models.trace import AgentTrace + + +def _pr_result(response: str) -> AgentExecutionResult: + return AgentExecutionResult( + success=True, + run_id="run_example", + response=response, + trace=AgentTrace( + tool_calls=[ + ToolCall( + server="github", + name="create_pull_request", + status=ToolCallStatus.SUCCESS, + operation_kind=ToolOperationKind.WRITE, + ) + ] + ), + ) + + +def example_offline_guardrail() -> GuardrailReport: + """Check a recorded trace the same way a pytest test would.""" + result = _pr_result("I opened the pull request.") + report = check_guardrails( + result, + [NoProfanityGuardrail(), must_open_pull_request(server="github")], + ) + report.assert_passed() + return report + + +def example_online_guardrail() -> GuardrailReport: + """Attach the same guardrails to a live stream of messages and tool calls.""" + monitor = GuardrailMonitor( + [NoProfanityGuardrail(), must_open_pull_request(server="github")], + fail_fast=True, + ) + + def on_agent_event(event: dict[str, object]) -> None: + monitor.handle(event) + + on_agent_event({"type": "message", "role": "assistant", "text": "I opened the pull request."}) + on_agent_event( + { + "type": "tool_call", + "server": "github", + "name": "create_pull_request", + "status": "success", + "operation_kind": "write", + } + ) + report = monitor.finish() + report.assert_passed() + return report + + +@eval_case(name="example_mentions_pull_request", threshold=1.0) +def example_mentions_pull_request(result: AgentExecutionResult) -> float: + """1.0 when the assistant mentions the pull request it opened.""" + text = response_text(result).casefold() + return 1.0 if "pull request" in text else 0.0 + + +def example_eval() -> EvalResult: + """Register an eval and score a recorded run.""" + result = _pr_result("I opened the pull request.") + report = run_evals( + result, + [example_mentions_pull_request, RequiredTermsEval(["pull request"])], + ) + report.assert_passed() + return report.results[0] + + +if __name__ == "__main__": + example_offline_guardrail() + example_online_guardrail() + example_eval() + print("guardrail and eval examples passed") diff --git a/pyproject.toml b/pyproject.toml index 87d2161..69d6fb4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,6 +93,7 @@ markers = [ "agent_write_action: Tests that perform write actions in target systems", "agent_nondeterministic: Tests that may flake due to model variance", "agent_multi_mcp: Multi-MCP server workflow tests", + "agent_eval: Registered agent evals selected by name for the agent_eval fixture", ] [tool.coverage.run] diff --git a/src/agent_test_kit/__init__.py b/src/agent_test_kit/__init__.py index 1e475cf..ca85af6 100644 --- a/src/agent_test_kit/__init__.py +++ b/src/agent_test_kit/__init__.py @@ -13,8 +13,35 @@ get_profile, ) from agent_test_kit.client.prompt_review_client import PromptReviewClient +from agent_test_kit.evals import ( + Eval, + EvalReport, + EvalResult, + RequiredTermsEval, + RequiredToolsEval, + eval_case, + get_eval, + register_eval, + registered_evals, + run_evals, +) +from agent_test_kit.guardrails import ( + Guardrail, + GuardrailCheck, + GuardrailEvent, + GuardrailMonitor, + GuardrailReport, + GuardrailViolation, + GuardrailViolationError, + NoProfanityGuardrail, + RequiredFinalToolGuardrail, + check_guardrails, + guardrail, + must_open_pull_request, +) from agent_test_kit.models.execution import AgentExecutionResult, AgentFlowResult, AssertionOutcome from agent_test_kit.models.run_context import RunContext +from agent_test_kit.models.tool_call import ToolCall from agent_test_kit.publishing import ( HttpResultPublisher, PublishResult, @@ -67,13 +94,24 @@ "AssertionOutcome", "CleanupManager", "CursorAgentClient", + "Eval", + "EvalReport", + "EvalResult", "FlowAssertionEngine", "ForbiddenTermsScorer", + "Guardrail", + "GuardrailCheck", + "GuardrailEvent", + "GuardrailMonitor", + "GuardrailReport", + "GuardrailViolation", + "GuardrailViolationError", "HtmlReportWriter", "HttpResultPublisher", "JsonReportWriter", "MarkdownStatusReportWriter", "OptionalStep", + "NoProfanityGuardrail", "PromptReviewClient", "PromptAiHelperProfile", "PublishResult", @@ -87,7 +125,10 @@ "RegressionToolExpectation", "RepeatedExecutionResult", "RequiredFieldsScorer", + "RequiredFinalToolGuardrail", + "RequiredTermsEval", "RequiredTermsScorer", + "RequiredToolsEval", "ResultPublishError", "ResultPublisher", "ResultPublisherConfig", @@ -95,16 +136,25 @@ "Score", "Scorer", "SideEffectVerifier", + "ToolCall", "ToolStep", "VerificationContext", "VerificationResult", "VerificationSummary", "__version__", + "check_guardrails", + "eval_case", "evaluate_readiness", + "get_eval", + "guardrail", "load_regression_cases", "load_report", "get_profile", + "must_open_pull_request", + "register_eval", + "registered_evals", "response_text", + "run_evals", "run_repeatedly", "run_verifiers", ] diff --git a/src/agent_test_kit/evals/__init__.py b/src/agent_test_kit/evals/__init__.py new file mode 100644 index 0000000..3a98d28 --- /dev/null +++ b/src/agent_test_kit/evals/__init__.py @@ -0,0 +1,26 @@ +"""Score-based evals with registration and pytest integration.""" + +from agent_test_kit.evals.base import ( + Eval, + EvalReport, + EvalResult, + eval_case, + get_eval, + register_eval, + registered_evals, + run_evals, +) +from agent_test_kit.evals.builtin import RequiredTermsEval, RequiredToolsEval + +__all__ = [ + "Eval", + "EvalReport", + "EvalResult", + "RequiredTermsEval", + "RequiredToolsEval", + "eval_case", + "get_eval", + "register_eval", + "registered_evals", + "run_evals", +] diff --git a/src/agent_test_kit/evals/base.py b/src/agent_test_kit/evals/base.py new file mode 100644 index 0000000..1eefc06 --- /dev/null +++ b/src/agent_test_kit/evals/base.py @@ -0,0 +1,197 @@ +"""Evals: named scores with a pass threshold, registered and run from pytest.""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable, Sequence +from dataclasses import dataclass, field +from typing import Any + +from agent_test_kit.models.execution import AgentExecutionResult +from agent_test_kit.scoring import Score, validate_unit_interval + +_REGISTRY: dict[str, Eval] = {} + + +@dataclass(frozen=True, slots=True) +class EvalResult: + """Score and pass/fail outcome for one eval on one run.""" + + name: str + passed: bool + score: float + threshold: float + reason: str | None = None + details: dict[str, Any] = field(default_factory=dict) + + def assert_passed(self) -> None: + if self.passed: + return + detail = f" ({self.reason})" if self.reason else "" + raise AssertionError( + f"Eval {self.name!r} failed: score {self.score:.2f} " + f"is below {self.threshold:.2f}{detail}" + ) + + +@dataclass(frozen=True, slots=True) +class EvalReport: + """Aggregate of every eval run together.""" + + results: tuple[EvalResult, ...] + + @property + def passed(self) -> bool: + return all(result.passed for result in self.results) + + def assert_passed(self) -> None: + failures = [result for result in self.results if not result.passed] + if not failures: + return + lines = [] + for result in failures: + detail = f" ({result.reason})" if result.reason else "" + lines.append( + f"{result.name}: score {result.score:.2f} is below {result.threshold:.2f}{detail}" + ) + raise AssertionError("Eval failures:\n" + "\n".join(lines)) + + +class Eval: + """Score one execution result. Pass when ``score >= threshold``. + + Subclass and implement ``score``, or build one with ``@eval_case``. + ``score`` may return a ``Score``, a float in ``[0.0, 1.0]``, or a bool. + """ + + name: str = "eval" + threshold: float = 1.0 + + def __init__(self, *, name: str | None = None, threshold: float = 1.0) -> None: + if name is not None: + self.name = name + if not isinstance(self.name, str) or not self.name.strip(): + raise ValueError("eval name must be a non-empty string") + validate_unit_interval(f"eval {self.name} threshold", threshold) + self.threshold = threshold + + def score(self, result: AgentExecutionResult) -> Score | float | bool: + """Return a score for ``result``.""" + raise NotImplementedError + + def evaluate(self, result: AgentExecutionResult) -> EvalResult: + """Score ``result`` and compare it to the threshold.""" + raw = self.score(result) + if inspect.isawaitable(raw): + close = getattr(raw, "close", None) + if close is not None: + close() + raise TypeError( + f"Eval {self.name!r} returned an awaitable. " + "score() must be synchronous and return a Score, float, or bool." + ) + value, reason, details = _coerce_score(self.name, raw) + passed = value >= self.threshold + if reason is None and not passed: + reason = f"score {value:.2f} is below {self.threshold:.2f}" + return EvalResult( + name=self.name, + passed=passed, + score=value, + threshold=self.threshold, + reason=reason, + details=details, + ) + + def __call__(self, result: AgentExecutionResult) -> EvalResult: + return self.evaluate(result) + + +def run_evals( + result: AgentExecutionResult, + evals: Eval | Sequence[Eval], +) -> EvalReport: + """Score ``result`` with each eval. Does not raise; call ``assert_passed``.""" + selected = _as_evals(evals) + return EvalReport(tuple(item.evaluate(result) for item in selected)) + + +def register_eval(evaluation: Eval) -> Eval: + """Register ``evaluation`` under its name. Registering the same object twice is a no-op.""" + if not isinstance(evaluation, Eval): + raise TypeError(f"Expected Eval. Observed: {type(evaluation).__name__}") + existing = _REGISTRY.get(evaluation.name) + if existing is not None and existing is not evaluation: + raise ValueError(f"eval {evaluation.name!r} is already registered") + _REGISTRY[evaluation.name] = evaluation + return evaluation + + +def get_eval(name: str) -> Eval: + """Return a registered eval by name.""" + try: + return _REGISTRY[name] + except KeyError: + known = ", ".join(registered_evals()) or "(none)" + raise KeyError(f"Unknown eval {name!r}. Registered: {known}") from None + + +def registered_evals() -> tuple[str, ...]: + """Names of evals registered with ``@eval_case`` or ``register_eval``.""" + return tuple(sorted(_REGISTRY)) + + +def eval_case( + func: Callable[[AgentExecutionResult], Score | float | bool] | None = None, + *, + name: str | None = None, + threshold: float = 1.0, + register: bool = True, +) -> Any: + """Register a function as an eval. + + The function receives an ``AgentExecutionResult`` and returns a score in + ``[0.0, 1.0]``, a bool, or a ``Score``. The eval passes when the score is + at least ``threshold``. + """ + + def decorate(fn: Callable[[AgentExecutionResult], Score | float | bool]) -> Eval: + label = name or fn.__name__ + + class _FunctionEval(Eval): + def score(self, result: AgentExecutionResult) -> Score | float | bool: + return fn(result) + + _FunctionEval.__name__ = label + _FunctionEval.__doc__ = fn.__doc__ + evaluation = _FunctionEval(name=label, threshold=threshold) + if register: + register_eval(evaluation) + return evaluation + + if func is not None: + return decorate(func) + return decorate + + +def _as_evals(evals: Eval | Sequence[Eval]) -> tuple[Eval, ...]: + selected = (evals,) if isinstance(evals, Eval) else tuple(evals) + if not selected: + raise ValueError("at least one eval is required") + for item in selected: + if not isinstance(item, Eval): + raise TypeError(f"Expected Eval. Observed: {type(item).__name__}") + return selected + + +def _coerce_score(name: str, raw: object) -> tuple[float, str | None, dict[str, Any]]: + if isinstance(raw, Score): + return raw.value, raw.reason, dict(raw.details) + if isinstance(raw, bool): + return (1.0 if raw else 0.0), None, {} + if isinstance(raw, int | float): + validate_unit_interval(f"eval {name} score", raw) + return float(raw), None, {} + raise TypeError( + f"Eval {name!r} must return a Score, float, or bool. Observed: {type(raw).__name__}" + ) diff --git a/src/agent_test_kit/evals/builtin.py b/src/agent_test_kit/evals/builtin.py new file mode 100644 index 0000000..5929954 --- /dev/null +++ b/src/agent_test_kit/evals/builtin.py @@ -0,0 +1,84 @@ +"""Built-in deterministic evals.""" + +from __future__ import annotations + +from collections.abc import Iterable, Sequence + +from agent_test_kit.evals.base import Eval +from agent_test_kit.models.execution import AgentExecutionResult +from agent_test_kit.scoring import RequiredTermsScorer, Score + +ToolSpec = str | tuple[str | None, str] + + +class RequiredTermsEval(Eval): + """Fraction of required terms present in the response text.""" + + def __init__( + self, + terms: Iterable[str], + *, + threshold: float = 1.0, + name: str = "required_terms", + case_sensitive: bool = False, + ) -> None: + super().__init__(name=name, threshold=threshold) + self._scorer = RequiredTermsScorer(terms, case_sensitive=case_sensitive, name=name) + + def score(self, result: AgentExecutionResult) -> Score: + return self._scorer.score(result) + + +class RequiredToolsEval(Eval): + """Fraction of required tools that appear in the trace. + + Each tool is a name, or a ``(server, name)`` pair. Order does not matter. + """ + + def __init__( + self, + tools: Sequence[ToolSpec], + *, + threshold: float = 1.0, + name: str = "required_tools", + ) -> None: + super().__init__(name=name, threshold=threshold) + parsed = tuple(_parse_tool(tool) for tool in tools) + if not parsed: + raise ValueError("at least one tool is required") + self.tools = parsed + + def score(self, result: AgentExecutionResult) -> Score: + missing = [ + _qualified(server, tool_name) + for server, tool_name in self.tools + if not any(call.matches(tool_name, server) for call in result.tool_calls) + ] + found = len(self.tools) - len(missing) + return Score( + found / len(self.tools), + reason=f"Missing tools: {missing}" if missing else None, + details={"missing": missing}, + ) + + +def _parse_tool(tool: ToolSpec) -> tuple[str | None, str]: + if isinstance(tool, str): + if not tool: + raise ValueError("tool names must be non-empty strings") + return None, tool + if ( + isinstance(tool, tuple) + and len(tool) == 2 + and (tool[0] is None or isinstance(tool[0], str)) + and isinstance(tool[1], str) + and tool[1] + ): + return tool + raise ValueError("tools must be names or (server, name) pairs") + + +def _qualified(server: str | None, tool_name: str) -> str: + if server: + return f"{server}/{tool_name}" + return tool_name diff --git a/src/agent_test_kit/guardrails/__init__.py b/src/agent_test_kit/guardrails/__init__.py new file mode 100644 index 0000000..7510032 --- /dev/null +++ b/src/agent_test_kit/guardrails/__init__.py @@ -0,0 +1,33 @@ +"""Offline and online guardrails.""" + +from agent_test_kit.guardrails.base import ( + Guardrail, + GuardrailCheck, + GuardrailEvent, + GuardrailMonitor, + GuardrailReport, + GuardrailViolation, + GuardrailViolationError, + check_guardrails, + guardrail, +) +from agent_test_kit.guardrails.builtin import ( + NoProfanityGuardrail, + RequiredFinalToolGuardrail, + must_open_pull_request, +) + +__all__ = [ + "Guardrail", + "GuardrailCheck", + "GuardrailEvent", + "GuardrailMonitor", + "GuardrailReport", + "GuardrailViolation", + "GuardrailViolationError", + "NoProfanityGuardrail", + "RequiredFinalToolGuardrail", + "check_guardrails", + "guardrail", + "must_open_pull_request", +] diff --git a/src/agent_test_kit/guardrails/base.py b/src/agent_test_kit/guardrails/base.py new file mode 100644 index 0000000..6ca1276 --- /dev/null +++ b/src/agent_test_kit/guardrails/base.py @@ -0,0 +1,489 @@ +"""Guardrails that share one definition across recorded traces and live runs.""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, field +from typing import Any, Literal + +from agent_test_kit.models.execution import AgentExecutionResult +from agent_test_kit.models.tool_call import ToolCall +from agent_test_kit.models.trace import AgentTrace +from agent_test_kit.scoring import response_text + +GuardrailPhase = Literal["message", "tool_call", "trace"] +GuardrailMode = Literal["offline", "online"] + + +@dataclass(frozen=True, slots=True) +class GuardrailViolation: + """One guardrail failure.""" + + guardrail: str + message: str + phase: GuardrailPhase = "trace" + evidence: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class GuardrailCheck: + """Outcome of one guardrail against a trace or a live session.""" + + name: str + passed: bool + mode: GuardrailMode + message: str | None = None + violations: tuple[GuardrailViolation, ...] = () + + def assert_passed(self) -> None: + if not self.passed: + raise AssertionError(f"Guardrail {self.name!r} failed: {self.message}") + + +@dataclass(frozen=True, slots=True) +class GuardrailReport: + """Aggregate of every guardrail checked together.""" + + checks: tuple[GuardrailCheck, ...] + mode: GuardrailMode + + @property + def passed(self) -> bool: + return all(check.passed for check in self.checks) + + @property + def violations(self) -> tuple[GuardrailViolation, ...]: + return tuple(violation for check in self.checks for violation in check.violations) + + def assert_passed(self) -> None: + if self.passed: + return + lines = [f"{check.name}: {check.message}" for check in self.checks if not check.passed] + raise AssertionError("Guardrail violations:\n" + "\n".join(lines)) + + +@dataclass(frozen=True, slots=True) +class GuardrailEvent: + """One streamed message or tool call seen by an online guardrail.""" + + type: Literal["message", "tool_call"] + text: str | None = None + role: str = "assistant" + tool_call: ToolCall | None = None + + +class GuardrailViolationError(AssertionError): + """Raised when an online guardrail is configured to fail on the first violation.""" + + def __init__(self, violation: GuardrailViolation) -> None: + self.violation = violation + super().__init__(f"Guardrail {violation.guardrail!r} failed: {violation.message}") + + +class Guardrail: + """Base class for a guardrail that works offline and online. + + Override the hooks you need. ``check`` replays a recorded result through + those hooks. ``GuardrailMonitor`` calls the same hooks as events arrive. + Return a violation message from a hook, or ``None`` when the event is allowed. + """ + + name: str = "guardrail" + + def on_message(self, text: str, *, role: str = "assistant") -> str | None: + """Inspect one message. Assistant output is the usual place to look.""" + return None + + def on_tool_call(self, call: ToolCall) -> str | None: + """Inspect one tool call as it happens.""" + return None + + def on_trace(self, result: AgentExecutionResult) -> str | None: + """Inspect the finished run. Online monitors call this from ``finish``.""" + return None + + def check(self, result: AgentExecutionResult) -> GuardrailCheck: + """Run this guardrail against a recorded result.""" + violations: list[GuardrailViolation] = [] + text = response_text(result) + if text.strip(): + found = self._message_violation(text, role="assistant") + if found is not None: + violations.append(found) + for call in result.tool_calls: + found = self._tool_violation(call) + if found is not None: + violations.append(found) + found = self._trace_violation(result) + if found is not None: + violations.append(found) + return _check_from_violations(self.name, violations, mode="offline") + + def __call__(self, result: AgentExecutionResult) -> GuardrailCheck: + return self.check(result) + + def _message_violation(self, text: str, *, role: str) -> GuardrailViolation | None: + message = _coerce_violation(self.name, self.on_message(text, role=role)) + if message is None: + return None + return GuardrailViolation( + guardrail=self.name, + message=message, + phase="message", + evidence={"role": role}, + ) + + def _tool_violation(self, call: ToolCall) -> GuardrailViolation | None: + message = _coerce_violation(self.name, self.on_tool_call(call)) + if message is None: + return None + return GuardrailViolation( + guardrail=self.name, + message=message, + phase="tool_call", + evidence={"server": call.server, "tool": call.name}, + ) + + def _trace_violation(self, result: AgentExecutionResult) -> GuardrailViolation | None: + message = _coerce_violation(self.name, self.on_trace(result)) + if message is None: + return None + return GuardrailViolation(guardrail=self.name, message=message, phase="trace") + + +def check_guardrails( + result: AgentExecutionResult, + guardrails: Guardrail | Sequence[Guardrail], +) -> GuardrailReport: + """Run guardrails offline against a recorded execution result.""" + selected = _as_guardrails(guardrails) + checks = tuple(item.check(result) for item in selected) + return GuardrailReport(checks=checks, mode="offline") + + +class GuardrailMonitor: + """Online middleware. Feed messages and tool calls as the agent produces them. + + Message and tool-call hooks run immediately. Trace hooks, including a required + final tool, run when ``finish`` is called. ``fail_fast=True`` raises + ``GuardrailViolationError`` at the first violation so the caller can stop the run. + """ + + def __init__( + self, + guardrails: Guardrail | Sequence[Guardrail], + *, + fail_fast: bool = False, + on_finish: Callable[[GuardrailReport], None] | None = None, + ) -> None: + self.guardrails = _as_guardrails(guardrails) + self.fail_fast = fail_fast + self._on_finish = on_finish + self._violations: list[list[GuardrailViolation]] = [[] for _ in self.guardrails] + self._messages: list[tuple[str, str]] = [] + self._calls: list[ToolCall] = [] + self._finished = False + self.last_report: GuardrailReport | None = None + + @property + def finished(self) -> bool: + return self._finished + + def on_message(self, text: str, *, role: str = "assistant") -> list[GuardrailViolation]: + """Record one streamed message and run message hooks.""" + self._ensure_open() + if not isinstance(text, str): + raise TypeError("message text must be a string") + self._messages.append((role, text)) + found = self._run_message_hooks(text, role=role) + self._raise_fast(found) + return found + + def on_tool_call(self, call: ToolCall | Mapping[str, Any]) -> list[GuardrailViolation]: + """Record one tool call and run tool-call hooks.""" + self._ensure_open() + tool_call = call if isinstance(call, ToolCall) else ToolCall.model_validate(dict(call)) + self._calls.append(tool_call) + found = self._run_tool_hooks(tool_call) + self._raise_fast(found) + return found + + def handle(self, event: GuardrailEvent | Mapping[str, Any]) -> list[GuardrailViolation]: + """Middleware entry point for one streamed event. + + Message events use ``type="message"`` and ``text`` (or ``content``). + Tool events use ``type="tool_call"`` plus ``name`` or a nested ``tool_call``. + """ + parsed = event if isinstance(event, GuardrailEvent) else _parse_event(event) + if parsed.type == "message": + return self.on_message(parsed.text or "", role=parsed.role) + if parsed.tool_call is None: + raise ValueError("tool_call event requires a tool call") + return self.on_tool_call(parsed.tool_call) + + def finish( + self, + result: AgentExecutionResult | None = None, + *, + notify: bool = True, + raise_on_violation: bool | None = None, + ) -> GuardrailReport: + """Finish the live run and evaluate trace-level guardrails. + + When ``result`` is omitted, the trace is the messages and tool calls already + observed. ``raise_on_violation`` defaults to ``fail_fast``. + """ + if self._finished: + raise RuntimeError("GuardrailMonitor.finish() was already called") + should_raise = self.fail_fast if raise_on_violation is None else raise_on_violation + self._replay(result) + self._scan_joined_messages() + trace = result if result is not None else self._synthetic_result() + self._run_trace_hooks(trace) + self._finished = True + report = self._build_report() + self.last_report = report + if result is not None: + result.guardrail_checks.extend(report.checks) + if should_raise and not report.passed: + raise GuardrailViolationError(report.violations[0]) + if notify and self._on_finish is not None: + self._on_finish(report) + return report + + def _ensure_open(self) -> None: + if self._finished: + raise RuntimeError("GuardrailMonitor is already finished") + + def _raise_fast(self, found: list[GuardrailViolation]) -> None: + if self.fail_fast and found: + self.last_report = self._build_report() + raise GuardrailViolationError(found[0]) + + def _scan_joined_messages(self) -> None: + """Catch a disallowed word that was split across streamed chunks.""" + assistant = [message for role, message in self._messages if role == "assistant"] + joined = "".join(assistant) + if joined.strip() and joined not in assistant: + self._run_message_hooks(joined, role="assistant") + + def _replay(self, result: AgentExecutionResult | None) -> None: + if result is None: + return + text = response_text(result) + observed = [message for role, message in self._messages if role == "assistant"] + if text.strip() and text not in observed and "".join(observed) != text: + self._messages.append(("assistant", text)) + self._run_message_hooks(text, role="assistant") + if not self._calls: + for call in result.tool_calls: + self._calls.append(call) + self._run_tool_hooks(call) + + def _run_message_hooks(self, text: str, *, role: str) -> list[GuardrailViolation]: + found: list[GuardrailViolation] = [] + for index, guard in enumerate(self.guardrails): + violation = guard._message_violation(text, role=role) + if violation is not None: + self._violations[index].append(violation) + found.append(violation) + return found + + def _run_tool_hooks(self, call: ToolCall) -> list[GuardrailViolation]: + found: list[GuardrailViolation] = [] + for index, guard in enumerate(self.guardrails): + violation = guard._tool_violation(call) + if violation is not None: + self._violations[index].append(violation) + found.append(violation) + return found + + def _run_trace_hooks(self, result: AgentExecutionResult) -> None: + for index, guard in enumerate(self.guardrails): + violation = guard._trace_violation(result) + if violation is not None: + self._violations[index].append(violation) + + def _synthetic_result(self) -> AgentExecutionResult: + text = "".join(message for role, message in self._messages if role == "assistant") + return AgentExecutionResult( + success=True, + run_id="online", + response=text or None, + trace=AgentTrace(tool_calls=list(self._calls)), + ) + + def _build_report(self) -> GuardrailReport: + checks = tuple( + _check_from_violations(guard.name, self._violations[index], mode="online") + for index, guard in enumerate(self.guardrails) + ) + return GuardrailReport(checks=checks, mode="online") + + +def guardrail( + func: Callable[..., str | None] | None = None, + *, + name: str | None = None, + phase: GuardrailPhase = "trace", +) -> Any: + """Turn a function into a guardrail. + + ``phase="message"`` receives ``(text, *, role)``. ``role`` is omitted when the + function does not accept it. ``phase="tool_call"`` receives a ``ToolCall``. + ``phase="trace"`` receives an ``AgentExecutionResult``. Return a violation + message, or ``None`` when the event is allowed. + """ + if phase not in {"message", "tool_call", "trace"}: + raise ValueError("phase must be 'message', 'tool_call', or 'trace'") + + def decorate(fn: Callable[..., str | None]) -> Guardrail: + label = _require_name(name or fn.__name__, what="guardrail name") + + class _FunctionGuardrail(Guardrail): + pass + + _FunctionGuardrail.name = label + _FunctionGuardrail.__name__ = label + _FunctionGuardrail.__doc__ = fn.__doc__ + if phase == "message": + accepts_role = _accepts_keyword(fn, "role") + + def on_message( + self: Guardrail, + text: str, + *, + role: str = "assistant", + ) -> str | None: + if accepts_role: + return fn(text, role=role) + return fn(text) + + _FunctionGuardrail.on_message = on_message # type: ignore[method-assign] + elif phase == "tool_call": + + def on_tool_call(self: Guardrail, call: ToolCall) -> str | None: + return fn(call) + + _FunctionGuardrail.on_tool_call = on_tool_call # type: ignore[method-assign] + else: + + def on_trace(self: Guardrail, result: AgentExecutionResult) -> str | None: + return fn(result) + + _FunctionGuardrail.on_trace = on_trace # type: ignore[method-assign] + return _FunctionGuardrail() + + if func is not None: + return decorate(func) + return decorate + + +def _as_guardrails(guardrails: Guardrail | Sequence[Guardrail]) -> tuple[Guardrail, ...]: + selected = (guardrails,) if isinstance(guardrails, Guardrail) else tuple(guardrails) + if not selected: + raise ValueError("at least one guardrail is required") + for item in selected: + if not isinstance(item, Guardrail): + raise TypeError(f"Expected Guardrail. Observed: {type(item).__name__}") + return selected + + +def _coerce_violation(name: str, value: object) -> str | None: + if value is None: + return None + if isinstance(value, str): + text = value.strip() + return text or None + raise TypeError( + f"Guardrail {name!r} must return a violation message or None. " + f"Observed: {type(value).__name__}" + ) + + +def _check_from_violations( + name: str, + violations: Sequence[GuardrailViolation], + *, + mode: GuardrailMode, +) -> GuardrailCheck: + unique = _dedupe_violations(violations) + if not unique: + return GuardrailCheck(name=name, passed=True, mode=mode) + message = "; ".join(item.message for item in unique) + return GuardrailCheck( + name=name, + passed=False, + mode=mode, + message=message, + violations=unique, + ) + + +def _dedupe_violations( + violations: Sequence[GuardrailViolation], +) -> tuple[GuardrailViolation, ...]: + unique: list[GuardrailViolation] = [] + seen: set[tuple[str, str, str]] = set() + for violation in violations: + key = (violation.guardrail, violation.phase, violation.message) + if key in seen: + continue + seen.add(key) + unique.append(violation) + return tuple(unique) + + +def _require_name(name: str, *, what: str) -> str: + if not isinstance(name, str) or not name.strip(): + raise ValueError(f"{what} must be a non-empty string") + return name + + +def _accepts_keyword(func: Callable[..., Any], keyword: str) -> bool: + try: + signature = inspect.signature(func) + except (TypeError, ValueError): + return True + for parameter in signature.parameters.values(): + if parameter.kind == inspect.Parameter.VAR_KEYWORD: + return True + if parameter.name == keyword: + return True + return False + + +def _parse_event(event: Mapping[str, Any]) -> GuardrailEvent: + if isinstance(event, str) or not isinstance(event, Mapping): + raise TypeError("guardrail event must be a mapping or GuardrailEvent") + event_type = event.get("type") + if event_type == "message": + text = event.get("text", event.get("content", "")) + if text is None: + text = "" + if not isinstance(text, str): + raise TypeError("message text must be a string") + role = event.get("role") or "assistant" + if not isinstance(role, str): + raise TypeError("message role must be a string") + return GuardrailEvent(type="message", text=text, role=role) + if event_type == "tool_call": + return GuardrailEvent(type="tool_call", tool_call=_tool_call_from_event(event)) + raise ValueError(f"Unknown guardrail event type: {event_type!r}") + + +def _tool_call_from_event(payload: Mapping[str, Any]) -> ToolCall: + nested = payload.get("tool_call", payload.get("tool")) + if isinstance(nested, ToolCall): + return nested + if isinstance(nested, Mapping): + return ToolCall.model_validate(dict(nested)) + data = { + key: payload[key] + for key in ("name", "server", "arguments", "status", "operation_kind", "output", "id") + if key in payload + } + if "name" not in data: + raise ValueError("tool_call event requires a name") + return ToolCall.model_validate(data) diff --git a/src/agent_test_kit/guardrails/builtin.py b/src/agent_test_kit/guardrails/builtin.py new file mode 100644 index 0000000..6184187 --- /dev/null +++ b/src/agent_test_kit/guardrails/builtin.py @@ -0,0 +1,140 @@ +"""Built-in guardrails for assistant language and required final tool calls.""" + +from __future__ import annotations + +import re +from collections.abc import Iterable + +from agent_test_kit.guardrails.base import Guardrail +from agent_test_kit.models.execution import AgentExecutionResult + +_DEFAULT_PROFANITY: tuple[str, ...] = ( + "fuck", + "fucking", + "fucked", + "shit", + "shitty", + "bitch", + "asshole", + "bastard", + "dick", + "piss", +) + + +class NoProfanityGuardrail(Guardrail): + """Reject disallowed language in assistant output. + + The default term list is a small profanity set. Pass ``terms`` to replace it + with any disallowed language, or ``extra_terms`` to add phrases. Matching is + case-insensitive. A single word uses word boundaries; a phrase matches as text. + """ + + def __init__( + self, + terms: Iterable[str] | None = None, + *, + extra_terms: Iterable[str] = (), + name: str = "no_profanity", + ) -> None: + selected = tuple(terms) if terms is not None else _DEFAULT_PROFANITY + combined = _validated_terms((*selected, *extra_terms)) + self.terms = combined + self._compiled = tuple(_compile_term(term) for term in combined) + self.name = name + + def on_message(self, text: str, *, role: str = "assistant") -> str | None: + if role != "assistant" or not text: + return None + found = _find_terms(text, self.terms, self._compiled) + if not found: + return None + return f"Disallowed language in assistant output: {', '.join(found)}" + + +class RequiredFinalToolGuardrail(Guardrail): + """The run must finish with a successful call to ``tool_name``. + + Use this for flow rules such as "this agent must open a pull request at the end". + The check runs on the finished trace, offline or when an online monitor finishes. + """ + + def __init__( + self, + tool_name: str, + *, + server: str | None = None, + name: str | None = None, + require_success: bool = True, + ) -> None: + if not isinstance(tool_name, str) or not tool_name: + raise ValueError("tool_name must be a non-empty string") + self.tool_name = tool_name + self.server = server + self.require_success = require_success + self.name = name or "required_final_tool" + + def on_trace(self, result: AgentExecutionResult) -> str | None: + expected = _qualified(self.server, self.tool_name) + calls = result.tool_calls + if not calls: + return f"Expected final tool {expected}; the run recorded no tool calls" + last = calls[-1] + if not last.matches(self.tool_name, self.server): + return f"Expected final tool {expected}; observed {_qualified(last.server, last.name)}" + if self.require_success and not last.is_success: + status = last.status.value if hasattr(last.status, "value") else str(last.status) + return f"Final tool {expected} finished with status {status}" + return None + + +def must_open_pull_request( + tool_name: str = "create_pull_request", + *, + server: str | None = None, +) -> RequiredFinalToolGuardrail: + """Flow rule: the agent must open a pull request as its final tool call.""" + return RequiredFinalToolGuardrail( + tool_name, + server=server, + name="must_open_pull_request", + ) + + +def _validated_terms(terms: tuple[str, ...]) -> tuple[str, ...]: + unique: list[str] = [] + for term in terms: + if not isinstance(term, str) or not term.strip(): + raise ValueError("disallowed terms must be non-empty strings") + if term not in unique: + unique.append(term) + if not unique: + raise ValueError("at least one disallowed term is required") + return tuple(unique) + + +def _compile_term(term: str) -> tuple[str, re.Pattern[str] | None]: + needle = term.casefold() + if any(character.isspace() for character in needle) or not needle.isalnum(): + return needle, None + return needle, re.compile(rf"\b{re.escape(needle)}\b") + + +def _find_terms( + text: str, + terms: tuple[str, ...], + compiled: tuple[tuple[str, re.Pattern[str] | None], ...], +) -> list[str]: + folded = text.casefold() + found: list[str] = [] + for original, (needle, pattern) in zip(terms, compiled, strict=True): + matched = needle in folded if pattern is None else pattern.search(folded) is not None + if matched: + found.append(original) + return found + + +def _qualified(server: str | None, tool_name: str) -> str: + if server: + return f"{server}/{tool_name}" + return tool_name diff --git a/src/agent_test_kit/models/execution.py b/src/agent_test_kit/models/execution.py index 875d0c1..fd5b4d8 100644 --- a/src/agent_test_kit/models/execution.py +++ b/src/agent_test_kit/models/execution.py @@ -18,6 +18,8 @@ StatusFilter, WorkflowStep, ) + from agent_test_kit.evals.base import Eval, EvalReport + from agent_test_kit.guardrails.base import Guardrail, GuardrailReport from agent_test_kit.models.enums import ToolCallStatus from agent_test_kit.scoring import Score, Scorer @@ -47,6 +49,8 @@ class AgentExecutionResult(BaseModel): duration_ms: int | None = None assertions: list[str] = Field(default_factory=list) assertion_outcomes: list[AssertionOutcome] = Field(default_factory=list) + guardrail_checks: list[Any] = Field(default_factory=list, exclude=True) + eval_checks: list[Any] = Field(default_factory=list, exclude=True) model_config = {"arbitrary_types_allowed": True} @@ -359,6 +363,48 @@ def evaluate() -> None: ) return score + def assert_guardrails(self, guardrails: Sequence[Guardrail] | Guardrail) -> GuardrailReport: + """Run guardrails offline and record the outcome like other assertions.""" + from agent_test_kit.guardrails.base import check_guardrails + + report = check_guardrails(self, guardrails) + self.guardrail_checks.extend(report.checks) + + def evaluate() -> None: + report.assert_passed() + + self._capture_assertion( + "assert_guardrails", + expected=[item.name for item in report.checks], + actual=[ + {"name": check.name, "passed": check.passed, "message": check.message} + for check in report.checks + ], + assertion=evaluate, + ) + return report + + def assert_evals(self, evals: Sequence[Eval] | Eval) -> EvalReport: + """Score evals and record pass/fail like other assertions.""" + from agent_test_kit.evals.base import run_evals + + report = run_evals(self, evals) + self.eval_checks.extend(report.results) + + def evaluate() -> None: + report.assert_passed() + + self._capture_assertion( + "assert_evals", + expected=[{"name": item.name, "threshold": item.threshold} for item in report.results], + actual=[ + {"name": item.name, "score": item.score, "passed": item.passed} + for item in report.results + ], + assertion=evaluate, + ) + return report + @classmethod def from_api_response( cls, diff --git a/src/agent_test_kit/models/report.py b/src/agent_test_kit/models/report.py index 688f0a4..4e2984c 100644 --- a/src/agent_test_kit/models/report.py +++ b/src/agent_test_kit/models/report.py @@ -26,6 +26,36 @@ class AssertionReport(BaseModel): message: str | None = None +class GuardrailViolationReport(BaseModel): + """One guardrail violation included in a scenario report.""" + + guardrail: str + message: str + phase: Literal["message", "tool_call", "trace"] = "trace" + evidence: dict[str, Any] = Field(default_factory=dict) + + +class GuardrailCheckReport(BaseModel): + """Outcome of one guardrail, offline or online.""" + + name: str + passed: bool + mode: Literal["offline", "online"] = "offline" + message: str | None = None + violations: list[GuardrailViolationReport] = Field(default_factory=list) + + +class EvalCheckReport(BaseModel): + """Score and pass/fail for one eval.""" + + name: str + passed: bool + score: float + threshold: float + reason: str | None = None + details: dict[str, Any] = Field(default_factory=dict) + + class SideEffectVerificationReport(BaseModel): """Outcome of an external side-effect verifier.""" @@ -53,6 +83,8 @@ class ScenarioReport(BaseModel): tool_calls: list[ToolCall] = Field(default_factory=list) timeline: list[TraceEvent] = Field(default_factory=list) assertions: list[AssertionReport] = Field(default_factory=list) + guardrails: list[GuardrailCheckReport] = Field(default_factory=list) + evals: list[EvalCheckReport] = Field(default_factory=list) side_effect_verifications: list[SideEffectVerificationReport] = Field(default_factory=list) error_message: str | None = None retry_count: int = 0 @@ -65,6 +97,7 @@ class ScenarioReport(BaseModel): suggested_prompt: str | None = None endpoint_base_url: str | None = None connected_mcp_servers: list[str] = Field(default_factory=list) + expected_mcp_servers: list[str] = Field(default_factory=list) injection_detected: bool | None = None violation_types: list[str] = Field(default_factory=list) golden_outcome: str | None = None diff --git a/src/agent_test_kit/plugin/pytest_plugin.py b/src/agent_test_kit/plugin/pytest_plugin.py index 4a400d0..449b557 100644 --- a/src/agent_test_kit/plugin/pytest_plugin.py +++ b/src/agent_test_kit/plugin/pytest_plugin.py @@ -3,7 +3,7 @@ from __future__ import annotations import os -from collections.abc import AsyncIterator, Sequence +from collections.abc import AsyncIterator, Iterator, Sequence from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Any @@ -16,6 +16,13 @@ from agent_test_kit.client.config import AgentTestConfig from agent_test_kit.client.cursor_agent_client import CursorAgentClient from agent_test_kit.client.prompt_ai_helper_profiles import get_profile +from agent_test_kit.evals.base import Eval, EvalReport, get_eval +from agent_test_kit.guardrails.base import ( + Guardrail, + GuardrailMonitor, + GuardrailReport, + check_guardrails, +) from agent_test_kit.models.execution import AgentExecutionResult from agent_test_kit.models.report import AgentTestRunReport, ReleaseReadiness, ScenarioStatus from agent_test_kit.reporting.html_report import HtmlReportWriter @@ -40,6 +47,8 @@ class PluginState: results: dict[str, AgentExecutionResult] = field(default_factory=dict) verification_results: dict[str, list[VerificationResult]] = field(default_factory=dict) regression_evaluations: dict[str, RegressionRunEvaluation] = field(default_factory=dict) + guardrail_checks: dict[str, list[Any]] = field(default_factory=dict) + eval_checks: dict[str, list[Any]] = field(default_factory=dict) phase_reports: dict[str, dict[str, pytest.TestReport]] = field(default_factory=dict) html_path: Path | None = None write_json: bool = True @@ -121,6 +130,14 @@ def attach_regression_evaluation(self, evaluation: RegressionRunEvaluation) -> N if state is not None: state.regression_evaluations[self.nodeid] = evaluation + def attach_guardrail_report(self, report: GuardrailReport) -> None: + """Record guardrail checks on the current scenario.""" + attach_guardrail_report(self.config, self.nodeid, report) + + def attach_eval_report(self, report: EvalReport) -> None: + """Record eval scores on the current scenario.""" + attach_eval_report(self.config, self.nodeid, report) + async def verify( self, verifiers: Sequence[SideEffectVerifier], @@ -187,6 +204,106 @@ def agent_scenario(request: pytest.FixtureRequest) -> ScenarioResultRecorder: return ScenarioResultRecorder(request.config, request.node.nodeid) +@dataclass +class _GuardrailFixture: + """Run guardrails for the current test and attach them to the report.""" + + config: pytest.Config + nodeid: str + monitors: list[GuardrailMonitor] = field(default_factory=list) + _closed: bool = False + + def check( + self, + result: AgentExecutionResult, + guardrails: Guardrail | Sequence[Guardrail], + *, + fail: bool = True, + ) -> GuardrailReport: + """Run guardrails offline. Failures are recorded even when the assertion fails.""" + report = check_guardrails(result, guardrails) + result.guardrail_checks.extend(report.checks) + attach_guardrail_report(self.config, self.nodeid, report) + if fail: + report.assert_passed() + return report + + def monitor( + self, + guardrails: Guardrail | Sequence[Guardrail], + *, + fail_fast: bool = False, + ) -> GuardrailMonitor: + """Online hook. Trace rules run when the monitor or this fixture finishes.""" + monitor = GuardrailMonitor(guardrails, fail_fast=fail_fast) + self.monitors.append(monitor) + return monitor + + def close(self) -> None: + if self._closed: + return + self._closed = True + for monitor in self.monitors: + if not monitor.finished: + monitor.finish(notify=False, raise_on_violation=False) + if monitor.last_report is not None: + attach_guardrail_report(self.config, self.nodeid, monitor.last_report) + + +@dataclass +class _EvalFixture: + """Run registered or explicit evals for the current test.""" + + config: pytest.Config + nodeid: str + names: list[str] + + def run( + self, + result: AgentExecutionResult, + evals: Eval | Sequence[Eval] | None = None, + *, + fail: bool = True, + ) -> EvalReport: + """Score evals. Marker names are used when ``evals`` is omitted.""" + from agent_test_kit.evals.base import run_evals + + selected: Eval | Sequence[Eval] + if evals is None: + if not self.names: + raise RuntimeError( + "No evals to run. Pass evals=[...] or mark the test with " + "@pytest.mark.agent_eval('registered_name')." + ) + selected = [get_eval(name) for name in self.names] + else: + selected = evals + report = run_evals(result, selected) + result.eval_checks.extend(report.results) + attach_eval_report(self.config, self.nodeid, report) + if fail: + report.assert_passed() + return report + + +@pytest.fixture +def agent_guardrails(request: pytest.FixtureRequest) -> Iterator[_GuardrailFixture]: + """Offline ``check`` and online ``monitor`` for guardrails on this test.""" + runner = _GuardrailFixture(request.config, request.node.nodeid) + yield runner + runner.close() + + +@pytest.fixture +def agent_eval(request: pytest.FixtureRequest) -> _EvalFixture: + """Run evals named by ``@pytest.mark.agent_eval`` or passed to ``run``.""" + return _EvalFixture( + request.config, + request.node.nodeid, + _agent_eval_names(request.node), + ) + + @pytest_asyncio.fixture async def agent_cleanup() -> AsyncIterator[CleanupManager]: manager = CleanupManager() @@ -245,6 +362,8 @@ def _record_scenario_from_reports( execution_result=stored_result, verification_results=state.verification_results.get(item.nodeid, []), regression_evaluation=state.regression_evaluations.get(item.nodeid), + guardrail_checks=state.guardrail_checks.get(item.nodeid, []), + eval_checks=state.eval_checks.get(item.nodeid, []), ) @@ -316,6 +435,46 @@ def store_execution_result( state.results[nodeid] = result +def _agent_eval_names(node: pytest.Item | pytest.Collector) -> list[str]: + names: list[str] = [] + for marker in node.iter_markers("agent_eval"): + for arg in marker.args: + if not isinstance(arg, str) or not arg.strip(): + raise ValueError("agent_eval marker arguments must be non-empty eval names") + names.append(arg) + return names + + +def attach_guardrail_report( + config: pytest.Config, + nodeid: str, + report: GuardrailReport, +) -> None: + """Attach guardrail checks to a report scenario.""" + state = config.stash.get(_STASH_KEY, None) + if state is None: + return + bucket = state.guardrail_checks.setdefault(nodeid, []) + for check in report.checks: + if check not in bucket: + bucket.append(check) + + +def attach_eval_report( + config: pytest.Config, + nodeid: str, + report: EvalReport, +) -> None: + """Attach eval results to a report scenario.""" + state = config.stash.get(_STASH_KEY, None) + if state is None: + return + bucket = state.eval_checks.setdefault(nodeid, []) + for result in report.results: + if result not in bucket: + bucket.append(result) + + def attach_verification_results( config: pytest.Config, nodeid: str, diff --git a/src/agent_test_kit/reporting/html_report.py b/src/agent_test_kit/reporting/html_report.py index 592c4e6..c77e431 100644 --- a/src/agent_test_kit/reporting/html_report.py +++ b/src/agent_test_kit/reporting/html_report.py @@ -72,7 +72,7 @@ def _flow_sequence(tool_calls: list[dict[str, Any]]) -> str: step = ( f'' f'{escape(kind.upper())}' - f"{escape(label)}" + f'{escape(label)}' f"" ) steps.append(step) @@ -217,6 +217,24 @@ def _readiness_block(readiness: dict[str, Any] | None) -> str: return "".join(parts) +def _phases(item: dict[str, Any]) -> str: + phases: list[str] = [] + for violation in item.get("violations") or []: + phase = str(violation.get("phase") or "") + if phase and phase not in phases: + phases.append(phase) + return ", ".join(phases) if phases else "—" + + +def _optional_details(title: str, headers: list[str], rows: list[list[Any]]) -> str: + if not rows: + return "" + return ( + f"

{escape(title)} ({len(rows)})" + f"{_table(headers, rows)}
" + ) + + def _behavior_rows(scenario: dict[str, Any]) -> list[list[Any]]: rows: list[list[Any]] = [] if scenario.get("case_id"): @@ -384,6 +402,26 @@ def _scenario(self, scenario: dict[str, Any]) -> str: ] for assertion in scenario["assertions"] ] + guardrail_rows = [ + [ + item["name"], + item.get("mode"), + item["passed"], + _phases(item), + item.get("message"), + ] + for item in scenario.get("guardrails") or [] + ] + eval_rows = [ + [ + item["name"], + f"{float(item['score']):.2f}", + f"{float(item['threshold']):.2f}", + item["passed"], + item.get("reason"), + ] + for item in scenario.get("evals") or [] + ] verification_rows = [ [ verification["verifier_name"], @@ -423,9 +461,7 @@ def _scenario(self, scenario: dict[str, Any]) -> str: ) flow_html = _flow_sequence(scenario["tool_calls"]) kind = scenario.get("scenario_kind") - kind_badge = ( - f'{escape(str(kind))}' if kind else "" - ) + kind_badge = f'{escape(str(kind))}' if kind else "" prompt_review_html = _prompt_review_block(scenario) golden_html = _golden_outcome_block(scenario) connected = scenario.get("connected_mcp_servers") or [] @@ -442,7 +478,9 @@ def _scenario(self, scenario: dict[str, Any]) -> str: injection_html = f'{label}' status_class = escape(status) return f"""
-

{_text(scenario["name"])}{kind_badge}{injection_html} — {escape(status)}

+

{_text(scenario["name"])}{kind_badge}{injection_html} — { + escape(status) + }

{_table(["Field", "Value"], summary)}

Connected MCP servers

{mcp_html} @@ -452,6 +490,20 @@ def _scenario(self, scenario: dict[str, Any]) -> str: {flow_html}
Assertions ({len(assertion_rows)}) {_table(["Name", "Passed", "Expected", "Actual", "Message"], assertion_rows)}
+{ + _optional_details( + "Guardrails", + ["Guardrail", "Mode", "Passed", "Phase", "Message"], + guardrail_rows, + ) + } +{ + _optional_details( + "Evals", + ["Eval", "Score", "Threshold", "Passed", "Reason"], + eval_rows, + ) + }
Side effects ({len(verification_rows)}) {verification_table}
Tool / MCP calls ({len(tool_rows)}) diff --git a/src/agent_test_kit/reporting/json_report.py b/src/agent_test_kit/reporting/json_report.py index a508e88..6fc16ab 100644 --- a/src/agent_test_kit/reporting/json_report.py +++ b/src/agent_test_kit/reporting/json_report.py @@ -14,6 +14,9 @@ from agent_test_kit.models.report import ( AgentTestRunReport, AssertionReport, + EvalCheckReport, + GuardrailCheckReport, + GuardrailViolationReport, ScenarioReport, ScenarioStatus, SideEffectVerificationReport, @@ -26,6 +29,51 @@ from agent_test_kit.regression import RegressionRunEvaluation +def _unique(items: Sequence[Any]) -> list[Any]: + unique: list[Any] = [] + for item in items: + if item not in unique: + unique.append(item) + return unique + + +def _guardrail_reports(checks: Sequence[Any]) -> list[GuardrailCheckReport]: + reports: list[GuardrailCheckReport] = [] + for check in checks: + reports.append( + GuardrailCheckReport( + name=check.name, + passed=bool(check.passed), + mode=check.mode, + message=check.message, + violations=[ + GuardrailViolationReport( + guardrail=item.guardrail, + message=item.message, + phase=item.phase, + evidence=dict(item.evidence), + ) + for item in check.violations + ], + ) + ) + return reports + + +def _eval_reports(checks: Sequence[Any]) -> list[EvalCheckReport]: + return [ + EvalCheckReport( + name=check.name, + passed=bool(check.passed), + score=float(check.score), + threshold=float(check.threshold), + reason=check.reason, + details=dict(check.details), + ) + for check in checks + ] + + def _evidence_links(value: Any) -> list[str]: if isinstance(value, str): return [value] if value.startswith(("https://", "http://")) else [] @@ -68,6 +116,8 @@ def record_scenario( execution_result: AgentExecutionResult | None = None, verification_results: Sequence[VerificationResult] = (), regression_evaluation: RegressionRunEvaluation | None = None, + guardrail_checks: Sequence[Any] = (), + eval_checks: Sequence[Any] = (), ) -> None: extra = extra or {} evaluation = regression_evaluation @@ -88,6 +138,22 @@ def record_scenario( if execution_result is not None else extra.get("assertions", []) ) + guardrail_reports = _guardrail_reports( + _unique( + [ + *(execution_result.guardrail_checks if execution_result is not None else []), + *guardrail_checks, + ] + ) + ) + eval_reports = _eval_reports( + _unique( + [ + *(execution_result.eval_checks if execution_result is not None else []), + *eval_checks, + ] + ) + ) verification_reports = [ SideEffectVerificationReport( verifier_name=result.verifier_name or "unnamed-verifier", @@ -133,6 +199,8 @@ def record_scenario( tool_calls=tool_calls, timeline=execution_result.trace.events if execution_result is not None else [], assertions=assertions, + guardrails=guardrail_reports, + evals=eval_reports, side_effect_verifications=verification_reports, error_message=error_message, retry_count=sum(max(call.attempt - 1, 0) for call in tool_calls), @@ -156,8 +224,7 @@ def record_scenario( or ("prompt_review" if response_dict.get("suggested_prompt") else None) ), original_prompt=( - extra.get("original_prompt") - or response_dict.get("original_prompt") + extra.get("original_prompt") or response_dict.get("original_prompt") ), suggested_prompt=( extra.get("suggested_prompt") @@ -184,9 +251,7 @@ def record_scenario( extra.get("expected_mcp_servers") or self.expected_mcp_servers or [] ), violation_types=list( - extra.get("violation_types") - or response_dict.get("violation_types") - or [] + extra.get("violation_types") or response_dict.get("violation_types") or [] ), golden_outcome=extra.get("golden_outcome") or response_dict.get("golden_outcome"), expected_classification=extra.get("expected_classification") diff --git a/src/agent_test_kit_pytest_entrypoint.py b/src/agent_test_kit_pytest_entrypoint.py index 9a8963e..2709535 100644 --- a/src/agent_test_kit_pytest_entrypoint.py +++ b/src/agent_test_kit_pytest_entrypoint.py @@ -41,6 +41,14 @@ def pytest_addoption(parser: pytest.Parser) -> None: ) +def pytest_configure(config: pytest.Config) -> None: + """Register markers before collection without importing the measured package.""" + config.addinivalue_line( + "markers", + "agent_eval(name): run registered evals by name via the agent_eval fixture", + ) + + @pytest.hookimpl(trylast=True) def pytest_sessionstart(session: pytest.Session) -> None: """Load runtime hooks only after coverage startup is complete.""" diff --git a/tests/test_evals.py b/tests/test_evals.py new file mode 100644 index 0000000..ae5b3d9 --- /dev/null +++ b/tests/test_evals.py @@ -0,0 +1,229 @@ +"""Evals: registration, scoring, and known-good / known-bad traces.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from agent_test_kit import ( + AgentExecutionResult, + Eval, + RequiredTermsEval, + RequiredToolsEval, + Score, + ToolCall, + eval_case, + get_eval, + register_eval, + response_text, + run_evals, +) +from agent_test_kit.models.enums import ToolCallStatus, ToolOperationKind +from agent_test_kit.models.trace import AgentTrace + +pytestmark = pytest.mark.agent_unit + + +def _result( + response: str = "", + calls: list[ToolCall] | None = None, +) -> AgentExecutionResult: + return AgentExecutionResult( + success=True, + run_id="run-eval", + response=response, + trace=AgentTrace(tool_calls=list(calls or [])), + ) + + +def _call(name: str, *, server: str | None = None) -> ToolCall: + return ToolCall( + server=server, + name=name, + status=ToolCallStatus.SUCCESS, + operation_kind=ToolOperationKind.READ, + ) + + +@pytest.mark.parametrize( + ("response", "expect_pass"), + [ + ("Your refund for order 123 is pending.", True), + ("I don't know.", False), + ("The refund is pending.", False), + ], +) +def test_required_terms_eval_known_traces(response: str, expect_pass: bool) -> None: + evaluation = RequiredTermsEval(["refund", "123"]) + result = evaluation.evaluate(_result(response)) + assert result.passed is expect_pass + assert result.name == "required_terms" + if expect_pass: + assert result.score == 1.0 + result.assert_passed() + else: + assert result.score < 1.0 + with pytest.raises(AssertionError, match="required_terms"): + result.assert_passed() + + +@pytest.mark.parametrize( + ("calls", "expect_pass"), + [ + ( + [_call("get_invoice", server="billing"), _call("create_issue", server="jira")], + True, + ), + ([_call("get_invoice", server="billing")], False), + ([], False), + ], +) +def test_required_tools_eval_known_traces(calls: list[ToolCall], expect_pass: bool) -> None: + evaluation = RequiredToolsEval([("billing", "get_invoice"), ("jira", "create_issue")]) + result = evaluation.evaluate(_result("done", calls)) + assert result.passed is expect_pass + if expect_pass: + assert result.score == 1.0 + result.assert_passed() + else: + assert result.score < 1.0 + assert "missing" in result.details + with pytest.raises(AssertionError, match="required_tools"): + result.assert_passed() + + +def test_required_terms_threshold_boundary() -> None: + evaluation = RequiredTermsEval(["refund", "123"], threshold=0.5, name="refund_partial") + partial = evaluation.evaluate(_result("The refund is pending.")) + assert partial.score == 0.5 + partial.assert_passed() + missed = evaluation.evaluate(_result("nothing")) + assert missed.score == 0.0 + with pytest.raises(AssertionError): + missed.assert_passed() + + +def test_assert_evals_records_failure_and_success() -> None: + bad = _result("nope") + with pytest.raises(AssertionError, match="required_terms"): + bad.assert_evals(RequiredTermsEval(["refund"])) + assert bad.assertion_outcomes[-1].name == "assert_evals" + assert bad.assertion_outcomes[-1].passed is False + assert bad.eval_checks[0].passed is False + + mixed = _result("refund ready") + with pytest.raises(AssertionError, match="required_tools"): + mixed.assert_evals([RequiredTermsEval(["refund"]), RequiredToolsEval(["lookup"])]) + assert mixed.eval_checks[0].passed is True + assert mixed.eval_checks[1].passed is False + + passed = _result("refund ready", [_call("lookup")]) + report = passed.assert_evals([RequiredTermsEval(["refund"]), RequiredToolsEval(["lookup"])]) + assert report.passed + assert passed.assertion_outcomes[-1].passed is True + + +def test_eval_case_registers_and_runs() -> None: + @eval_case(name="kit_mentions_ready", threshold=1.0) + def mentions_ready(result: AgentExecutionResult) -> float: + return 1.0 if "ready" in response_text(result).casefold() else 0.0 + + assert get_eval("kit_mentions_ready") is mentions_ready + run_evals(_result("system ready"), mentions_ready).assert_passed() + bad = run_evals(_result("not yet"), mentions_ready) + assert not bad.passed + with pytest.raises(AssertionError, match="kit_mentions_ready"): + bad.assert_passed() + + +def test_eval_case_bool_and_score_returns() -> None: + from agent_test_kit import registered_evals + + @eval_case(name="kit_bool_eval", register=True) + def as_bool(result: AgentExecutionResult) -> bool: + return bool(response_text(result)) + + @eval_case(name="kit_score_eval", threshold=0.8, register=False) + def as_score(result: AgentExecutionResult) -> Score: + return Score(0.8, reason="judge", details={"source": "test"}) + + assert as_bool.evaluate(_result("")).score == 0.0 + assert as_bool.evaluate(_result("hi")).passed is True + scored = as_score.evaluate(_result("hi")) + assert scored.passed is True + assert scored.reason == "judge" + assert scored.details == {"source": "test"} + with pytest.raises(ValueError, match="already registered"): + eval_case(name="kit_bool_eval")(lambda result: 1.0) + register_eval(as_bool) + assert "kit_bool_eval" in registered_evals() + assert "kit_score_eval" not in registered_evals() + + +def test_eval_rejects_bad_scores_and_empty_input() -> None: + class BadEval(Eval): + name = "kit_bad_eval" + + def score(self, result: AgentExecutionResult) -> Any: + return "high" + + class AsyncEval(Eval): + name = "kit_async_eval" + + def score(self, result: AgentExecutionResult) -> Any: + async def inner() -> float: + return 1.0 + + return inner() + + with pytest.raises(TypeError, match="kit_bad_eval"): + BadEval().evaluate(_result("x")) + with pytest.raises(TypeError, match="awaitable"): + AsyncEval().evaluate(_result("x")) + with pytest.raises(ValueError, match="between"): + RequiredTermsEval(["ok"], threshold=1.5) + with pytest.raises(ValueError, match="at least one"): + run_evals(_result("x"), []) + with pytest.raises(ValueError, match="at least one"): + RequiredToolsEval([]) + with pytest.raises(ValueError, match="names or"): + RequiredToolsEval([("only-server",)]) # type: ignore[list-item] + with pytest.raises(KeyError, match="not_a_real_eval"): + get_eval("not_a_real_eval") + with pytest.raises(TypeError, match="Expected Eval"): + run_evals(_result("x"), ["nope"]) # type: ignore[arg-type] + + +@eval_case(name="kit_marker_mentions_ready") +def kit_marker_mentions_ready(result: AgentExecutionResult) -> float: + return 1.0 if "ready" in response_text(result).casefold() else 0.0 + + +@pytest.mark.agent_eval("kit_marker_mentions_ready") +def test_agent_eval_marker_passes_a_known_good_trace(agent_eval: Any) -> None: + report = agent_eval.run(_result("system ready")) + report.assert_passed() + assert report.results[0].score == 1.0 + + +@pytest.mark.agent_eval("kit_marker_mentions_ready") +def test_agent_eval_marker_fails_a_known_bad_trace(agent_eval: Any) -> None: + with pytest.raises(AssertionError, match="kit_marker_mentions_ready"): + agent_eval.run(_result("nope")) + + +def test_agent_eval_requires_a_selection(agent_eval: Any) -> None: + with pytest.raises(RuntimeError, match="No evals"): + agent_eval.run(_result("ready")) + + +def test_agent_eval_explicit_list_can_fail_closed(agent_eval: Any) -> None: + report = agent_eval.run( + _result("refund"), + RequiredTermsEval(["refund"]), + fail=False, + ) + assert report.passed + bad = agent_eval.run(_result("nope"), RequiredTermsEval(["refund"]), fail=False) + assert not bad.passed diff --git a/tests/test_guardrail_eval_reporting.py b/tests/test_guardrail_eval_reporting.py new file mode 100644 index 0000000..7395f05 --- /dev/null +++ b/tests/test_guardrail_eval_reporting.py @@ -0,0 +1,232 @@ +"""Guardrail and eval results render in the JSON and HTML reports.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from agent_test_kit import ( + AgentExecutionResult, + AgentTestConfig, + HtmlReportWriter, + JsonReportWriter, + NoProfanityGuardrail, + RequiredTermsEval, + ToolCall, + guardrail, + must_open_pull_request, +) +from agent_test_kit.models.enums import ToolCallStatus, ToolOperationKind +from agent_test_kit.models.trace import AgentTrace +from agent_test_kit.plugin import pytest_plugin + +pytestmark = pytest.mark.agent_unit + + +def _result() -> AgentExecutionResult: + return AgentExecutionResult( + success=True, + run_id="run_report", + response="What the fuck — I opened the pull request.", + trace=AgentTrace( + tool_calls=[ + ToolCall( + server="github", + name="create_pull_request", + status=ToolCallStatus.SUCCESS, + operation_kind=ToolOperationKind.WRITE, + ) + ] + ), + ) + + +def test_html_report_includes_guardrail_and_eval_sections(tmp_path: Path) -> None: + result = _result() + with pytest.raises(AssertionError): + result.assert_guardrails([NoProfanityGuardrail(), must_open_pull_request(server="github")]) + with pytest.raises(AssertionError): + result.assert_evals(RequiredTermsEval(["refund"], name="mentions_refund")) + + writer = JsonReportWriter( + config=AgentTestConfig(agent_id="report-agent"), + output_path=tmp_path / "report.json", + ) + writer.record_scenario( + name="guardrails and evals", + nodeid="tests/test_report.py::test_guardrails", + passed=False, + status="failed", + duration_ms=5, + markers=["agent_unit"], + execution_result=result, + ) + report = writer.build_report() + scenario = report.scenarios[0] + assert [item.name for item in scenario.guardrails] == [ + "no_profanity", + "must_open_pull_request", + ] + assert scenario.guardrails[0].passed is False + assert scenario.guardrails[0].mode == "offline" + assert scenario.guardrails[1].passed is True + assert scenario.evals[0].name == "mentions_refund" + assert scenario.evals[0].passed is False + assert scenario.evals[0].score == 0.0 + + html = HtmlReportWriter(tmp_path / "report.html").write(report).read_text(encoding="utf-8") + assert "Guardrails (2)" in html + assert "Evals (1)" in html + assert "no_profanity" in html + assert "must_open_pull_request" in html + assert "mentions_refund" in html + assert "Disallowed language" in html + assert "offline" in html + + +def test_report_escapes_guardrail_messages_and_dedupes_checks(tmp_path: Path) -> None: + @guardrail(phase="message", name="markup") + def markup(text: str) -> str: + return "" + + result = AgentExecutionResult(success=True, run_id="run_html", response="hello") + with pytest.raises(AssertionError): + result.assert_guardrails(markup) + writer = JsonReportWriter( + config=AgentTestConfig(agent_id="report-agent"), + output_path=tmp_path / "report.json", + ) + writer.record_scenario( + name="", + nodeid="tests/demo.py::test_demo", + passed=False, + duration_ms=1, + markers=[], + execution_result=result, + guardrail_checks=list(result.guardrail_checks), + ) + scenario = writer.build_report().scenarios[0] + assert len(scenario.guardrails) == 1 + html = ( + HtmlReportWriter(tmp_path / "report.html") + .write(writer.build_report()) + .read_text(encoding="utf-8") + ) + assert "" not in html + assert "<script>alert(1)</script>" in html + + +def test_plugin_records_guardrails_and_evals_from_fixture_state(tmp_path: Path) -> None: + from unittest.mock import MagicMock + + result = AgentExecutionResult(success=True, run_id="run_plugin", response="hello refund") + evaluation = result.assert_evals(RequiredTermsEval(["refund"])) + writer = JsonReportWriter( + config=AgentTestConfig(agent_id="demo"), + output_path=tmp_path / "report.json", + ) + state = pytest_plugin.PluginState( + agent_config=AgentTestConfig(), + json_writer=writer, + results={"tests/demo.py::test_demo": result}, + eval_checks={"tests/demo.py::test_demo": list(evaluation.results)}, + ) + item = MagicMock() + item.nodeid = "tests/demo.py::test_demo" + item.name = "test_demo" + item.iter_markers.return_value = [] + reports: dict[str, MagicMock] = {} + for phase in ("setup", "call", "teardown"): + report = MagicMock() + report.when = phase + report.duration = 0.01 + report.skipped = False + report.failed = False + report.passed = True + report.longrepr = None + reports[phase] = report + pytest_plugin._record_scenario_from_reports(state, item, reports) + scenario = writer.scenarios[0] + assert len(scenario.evals) == 1 + assert scenario.evals[0].passed is True + assert scenario.evals[0].score == 1.0 + payload = json.loads(writer.write().read_text(encoding="utf-8")) + assert payload["scenarios"][0]["evals"][0]["name"] == "required_terms" + + +def test_html_omits_guardrail_and_eval_sections_when_absent(tmp_path: Path) -> None: + writer = JsonReportWriter( + config=AgentTestConfig(agent_id="empty"), + output_path=tmp_path / "report.json", + ) + writer.record_scenario( + name="plain", + nodeid="tests/plain.py::test_plain", + passed=True, + duration_ms=1, + markers=[], + ) + html = ( + HtmlReportWriter(tmp_path / "report.html") + .write(writer.build_report()) + .read_text(encoding="utf-8") + ) + assert "Guardrails (" not in html + assert "Evals (" not in html + + +def test_pytest_session_html_includes_guardrails_and_evals( + pytester: pytest.Pytester, + tmp_path: Path, +) -> None: + html_path = tmp_path / "report.html" + pytester.makepyfile( + """ +import pytest + +from agent_test_kit import ( + AgentExecutionResult, + NoProfanityGuardrail, + RequiredTermsEval, + ToolCall, + must_open_pull_request, +) +from agent_test_kit.models.enums import ToolCallStatus, ToolOperationKind +from agent_test_kit.models.trace import AgentTrace + + +@pytest.mark.agent_unit +def test_recorded_run(agent_guardrails, agent_eval): + result = AgentExecutionResult( + success=True, + run_id="run_1", + response="I opened the pull request.", + trace=AgentTrace( + tool_calls=[ + ToolCall( + server="github", + name="create_pull_request", + status=ToolCallStatus.SUCCESS, + operation_kind=ToolOperationKind.WRITE, + ) + ] + ), + ) + agent_guardrails.check( + result, + [NoProfanityGuardrail(), must_open_pull_request(server="github")], + ) + agent_eval.run(result, [RequiredTermsEval(["pull request"])]) +""" + ) + result = pytester.runpytest(f"--agent-report-html={html_path}", "-q") + result.assert_outcomes(passed=1) + html = html_path.read_text(encoding="utf-8") + assert "Guardrails (2)" in html + assert "no_profanity" in html + assert "must_open_pull_request" in html + assert "Evals (1)" in html + assert "required_terms" in html + assert "pull request" in html or "1.00" in html diff --git a/tests/test_guardrails.py b/tests/test_guardrails.py new file mode 100644 index 0000000..de4f149 --- /dev/null +++ b/tests/test_guardrails.py @@ -0,0 +1,333 @@ +"""Guardrails: offline traces, live hooks, and known-good / known-bad traces.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from agent_test_kit import ( + AgentExecutionResult, + Guardrail, + GuardrailEvent, + GuardrailMonitor, + GuardrailViolationError, + NoProfanityGuardrail, + RequiredFinalToolGuardrail, + ToolCall, + check_guardrails, + guardrail, + must_open_pull_request, +) +from agent_test_kit.models.enums import ToolCallStatus, ToolOperationKind +from agent_test_kit.models.trace import AgentTrace +from agent_test_kit.plugin import pytest_plugin +from agent_test_kit.scoring import response_text + +pytestmark = pytest.mark.agent_unit + + +def _result( + response: str = "", + calls: list[ToolCall] | None = None, + *, + success: bool = True, +) -> AgentExecutionResult: + return AgentExecutionResult( + success=success, + run_id="run-guard", + response=response, + trace=AgentTrace(tool_calls=list(calls or [])), + ) + + +def _call( + name: str, + *, + server: str | None = None, + status: ToolCallStatus = ToolCallStatus.SUCCESS, +) -> ToolCall: + return ToolCall( + server=server, + name=name, + status=status, + operation_kind=ToolOperationKind.WRITE, + ) + + +def _run_both(guard: Guardrail, result: AgentExecutionResult) -> tuple[bool, bool]: + """One guardrail definition, offline against the trace and online as a stream.""" + offline = check_guardrails(result, guard) + monitor = GuardrailMonitor([guard]) + text = response_text(result) + if text: + monitor.on_message(text) + for call in result.tool_calls: + monitor.on_tool_call(call) + online = monitor.finish(result) + return offline.passed, online.passed + + +@pytest.mark.parametrize( + ("response", "expect_pass"), + [ + ("I opened the pull request.", True), + ("What the fuck is this diff?", False), + ("SHIT, that failed.", False), + ], +) +def test_no_profanity_guardrail_known_traces(response: str, expect_pass: bool) -> None: + guard = NoProfanityGuardrail() + offline_passed, online_passed = _run_both(guard, _result(response)) + assert offline_passed is expect_pass + assert online_passed is expect_pass + report = check_guardrails(_result(response), guard) + if expect_pass: + report.assert_passed() + else: + with pytest.raises(AssertionError, match="no_profanity"): + report.assert_passed() + + +@pytest.mark.parametrize( + ("calls", "expect_pass"), + [ + ( + [_call("get_diff", server="github"), _call("create_pull_request", server="github")], + True, + ), + ( + [_call("create_pull_request", server="github"), _call("add_comment", server="github")], + False, + ), + ([], False), + ( + [_call("create_pull_request", server="github", status=ToolCallStatus.ERROR)], + False, + ), + ], +) +def test_required_final_tool_guardrail_known_traces( + calls: list[ToolCall], + expect_pass: bool, +) -> None: + guard = RequiredFinalToolGuardrail("create_pull_request", server="github") + offline_passed, online_passed = _run_both(guard, _result("done", calls)) + assert offline_passed is expect_pass + assert online_passed is expect_pass + report = check_guardrails(_result("done", calls), guard) + if expect_pass: + report.assert_passed() + else: + with pytest.raises(AssertionError, match="required_final_tool"): + report.assert_passed() + + +@pytest.mark.parametrize( + ("calls", "expect_pass"), + [ + ([_call("get_diff", server="github"), _call("create_pull_request", server="github")], True), + ([_call("create_pull_request", server="github"), _call("add_comment")], False), + ([], False), + ], +) +def test_must_open_pull_request_known_traces(calls: list[ToolCall], expect_pass: bool) -> None: + guard = must_open_pull_request(server="github") + offline_passed, online_passed = _run_both(guard, _result("I opened the pull request.", calls)) + assert offline_passed is expect_pass + assert online_passed is expect_pass + report = check_guardrails(_result("opening", calls), [guard]) + if expect_pass: + report.assert_passed() + else: + with pytest.raises(AssertionError, match="must_open_pull_request"): + report.assert_passed() + + +def test_online_profanity_fails_fast_on_the_stream() -> None: + monitor = GuardrailMonitor([NoProfanityGuardrail()], fail_fast=True) + with pytest.raises(GuardrailViolationError, match="no_profanity") as caught: + monitor.handle({"type": "message", "role": "assistant", "text": "fuck this"}) + assert caught.value.violation.phase == "message" + assert monitor.last_report is not None + assert not monitor.last_report.passed + + +def test_online_pull_request_rule_uses_the_same_guardrail() -> None: + monitor = GuardrailMonitor([must_open_pull_request(server="github")]) + monitor.handle({"type": "message", "content": "I opened the pull request."}) + monitor.handle( + { + "type": "tool_call", + "server": "github", + "name": "create_pull_request", + "status": "success", + "operation_kind": "write", + } + ) + report = monitor.finish() + report.assert_passed() + assert report.mode == "online" + assert report.checks[0].name == "must_open_pull_request" + + +def test_online_split_profanity_is_caught_when_the_run_finishes() -> None: + monitor = GuardrailMonitor([NoProfanityGuardrail()]) + monitor.on_message("fu") + monitor.on_message("ck") + report = monitor.finish() + assert not report.passed + assert report.checks[0].message is not None + assert "fuck" in report.checks[0].message + + +def test_user_messages_are_not_agent_output() -> None: + monitor = GuardrailMonitor([NoProfanityGuardrail()]) + monitor.on_message("fuck", role="user") + monitor.finish().assert_passed() + + +def test_word_boundaries_and_custom_disallowed_language() -> None: + check_guardrails(_result("classic classification"), NoProfanityGuardrail()).assert_passed() + check_guardrails(_result("a shitake mushroom"), NoProfanityGuardrail()).assert_passed() + custom = NoProfanityGuardrail(terms=["banana"], extra_terms=["system prompt"]) + check_guardrails(_result("fuck"), custom).assert_passed() + bad = check_guardrails(_result("leaked the system prompt"), custom) + assert not bad.passed + assert "system prompt" in (bad.checks[0].message or "") + + +def test_profanity_terms_must_be_non_empty() -> None: + with pytest.raises(ValueError, match="at least one"): + NoProfanityGuardrail(terms=[]) + with pytest.raises(ValueError, match="non-empty"): + NoProfanityGuardrail(extra_terms=[" "]) + + +def test_final_tool_wrong_server_and_optional_success() -> None: + calls = [_call("create_pull_request", server="bitbucket")] + report = check_guardrails( + _result("done", calls), + RequiredFinalToolGuardrail("create_pull_request", server="github"), + ) + assert not report.passed + assert "bitbucket/create_pull_request" in (report.checks[0].message or "") + allowed = RequiredFinalToolGuardrail( + "create_pull_request", + require_success=False, + ) + errored = [_call("create_pull_request", status=ToolCallStatus.ERROR)] + check_guardrails(_result("done", errored), allowed).assert_passed() + + +def test_assert_guardrails_records_a_failure_before_raising() -> None: + result = _result("fuck") + with pytest.raises(AssertionError, match="no_profanity"): + result.assert_guardrails(NoProfanityGuardrail()) + assert result.assertion_outcomes[-1].name == "assert_guardrails" + assert result.assertion_outcomes[-1].passed is False + assert result.guardrail_checks[0].passed is False + + +def test_assert_guardrails_passes_and_records() -> None: + calls = [_call("create_pull_request", server="github")] + result = _result("I opened the pull request.", calls) + report = result.assert_guardrails( + [NoProfanityGuardrail(), must_open_pull_request(server="github")] + ) + assert report.passed + assert result.assertion_outcomes[-1].passed is True + assert [check.name for check in result.guardrail_checks] == [ + "no_profanity", + "must_open_pull_request", + ] + + +def test_custom_guardrail_decorator_and_subclass_share_both_modes() -> None: + @guardrail(phase="message") + def no_secrets(text: str, *, role: str = "assistant") -> str | None: + if role == "assistant" and "sk-" in text: + return "assistant output looks like a secret key" + return None + + class NoDeleteGuardrail(Guardrail): + name = "no_delete" + + def on_tool_call(self, call: ToolCall) -> str | None: + if call.name.startswith("delete_"): + return f"delete tool is not allowed: {call.name}" + return None + + @guardrail + def needs_success(result: AgentExecutionResult) -> str | None: + if not result.success: + return "run failed" + return None + + secret = _result("token sk-live") + assert _run_both(no_secrets, secret) == (False, False) + deleted = _result("ok", [_call("delete_issue")]) + assert _run_both(NoDeleteGuardrail(), deleted) == (False, False) + clean = _result("ok", [_call("create_pull_request")]) + assert _run_both(no_secrets, clean) == (True, True) + assert _run_both(needs_success, _result("ok", success=False)) == (False, False) + no_role = guardrail(phase="message", name="no_email")( + lambda text: "email" if "@" in text else None + ) + assert not check_guardrails(_result("a@b.test"), no_role).passed + + +def test_guardrail_return_type_and_empty_selection() -> None: + @guardrail(name="bad_return") + def bad_return(result: AgentExecutionResult) -> Any: + return 1 + + with pytest.raises(TypeError, match="bad_return"): + check_guardrails(_result("ok"), bad_return) + with pytest.raises(ValueError, match="at least one"): + check_guardrails(_result("ok"), []) + with pytest.raises(ValueError, match="at least one"): + GuardrailMonitor([]) + with pytest.raises(TypeError, match="Guardrail"): + check_guardrails(_result("ok"), ["nope"]) # type: ignore[arg-type] + + +def test_monitor_rejects_a_second_finish_and_unknown_events() -> None: + monitor = GuardrailMonitor(NoProfanityGuardrail()) + monitor.finish() + with pytest.raises(RuntimeError, match="already"): + monitor.finish() + with pytest.raises(RuntimeError, match="already"): + monitor.on_message("hi") + fresh = GuardrailMonitor(NoProfanityGuardrail()) + with pytest.raises(ValueError, match="Unknown"): + fresh.handle({"type": "pause"}) + with pytest.raises(ValueError, match="name"): + fresh.handle({"type": "tool_call"}) + event = GuardrailEvent( + type="tool_call", + tool_call=_call("create_pull_request", server="github"), + ) + assert fresh.handle(event) == [] + + +def test_fixture_records_online_violation_for_the_report( + agent_guardrails: Any, + request: pytest.FixtureRequest, +) -> None: + monitor = agent_guardrails.monitor([NoProfanityGuardrail()], fail_fast=True) + with pytest.raises(GuardrailViolationError): + monitor.on_message("fuck") + agent_guardrails.close() + state = request.config.stash[pytest_plugin._STASH_KEY] + checks = state.guardrail_checks[request.node.nodeid] + assert checks[0].mode == "online" + assert checks[0].passed is False + assert "fuck" in (checks[0].message or "") + + +def test_fixture_offline_check_fails_a_known_bad_trace(agent_guardrails: Any) -> None: + result = _result("fuck") + with pytest.raises(AssertionError, match="no_profanity"): + agent_guardrails.check(result, NoProfanityGuardrail()) + assert result.guardrail_checks[0].passed is False diff --git a/tests/test_pytest_plugin.py b/tests/test_pytest_plugin.py index 6660d83..1c67c9c 100644 --- a/tests/test_pytest_plugin.py +++ b/tests/test_pytest_plugin.py @@ -148,7 +148,9 @@ def test_agent_report_html_includes_tool_flow_from_fixture( asyncio_mode = auto """ ) - pytester.makeconftest( + # The fake client lives in the test module. The runtime plugin is registered + # at session start, so a conftest fixture of the same name does not win. + pytester.makepyfile( """ import httpx import pytest @@ -186,11 +188,6 @@ def agent_client(agent_test_config, request): result, ), ) -""" - ) - pytester.makepyfile( - """ -import pytest @pytest.mark.asyncio