agentprdiff 0.3.1__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.
Files changed (32) hide show
  1. {agentprdiff-0.3.1 → agentprdiff-0.4.0}/CHANGELOG.md +28 -0
  2. {agentprdiff-0.3.1 → agentprdiff-0.4.0}/PKG-INFO +2 -2
  3. {agentprdiff-0.3.1 → agentprdiff-0.4.0}/pyproject.toml +1 -1
  4. {agentprdiff-0.3.1 → agentprdiff-0.4.0}/src/agentprdiff/__init__.py +2 -1
  5. agentprdiff-0.4.0/src/agentprdiff/graders/http_judge.py +93 -0
  6. agentprdiff-0.4.0/src/agentprdiff/masking.py +120 -0
  7. {agentprdiff-0.3.1 → agentprdiff-0.4.0}/src/agentprdiff/runner.py +2 -1
  8. agentprdiff-0.4.0/src/agentprdiff/trace_store.py +103 -0
  9. {agentprdiff-0.3.1 → agentprdiff-0.4.0}/studio/README.md +99 -1
  10. agentprdiff-0.4.0/video-tutorials/customer_support_agent/README.md +181 -0
  11. agentprdiff-0.4.0/video-tutorials/customer_support_agent/suites/README.md +82 -0
  12. {agentprdiff-0.3.1 → agentprdiff-0.4.0}/.gitignore +0 -0
  13. {agentprdiff-0.3.1 → agentprdiff-0.4.0}/LICENSE +0 -0
  14. {agentprdiff-0.3.1 → agentprdiff-0.4.0}/README.md +0 -0
  15. {agentprdiff-0.3.1 → agentprdiff-0.4.0}/examples/quickstart/README.md +0 -0
  16. {agentprdiff-0.3.1 → agentprdiff-0.4.0}/examples/regression-tour/README.md +0 -0
  17. {agentprdiff-0.3.1 → agentprdiff-0.4.0}/src/agentprdiff/adapters/__init__.py +0 -0
  18. {agentprdiff-0.3.1 → agentprdiff-0.4.0}/src/agentprdiff/adapters/anthropic.py +0 -0
  19. {agentprdiff-0.3.1 → agentprdiff-0.4.0}/src/agentprdiff/adapters/openai.py +0 -0
  20. {agentprdiff-0.3.1 → agentprdiff-0.4.0}/src/agentprdiff/adapters/pricing.py +0 -0
  21. {agentprdiff-0.3.1 → agentprdiff-0.4.0}/src/agentprdiff/cli.py +0 -0
  22. {agentprdiff-0.3.1 → agentprdiff-0.4.0}/src/agentprdiff/core.py +0 -0
  23. {agentprdiff-0.3.1 → agentprdiff-0.4.0}/src/agentprdiff/differ.py +0 -0
  24. {agentprdiff-0.3.1 → agentprdiff-0.4.0}/src/agentprdiff/filtering.py +0 -0
  25. {agentprdiff-0.3.1 → agentprdiff-0.4.0}/src/agentprdiff/graders/__init__.py +0 -0
  26. {agentprdiff-0.3.1 → agentprdiff-0.4.0}/src/agentprdiff/graders/deterministic.py +0 -0
  27. {agentprdiff-0.3.1 → agentprdiff-0.4.0}/src/agentprdiff/graders/semantic.py +0 -0
  28. {agentprdiff-0.3.1 → agentprdiff-0.4.0}/src/agentprdiff/loader.py +0 -0
  29. {agentprdiff-0.3.1 → agentprdiff-0.4.0}/src/agentprdiff/reporters.py +0 -0
  30. {agentprdiff-0.3.1 → agentprdiff-0.4.0}/src/agentprdiff/scaffold.py +0 -0
  31. {agentprdiff-0.3.1 → agentprdiff-0.4.0}/src/agentprdiff/store.py +0 -0
  32. {agentprdiff-0.3.1 → agentprdiff-0.4.0}/studio/backend/README.md +0 -0
@@ -8,6 +8,34 @@ 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
+
11
39
  ## [0.3.1] — 2026-05-17
12
40
 
13
41
  Patch release. Fixes a long-standing drift between `pyproject.toml`'s
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: agentprdiff
3
- Version: 0.3.1
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.3.1"
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,7 +50,8 @@ See ``docs/adapters.md`` for the full reference.
50
50
 
51
51
  from __future__ import annotations
52
52
 
53
- from importlib.metadata import PackageNotFoundError, version as _pkg_version
53
+ from importlib.metadata import PackageNotFoundError
54
+ from importlib.metadata import version as _pkg_version
54
55
 
55
56
  from .core import (
56
57
  AgentFn,
@@ -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