debrief-mcp 0.1.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.
- debrief_mcp-0.1.0/PKG-INFO +84 -0
- debrief_mcp-0.1.0/README.md +74 -0
- debrief_mcp-0.1.0/pyproject.toml +52 -0
- debrief_mcp-0.1.0/pyproject.toml.orig +47 -0
- debrief_mcp-0.1.0/src/debrief/__init__.py +16 -0
- debrief_mcp-0.1.0/src/debrief/adapters/__init__.py +0 -0
- debrief_mcp-0.1.0/src/debrief/adapters/buildwright_log.py +213 -0
- debrief_mcp-0.1.0/src/debrief/adapters/ggufbench_store.py +250 -0
- debrief_mcp-0.1.0/src/debrief/domain.py +79 -0
- debrief_mcp-0.1.0/src/debrief/errors.py +24 -0
- debrief_mcp-0.1.0/src/debrief/mcp_server.py +218 -0
- debrief_mcp-0.1.0/src/debrief/ports.py +20 -0
- debrief_mcp-0.1.0/src/debrief/use_cases.py +131 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: debrief-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Evidence MCP for agents: what ggufbench measured, where a buildwright run broke, role proposals as text — never writes
|
|
5
|
+
Author: Jordi Marsal
|
|
6
|
+
Author-email: Jordi Marsal <jordi.marsal@gmail.com>
|
|
7
|
+
Requires-Dist: mcp>=2.3,<3
|
|
8
|
+
Requires-Python: >=3.14
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# debrief
|
|
12
|
+
|
|
13
|
+
The evidence MCP: answers agent questions from receipts — what
|
|
14
|
+
[ggufbench](https://github.com/jordimarsal/ggufbench) measured, and where the
|
|
15
|
+
last [buildwright](https://github.com/jordimarsal/buildwright) run broke — and
|
|
16
|
+
proposes role changes **as text**. It never writes.
|
|
17
|
+
|
|
18
|
+
> Status: scaffolding. The harness is installed and the parent feature is
|
|
19
|
+
> `pending` — the spec (`harness/specs/debrief/`) is not written yet, so the
|
|
20
|
+
> tool list below is the plan, not the product.
|
|
21
|
+
|
|
22
|
+
## The idea
|
|
23
|
+
|
|
24
|
+
- `ggufbench` owns the **catalog**: what a model measured, under which task
|
|
25
|
+
signature, and why rows were excluded.
|
|
26
|
+
- `buildwright` owns the **episode**: one run, its gates, its failures.
|
|
27
|
+
- `debrief` is the reader. One query surface, one shared finding type, and a
|
|
28
|
+
proposal that cites both. Two ports that answer different questions:
|
|
29
|
+
|
|
30
|
+
| Port | Question | Reads | Tools (planned) |
|
|
31
|
+
| --- | --- | --- | --- |
|
|
32
|
+
| `bench` | What did we measure? | ggufbench artifacts | `bench.which_model` · `bench.compare` · `bench.why_excluded` |
|
|
33
|
+
| `run` | Where did this run break? | a buildwright run log | `run.inspect_run` · `run.failures` · `run.tool_calls` |
|
|
34
|
+
| `routing.propose` | What would you change? | findings from both | prints a `BW_ROLE_*` diff as text |
|
|
35
|
+
|
|
36
|
+
## Product rules (the gates that matter in code)
|
|
37
|
+
|
|
38
|
+
1. **debrief never writes.** Every tool is read-only or returns a proposal as
|
|
39
|
+
text. No tool touches `.env`, `BW_ROLE_*`, `models.yaml`, or any other file.
|
|
40
|
+
2. **Proposals target roles, not the registry.** A proposed change is a
|
|
41
|
+
`BW_ROLE_<ROLE>` diff. `models.yaml` is the registry of what exists — not
|
|
42
|
+
debrief's to edit.
|
|
43
|
+
3. **bench and run answer from their own source.** `run` may cite `bench`
|
|
44
|
+
findings; `bench` never reads runs.
|
|
45
|
+
4. **Rows never cross a task signature.** Incomparable rows are refused with a
|
|
46
|
+
reason — never averaged.
|
|
47
|
+
|
|
48
|
+
## The gates (process)
|
|
49
|
+
|
|
50
|
+
The repo runs the [harness](https://github.com/jordimarsal/harness) SDD
|
|
51
|
+
workflow in **hybrid** mode: one in-session agent plays the roles, the human
|
|
52
|
+
gates stay mandatory. Architecture is **hexagonal** (`docs/architecture.md`);
|
|
53
|
+
features are cut into **vertical slices** (`docs/slicing-protocol.md`) —
|
|
54
|
+
S1 `bench_query` → S2 `run_inspect` → S3 `role_propose`, each demoable
|
|
55
|
+
end-to-end.
|
|
56
|
+
|
|
57
|
+
| Gate | What it checks | Command / artefact |
|
|
58
|
+
| --- | --- | --- |
|
|
59
|
+
| **Spec Gate** (human) | EARS trio in `harness/specs/<name>/`, slice map approved before any slice starts | review + explicit approval |
|
|
60
|
+
| **Batch gates** (every 2–4 tasks) | tests + lint + types, evidence saved | `uv run pytest tests`, `ruff check`, `mypy --strict` — full output to `harness/logs/<feature>/batch-<n>.log` |
|
|
61
|
+
| **Security audit** (`audit_level: standard`) | path traversal, input validation, secrets — run at review time | `bash harness/tools/audit-security.sh` |
|
|
62
|
+
| **Gauntlet G1–G4** (before Completion) | correctness, adversarial input, maintainability, evidence | `bash harness/tools/gauntlet.sh --feature <name>` + report in `harness/progress/gauntlet_<name>.md` |
|
|
63
|
+
| **Completion Gate** (human) | traceability `R<n>` ↔ test, plus a **recorded end-to-end demo** per slice | `python3 harness/tools/check-traceability.py --feature <name>` → `harness/specs/<name>/review.md` |
|
|
64
|
+
| **Session verify** | harness files, docs, tests, state coherence | `harness/init.sh` |
|
|
65
|
+
|
|
66
|
+
Two gates are non-negotiable and human: the **Spec Gate** and the **Completion
|
|
67
|
+
Gate**. The gauntlet filters what reaches them; it never replaces them.
|
|
68
|
+
|
|
69
|
+
## Development
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
uv sync
|
|
73
|
+
uv run pytest tests
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Requires Python ≥ 3.14 ([uv](https://docs.astral.sh/uv/)).
|
|
77
|
+
|
|
78
|
+
## Repository map
|
|
79
|
+
|
|
80
|
+
See [`AGENTS.md`](AGENTS.md) — it is the navigation map for agents (and humans).
|
|
81
|
+
Architecture: [`docs/architecture.md`](docs/architecture.md). Decisions worth
|
|
82
|
+
remembering: [`harness/decisions/`](harness/decisions/).
|
|
83
|
+
|
|
84
|
+
[](https://github.com/jordimarsal/harness)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# debrief
|
|
2
|
+
|
|
3
|
+
The evidence MCP: answers agent questions from receipts — what
|
|
4
|
+
[ggufbench](https://github.com/jordimarsal/ggufbench) measured, and where the
|
|
5
|
+
last [buildwright](https://github.com/jordimarsal/buildwright) run broke — and
|
|
6
|
+
proposes role changes **as text**. It never writes.
|
|
7
|
+
|
|
8
|
+
> Status: scaffolding. The harness is installed and the parent feature is
|
|
9
|
+
> `pending` — the spec (`harness/specs/debrief/`) is not written yet, so the
|
|
10
|
+
> tool list below is the plan, not the product.
|
|
11
|
+
|
|
12
|
+
## The idea
|
|
13
|
+
|
|
14
|
+
- `ggufbench` owns the **catalog**: what a model measured, under which task
|
|
15
|
+
signature, and why rows were excluded.
|
|
16
|
+
- `buildwright` owns the **episode**: one run, its gates, its failures.
|
|
17
|
+
- `debrief` is the reader. One query surface, one shared finding type, and a
|
|
18
|
+
proposal that cites both. Two ports that answer different questions:
|
|
19
|
+
|
|
20
|
+
| Port | Question | Reads | Tools (planned) |
|
|
21
|
+
| --- | --- | --- | --- |
|
|
22
|
+
| `bench` | What did we measure? | ggufbench artifacts | `bench.which_model` · `bench.compare` · `bench.why_excluded` |
|
|
23
|
+
| `run` | Where did this run break? | a buildwright run log | `run.inspect_run` · `run.failures` · `run.tool_calls` |
|
|
24
|
+
| `routing.propose` | What would you change? | findings from both | prints a `BW_ROLE_*` diff as text |
|
|
25
|
+
|
|
26
|
+
## Product rules (the gates that matter in code)
|
|
27
|
+
|
|
28
|
+
1. **debrief never writes.** Every tool is read-only or returns a proposal as
|
|
29
|
+
text. No tool touches `.env`, `BW_ROLE_*`, `models.yaml`, or any other file.
|
|
30
|
+
2. **Proposals target roles, not the registry.** A proposed change is a
|
|
31
|
+
`BW_ROLE_<ROLE>` diff. `models.yaml` is the registry of what exists — not
|
|
32
|
+
debrief's to edit.
|
|
33
|
+
3. **bench and run answer from their own source.** `run` may cite `bench`
|
|
34
|
+
findings; `bench` never reads runs.
|
|
35
|
+
4. **Rows never cross a task signature.** Incomparable rows are refused with a
|
|
36
|
+
reason — never averaged.
|
|
37
|
+
|
|
38
|
+
## The gates (process)
|
|
39
|
+
|
|
40
|
+
The repo runs the [harness](https://github.com/jordimarsal/harness) SDD
|
|
41
|
+
workflow in **hybrid** mode: one in-session agent plays the roles, the human
|
|
42
|
+
gates stay mandatory. Architecture is **hexagonal** (`docs/architecture.md`);
|
|
43
|
+
features are cut into **vertical slices** (`docs/slicing-protocol.md`) —
|
|
44
|
+
S1 `bench_query` → S2 `run_inspect` → S3 `role_propose`, each demoable
|
|
45
|
+
end-to-end.
|
|
46
|
+
|
|
47
|
+
| Gate | What it checks | Command / artefact |
|
|
48
|
+
| --- | --- | --- |
|
|
49
|
+
| **Spec Gate** (human) | EARS trio in `harness/specs/<name>/`, slice map approved before any slice starts | review + explicit approval |
|
|
50
|
+
| **Batch gates** (every 2–4 tasks) | tests + lint + types, evidence saved | `uv run pytest tests`, `ruff check`, `mypy --strict` — full output to `harness/logs/<feature>/batch-<n>.log` |
|
|
51
|
+
| **Security audit** (`audit_level: standard`) | path traversal, input validation, secrets — run at review time | `bash harness/tools/audit-security.sh` |
|
|
52
|
+
| **Gauntlet G1–G4** (before Completion) | correctness, adversarial input, maintainability, evidence | `bash harness/tools/gauntlet.sh --feature <name>` + report in `harness/progress/gauntlet_<name>.md` |
|
|
53
|
+
| **Completion Gate** (human) | traceability `R<n>` ↔ test, plus a **recorded end-to-end demo** per slice | `python3 harness/tools/check-traceability.py --feature <name>` → `harness/specs/<name>/review.md` |
|
|
54
|
+
| **Session verify** | harness files, docs, tests, state coherence | `harness/init.sh` |
|
|
55
|
+
|
|
56
|
+
Two gates are non-negotiable and human: the **Spec Gate** and the **Completion
|
|
57
|
+
Gate**. The gauntlet filters what reaches them; it never replaces them.
|
|
58
|
+
|
|
59
|
+
## Development
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
uv sync
|
|
63
|
+
uv run pytest tests
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Requires Python ≥ 3.14 ([uv](https://docs.astral.sh/uv/)).
|
|
67
|
+
|
|
68
|
+
## Repository map
|
|
69
|
+
|
|
70
|
+
See [`AGENTS.md`](AGENTS.md) — it is the navigation map for agents (and humans).
|
|
71
|
+
Architecture: [`docs/architecture.md`](docs/architecture.md). Decisions worth
|
|
72
|
+
remembering: [`harness/decisions/`](harness/decisions/).
|
|
73
|
+
|
|
74
|
+
[](https://github.com/jordimarsal/harness)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "debrief-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Evidence MCP for agents: what ggufbench measured, where a buildwright run broke, role proposals as text — never writes"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
dependencies = ["mcp>=2.3,<3"]
|
|
8
|
+
|
|
9
|
+
[[project.authors]]
|
|
10
|
+
name = "Jordi Marsal"
|
|
11
|
+
email = "jordi.marsal@gmail.com"
|
|
12
|
+
|
|
13
|
+
[project.scripts]
|
|
14
|
+
debrief = "debrief:main"
|
|
15
|
+
debrief-mcp = "debrief:main"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["uv_build>=0.12.19,<0.13.0"]
|
|
19
|
+
build-backend = "uv_build"
|
|
20
|
+
|
|
21
|
+
[dependency-groups]
|
|
22
|
+
dev = [
|
|
23
|
+
"mypy>=1.15",
|
|
24
|
+
"pytest>=9.2.0",
|
|
25
|
+
"ruff>=0.14",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[tool.ruff]
|
|
29
|
+
line-length = 100
|
|
30
|
+
target-version = "py314"
|
|
31
|
+
extend-exclude = ["harness/tools"]
|
|
32
|
+
|
|
33
|
+
[tool.ruff.lint]
|
|
34
|
+
select = [
|
|
35
|
+
"E",
|
|
36
|
+
"F",
|
|
37
|
+
"W",
|
|
38
|
+
"I",
|
|
39
|
+
"UP",
|
|
40
|
+
"B",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[tool.mypy]
|
|
44
|
+
strict = true
|
|
45
|
+
files = [
|
|
46
|
+
"src",
|
|
47
|
+
"tests",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[tool.uv.build-backend]
|
|
51
|
+
module-name = "debrief"
|
|
52
|
+
module-root = "src"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "debrief-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Evidence MCP for agents: what ggufbench measured, where a buildwright run broke, role proposals as text — never writes"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Jordi Marsal", email = "jordi.marsal@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.14"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"mcp>=2.3,<3",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
debrief = "debrief:main"
|
|
16
|
+
debrief-mcp = "debrief:main"
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["uv_build>=0.12.19,<0.13.0"]
|
|
20
|
+
build-backend = "uv_build"
|
|
21
|
+
|
|
22
|
+
[dependency-groups]
|
|
23
|
+
dev = [
|
|
24
|
+
"mypy>=1.15",
|
|
25
|
+
"pytest>=9.2.0",
|
|
26
|
+
"ruff>=0.14",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[tool.ruff]
|
|
30
|
+
line-length = 100
|
|
31
|
+
target-version = "py314"
|
|
32
|
+
# harness/tools are harness-owned scripts executed by the system python3 (<3.14),
|
|
33
|
+
# so py314-only syntax ruff formats towards must never be applied to them.
|
|
34
|
+
extend-exclude = ["harness/tools"]
|
|
35
|
+
|
|
36
|
+
[tool.ruff.lint]
|
|
37
|
+
select = ["E", "F", "W", "I", "UP", "B"]
|
|
38
|
+
|
|
39
|
+
[tool.mypy]
|
|
40
|
+
strict = true
|
|
41
|
+
files = ["src", "tests"]
|
|
42
|
+
|
|
43
|
+
[tool.uv.build-backend]
|
|
44
|
+
# Distribution is `debrief-mcp` (PyPI name), but the importable package stays
|
|
45
|
+
# `debrief` — module name does not have to match the distribution name.
|
|
46
|
+
module-name = "debrief"
|
|
47
|
+
module-root = "src"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""debrief — answers agent questions from receipts; never writes (ADR-0001)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
__all__ = ["main"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def __getattr__(name: str) -> Any:
|
|
11
|
+
# Lazy on purpose: `import debrief` (and so debrief.domain) must not pull in the mcp SDK.
|
|
12
|
+
if name == "main":
|
|
13
|
+
from debrief.mcp_server import main
|
|
14
|
+
|
|
15
|
+
return main
|
|
16
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
File without changes
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""buildwright run log adapter: read-only mapping of one run log to Findings (R4).
|
|
2
|
+
|
|
3
|
+
Format: ``key: value`` header lines (``run:``, ``engine_build:``, ``task:``,
|
|
4
|
+
``roles:``, ``result:``) followed by one ``step:`` line per logged step. The
|
|
5
|
+
adapter is the only place that knows this format (ADR-0002); a run log never
|
|
6
|
+
yields a task signature — it is one episode, not a measurement.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
import stat
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from debrief.domain import FAIL, OUTCOMES, PASS, Finding
|
|
18
|
+
from debrief.errors import RUN_SOURCE_MISSING, SOURCE_UNPARSEABLE, DebriefError
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# A named tuple, not an inline `except (A, B):`: ruff format (target py314) rewrites
|
|
23
|
+
# the inline spelling towards the PEP 758 bare-comma form, which this repo's review
|
|
24
|
+
# rejected; the named tuple keeps one spelling that is valid on every Python 3.
|
|
25
|
+
_MISSING_PATH_ERRORS = (FileNotFoundError, NotADirectoryError)
|
|
26
|
+
|
|
27
|
+
_STEP_RE = re.compile(
|
|
28
|
+
r"^step:\s*(?P<ordinal>\d+)\s+"
|
|
29
|
+
r"role=(?P<role>\S+)\s+"
|
|
30
|
+
r"model=(?P<model>\S+)\s+"
|
|
31
|
+
r"outcome=(?P<outcome>\S+)\s+"
|
|
32
|
+
r'reason="(?P<reason>[^"]*)"\s+'
|
|
33
|
+
r"evidence=(?P<evidence>\S+)$"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_RESULT_RE = re.compile(r"^(?P<outcome>\S+)(?:\s+failed_steps=(?P<failed_steps>\S*))?$")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _Unparseable(Exception):
|
|
40
|
+
"""A run log did not match the buildwright log format."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class _Step:
|
|
45
|
+
"""One logged step before it becomes a Finding; the ordinal exists only
|
|
46
|
+
here because the ``result:`` header names failed steps by it, and a
|
|
47
|
+
Finding never carries run-internal bookkeeping (ADR-0002)."""
|
|
48
|
+
|
|
49
|
+
ordinal: str
|
|
50
|
+
role: str
|
|
51
|
+
model: str
|
|
52
|
+
outcome: str
|
|
53
|
+
reason: str
|
|
54
|
+
evidence: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class BuildwrightLog:
|
|
58
|
+
"""RunLog over one buildwright run log; opens the file for reading only (R2, R4)."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, log_file: Path) -> None:
|
|
61
|
+
self._log_file = log_file
|
|
62
|
+
|
|
63
|
+
def findings(self) -> list[Finding] | DebriefError:
|
|
64
|
+
# stat()-based probe, mirroring the bench adapter: pathlib's is_file()
|
|
65
|
+
# answers False for a log whose parent directory is unreadable, which
|
|
66
|
+
# would mislabel an existing-but-unreadable log as missing. An evidence
|
|
67
|
+
# tool never silently drops a source it was asked to read.
|
|
68
|
+
try:
|
|
69
|
+
st = self._log_file.stat()
|
|
70
|
+
except _MISSING_PATH_ERRORS:
|
|
71
|
+
return DebriefError(
|
|
72
|
+
code=RUN_SOURCE_MISSING,
|
|
73
|
+
message="no buildwright run log at the requested path",
|
|
74
|
+
detail=str(self._log_file),
|
|
75
|
+
)
|
|
76
|
+
except PermissionError as err:
|
|
77
|
+
return DebriefError(
|
|
78
|
+
code=SOURCE_UNPARSEABLE,
|
|
79
|
+
message=f"{self._log_file} exists but cannot be probed",
|
|
80
|
+
detail=str(err),
|
|
81
|
+
)
|
|
82
|
+
if not stat.S_ISREG(st.st_mode):
|
|
83
|
+
return DebriefError(
|
|
84
|
+
code=RUN_SOURCE_MISSING,
|
|
85
|
+
message="no buildwright run log at the requested path",
|
|
86
|
+
detail=str(self._log_file),
|
|
87
|
+
)
|
|
88
|
+
try:
|
|
89
|
+
lines = self._log_file.read_text(encoding="utf-8").splitlines()
|
|
90
|
+
except (OSError, ValueError) as err:
|
|
91
|
+
# ValueError covers undecodable bytes (UnicodeDecodeError), not just I/O errors.
|
|
92
|
+
return DebriefError(
|
|
93
|
+
code=SOURCE_UNPARSEABLE,
|
|
94
|
+
message=f"{self._log_file} could not be read",
|
|
95
|
+
detail=str(err),
|
|
96
|
+
)
|
|
97
|
+
try:
|
|
98
|
+
findings = _map_log(lines)
|
|
99
|
+
except _Unparseable as err:
|
|
100
|
+
return DebriefError(
|
|
101
|
+
code=SOURCE_UNPARSEABLE,
|
|
102
|
+
message=f"{self._log_file} is not a buildwright run log",
|
|
103
|
+
detail=str(err),
|
|
104
|
+
)
|
|
105
|
+
log.info("read %d step(s) from %s", len(findings), self._log_file.name)
|
|
106
|
+
return findings
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _map_log(lines: list[str]) -> list[Finding]:
|
|
110
|
+
task = _header(lines, "task")
|
|
111
|
+
steps = [_parse_step(line) for line in lines if line.startswith("step:")]
|
|
112
|
+
if not steps:
|
|
113
|
+
# A headers-only log (truncated or step-stripped) would otherwise answer
|
|
114
|
+
# [] as a structured success and flip routing.propose to the all-passed
|
|
115
|
+
# claim even when the log's own result: header declares a failure — the
|
|
116
|
+
# same fabrication class the outcome-enum check guards. An evidence tool
|
|
117
|
+
# never silently accepts an episode with no logged steps.
|
|
118
|
+
raise _Unparseable("run log has a task header but no step: lines")
|
|
119
|
+
_check_result_header(_optional_header(lines, "result"), steps)
|
|
120
|
+
return [_finding(step, task) for step in steps]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _header(lines: list[str], key: str) -> str:
|
|
124
|
+
prefix = f"{key}:"
|
|
125
|
+
for line in lines:
|
|
126
|
+
if line.startswith(prefix):
|
|
127
|
+
value = line[len(prefix) :].strip()
|
|
128
|
+
if value:
|
|
129
|
+
return value
|
|
130
|
+
raise _Unparseable(f"no {key}: header line")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _optional_header(lines: list[str], key: str) -> str | None:
|
|
134
|
+
prefix = f"{key}:"
|
|
135
|
+
for line in lines:
|
|
136
|
+
if line.startswith(prefix):
|
|
137
|
+
value = line[len(prefix) :].strip()
|
|
138
|
+
return value or None
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _parse_step(line: str) -> _Step:
|
|
143
|
+
match = _STEP_RE.match(line)
|
|
144
|
+
if match is None:
|
|
145
|
+
raise _Unparseable(f"malformed step line: {line!r}")
|
|
146
|
+
outcome = match.group("outcome")
|
|
147
|
+
if outcome not in OUTCOMES:
|
|
148
|
+
# An unvalidated token would flip _propose to "every logged step passed"
|
|
149
|
+
# for a failed run, so the enum is checked at this boundary (conventions:
|
|
150
|
+
# validate at the boundary, trust types inside).
|
|
151
|
+
raise _Unparseable(f"outcome is not one of {sorted(OUTCOMES)}: {outcome!r}")
|
|
152
|
+
return _Step(
|
|
153
|
+
ordinal=match.group("ordinal"),
|
|
154
|
+
role=match.group("role"),
|
|
155
|
+
model=match.group("model"),
|
|
156
|
+
outcome=outcome,
|
|
157
|
+
reason=match.group("reason"),
|
|
158
|
+
evidence=match.group("evidence"),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _finding(step: _Step, task: str) -> Finding:
|
|
163
|
+
return Finding(
|
|
164
|
+
model=step.model,
|
|
165
|
+
task=task,
|
|
166
|
+
outcome=step.outcome,
|
|
167
|
+
reason=step.reason,
|
|
168
|
+
evidence=step.evidence,
|
|
169
|
+
role=step.role,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _check_result_header(result_line: str | None, steps: list[_Step]) -> None:
|
|
174
|
+
"""A log's ``result:`` header is evidence too: when present it must not
|
|
175
|
+
contradict the steps it summarizes. Left unchecked, ``result: pass`` over a
|
|
176
|
+
logged failure (or a failed_steps list naming steps that passed) would let
|
|
177
|
+
run.inspect answer a success the log itself calls a lie, and
|
|
178
|
+
routing.propose the all-passed claim for a failed episode. The header stays
|
|
179
|
+
optional — producers older than the failed_steps convention omit it — and
|
|
180
|
+
the check runs only once every step line has parsed."""
|
|
181
|
+
if result_line is None:
|
|
182
|
+
return
|
|
183
|
+
match = _RESULT_RE.fullmatch(result_line)
|
|
184
|
+
if match is None:
|
|
185
|
+
raise _Unparseable(f"malformed result: header: {result_line!r}")
|
|
186
|
+
outcome = match.group("outcome")
|
|
187
|
+
if outcome not in OUTCOMES:
|
|
188
|
+
raise _Unparseable(f"result header outcome is not one of {sorted(OUTCOMES)}: {outcome!r}")
|
|
189
|
+
failed = [step for step in steps if step.outcome == FAIL]
|
|
190
|
+
if outcome == PASS:
|
|
191
|
+
if failed:
|
|
192
|
+
raise _Unparseable(
|
|
193
|
+
f"result header declares pass but logged step {failed[0].ordinal} failed"
|
|
194
|
+
)
|
|
195
|
+
return
|
|
196
|
+
declared = _declared_failed_steps(match.group("failed_steps"))
|
|
197
|
+
if not failed:
|
|
198
|
+
raise _Unparseable("result header declares a failure no logged step records")
|
|
199
|
+
logged = {step.ordinal.lstrip("0") or "0" for step in failed}
|
|
200
|
+
if declared != logged:
|
|
201
|
+
raise _Unparseable(
|
|
202
|
+
f"result header failed_steps={match.group('failed_steps')!r} does not match"
|
|
203
|
+
f" the logged failing step(s) {sorted(logged)}"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _declared_failed_steps(token: str | None) -> set[str]:
|
|
208
|
+
if token is None:
|
|
209
|
+
raise _Unparseable("result header declares a failure without a failed_steps list")
|
|
210
|
+
declared = {part.strip().lstrip("0") or "0" for part in token.split(",") if part.strip()}
|
|
211
|
+
if not declared:
|
|
212
|
+
raise _Unparseable(f"result header failed_steps is empty: {token!r}")
|
|
213
|
+
return declared
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""ggufbench artifacts adapter: read-only mapping of artifact rows to Findings (R3).
|
|
2
|
+
|
|
3
|
+
Layout: one ``<run-id>/results.json`` per ggufbench run under the artifact root.
|
|
4
|
+
The adapter is the only place that knows this format (ADR-0002).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import math
|
|
12
|
+
import re
|
|
13
|
+
import stat
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from debrief.domain import OUTCOMES, Finding, TaskSignature
|
|
18
|
+
from debrief.errors import BENCH_SOURCE_MISSING, SOURCE_UNPARSEABLE, DebriefError
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
RESULTS_FILE = "results.json"
|
|
23
|
+
|
|
24
|
+
# A named tuple, not an inline `except (A, B):`: ruff format (target py314) rewrites
|
|
25
|
+
# the inline spelling towards the PEP 758 bare-comma form, which this repo's review
|
|
26
|
+
# rejected; the named tuple keeps one spelling that is valid on every Python 3.
|
|
27
|
+
_MISSING_PATH_ERRORS = (FileNotFoundError, NotADirectoryError)
|
|
28
|
+
|
|
29
|
+
# Artifact string fields reach the routing.propose text verbatim, so a newline
|
|
30
|
+
# in a poisoned row could forge arbitrary proposal diff lines (ADR-0004
|
|
31
|
+
# fabrication class). Identifier-like fields must be single-line whitespace-free
|
|
32
|
+
# tokens; free-text fields single-line control-char-free — both validated here,
|
|
33
|
+
# at the boundary, per conventions.
|
|
34
|
+
_TOKEN_RE = re.compile(r"\S+")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _Unparseable(Exception):
|
|
38
|
+
"""An artifact did not match the ggufbench results schema."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class GgufbenchStore:
|
|
42
|
+
"""BenchStore over ggufbench artifacts; opens files for reading only (R2, R3)."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, artifact_root: Path) -> None:
|
|
45
|
+
self._artifact_root = artifact_root
|
|
46
|
+
|
|
47
|
+
def findings(self) -> list[Finding] | DebriefError:
|
|
48
|
+
try:
|
|
49
|
+
results_files = self._results_files()
|
|
50
|
+
except OSError as err:
|
|
51
|
+
return DebriefError(
|
|
52
|
+
code=SOURCE_UNPARSEABLE,
|
|
53
|
+
message=f"{self._artifact_root} could not be scanned for {RESULTS_FILE}",
|
|
54
|
+
detail=str(err),
|
|
55
|
+
)
|
|
56
|
+
except _Unparseable as err:
|
|
57
|
+
# A symlink below the requested path must not turn the store into a
|
|
58
|
+
# read primitive for files outside the evidence root (conventions:
|
|
59
|
+
# resolve against an explicit allowed root — security-audit A03).
|
|
60
|
+
return DebriefError(
|
|
61
|
+
code=SOURCE_UNPARSEABLE,
|
|
62
|
+
message=f"a run's {RESULTS_FILE} escapes the ggufbench artifact root",
|
|
63
|
+
detail=str(err),
|
|
64
|
+
)
|
|
65
|
+
if results_files is None:
|
|
66
|
+
return DebriefError(
|
|
67
|
+
code=BENCH_SOURCE_MISSING,
|
|
68
|
+
message="no ggufbench artifacts at the requested path",
|
|
69
|
+
detail=str(self._artifact_root),
|
|
70
|
+
)
|
|
71
|
+
if not results_files:
|
|
72
|
+
return DebriefError(
|
|
73
|
+
code=BENCH_SOURCE_MISSING,
|
|
74
|
+
message=f"no {RESULTS_FILE} under any run directory",
|
|
75
|
+
detail=str(self._artifact_root),
|
|
76
|
+
)
|
|
77
|
+
findings: list[Finding] = []
|
|
78
|
+
for results_file in results_files:
|
|
79
|
+
mapped = _read_run(results_file)
|
|
80
|
+
if isinstance(mapped, DebriefError):
|
|
81
|
+
return mapped
|
|
82
|
+
findings.extend(mapped)
|
|
83
|
+
log.info("read %d ggufbench row(s) from %d run(s)", len(findings), len(results_files))
|
|
84
|
+
return findings
|
|
85
|
+
|
|
86
|
+
def _results_files(self) -> list[Path] | None:
|
|
87
|
+
"""One results.json per run under the root; None when the root itself is absent.
|
|
88
|
+
|
|
89
|
+
Scans with stat(), not is_file(): pathlib's is_*() answer False for
|
|
90
|
+
unreadable directories, which would silently drop a run the tool must
|
|
91
|
+
report; stat() raises, and findings() maps that to a structured error.
|
|
92
|
+
Every candidate is resolved and required to stay under the resolved
|
|
93
|
+
root before anything is read, so symlink indirection below the
|
|
94
|
+
requested path cannot escape the evidence root (A03).
|
|
95
|
+
"""
|
|
96
|
+
root = self._artifact_root.resolve()
|
|
97
|
+
if not root.is_dir():
|
|
98
|
+
return None
|
|
99
|
+
own = _contained_results(root, root / RESULTS_FILE)
|
|
100
|
+
if own is not None:
|
|
101
|
+
return [own]
|
|
102
|
+
found: list[Path] = []
|
|
103
|
+
for path in root.iterdir():
|
|
104
|
+
contained = _contained_results(root, path / RESULTS_FILE)
|
|
105
|
+
if contained is not None:
|
|
106
|
+
found.append(contained)
|
|
107
|
+
return sorted(found)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _contained_results(root: Path, candidate: Path) -> Path | None:
|
|
111
|
+
"""Resolve `candidate` and return it only when it stays under `root` and is a file.
|
|
112
|
+
|
|
113
|
+
A `<run>/results.json` may be a symlink: without this check an artifact
|
|
114
|
+
could name any file on the host and its bytes would enter findings,
|
|
115
|
+
bypassing the root boundary every agent-controlled path respects.
|
|
116
|
+
An escaping candidate refuses the whole scan — an evidence tool never
|
|
117
|
+
silently drops (or silently reads past its boundary) a run directory.
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
resolved = candidate.resolve()
|
|
121
|
+
except OSError as err:
|
|
122
|
+
raise _Unparseable(f"{candidate} could not be resolved") from err
|
|
123
|
+
if not resolved.is_relative_to(root):
|
|
124
|
+
raise _Unparseable(f"{candidate} resolves outside the artifact root")
|
|
125
|
+
return resolved if _is_regular_file(resolved) else None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _is_regular_file(path: Path) -> bool:
|
|
129
|
+
try:
|
|
130
|
+
return stat.S_ISREG(path.stat().st_mode)
|
|
131
|
+
except _MISSING_PATH_ERRORS:
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _read_run(results_file: Path) -> list[Finding] | DebriefError:
|
|
136
|
+
try:
|
|
137
|
+
document: Any = json.loads(results_file.read_text(encoding="utf-8"))
|
|
138
|
+
except (OSError, ValueError, RecursionError) as err:
|
|
139
|
+
# ValueError covers JSONDecodeError and undecodable bytes; RecursionError
|
|
140
|
+
# covers nesting-bomb JSON, which no legitimate artifact carries.
|
|
141
|
+
return DebriefError(
|
|
142
|
+
code=SOURCE_UNPARSEABLE,
|
|
143
|
+
message=f"{results_file} could not be read as JSON",
|
|
144
|
+
detail=str(err),
|
|
145
|
+
)
|
|
146
|
+
try:
|
|
147
|
+
return _map_document(document)
|
|
148
|
+
except _Unparseable as err:
|
|
149
|
+
return DebriefError(
|
|
150
|
+
code=SOURCE_UNPARSEABLE,
|
|
151
|
+
message=f"{results_file} is not a ggufbench results document",
|
|
152
|
+
detail=str(err),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _map_document(document: Any) -> list[Finding]:
|
|
157
|
+
if not isinstance(document, dict):
|
|
158
|
+
raise _Unparseable("document is not an object")
|
|
159
|
+
engine_build = _required_token(document, "engine_build")
|
|
160
|
+
baseline = _required_token(document, "baseline")
|
|
161
|
+
rows = document.get("rows")
|
|
162
|
+
if not isinstance(rows, list):
|
|
163
|
+
raise _Unparseable("rows is not an array")
|
|
164
|
+
return [_map_row(row, baseline, engine_build) for row in rows]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _map_row(row: Any, baseline: str, engine_build: str) -> Finding:
|
|
168
|
+
if not isinstance(row, dict):
|
|
169
|
+
raise _Unparseable("row is not an object")
|
|
170
|
+
graded = _required_counts(row)
|
|
171
|
+
score = row.get("score")
|
|
172
|
+
if isinstance(score, bool) or not isinstance(score, (int, float)):
|
|
173
|
+
raise _Unparseable("score is not a number")
|
|
174
|
+
try:
|
|
175
|
+
measure = float(score)
|
|
176
|
+
except OverflowError as err:
|
|
177
|
+
# JSON integers are arbitrary-precision; a score too wide for a float is
|
|
178
|
+
# corrupt evidence, not a crash (R1: every answer is findings or a
|
|
179
|
+
# structured error).
|
|
180
|
+
raise _Unparseable(f"score overflows a float: {score}") from err
|
|
181
|
+
if not math.isfinite(measure):
|
|
182
|
+
# json.loads accepts the NaN/Infinity/-Infinity tokens; a non-finite
|
|
183
|
+
# measure would win the proposal ranking and fabricate a role change
|
|
184
|
+
# (the ADR-0004 fabrication class), so it refuses at this boundary.
|
|
185
|
+
raise _Unparseable(f"score is not a finite number: {measure!r}")
|
|
186
|
+
outcome = _required_str(row, "outcome")
|
|
187
|
+
if outcome not in OUTCOMES:
|
|
188
|
+
raise _Unparseable(f"outcome is not one of {sorted(OUTCOMES)}: {outcome!r}")
|
|
189
|
+
return Finding(
|
|
190
|
+
model=_required_token(row, "model"),
|
|
191
|
+
task=_required_token(row, "task"),
|
|
192
|
+
outcome=outcome,
|
|
193
|
+
reason=_required_line(row, "reason"),
|
|
194
|
+
evidence=_required_line(row, "artifact"),
|
|
195
|
+
signature=TaskSignature.of(graded, baseline, engine_build),
|
|
196
|
+
measure=measure,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _required_counts(row: dict[str, Any]) -> dict[str, int]:
|
|
201
|
+
graded = row.get("graded")
|
|
202
|
+
if not isinstance(graded, dict):
|
|
203
|
+
raise _Unparseable("graded is not an object of counts")
|
|
204
|
+
counts: dict[str, int] = {}
|
|
205
|
+
for dimension, count in graded.items():
|
|
206
|
+
if not isinstance(dimension, str) or isinstance(count, bool) or not isinstance(count, int):
|
|
207
|
+
raise _Unparseable("graded counts must map dimension names to integers")
|
|
208
|
+
if _TOKEN_RE.fullmatch(dimension) is None:
|
|
209
|
+
# Dimension names join the task signature and the proposal text; a
|
|
210
|
+
# multi-line name would forge proposal lines exactly like a model id.
|
|
211
|
+
raise _Unparseable(f"graded dimension is not a single-line token: {dimension!r}")
|
|
212
|
+
counts[dimension] = count
|
|
213
|
+
return counts
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _required_str(document: dict[str, Any], key: str) -> str:
|
|
217
|
+
value = document.get(key)
|
|
218
|
+
if not isinstance(value, str) or not value:
|
|
219
|
+
raise _Unparseable(f"{key} is not a non-empty string")
|
|
220
|
+
return value
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _required_token(document: dict[str, Any], key: str) -> str:
|
|
224
|
+
"""Identifier-like field: one non-empty, whitespace-free line (no newline forgery)."""
|
|
225
|
+
value = _required_str(document, key)
|
|
226
|
+
if _TOKEN_RE.fullmatch(value) is None:
|
|
227
|
+
raise _Unparseable(f"{key} is not a single-line whitespace-free token: {value!r}")
|
|
228
|
+
_refuse_control_chars(key, value)
|
|
229
|
+
return value
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _required_line(document: dict[str, Any], key: str) -> str:
|
|
233
|
+
"""Free-text field: one non-empty line with no control characters (incl. newlines).
|
|
234
|
+
|
|
235
|
+
The single-line rule is str.splitlines(), not just ``ord(ch) < 0x20``:
|
|
236
|
+
Unicode line separators (U+0085 NEL, U+2028, U+2029) sit above that range
|
|
237
|
+
but still split the value everywhere Python splits text, so a poisoned
|
|
238
|
+
reason or artifact path could smuggle a forged proposal line past a pure
|
|
239
|
+
control-character check.
|
|
240
|
+
"""
|
|
241
|
+
value = _required_str(document, key)
|
|
242
|
+
_refuse_control_chars(key, value)
|
|
243
|
+
if value.splitlines() != [value]:
|
|
244
|
+
raise _Unparseable(f"{key} is not a single line: {value!r}")
|
|
245
|
+
return value
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _refuse_control_chars(key: str, value: str) -> None:
|
|
249
|
+
if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in value):
|
|
250
|
+
raise _Unparseable(f"{key} contains control characters: {value!r}")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Domain: the shared currency of both ports and the comparability rule (ADR-0002, ADR-0004).
|
|
2
|
+
|
|
3
|
+
Imports nothing outward — no ggufbench, buildwright, mcp or filesystem here.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from debrief.errors import INCOMPARABLE_ROWS, DebriefError
|
|
12
|
+
|
|
13
|
+
PASS = "pass"
|
|
14
|
+
FAIL = "fail"
|
|
15
|
+
OUTCOMES = frozenset({PASS, FAIL})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class TaskSignature:
|
|
20
|
+
"""Graded counts per dimension plus the baseline and engine build (ADR-0004).
|
|
21
|
+
|
|
22
|
+
ggufbench derives it from the data because version labels have lied before;
|
|
23
|
+
two rows compare only inside one signature.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
graded_counts: tuple[tuple[str, int], ...]
|
|
27
|
+
baseline: str
|
|
28
|
+
engine_build: str
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def of(cls, graded: Mapping[str, int], baseline: str, engine_build: str) -> TaskSignature:
|
|
32
|
+
return cls(tuple(sorted(graded.items())), baseline, engine_build)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def key(self) -> str:
|
|
36
|
+
graded = ",".join(f"{dimension}:{count}" for dimension, count in self.graded_counts)
|
|
37
|
+
return f"{graded}@{self.baseline}@{self.engine_build}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Finding:
|
|
42
|
+
"""The shared currency: model, task, outcome, reason, evidence pointer (ADR-0002).
|
|
43
|
+
|
|
44
|
+
`signature`, `measure` and `role` carry what one port knows and the other
|
|
45
|
+
does not: bench rows carry a signature and a score, run steps carry the
|
|
46
|
+
buildwright role. Neither port fills the other's fields.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
model: str
|
|
50
|
+
task: str
|
|
51
|
+
outcome: str
|
|
52
|
+
reason: str
|
|
53
|
+
evidence: str
|
|
54
|
+
signature: TaskSignature | None = None
|
|
55
|
+
measure: float | None = None
|
|
56
|
+
role: str | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def shared_signature(rows: Sequence[Finding]) -> TaskSignature | DebriefError:
|
|
60
|
+
"""The one task signature covering every row, or a refusal (ADR-0004).
|
|
61
|
+
|
|
62
|
+
Rows that do not share a signature answer different questions, so the
|
|
63
|
+
caller gets `incomparable_rows` with the groups named — never an average.
|
|
64
|
+
"""
|
|
65
|
+
signatures = {row.signature for row in rows if row.signature is not None}
|
|
66
|
+
if len(signatures) > 1:
|
|
67
|
+
listed = ", ".join(sorted(signature.key for signature in signatures))
|
|
68
|
+
return DebriefError(
|
|
69
|
+
code=INCOMPARABLE_ROWS,
|
|
70
|
+
message="rows span more than one task signature; refusing to rank or average",
|
|
71
|
+
detail=f"{len(signatures)} signatures: {listed}",
|
|
72
|
+
)
|
|
73
|
+
if not signatures:
|
|
74
|
+
return DebriefError(
|
|
75
|
+
code=INCOMPARABLE_ROWS,
|
|
76
|
+
message="no row records a task signature, so comparability cannot be established",
|
|
77
|
+
detail=f"{len(rows)} row(s): " + ", ".join(f"{row.model}/{row.task}" for row in rows),
|
|
78
|
+
)
|
|
79
|
+
return signatures.pop()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Structured errors: every debrief answer is findings or one of these (R1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
BENCH_SOURCE_MISSING = "bench_source_missing"
|
|
8
|
+
RUN_SOURCE_MISSING = "run_source_missing"
|
|
9
|
+
SOURCE_UNPARSEABLE = "source_unparseable"
|
|
10
|
+
INCOMPARABLE_ROWS = "incomparable_rows"
|
|
11
|
+
PATH_OUTSIDE_ROOT = "path_outside_root"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class DebriefError:
|
|
16
|
+
"""A refusal a tool returns instead of findings.
|
|
17
|
+
|
|
18
|
+
Returned, never raised, so the transport adapter can always render a typed
|
|
19
|
+
response; `code` is the stable contract an agent branches on.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
code: str
|
|
23
|
+
message: str
|
|
24
|
+
detail: str
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""MCP stdio adapter: three tools that answer from receipts and never write (R1, R2).
|
|
2
|
+
|
|
3
|
+
Tool names carry the port (bench.*, run.*, routing.propose) so an agent cannot
|
|
4
|
+
confuse what was measured with what happened in one run (ADR-0002).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import importlib.metadata
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import signal
|
|
13
|
+
import sys
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from mcp.server.mcpserver import MCPServer
|
|
18
|
+
|
|
19
|
+
from debrief.adapters.buildwright_log import BuildwrightLog
|
|
20
|
+
from debrief.adapters.ggufbench_store import GgufbenchStore
|
|
21
|
+
from debrief.domain import Finding
|
|
22
|
+
from debrief.errors import PATH_OUTSIDE_ROOT, DebriefError
|
|
23
|
+
from debrief.use_cases import InspectRun, ProposeRole, QueryBench
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
BENCH_ROOT_ENV = "DEBRIEF_BENCH_ROOT"
|
|
28
|
+
RUN_ROOT_ENV = "DEBRIEF_RUN_ROOT"
|
|
29
|
+
DEFAULT_BENCH_ROOT = "artifacts/ggufbench"
|
|
30
|
+
DEFAULT_RUN_ROOT = "artifacts/buildwright"
|
|
31
|
+
|
|
32
|
+
_HELP = f"""\
|
|
33
|
+
debrief-mcp {importlib.metadata.version("debrief-mcp")} — answer agent questions from receipts
|
|
34
|
+
MCP stdio server, read-only: every answer is findings or a structured error; never writes.
|
|
35
|
+
|
|
36
|
+
usage: debrief [-h | --help] [--version]
|
|
37
|
+
|
|
38
|
+
tools (MCP stdio, JSON-RPC over stdin/stdout; logs go to stderr):
|
|
39
|
+
|
|
40
|
+
bench.query What did ggufbench measure? Findings from ggufbench artifacts.
|
|
41
|
+
arg: path — run directory (or the artifact root itself) relative
|
|
42
|
+
to the bench root.
|
|
43
|
+
run.inspect Where did a buildwright run break? Findings from one run log.
|
|
44
|
+
arg: path — the log file relative to the run root.
|
|
45
|
+
routing.propose A BW_ROLE_* proposal as text citing both ports' evidence.
|
|
46
|
+
args: bench_path (ggufbench artifacts dir), run_path (run log).
|
|
47
|
+
Text only; never applied (ADR-0001), targets roles, never
|
|
48
|
+
models.yaml (ADR-0003); incomparable rows are refused, never
|
|
49
|
+
averaged (ADR-0004).
|
|
50
|
+
|
|
51
|
+
environment:
|
|
52
|
+
|
|
53
|
+
DEBRIEF_BENCH_ROOT allowed root for ggufbench artifacts
|
|
54
|
+
(default: {DEFAULT_BENCH_ROOT})
|
|
55
|
+
DEBRIEF_RUN_ROOT allowed root for buildwright run logs
|
|
56
|
+
(default: {DEFAULT_RUN_ROOT})
|
|
57
|
+
DEBRIEF_LOG_LEVEL stderr logging level (default: INFO)
|
|
58
|
+
|
|
59
|
+
options:
|
|
60
|
+
|
|
61
|
+
-h, --help show this help and exit
|
|
62
|
+
--version show the installed debrief version and exit
|
|
63
|
+
|
|
64
|
+
exit codes:
|
|
65
|
+
|
|
66
|
+
0 normal (help/version, or the client closed the session)
|
|
67
|
+
2 unknown argument
|
|
68
|
+
130 interrupted (SIGINT)
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
server = MCPServer(
|
|
72
|
+
"debrief",
|
|
73
|
+
instructions=(
|
|
74
|
+
"debrief answers agent questions from receipts: bench.query (ggufbench artifacts), "
|
|
75
|
+
"run.inspect (one buildwright run log), routing.propose (a BW_ROLE_* diff as text). "
|
|
76
|
+
"It never writes; every answer is findings or a structured error."
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class FindingsResponse:
|
|
83
|
+
"""A bench.* / run.* answer: findings, or a structured error and no findings (R1)."""
|
|
84
|
+
|
|
85
|
+
findings: list[Finding] = field(default_factory=list)
|
|
86
|
+
error: DebriefError | None = None
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def of(cls, answer: list[Finding] | DebriefError) -> FindingsResponse:
|
|
90
|
+
if isinstance(answer, DebriefError):
|
|
91
|
+
return cls(error=answer)
|
|
92
|
+
return cls(findings=list(answer))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class ProposalResponse:
|
|
97
|
+
"""The routing.propose answer: a BW_ROLE_* diff as text, or a structured error (R1, R5)."""
|
|
98
|
+
|
|
99
|
+
proposal: str = ""
|
|
100
|
+
error: DebriefError | None = None
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def of(cls, answer: str | DebriefError) -> ProposalResponse:
|
|
104
|
+
if isinstance(answer, DebriefError):
|
|
105
|
+
return cls(error=answer)
|
|
106
|
+
return cls(proposal=answer)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@server.tool(
|
|
110
|
+
name="bench.query",
|
|
111
|
+
title="What did ggufbench measure?",
|
|
112
|
+
description=(
|
|
113
|
+
"Findings from ggufbench artifacts. `path` is a run directory (or the artifact root "
|
|
114
|
+
"itself) relative to DEBRIEF_BENCH_ROOT. Read-only."
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
def bench_query(path: str) -> FindingsResponse:
|
|
118
|
+
resolved = _resolve_within(BENCH_ROOT_ENV, DEFAULT_BENCH_ROOT, path)
|
|
119
|
+
if isinstance(resolved, DebriefError):
|
|
120
|
+
return FindingsResponse(error=resolved)
|
|
121
|
+
return FindingsResponse.of(QueryBench(GgufbenchStore(resolved)).run())
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@server.tool(
|
|
125
|
+
name="run.inspect",
|
|
126
|
+
title="Where did this run break?",
|
|
127
|
+
description=(
|
|
128
|
+
"Findings from one buildwright run log. `path` is the log file relative to "
|
|
129
|
+
"DEBRIEF_RUN_ROOT. Read-only."
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
def run_inspect(path: str) -> FindingsResponse:
|
|
133
|
+
resolved = _resolve_within(RUN_ROOT_ENV, DEFAULT_RUN_ROOT, path)
|
|
134
|
+
if isinstance(resolved, DebriefError):
|
|
135
|
+
return FindingsResponse(error=resolved)
|
|
136
|
+
return FindingsResponse.of(InspectRun(BuildwrightLog(resolved)).run())
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@server.tool(
|
|
140
|
+
name="routing.propose",
|
|
141
|
+
title="What would you change?",
|
|
142
|
+
description=(
|
|
143
|
+
"A BW_ROLE_* proposal as text, citing bench.query and run.inspect evidence. "
|
|
144
|
+
"`bench_path` is a ggufbench artifact directory under DEBRIEF_BENCH_ROOT, `run_path` "
|
|
145
|
+
"a buildwright log under DEBRIEF_RUN_ROOT. Returns text; never writes."
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
def routing_propose(bench_path: str, run_path: str) -> ProposalResponse:
|
|
149
|
+
bench_dir = _resolve_within(BENCH_ROOT_ENV, DEFAULT_BENCH_ROOT, bench_path)
|
|
150
|
+
if isinstance(bench_dir, DebriefError):
|
|
151
|
+
return ProposalResponse.of(bench_dir)
|
|
152
|
+
run_file = _resolve_within(RUN_ROOT_ENV, DEFAULT_RUN_ROOT, run_path)
|
|
153
|
+
if isinstance(run_file, DebriefError):
|
|
154
|
+
return ProposalResponse.of(run_file)
|
|
155
|
+
propose = ProposeRole(
|
|
156
|
+
QueryBench(GgufbenchStore(bench_dir)), InspectRun(BuildwrightLog(run_file))
|
|
157
|
+
)
|
|
158
|
+
return ProposalResponse.of(propose.run())
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _resolve_within(root_env: str, default_root: str, requested: str) -> Path | DebriefError:
|
|
162
|
+
"""Resolve `requested` against the allowed root; refuse anything that escapes it (A03)."""
|
|
163
|
+
root = Path(os.environ.get(root_env, default_root)).resolve()
|
|
164
|
+
try:
|
|
165
|
+
candidate = (root / requested).resolve() if requested else root
|
|
166
|
+
except (ValueError, OSError) as err:
|
|
167
|
+
# ValueError: embedded NUL bytes survive Path construction and only
|
|
168
|
+
# explode when os calls lstat under resolve(); refuse, never crash.
|
|
169
|
+
return DebriefError(
|
|
170
|
+
code=PATH_OUTSIDE_ROOT,
|
|
171
|
+
message="requested path could not be resolved within the allowed evidence root",
|
|
172
|
+
detail=f"{requested!r}: {err}",
|
|
173
|
+
)
|
|
174
|
+
if not candidate.is_relative_to(root):
|
|
175
|
+
return DebriefError(
|
|
176
|
+
code=PATH_OUTSIDE_ROOT,
|
|
177
|
+
message="requested path is outside the allowed evidence root",
|
|
178
|
+
detail=f"{requested!r} resolves outside {root}",
|
|
179
|
+
)
|
|
180
|
+
return candidate
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _on_sigint(signum: int, frame: object) -> None:
|
|
184
|
+
# Ctrl+C on a stdio server is a normal shutdown, not a crash. Exit
|
|
185
|
+
# immediately with the conventional SIGINT code: os._exit skips the
|
|
186
|
+
# interpreter shutdown, where a still-running SDK thread would otherwise
|
|
187
|
+
# turn a second Ctrl+C into an "Exception ignored" traceback.
|
|
188
|
+
log.info("debrief MCP server stopping (SIGINT)")
|
|
189
|
+
sys.stderr.flush()
|
|
190
|
+
os._exit(130)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def main() -> None:
|
|
194
|
+
args = sys.argv[1:]
|
|
195
|
+
if "-h" in args or "--help" in args:
|
|
196
|
+
print(_HELP, end="")
|
|
197
|
+
return
|
|
198
|
+
if "--version" in args:
|
|
199
|
+
print(f"debrief-mcp {importlib.metadata.version('debrief-mcp')}")
|
|
200
|
+
return
|
|
201
|
+
if args:
|
|
202
|
+
# The server takes no positional input: anything else is a typo that
|
|
203
|
+
# should fail fast instead of silently starting a stdio session that
|
|
204
|
+
# would then hang waiting for JSON-RPC on the terminal.
|
|
205
|
+
print(
|
|
206
|
+
f"debrief: unrecognized argument(s): {' '.join(args)}\ntry 'debrief --help'",
|
|
207
|
+
file=sys.stderr,
|
|
208
|
+
)
|
|
209
|
+
raise SystemExit(2)
|
|
210
|
+
|
|
211
|
+
logging.basicConfig(
|
|
212
|
+
level=os.environ.get("DEBRIEF_LOG_LEVEL", "INFO"),
|
|
213
|
+
stream=sys.stderr,
|
|
214
|
+
format="%(levelname)s %(name)s %(message)s",
|
|
215
|
+
)
|
|
216
|
+
log.info("debrief MCP server starting on stdio (read-only)")
|
|
217
|
+
signal.signal(signal.SIGINT, _on_sigint)
|
|
218
|
+
server.run()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Ports the domain drives: two independent evidence sources (ADR-0002)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
from debrief.domain import Finding
|
|
8
|
+
from debrief.errors import DebriefError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BenchStore(Protocol):
|
|
12
|
+
"""What ggufbench measured (R3): the only bench source debrief reads."""
|
|
13
|
+
|
|
14
|
+
def findings(self) -> list[Finding] | DebriefError: ...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RunLog(Protocol):
|
|
18
|
+
"""One buildwright run (R4): the only run source debrief reads."""
|
|
19
|
+
|
|
20
|
+
def findings(self) -> list[Finding] | DebriefError: ...
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Driving use cases: one per agent question (QueryBench S1, InspectRun S2, ProposeRole S3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from debrief.domain import FAIL, PASS, Finding, shared_signature
|
|
8
|
+
from debrief.errors import DebriefError
|
|
9
|
+
from debrief.ports import BenchStore, RunLog
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class QueryBench:
|
|
15
|
+
""" "What did ggufbench measure?" — answered only from a BenchStore (R3)."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, store: BenchStore) -> None:
|
|
18
|
+
self._store = store
|
|
19
|
+
|
|
20
|
+
def run(self) -> list[Finding] | DebriefError:
|
|
21
|
+
answer = self._store.findings()
|
|
22
|
+
if isinstance(answer, DebriefError):
|
|
23
|
+
log.warning("bench query refused: %s", answer.code)
|
|
24
|
+
return answer
|
|
25
|
+
log.info("bench query answered from receipts: %d findings", len(answer))
|
|
26
|
+
return answer
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class InspectRun:
|
|
30
|
+
""" "Where did this run break?" — answered only from a RunLog (R4)."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, run_log: RunLog) -> None:
|
|
33
|
+
self._run_log = run_log
|
|
34
|
+
|
|
35
|
+
def run(self) -> list[Finding] | DebriefError:
|
|
36
|
+
answer = self._run_log.findings()
|
|
37
|
+
if isinstance(answer, DebriefError):
|
|
38
|
+
log.warning("run inspection refused: %s", answer.code)
|
|
39
|
+
return answer
|
|
40
|
+
log.info("run inspection answered from receipts: %d findings", len(answer))
|
|
41
|
+
return answer
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ProposeRole:
|
|
45
|
+
""" "What would you change?" — a BW_ROLE_* diff as text (R5, ADR-0003).
|
|
46
|
+
|
|
47
|
+
The only use case allowed to read findings from both ports (ADR-0002);
|
|
48
|
+
it renders text and writes nothing (ADR-0001).
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, bench: QueryBench, run: InspectRun) -> None:
|
|
52
|
+
self._bench = bench
|
|
53
|
+
self._run = run
|
|
54
|
+
|
|
55
|
+
def run(self) -> str | DebriefError:
|
|
56
|
+
bench_answer = self._bench.run()
|
|
57
|
+
if isinstance(bench_answer, DebriefError):
|
|
58
|
+
return bench_answer
|
|
59
|
+
run_answer = self._run.run()
|
|
60
|
+
if isinstance(run_answer, DebriefError):
|
|
61
|
+
return run_answer
|
|
62
|
+
return _propose(bench_answer, run_answer)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _propose(bench_rows: list[Finding], run_rows: list[Finding]) -> str | DebriefError:
|
|
66
|
+
failures = [row for row in run_rows if row.outcome == FAIL]
|
|
67
|
+
if not failures:
|
|
68
|
+
return "no proposal: every logged step passed"
|
|
69
|
+
lines: list[str] = []
|
|
70
|
+
proposed_roles: set[str] = set()
|
|
71
|
+
for failure in failures:
|
|
72
|
+
role = failure.role
|
|
73
|
+
if role is None:
|
|
74
|
+
lines.append(
|
|
75
|
+
f"note: step on {failure.task} recorded no role, nothing to propose"
|
|
76
|
+
f" ({failure.evidence})"
|
|
77
|
+
)
|
|
78
|
+
continue
|
|
79
|
+
canonical = _canonical_role(role)
|
|
80
|
+
if canonical in proposed_roles:
|
|
81
|
+
continue
|
|
82
|
+
proposal = _role_proposal(canonical, failure, bench_rows)
|
|
83
|
+
if isinstance(proposal, DebriefError):
|
|
84
|
+
return proposal
|
|
85
|
+
lines.extend(proposal)
|
|
86
|
+
proposed_roles.add(canonical)
|
|
87
|
+
return "\n".join(lines)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _canonical_role(role: str) -> str:
|
|
91
|
+
"""One env name per role: step lines record the env-var name as often as the
|
|
92
|
+
bare role, and a token that already carries the canonical prefix would
|
|
93
|
+
otherwise render a `BW_ROLE_BW_ROLE_*` diff nobody can paste into the
|
|
94
|
+
buildwright env."""
|
|
95
|
+
return role.upper().removeprefix("BW_ROLE_")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _role_proposal(
|
|
99
|
+
role: str, failure: Finding, bench_rows: list[Finding]
|
|
100
|
+
) -> list[str] | DebriefError:
|
|
101
|
+
role_env = f"BW_ROLE_{role.upper()}"
|
|
102
|
+
cohort = [row for row in bench_rows if row.task == failure.task]
|
|
103
|
+
if not cohort:
|
|
104
|
+
return [
|
|
105
|
+
f"note: ggufbench never measured {failure.task}, keeping {role_env}={failure.model}"
|
|
106
|
+
]
|
|
107
|
+
signature = shared_signature(cohort)
|
|
108
|
+
if isinstance(signature, DebriefError):
|
|
109
|
+
return signature
|
|
110
|
+
candidates = [
|
|
111
|
+
row
|
|
112
|
+
for row in cohort
|
|
113
|
+
if row.model != failure.model and row.outcome == PASS and row.measure is not None
|
|
114
|
+
]
|
|
115
|
+
if not candidates:
|
|
116
|
+
return [
|
|
117
|
+
f"note: no passing model for {failure.task} in the bench cohort,"
|
|
118
|
+
f" keeping {role_env}={failure.model}"
|
|
119
|
+
]
|
|
120
|
+
winner = sorted(candidates, key=lambda row: (-(row.measure or 0.0), row.model))[0]
|
|
121
|
+
return [
|
|
122
|
+
f"proposal: {role_env} holds {failure.model}, which failed task {failure.task}",
|
|
123
|
+
f"- {role_env}={failure.model}",
|
|
124
|
+
f"+ {role_env}={winner.model}",
|
|
125
|
+
f"why run: {failure.model} failed {failure.task} ({failure.reason}) — {failure.evidence}",
|
|
126
|
+
f"why bench: {winner.model} passed {failure.task} at score {winner.measure:.2f}"
|
|
127
|
+
f" ({winner.reason}) — {winner.evidence}",
|
|
128
|
+
f"signature: {signature.key}",
|
|
129
|
+
"not applied: debrief never writes; paste the + line into the buildwright role env"
|
|
130
|
+
" (ADR-0001). Proposals target roles, never models.yaml (ADR-0003).",
|
|
131
|
+
]
|