agentprdiff 0.3.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agentprdiff-0.3.0 → agentprdiff-0.4.0}/CHANGELOG.md +49 -0
- {agentprdiff-0.3.0 → agentprdiff-0.4.0}/PKG-INFO +2 -2
- {agentprdiff-0.3.0 → agentprdiff-0.4.0}/pyproject.toml +1 -1
- {agentprdiff-0.3.0 → agentprdiff-0.4.0}/src/agentprdiff/__init__.py +12 -1
- agentprdiff-0.4.0/src/agentprdiff/graders/http_judge.py +93 -0
- agentprdiff-0.4.0/src/agentprdiff/masking.py +120 -0
- {agentprdiff-0.3.0 → agentprdiff-0.4.0}/src/agentprdiff/runner.py +2 -1
- agentprdiff-0.4.0/src/agentprdiff/trace_store.py +103 -0
- {agentprdiff-0.3.0 → agentprdiff-0.4.0}/studio/README.md +99 -1
- agentprdiff-0.4.0/video-tutorials/customer_support_agent/README.md +181 -0
- agentprdiff-0.4.0/video-tutorials/customer_support_agent/suites/README.md +82 -0
- {agentprdiff-0.3.0 → agentprdiff-0.4.0}/.gitignore +0 -0
- {agentprdiff-0.3.0 → agentprdiff-0.4.0}/LICENSE +0 -0
- {agentprdiff-0.3.0 → agentprdiff-0.4.0}/README.md +0 -0
- {agentprdiff-0.3.0 → agentprdiff-0.4.0}/examples/quickstart/README.md +0 -0
- {agentprdiff-0.3.0 → agentprdiff-0.4.0}/examples/regression-tour/README.md +0 -0
- {agentprdiff-0.3.0 → agentprdiff-0.4.0}/src/agentprdiff/adapters/__init__.py +0 -0
- {agentprdiff-0.3.0 → agentprdiff-0.4.0}/src/agentprdiff/adapters/anthropic.py +0 -0
- {agentprdiff-0.3.0 → agentprdiff-0.4.0}/src/agentprdiff/adapters/openai.py +0 -0
- {agentprdiff-0.3.0 → agentprdiff-0.4.0}/src/agentprdiff/adapters/pricing.py +0 -0
- {agentprdiff-0.3.0 → agentprdiff-0.4.0}/src/agentprdiff/cli.py +0 -0
- {agentprdiff-0.3.0 → agentprdiff-0.4.0}/src/agentprdiff/core.py +0 -0
- {agentprdiff-0.3.0 → agentprdiff-0.4.0}/src/agentprdiff/differ.py +0 -0
- {agentprdiff-0.3.0 → agentprdiff-0.4.0}/src/agentprdiff/filtering.py +0 -0
- {agentprdiff-0.3.0 → agentprdiff-0.4.0}/src/agentprdiff/graders/__init__.py +0 -0
- {agentprdiff-0.3.0 → agentprdiff-0.4.0}/src/agentprdiff/graders/deterministic.py +0 -0
- {agentprdiff-0.3.0 → agentprdiff-0.4.0}/src/agentprdiff/graders/semantic.py +0 -0
- {agentprdiff-0.3.0 → agentprdiff-0.4.0}/src/agentprdiff/loader.py +0 -0
- {agentprdiff-0.3.0 → agentprdiff-0.4.0}/src/agentprdiff/reporters.py +0 -0
- {agentprdiff-0.3.0 → agentprdiff-0.4.0}/src/agentprdiff/scaffold.py +0 -0
- {agentprdiff-0.3.0 → agentprdiff-0.4.0}/src/agentprdiff/store.py +0 -0
- {agentprdiff-0.3.0 → agentprdiff-0.4.0}/studio/backend/README.md +0 -0
|
@@ -8,6 +8,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.4.0] — 2026-08-29
|
|
12
|
+
|
|
13
|
+
Minor release bundling the three feature PRs merged since 0.3.1, plus
|
|
14
|
+
the Studio hardening work. All changes are additive; no breaking API
|
|
15
|
+
changes.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- `http_judge` grader backend for custom LLM-as-judge endpoints, so
|
|
20
|
+
suites can score traces against a self-hosted or third-party judge
|
|
21
|
+
service instead of the built-in providers (#7).
|
|
22
|
+
- Field-level trace masking for PII and other sensitive data, letting
|
|
23
|
+
suites redact specific fields from recorded traces before they are
|
|
24
|
+
stored or diffed (#8).
|
|
25
|
+
- Pluggable `TraceStore` interface with an `InMemoryTraceStore`
|
|
26
|
+
implementation, decoupling trace persistence from the runner so
|
|
27
|
+
custom storage backends can be dropped in (#9).
|
|
28
|
+
- Studio: private-repo git auth (SSH + HTTPS PAT), adapter and
|
|
29
|
+
`extend_existing` scan strategies, path-to-import sanitization with
|
|
30
|
+
import preflight, hardened preflight pipeline with scan manifest and
|
|
31
|
+
auto-install preview.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- Ruff lint cleanups in the new masking and http_judge modules and
|
|
36
|
+
their tests; suppressed the intentional B027 warning on the no-op
|
|
37
|
+
`ensure_initialized` hook.
|
|
38
|
+
|
|
39
|
+
## [0.3.1] — 2026-05-17
|
|
40
|
+
|
|
41
|
+
Patch release. Fixes a long-standing drift between `pyproject.toml`'s
|
|
42
|
+
`version` field and the in-code `__version__` constant: the constant
|
|
43
|
+
in `src/agentprdiff/__init__.py` was hardcoded and got out of sync at
|
|
44
|
+
every release (0.3.0 shipped while still reporting "0.2.5" via
|
|
45
|
+
`agentprdiff.__version__`, even though `pip show` correctly read
|
|
46
|
+
"0.3.0" from the wheel metadata).
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- `agentprdiff.__version__` is now read from the installed package
|
|
51
|
+
metadata via `importlib.metadata.version("agentprdiff")` instead of
|
|
52
|
+
a hardcoded string. The runtime constant matches the wheel metadata
|
|
53
|
+
by construction; no human step can let them drift again. Falls back
|
|
54
|
+
to `"0+unknown"` when running from an uninstalled source checkout.
|
|
55
|
+
|
|
56
|
+
This is purely a metadata fix — no behavior change, no API additions,
|
|
57
|
+
no removed APIs. The `set_default_model()` hook from 0.3.0 is
|
|
58
|
+
unchanged.
|
|
59
|
+
|
|
11
60
|
## [0.3.0] — 2026-05-17
|
|
12
61
|
|
|
13
62
|
Adds a model-override hook on the adapters package. This unblocks
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: agentprdiff
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Guard your LLM agents in CI. Snapshot tests that catch behavioral regressions when models, prompts, or vendors change.
|
|
5
5
|
Project-URL: Homepage, https://agentprdiff.dev
|
|
6
6
|
Project-URL: Documentation, https://agentprdiff.dev
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "agentprdiff"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Guard your LLM agents in CI. Snapshot tests that catch behavioral regressions when models, prompts, or vendors change."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -50,6 +50,9 @@ See ``docs/adapters.md`` for the full reference.
|
|
|
50
50
|
|
|
51
51
|
from __future__ import annotations
|
|
52
52
|
|
|
53
|
+
from importlib.metadata import PackageNotFoundError
|
|
54
|
+
from importlib.metadata import version as _pkg_version
|
|
55
|
+
|
|
53
56
|
from .core import (
|
|
54
57
|
AgentFn,
|
|
55
58
|
Case,
|
|
@@ -67,7 +70,15 @@ from .differ import AssertionChange, TraceDelta, diff_traces
|
|
|
67
70
|
from .runner import CaseReport, Runner, RunReport
|
|
68
71
|
from .store import BaselineStore
|
|
69
72
|
|
|
70
|
-
|
|
73
|
+
# Source of truth is pyproject.toml's [project] version. Reading it here
|
|
74
|
+
# means the runtime constant can never drift from the wheel metadata; the
|
|
75
|
+
# fallback covers the "running from a source checkout that wasn't pip
|
|
76
|
+
# installed" case (e.g. `python -m pytest` on a fresh clone before
|
|
77
|
+
# `pip install -e .`).
|
|
78
|
+
try:
|
|
79
|
+
__version__ = _pkg_version("agentprdiff")
|
|
80
|
+
except PackageNotFoundError: # pragma: no cover — only hit in source-tree runs
|
|
81
|
+
__version__ = "0+unknown"
|
|
71
82
|
|
|
72
83
|
__all__ = [
|
|
73
84
|
# core
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""HTTP judge — call any REST endpoint as an LLM-as-judge backend.
|
|
4
|
+
|
|
5
|
+
Lets teams route evaluation traffic to an internal model gateway, a private
|
|
6
|
+
inference server, or any OpenAI-compatible endpoint without modifying the
|
|
7
|
+
agentprdiff core.
|
|
8
|
+
|
|
9
|
+
Usage::
|
|
10
|
+
|
|
11
|
+
from agentprdiff.graders.http_judge import http_judge
|
|
12
|
+
from agentprdiff.graders import semantic
|
|
13
|
+
|
|
14
|
+
judge = http_judge(
|
|
15
|
+
url="https://my-gateway.internal/v1/chat/completions",
|
|
16
|
+
model="my-model",
|
|
17
|
+
api_key="sk-...", # or set HTTP_JUDGE_API_KEY env var
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
grader = semantic("agent acknowledged the refund", judge=judge)
|
|
21
|
+
|
|
22
|
+
The endpoint must accept the OpenAI Chat Completions request schema and return
|
|
23
|
+
a response with ``choices[0].message.content``. The content must start with
|
|
24
|
+
``PASS`` or ``FAIL`` on the first line, with an optional reason on the second
|
|
25
|
+
line — the same format used by the built-in ``openai_judge`` and
|
|
26
|
+
``anthropic_judge``.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import os
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from ..core import Trace
|
|
35
|
+
from .semantic import _JUDGE_PROMPT, Judge, _parse_verdict
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def http_judge(
|
|
39
|
+
url: str,
|
|
40
|
+
*,
|
|
41
|
+
model: str,
|
|
42
|
+
api_key: str | None = None,
|
|
43
|
+
extra_headers: dict[str, str] | None = None,
|
|
44
|
+
timeout: float = 30.0,
|
|
45
|
+
temperature: float = 0.0,
|
|
46
|
+
max_tokens: int = 120,
|
|
47
|
+
) -> Judge:
|
|
48
|
+
"""Return a judge that POSTs to an OpenAI-compatible chat completions endpoint.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
url: Full URL of the ``/chat/completions`` endpoint.
|
|
52
|
+
model: Model name forwarded in the request body.
|
|
53
|
+
api_key: Bearer token. Falls back to the ``HTTP_JUDGE_API_KEY``
|
|
54
|
+
environment variable when omitted.
|
|
55
|
+
extra_headers: Additional HTTP headers merged into every request
|
|
56
|
+
(e.g. ``{"X-Tenant-Id": "my-team"}``).
|
|
57
|
+
timeout: Request timeout in seconds.
|
|
58
|
+
temperature: Sampling temperature forwarded to the endpoint.
|
|
59
|
+
max_tokens: Maximum tokens in the judge response.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
A :data:`~agentprdiff.graders.semantic.Judge` callable compatible with
|
|
63
|
+
:func:`~agentprdiff.graders.semantic.semantic`.
|
|
64
|
+
"""
|
|
65
|
+
resolved_key = api_key or os.environ.get("HTTP_JUDGE_API_KEY", "")
|
|
66
|
+
|
|
67
|
+
def _judge(rubric: str, trace: Trace) -> tuple[bool, str]:
|
|
68
|
+
try:
|
|
69
|
+
import json as _json
|
|
70
|
+
import urllib.request
|
|
71
|
+
|
|
72
|
+
prompt = _JUDGE_PROMPT.format(rubric=rubric, output=str(trace.output or ""))
|
|
73
|
+
payload: dict[str, Any] = {
|
|
74
|
+
"model": model,
|
|
75
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
76
|
+
"temperature": temperature,
|
|
77
|
+
"max_tokens": max_tokens,
|
|
78
|
+
}
|
|
79
|
+
headers = {
|
|
80
|
+
"Content-Type": "application/json",
|
|
81
|
+
**({"Authorization": f"Bearer {resolved_key}"} if resolved_key else {}),
|
|
82
|
+
**(extra_headers or {}),
|
|
83
|
+
}
|
|
84
|
+
data = _json.dumps(payload).encode()
|
|
85
|
+
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
|
86
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
|
|
87
|
+
body = _json.loads(resp.read())
|
|
88
|
+
text: str = body["choices"][0]["message"]["content"] or ""
|
|
89
|
+
return _parse_verdict(text)
|
|
90
|
+
except Exception as exc: # noqa: BLE001
|
|
91
|
+
return False, f"http_judge error ({type(exc).__name__}): {exc}"
|
|
92
|
+
|
|
93
|
+
return _judge
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""Field-level masking for Trace objects before baseline storage or diffing.
|
|
4
|
+
|
|
5
|
+
Teams often capture traces that contain PII, credentials, or environment-
|
|
6
|
+
specific values (user IDs, session tokens, hostnames) that should not be
|
|
7
|
+
committed to git or compared across runs. This module provides a lightweight
|
|
8
|
+
masking layer that redacts or replaces selected fields before a Trace leaves
|
|
9
|
+
the runner.
|
|
10
|
+
|
|
11
|
+
Usage::
|
|
12
|
+
|
|
13
|
+
from agentprdiff.masking import mask_trace, MaskRule
|
|
14
|
+
|
|
15
|
+
rules = [
|
|
16
|
+
MaskRule(field="output", pattern=r"user-\\d+", replacement="user-***"),
|
|
17
|
+
MaskRule(field="metadata.api_key", replacement="<redacted>"),
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
clean_trace = mask_trace(trace, rules)
|
|
21
|
+
|
|
22
|
+
``field`` is a dot-separated path into the Trace JSON (e.g. ``"output"``,
|
|
23
|
+
``"metadata.session_id"``, ``"tool_calls.0.arguments.password"``).
|
|
24
|
+
|
|
25
|
+
If ``pattern`` is given, only substrings matching the regex are replaced.
|
|
26
|
+
If ``pattern`` is omitted the entire field value is replaced with
|
|
27
|
+
``replacement``.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import copy
|
|
33
|
+
import re
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from pydantic import BaseModel
|
|
37
|
+
|
|
38
|
+
from .core import Trace
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MaskRule(BaseModel):
|
|
42
|
+
"""A single masking rule.
|
|
43
|
+
|
|
44
|
+
Attributes:
|
|
45
|
+
field: Dot-separated path to the field inside the Trace JSON structure.
|
|
46
|
+
List indices are supported as numeric segments (e.g.
|
|
47
|
+
``"tool_calls.0.arguments.token"``).
|
|
48
|
+
pattern: Optional regex. When set, only matching substrings are
|
|
49
|
+
replaced. When omitted the entire field value is overwritten.
|
|
50
|
+
replacement: The string substituted in place of the matched content.
|
|
51
|
+
Defaults to ``"<masked>"``.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
field: str
|
|
55
|
+
pattern: str | None = None
|
|
56
|
+
replacement: str = "<masked>"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def mask_trace(trace: Trace, rules: list[MaskRule]) -> Trace:
|
|
60
|
+
"""Return a deep copy of *trace* with all *rules* applied.
|
|
61
|
+
|
|
62
|
+
The original trace is never mutated.
|
|
63
|
+
"""
|
|
64
|
+
data: dict[str, Any] = copy.deepcopy(trace.model_dump(mode="json"))
|
|
65
|
+
for rule in rules:
|
|
66
|
+
_apply_rule(data, rule)
|
|
67
|
+
return Trace.model_validate(data)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Internal helpers
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _apply_rule(data: dict[str, Any], rule: MaskRule) -> None:
|
|
76
|
+
segments = rule.field.split(".")
|
|
77
|
+
_set_at_path(data, segments, rule)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _set_at_path(
|
|
81
|
+
node: Any,
|
|
82
|
+
segments: list[str],
|
|
83
|
+
rule: MaskRule,
|
|
84
|
+
) -> None:
|
|
85
|
+
if not segments:
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
key = segments[0]
|
|
89
|
+
rest = segments[1:]
|
|
90
|
+
|
|
91
|
+
# Resolve integer indices for lists.
|
|
92
|
+
if isinstance(node, list):
|
|
93
|
+
try:
|
|
94
|
+
idx = int(key)
|
|
95
|
+
except ValueError:
|
|
96
|
+
return
|
|
97
|
+
if idx < 0 or idx >= len(node):
|
|
98
|
+
return
|
|
99
|
+
if not rest:
|
|
100
|
+
node[idx] = _apply_replacement(node[idx], rule)
|
|
101
|
+
else:
|
|
102
|
+
_set_at_path(node[idx], rest, rule)
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
if not isinstance(node, dict) or key not in node:
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
if not rest:
|
|
109
|
+
node[key] = _apply_replacement(node[key], rule)
|
|
110
|
+
else:
|
|
111
|
+
_set_at_path(node[key], rest, rule)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _apply_replacement(value: Any, rule: MaskRule) -> Any:
|
|
115
|
+
if rule.pattern is None:
|
|
116
|
+
return rule.replacement
|
|
117
|
+
if not isinstance(value, str):
|
|
118
|
+
# Only string fields support regex replacement.
|
|
119
|
+
return value
|
|
120
|
+
return re.sub(rule.pattern, rule.replacement, value)
|
|
@@ -16,6 +16,7 @@ from pydantic import BaseModel, ConfigDict, Field
|
|
|
16
16
|
from .core import GradeResult, Suite, Trace, run_agent
|
|
17
17
|
from .differ import TraceDelta, diff_traces
|
|
18
18
|
from .store import BaselineStore
|
|
19
|
+
from .trace_store import TraceStore
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
class CaseReport(BaseModel):
|
|
@@ -73,7 +74,7 @@ class RunReport(BaseModel):
|
|
|
73
74
|
class Runner:
|
|
74
75
|
"""Runs suites in record or check mode."""
|
|
75
76
|
|
|
76
|
-
def __init__(self, store: BaselineStore) -> None:
|
|
77
|
+
def __init__(self, store: BaselineStore | TraceStore) -> None:
|
|
77
78
|
self.store = store
|
|
78
79
|
|
|
79
80
|
# ------------------------------------------------------------------ api
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""Pluggable trace store interface.
|
|
4
|
+
|
|
5
|
+
The built-in :class:`~agentprdiff.store.BaselineStore` writes traces to the
|
|
6
|
+
local filesystem. ``TraceStore`` is a simple protocol that lets teams route
|
|
7
|
+
traces to any backend — a database, object storage, or a remote telemetry
|
|
8
|
+
service — without modifying the runner.
|
|
9
|
+
|
|
10
|
+
Usage::
|
|
11
|
+
|
|
12
|
+
from agentprdiff.trace_store import TraceStore
|
|
13
|
+
from agentprdiff.core import Trace
|
|
14
|
+
|
|
15
|
+
class MyDBStore(TraceStore):
|
|
16
|
+
def save_baseline(self, trace: Trace) -> None:
|
|
17
|
+
db.upsert(suite=trace.suite_name, case=trace.case_name, data=trace.model_dump())
|
|
18
|
+
|
|
19
|
+
def load_baseline(self, suite_name: str, case_name: str) -> Trace | None:
|
|
20
|
+
row = db.get(suite=suite_name, case=case_name)
|
|
21
|
+
return Trace.model_validate(row) if row else None
|
|
22
|
+
|
|
23
|
+
def save_run_trace(self, run_id: str, trace: Trace) -> None:
|
|
24
|
+
db.insert(run_id=run_id, data=trace.model_dump())
|
|
25
|
+
|
|
26
|
+
runner = Runner(store=MyDBStore())
|
|
27
|
+
|
|
28
|
+
Implementations only need to satisfy the three-method interface. The
|
|
29
|
+
``ensure_initialized`` and ``fresh_run_id`` methods have sensible defaults and
|
|
30
|
+
are optional to override.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import uuid
|
|
36
|
+
from abc import ABC, abstractmethod
|
|
37
|
+
|
|
38
|
+
from .core import Trace
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TraceStore(ABC):
|
|
42
|
+
"""Abstract base class for trace persistence backends.
|
|
43
|
+
|
|
44
|
+
Subclass this to plug in any storage backend. The :class:`Runner` only
|
|
45
|
+
calls the three abstract methods plus :meth:`ensure_initialized` and
|
|
46
|
+
:meth:`fresh_run_id`.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def save_baseline(self, trace: Trace) -> None:
|
|
51
|
+
"""Persist *trace* as the canonical baseline for its suite+case."""
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def load_baseline(self, suite_name: str, case_name: str) -> Trace | None:
|
|
55
|
+
"""Return the stored baseline, or ``None`` if none exists yet."""
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def save_run_trace(self, run_id: str, trace: Trace) -> None:
|
|
59
|
+
"""Persist a trace produced during a ``check`` run."""
|
|
60
|
+
|
|
61
|
+
def ensure_initialized(self) -> None: # noqa: B027
|
|
62
|
+
"""Called once per runner invocation before any traces are written.
|
|
63
|
+
|
|
64
|
+
Override to create tables, buckets, or directories on first use.
|
|
65
|
+
The default implementation is a no-op.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def fresh_run_id(self) -> str:
|
|
69
|
+
"""Return a unique identifier for the current run.
|
|
70
|
+
|
|
71
|
+
Override to use your own ID scheme (e.g. a database sequence).
|
|
72
|
+
The default returns a random 12-hex-character string.
|
|
73
|
+
"""
|
|
74
|
+
return uuid.uuid4().hex[:12]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class InMemoryTraceStore(TraceStore):
|
|
78
|
+
"""In-memory store for testing and ephemeral pipelines.
|
|
79
|
+
|
|
80
|
+
Baselines and run traces are stored in plain dicts and lost when the
|
|
81
|
+
process exits. Useful in CI when you want to run ``check`` immediately
|
|
82
|
+
after ``record`` without touching the filesystem.
|
|
83
|
+
|
|
84
|
+
Example::
|
|
85
|
+
|
|
86
|
+
store = InMemoryTraceStore()
|
|
87
|
+
runner = Runner(store=store)
|
|
88
|
+
runner.record(my_suite)
|
|
89
|
+
report = runner.check(my_suite)
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self) -> None:
|
|
93
|
+
self._baselines: dict[tuple[str, str], Trace] = {}
|
|
94
|
+
self._runs: dict[tuple[str, str, str], Trace] = {}
|
|
95
|
+
|
|
96
|
+
def save_baseline(self, trace: Trace) -> None:
|
|
97
|
+
self._baselines[(trace.suite_name, trace.case_name)] = trace
|
|
98
|
+
|
|
99
|
+
def load_baseline(self, suite_name: str, case_name: str) -> Trace | None:
|
|
100
|
+
return self._baselines.get((suite_name, case_name))
|
|
101
|
+
|
|
102
|
+
def save_run_trace(self, run_id: str, trace: Trace) -> None:
|
|
103
|
+
self._runs[(run_id, trace.suite_name, trace.case_name)] = trace
|
|
@@ -37,6 +37,104 @@ Plus the orthogonal **intake modes** for projects you create inside Studio:
|
|
|
37
37
|
* **zip** — upload an archive, same execution path as git.
|
|
38
38
|
* **http** — point Studio at a deployed endpoint, author suites as JSON, runs go in-process via httpx (no Python execution, baselines stored in the DB).
|
|
39
39
|
|
|
40
|
+
## Private git repos
|
|
41
|
+
|
|
42
|
+
Studio supports two non-interactive auth paths for private repos. The
|
|
43
|
+
container has no TTY, so git **never prompts** — you choose one of these
|
|
44
|
+
before creating the project.
|
|
45
|
+
|
|
46
|
+
### Option A — SSH (`git@github.com:owner/repo.git`)
|
|
47
|
+
|
|
48
|
+
The Studio image installs `openssh-client`, so the only thing you need
|
|
49
|
+
to provide is your SSH key. Mount your host's `~/.ssh` into the
|
|
50
|
+
container (read-only is fine):
|
|
51
|
+
|
|
52
|
+
```yaml
|
|
53
|
+
# studio/docker-compose.yml — add to the studio service
|
|
54
|
+
services:
|
|
55
|
+
studio:
|
|
56
|
+
# … existing config …
|
|
57
|
+
volumes:
|
|
58
|
+
- studio-data:/data
|
|
59
|
+
- ${HOME}/.ssh:/root/.ssh:ro # ← add this line
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Two gotchas worth knowing:
|
|
63
|
+
|
|
64
|
+
* The container runs as root, so the SSH client looks at `/root/.ssh`.
|
|
65
|
+
If you'd prefer a non-default location, set `GIT_SSH_COMMAND="ssh -i
|
|
66
|
+
/path/to/key"` via `STUDIO_*` env (or directly on the service in
|
|
67
|
+
compose).
|
|
68
|
+
* `known_hosts` lives in the same directory you mount. If you've never
|
|
69
|
+
ssh'd to the remote from your host, run
|
|
70
|
+
`ssh-keyscan github.com >> ~/.ssh/known_hosts` once before starting
|
|
71
|
+
Studio, otherwise the first clone fails with *"Host key verification
|
|
72
|
+
failed."* (Studio surfaces this error inline with the same hint.)
|
|
73
|
+
|
|
74
|
+
Then point Studio at an SSH URL when you create the project:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
git@github.com:vnageshwaran-de/private-repo.git
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Option B — HTTPS + token (`https://github.com/owner/repo.git`)
|
|
81
|
+
|
|
82
|
+
Save a personal access token in **Studio's Secrets page** (top-right
|
|
83
|
+
nav), then create the project with a plain `https://` URL. Studio reads
|
|
84
|
+
the token at clone time, sends it via `Authorization: bearer …` in
|
|
85
|
+
git's transient config, and never embeds it in the URL or writes it to
|
|
86
|
+
disk.
|
|
87
|
+
|
|
88
|
+
Secret name → host mapping (project-scoped wins over global):
|
|
89
|
+
|
|
90
|
+
| Host | Secret name |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `github.com` | `GITHUB_TOKEN` |
|
|
93
|
+
| `gitlab.com` | `GITLAB_TOKEN` |
|
|
94
|
+
| `bitbucket.org` | `BITBUCKET_TOKEN` |
|
|
95
|
+
| Anything else (self-hosted Enterprise / on-prem) | `GIT_HTTPS_TOKEN` |
|
|
96
|
+
|
|
97
|
+
The fallback `GIT_HTTPS_TOKEN` lets you point Studio at self-hosted
|
|
98
|
+
GitHub Enterprise, self-hosted GitLab, or Gitea / Forgejo without
|
|
99
|
+
baking the hostname into Studio's config.
|
|
100
|
+
|
|
101
|
+
**What the token needs:** for GitHub classic PATs, `repo` scope. For
|
|
102
|
+
GitHub fine-grained PATs, `Contents: read-only` on the repos you want
|
|
103
|
+
to clone is enough. For GitLab, `read_repository` scope.
|
|
104
|
+
|
|
105
|
+
**What Studio does with it:**
|
|
106
|
+
|
|
107
|
+
* Reads the encrypted secret at clone time (Fernet at rest, plaintext
|
|
108
|
+
only in memory of the requesting worker).
|
|
109
|
+
* Injects `http.https://<host>/.extraheader: Authorization: bearer
|
|
110
|
+
<token>` via `GIT_CONFIG_COUNT` / `GIT_CONFIG_KEY_N` /
|
|
111
|
+
`GIT_CONFIG_VALUE_N` env vars on the git subprocess.
|
|
112
|
+
* Sets `GIT_TERMINAL_PROMPT=0` so git can't block on stdin even when
|
|
113
|
+
the token is wrong (you get a clean error in the UI instead of a
|
|
114
|
+
hung sync).
|
|
115
|
+
* Redacts the token from any error string before it reaches the UI,
|
|
116
|
+
the logs, or the project row.
|
|
117
|
+
|
|
118
|
+
**What Studio refuses:** URLs with embedded credentials
|
|
119
|
+
(`https://user:pat@host/...`). The error tells you to strip the
|
|
120
|
+
credential and save it in the Secrets page instead — embedded
|
|
121
|
+
credentials persist in the project row, in git's reflog, and in the
|
|
122
|
+
workspace's `.git/config`, which are all places they shouldn't be.
|
|
123
|
+
|
|
124
|
+
**Recovering from a wrong / expired token:** open the Secrets page,
|
|
125
|
+
update the value, click Sync on the project. The next clone picks up
|
|
126
|
+
the new token. The old one isn't kept anywhere.
|
|
127
|
+
|
|
128
|
+
### When to use which
|
|
129
|
+
|
|
130
|
+
| Situation | Pick |
|
|
131
|
+
|---|---|
|
|
132
|
+
| Personal use, ssh-agent already configured on host | SSH |
|
|
133
|
+
| Want to avoid mounting host paths into the container | HTTPS + token |
|
|
134
|
+
| Self-hosted Enterprise, no SSH access | HTTPS + token (`GIT_HTTPS_TOKEN`) |
|
|
135
|
+
| Multiple users sharing the Studio container | HTTPS + token (project-scoped secrets isolate per project) |
|
|
136
|
+
| CI / headless deployment | HTTPS + token (no key material to ship) |
|
|
137
|
+
|
|
40
138
|
## Local development (without Docker)
|
|
41
139
|
|
|
42
140
|
```bash
|
|
@@ -74,7 +172,7 @@ Everything is env-driven. The most useful knobs:
|
|
|
74
172
|
* Engine (`agentprdiff`) installed from PyPI (or a path, via `STUDIO_ENGINE_REQ`).
|
|
75
173
|
* Studio backend (FastAPI + SQLAlchemy + httpx + GitPython).
|
|
76
174
|
* Built SPA (Vite output) at `/opt/studio/frontend`.
|
|
77
|
-
* `git`, `build-essential`, `tini` for clean signal handling.
|
|
175
|
+
* `git`, `openssh-client`, `build-essential`, `tini` for clean signal handling. `openssh-client` is what makes `git@github.com:…` URLs work — see the [Private git repos](#private-git-repos) section above.
|
|
78
176
|
|
|
79
177
|
The image runs as `uvicorn agentprdiff_studio.main:app --host 0.0.0.0 --port 8080` under `tini`. A healthcheck hits `/api/health` every 30s.
|
|
80
178
|
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# Customer Support Agent — agentprdiff Video Tutorial
|
|
2
|
+
|
|
3
|
+
A **LangGraph ReAct agent** paired with **agentprdiff** snapshot tests to demonstrate
|
|
4
|
+
how to catch behavioral regressions when models, prompts, or tools change.
|
|
5
|
+
|
|
6
|
+
> **Tutorial narrative:** Build → Record → Break → Catch → Fix
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## What you'll build
|
|
11
|
+
|
|
12
|
+
| File | Purpose |
|
|
13
|
+
|---|---|
|
|
14
|
+
| `agent.py` | LangGraph ReAct agent with 3 tools: `lookup_order`, `process_refund`, `check_policy` |
|
|
15
|
+
| `suite.py` | 12 agentprdiff test cases across 5 suites covering all 10 built-in graders |
|
|
16
|
+
| `AGENTS.md` | Persistent instructions for AI coding agents working in this repo |
|
|
17
|
+
| `.github/workflows/agentprdiff.yml` | CI: runs `agentprdiff check` on every PR |
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Prerequisites
|
|
22
|
+
|
|
23
|
+
- Python 3.11+
|
|
24
|
+
- An Anthropic API key (`ANTHROPIC_API_KEY`)
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# 1. Enter the project
|
|
32
|
+
cd video-tutorials/customer_support_agent
|
|
33
|
+
|
|
34
|
+
# 2. Install dependencies
|
|
35
|
+
pip install -r requirements.txt
|
|
36
|
+
|
|
37
|
+
# 3. Set your API key
|
|
38
|
+
echo "ANTHROPIC_API_KEY=sk-ant-..." > .env
|
|
39
|
+
|
|
40
|
+
# 4. Smoke test the agent manually
|
|
41
|
+
python agent.py
|
|
42
|
+
|
|
43
|
+
# 5. Record baselines (run once on the known-good version)
|
|
44
|
+
agentprdiff record suite.py
|
|
45
|
+
|
|
46
|
+
# 6. Check for regressions
|
|
47
|
+
agentprdiff check suite.py
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Tutorial walkthrough
|
|
53
|
+
|
|
54
|
+
### Step 1 — Explore the agent
|
|
55
|
+
|
|
56
|
+
Open `agent.py`. The agent is a standard LangGraph `StateGraph` with three nodes:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
HumanMessage → [agent] → [tools] → [agent] → ... → AIMessage
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Three mock tools simulate a real backend (no real database needed):
|
|
63
|
+
|
|
64
|
+
- **`lookup_order(order_id)`** — returns order status, item, category, amount
|
|
65
|
+
- **`process_refund(order_id, reason)`** — approves refunds on delivered orders only
|
|
66
|
+
- **`check_policy(category)`** — returns the return/refund policy for an item category
|
|
67
|
+
|
|
68
|
+
### Step 2 — Run the agent manually
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
python agent.py
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
You'll see three queries answered: order status, refund request, and policy lookup.
|
|
75
|
+
|
|
76
|
+
### Step 3 — Understand the test suite
|
|
77
|
+
|
|
78
|
+
Open `suite.py`. It contains **5 suites** covering distinct behavior categories:
|
|
79
|
+
|
|
80
|
+
| Suite | Cases | Key graders demonstrated |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| `refund_flow` | 3 | `tool_sequence`, `no_tool_called`, `regex_match`, `semantic` |
|
|
83
|
+
| `policy_queries` | 3 | `tool_called`, `output_length_lt`, `contains_any` |
|
|
84
|
+
| `order_status` | 2 | `no_tool_called` (agent doesn't over-call) |
|
|
85
|
+
| `multi_step_reasoning` | 2 | `tool_sequence` (3 steps), `cost_lt_usd` |
|
|
86
|
+
| `out_of_scope` | 2 | `no_tool_called` (all tools), graceful fallback |
|
|
87
|
+
|
|
88
|
+
All 10 agentprdiff graders are used:
|
|
89
|
+
`contains` · `contains_any` · `regex_match` · `tool_called` · `tool_sequence` ·
|
|
90
|
+
`no_tool_called` · `output_length_lt` · `latency_lt_ms` · `cost_lt_usd` · `semantic`
|
|
91
|
+
|
|
92
|
+
### Step 4 — Record baselines
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
agentprdiff record suite.py
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
This runs every case once and writes JSON snapshots to `.agentprdiff/baselines/`.
|
|
99
|
+
Commit these files — they are the "known good" reference for CI.
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
git add .agentprdiff/baselines/
|
|
103
|
+
git commit -m "chore: record initial agentprdiff baselines"
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Step 5 — Introduce a regression (the "aha!" moment)
|
|
107
|
+
|
|
108
|
+
Swap the model to an older, less capable one:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
export ANTHROPIC_MODEL=claude-3-haiku-20240307
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Now run the check:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
agentprdiff check suite.py
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
You'll see failures like:
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
FAIL refund_flow / refund_happy_path
|
|
124
|
+
tool_sequence(["lookup_order", "process_refund"]) — FAILED
|
|
125
|
+
actual sequence: ["lookup_order"] ← haiku skipped the refund step
|
|
126
|
+
|
|
127
|
+
FAIL multi_step_reasoning / full_refund_journey
|
|
128
|
+
semantic(...) — FAILED
|
|
129
|
+
judge: "agent acknowledged the issue but did not process the refund"
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
This is the core value of agentprdiff: **a model swap that looks safe silently changes behavior**.
|
|
133
|
+
|
|
134
|
+
### Step 6 — Fix or re-record
|
|
135
|
+
|
|
136
|
+
**Option A — Fix the regression** (revert the model swap):
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
export ANTHROPIC_MODEL=claude-3-5-haiku-20241022
|
|
140
|
+
agentprdiff check suite.py # passes again
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
**Option B — Accept the new behavior** (intentional change):
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
agentprdiff record suite.py
|
|
147
|
+
git add .agentprdiff/baselines/
|
|
148
|
+
git commit -m "chore: update baselines after model change"
|
|
149
|
+
# Write a ## Behavior Change section in your PR description
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Step 7 — CI enforces it forever
|
|
153
|
+
|
|
154
|
+
Every PR that touches this directory triggers the GitHub Actions workflow.
|
|
155
|
+
If `agentprdiff check` exits non-zero, the PR is blocked. Reviewers see
|
|
156
|
+
the baseline diff in the uploaded artifact.
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## All 10 graders — quick reference
|
|
161
|
+
|
|
162
|
+
| Grader | What it checks | Example in suite.py |
|
|
163
|
+
|---|---|---|
|
|
164
|
+
| `contains(text)` | Output contains substring | `contains("refund")` |
|
|
165
|
+
| `contains_any([...])` | Output contains at least one substring | `contains_any(["30 days", "30-day"])` |
|
|
166
|
+
| `regex_match(pattern)` | Output matches regex | `regex_match(r"REF-\d+")` |
|
|
167
|
+
| `tool_called(name)` | Tool was called at least once | `tool_called("lookup_order")` |
|
|
168
|
+
| `tool_sequence([...])` | Tools were called in this exact order | `tool_sequence(["lookup_order", "process_refund"])` |
|
|
169
|
+
| `no_tool_called(name)` | Tool was never called | `no_tool_called("process_refund")` |
|
|
170
|
+
| `output_length_lt(n)` | Output is fewer than n characters | `output_length_lt(400)` |
|
|
171
|
+
| `latency_lt_ms(ms)` | End-to-end latency under budget | `latency_lt_ms(10_000)` |
|
|
172
|
+
| `cost_lt_usd(usd)` | Token cost under budget | `cost_lt_usd(0.05)` |
|
|
173
|
+
| `semantic(description)` | LLM-as-judge checks intent | `semantic("agent confirms refund approved")` |
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## Project conventions
|
|
178
|
+
|
|
179
|
+
See `AGENTS.md` for the full set of rules AI coding agents must follow when
|
|
180
|
+
modifying this project — including when to re-record baselines, code style,
|
|
181
|
+
and what they must never touch.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# ShopFast customer support agent — agentprdiff suite
|
|
2
|
+
|
|
3
|
+
Behavioral regression tests for the ShopFast LangGraph customer support agent.
|
|
4
|
+
The suite covers 12 cases across 5 suites pinning the agent's refund logic,
|
|
5
|
+
policy look-ups, order status, multi-step reasoning, and out-of-scope handling.
|
|
6
|
+
|
|
7
|
+
## Quick start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# Install dependencies (from the project root)
|
|
11
|
+
pip install -r requirements.txt
|
|
12
|
+
|
|
13
|
+
# Record baselines (first run, or after an intentional change)
|
|
14
|
+
agentprdiff record suites/customer_support.py
|
|
15
|
+
|
|
16
|
+
# Check for regressions (CI gate)
|
|
17
|
+
agentprdiff check suites/customer_support.py
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Running one case
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# By substring
|
|
24
|
+
agentprdiff check suites/customer_support.py --case refund_happy_path
|
|
25
|
+
agentprdiff review suites/customer_support.py --case refund_happy_path # verbose, exit 0
|
|
26
|
+
|
|
27
|
+
# By glob
|
|
28
|
+
agentprdiff check suites/customer_support.py --case "*refund*"
|
|
29
|
+
|
|
30
|
+
# List all case names
|
|
31
|
+
agentprdiff check suites/customer_support.py --list
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Semantic Judge Keys
|
|
35
|
+
|
|
36
|
+
This suite uses `semantic(...)` graders in the following cases:
|
|
37
|
+
`refund_happy_path`, `refund_order_not_found`, `refund_in_transit_order`,
|
|
38
|
+
`policy_footwear`, `policy_unknown_category`, `full_refund_journey`,
|
|
39
|
+
`status_then_policy_no_refund`, `off_topic_weather`, `vague_refund_no_order_id`.
|
|
40
|
+
|
|
41
|
+
**CI judge mode: `fake_judge` (keyword matching, free).**
|
|
42
|
+
|
|
43
|
+
The CI workflow does NOT set `AGENTGUARD_JUDGE` or a judge-provider key, so
|
|
44
|
+
the semantic graders run in `fake_judge` mode — fast, free, but only keyword
|
|
45
|
+
matching. The rubric strings are written to pass under keyword matching for the
|
|
46
|
+
happy path; they add a human-readable description of intent for reviewers.
|
|
47
|
+
|
|
48
|
+
To switch to a real LLM judge locally or in CI:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# Anthropic judge (recommended — cheaper)
|
|
52
|
+
export AGENTGUARD_JUDGE=anthropic
|
|
53
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
54
|
+
agentprdiff check suites/customer_support.py
|
|
55
|
+
|
|
56
|
+
# OpenAI judge
|
|
57
|
+
export AGENTGUARD_JUDGE=openai
|
|
58
|
+
export OPENAI_API_KEY=sk-...
|
|
59
|
+
agentprdiff check suites/customer_support.py
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Add the corresponding secret in GitHub Settings → Secrets and variables →
|
|
63
|
+
Actions, then add `AGENTGUARD_JUDGE: anthropic` and
|
|
64
|
+
`ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}` to the workflow YAML's
|
|
65
|
+
`env:` block.
|
|
66
|
+
|
|
67
|
+
## Stub strategy
|
|
68
|
+
|
|
69
|
+
The production tools (`lookup_order`, `process_refund`, `check_policy`) call
|
|
70
|
+
external APIs only when `LIVE_TOOLS=true`. The suite always runs with
|
|
71
|
+
`LIVE_TOOLS=false` (the default), which activates the in-module mock data.
|
|
72
|
+
See `_stubs.py` for the expected return shapes.
|
|
73
|
+
|
|
74
|
+
## Suite map
|
|
75
|
+
|
|
76
|
+
| Suite | Cases | What it pins |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| `refund_flow` | 3 | lookup-before-refund ordering; no refund on unknown/in-transit orders |
|
|
79
|
+
| `policy_queries` | 3 | correct tool called; right window returned; graceful unknown-category fallback |
|
|
80
|
+
| `order_status` | 2 | no spurious tool calls; correct status text |
|
|
81
|
+
| `multi_step_reasoning` | 2 | 3-tool chain; in-transit + policy without refund |
|
|
82
|
+
| `out_of_scope` | 2 | no tool called; agent asks for missing order ID |
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|