Spaces:
Running
Running
traceforge: pipeline behind a Gradio Space, keyed by content hash
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- BUILD.json +7 -0
- README.md +115 -6
- app.py +24 -0
- data/dataset/dataset_card.json +167 -0
- data/space/reference_census.json +23 -0
- guidelines/IDEAL_TRACE_GUIDELINES.md +230 -0
- requirements.txt +3 -0
- traceforge/__init__.py +16 -0
- traceforge/__pycache__/__init__.cpython-314.pyc +0 -0
- traceforge/__pycache__/config.cpython-314.pyc +0 -0
- traceforge/__pycache__/trace.cpython-314.pyc +0 -0
- traceforge/audit.py +272 -0
- traceforge/benchmark.py +811 -0
- traceforge/chunker.py +90 -0
- traceforge/classify.py +394 -0
- traceforge/cli.py +0 -0
- traceforge/config.py +276 -0
- traceforge/defects.py +186 -0
- traceforge/difficulty.py +66 -0
- traceforge/enrich.py +334 -0
- traceforge/export.py +564 -0
- traceforge/grounding.py +256 -0
- traceforge/identity.py +131 -0
- traceforge/improver/__init__.py +4 -0
- traceforge/improver/digest_prompts.py +105 -0
- traceforge/improver/harness.py +850 -0
- traceforge/improver/longtrace.py +182 -0
- traceforge/improver/prompt.py +241 -0
- traceforge/improver/review.py +231 -0
- traceforge/improver/verify.py +210 -0
- traceforge/live.py +508 -0
- traceforge/progress.py +194 -0
- traceforge/readers/__init__.py +11 -0
- traceforge/readers/_common.py +184 -0
- traceforge/readers/claude.py +531 -0
- traceforge/readers/codex.py +637 -0
- traceforge/readers/hermes.py +319 -0
- traceforge/readers/nemotron.py +491 -0
- traceforge/space/__init__.py +23 -0
- traceforge/space/__pycache__/__init__.cpython-314.pyc +0 -0
- traceforge/space/__pycache__/hashing.cpython-314.pyc +0 -0
- traceforge/space/__pycache__/ingest.cpython-314.pyc +0 -0
- traceforge/space/__pycache__/pipeline.cpython-314.pyc +0 -0
- traceforge/space/__pycache__/stats.cpython-314.pyc +0 -0
- traceforge/space/__pycache__/store.cpython-314.pyc +0 -0
- traceforge/space/app.py +465 -0
- traceforge/space/hashing.py +214 -0
- traceforge/space/ingest.py +175 -0
- traceforge/space/jobs.py +118 -0
- traceforge/space/pipeline.py +239 -0
BUILD.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"built_at": "2026-10-26T22:04:33",
|
| 3 |
+
"commit": "6bc868a",
|
| 4 |
+
"files": 48,
|
| 5 |
+
"with_store": false,
|
| 6 |
+
"with_card": true
|
| 7 |
+
}
|
README.md
CHANGED
|
@@ -1,13 +1,122 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.30.0
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: traceforge
|
| 3 |
+
emoji: 🧭
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.30.0
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
license: other
|
| 11 |
+
short_description: Turn real agent traces into ideal-world references
|
| 12 |
---
|
| 13 |
|
| 14 |
+
# traceforge
|
| 15 |
+
|
| 16 |
+
Turn a real agent trace into its **ideal-world reference**: the same work,
|
| 17 |
+
with the dead ends removed, gated for structure and grounding.
|
| 18 |
+
|
| 19 |
+
This Space is the portable face of the traceforge pipeline. The pipeline
|
| 20 |
+
itself is unchanged — triage labels every message, a windowed rewriter
|
| 21 |
+
produces the ideal path, and a judge admits only traces that pass the six
|
| 22 |
+
pillars. The UI hides that machinery behind three actions.
|
| 23 |
+
|
| 24 |
+
## Submit a trace
|
| 25 |
+
|
| 26 |
+
Upload a JSON/JSONL trace (traceforge native, OpenAI chat, or a bare message
|
| 27 |
+
list) or paste it. The app computes a **content hash** of the seed and
|
| 28 |
+
queues it.
|
| 29 |
+
|
| 30 |
+
**One seed, one output.** The hash covers the conversation content — message
|
| 31 |
+
text, tool calls and the set of tools the agent had — and excludes session
|
| 32 |
+
ids, timestamps, cwd and tool-call ids. Submitting the same trace twice
|
| 33 |
+
returns the existing output instead of creating a second one. That is what
|
| 34 |
+
makes the output dataset a set, not a log.
|
| 35 |
+
|
| 36 |
+
## Sponsor a dataset
|
| 37 |
+
|
| 38 |
+
Upload a JSONL of traces. Every row is hashed; new seeds are queued and
|
| 39 |
+
attributed to the sponsor name you give, known seeds are skipped. Processed
|
| 40 |
+
rows join the public output dataset. The dashboard shows the sponsor
|
| 41 |
+
leaderboard.
|
| 42 |
+
|
| 43 |
+
## Dashboard
|
| 44 |
+
|
| 45 |
+
Public statistics over this Space's corpus and, when bundled, the reference
|
| 46 |
+
corpus: seeds, outputs, queue, failure rate, task mix, languages, runtime
|
| 47 |
+
distribution, and **time sensitivity**.
|
| 48 |
+
|
| 49 |
+
## Time sensitivity
|
| 50 |
+
|
| 51 |
+
A trace whose correct answer is a fact that changes over time is a bad
|
| 52 |
+
training target when the agent had no way to look that fact up. Train on it
|
| 53 |
+
and the model learns to *assert* the fact from its weights — "the current
|
| 54 |
+
president is X" — which is wrong the moment X changes.
|
| 55 |
+
|
| 56 |
+
The same question is a good target when the agent had a retrieval tool. The
|
| 57 |
+
ideal path calls the tool and answers from its result, so what the model
|
| 58 |
+
imitates is the procedure (search, read, answer), not the fact. The
|
| 59 |
+
procedure does not decay.
|
| 60 |
+
|
| 61 |
+
| class | meaning |
|
| 62 |
+
|---|---|
|
| 63 |
+
| `time_sensitive` | temporal fact, no retrieval tool — the answer decays |
|
| 64 |
+
| `time_anchored` | temporal fact, but a retrieval tool can fetch it |
|
| 65 |
+
| `not_time_sensitive` | no time-dependent fact in the task |
|
| 66 |
+
|
| 67 |
+
`time_anchored` carries a sub-flag: whether the tool was actually **called**.
|
| 68 |
+
A temporal task with a search tool available but never used is anchored in
|
| 69 |
+
principle — the ideal rewrite should make the agent call it — but the raw
|
| 70 |
+
trace answered from weights, so it needs a rewrite rather than a pass.
|
| 71 |
+
|
| 72 |
+
The detector is deliberately conservative: a false `time_sensitive` throws
|
| 73 |
+
away a good trace, so a pattern must name the **fact** (an officeholder, a
|
| 74 |
+
price, a released version, the weather), not just a temporal word. "The
|
| 75 |
+
current branch" and "the latest fix round" are local artifacts of the
|
| 76 |
+
session, not decaying facts.
|
| 77 |
+
|
| 78 |
+
## Configuration
|
| 79 |
+
|
| 80 |
+
Set these as Space secrets / variables:
|
| 81 |
+
|
| 82 |
+
| variable | meaning |
|
| 83 |
+
|---|---|
|
| 84 |
+
| `TRACEFORGE_BASE_URL` | OpenAI-compatible endpoint (`…/v1`) |
|
| 85 |
+
| `TRACEFORGE_LLM_KEY` | bearer token |
|
| 86 |
+
| `TRACEFORGE_MODEL` | writer/judge model id |
|
| 87 |
+
| `TRACEFORGE_TRIAGE_MODEL` | cheap labeler (default: `TRACEFORGE_MODEL`) |
|
| 88 |
+
| `TRACEFORGE_JUDGE_MODEL` | judge override (default: `TRACEFORGE_MODEL`) |
|
| 89 |
+
| `TRACEFORGE_DATA_DIR` | store root (default `./data/space`) |
|
| 90 |
+
| `TRACEFORGE_SPACE_WORKERS` | parallel pipeline workers (default 1) |
|
| 91 |
+
|
| 92 |
+
Without an endpoint the app still starts and the dashboard still works; the
|
| 93 |
+
Submit tab reports that the pipeline is not configured instead of hanging.
|
| 94 |
+
|
| 95 |
+
## Storage
|
| 96 |
+
|
| 97 |
+
The store is content-addressed on disk:
|
| 98 |
+
|
| 99 |
+
```
|
| 100 |
+
data/space/index.jsonl append-only journal (last row per hash wins)
|
| 101 |
+
data/space/seeds/<hash>.json the seed exactly as submitted
|
| 102 |
+
data/space/outputs/<hash>.json the improved trace
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
A Hugging Face Space's disk is ephemeral unless persistent storage is
|
| 106 |
+
attached. For a durable public corpus, mount persistent storage at
|
| 107 |
+
`/data` and set `TRACEFORGE_DATA_DIR=/data/space`, or sync the store to a
|
| 108 |
+
dataset repo.
|
| 109 |
+
|
| 110 |
+
## Local use
|
| 111 |
+
|
| 112 |
+
```bash
|
| 113 |
+
pip install -e ".[webui]"
|
| 114 |
+
python app.py # http://127.0.0.1:7860
|
| 115 |
+
traceforge space serve --port 7860
|
| 116 |
+
traceforge space hash trace.json # print the seed hash
|
| 117 |
+
traceforge space stats # store census
|
| 118 |
+
```
|
| 119 |
+
|
| 120 |
+
## License
|
| 121 |
+
|
| 122 |
+
Private research corpus. All rights reserved.
|
app.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hugging Face Spaces entry point.
|
| 2 |
+
|
| 3 |
+
The Space runs this file. Everything it needs is in `traceforge/space/`;
|
| 4 |
+
configuration comes from Space secrets / variables:
|
| 5 |
+
|
| 6 |
+
TRACEFORGE_BASE_URL OpenAI-compatible endpoint (…/v1)
|
| 7 |
+
TRACEFORGE_LLM_KEY bearer token
|
| 8 |
+
TRACEFORGE_MODEL writer/judge model id
|
| 9 |
+
TRACEFORGE_TRIAGE_MODEL cheap labeler (default: TRACEFORGE_MODEL)
|
| 10 |
+
TRACEFORGE_DATA_DIR store root (default: ./data/space)
|
| 11 |
+
|
| 12 |
+
Locally: `python app.py` (or `traceforge space serve`).
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import os
|
| 17 |
+
|
| 18 |
+
from traceforge.space.app import CSS, build_app
|
| 19 |
+
|
| 20 |
+
demo = build_app()
|
| 21 |
+
demo.queue(default_concurrency_limit=4)
|
| 22 |
+
|
| 23 |
+
if __name__ == "__main__" or os.environ.get("SPACE_ID"):
|
| 24 |
+
demo.launch(show_error=True, css=CSS)
|
data/dataset/dataset_card.json
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"traces": 3652,
|
| 3 |
+
"dedup": {
|
| 4 |
+
"removed": 1,
|
| 5 |
+
"pct": 0.0,
|
| 6 |
+
"key": "(task, tool-names + sha1(args))"
|
| 7 |
+
},
|
| 8 |
+
"languages": {
|
| 9 |
+
"en": 3444,
|
| 10 |
+
"de": 208
|
| 11 |
+
},
|
| 12 |
+
"agent_roles": {
|
| 13 |
+
"subagent": 278,
|
| 14 |
+
"orchestrator": 11
|
| 15 |
+
},
|
| 16 |
+
"tasks": {
|
| 17 |
+
"review": 1118,
|
| 18 |
+
"debug": 682,
|
| 19 |
+
"implement": 620,
|
| 20 |
+
"research": 468,
|
| 21 |
+
"other": 293,
|
| 22 |
+
"test": 165,
|
| 23 |
+
"ops": 108,
|
| 24 |
+
"data-analysis": 86,
|
| 25 |
+
"creative": 54,
|
| 26 |
+
"refactor": 44,
|
| 27 |
+
"office-docs": 14
|
| 28 |
+
},
|
| 29 |
+
"messages": {
|
| 30 |
+
"total": 112327,
|
| 31 |
+
"by_role": {
|
| 32 |
+
"assistant": 54700,
|
| 33 |
+
"tool": 53265,
|
| 34 |
+
"user": 4070,
|
| 35 |
+
"system": 292
|
| 36 |
+
},
|
| 37 |
+
"per_trace": {
|
| 38 |
+
"mean": 30.8,
|
| 39 |
+
"median": 20,
|
| 40 |
+
"p90": 64,
|
| 41 |
+
"max": 252
|
| 42 |
+
}
|
| 43 |
+
},
|
| 44 |
+
"tool_calls": {
|
| 45 |
+
"total": 53265,
|
| 46 |
+
"per_trace": {
|
| 47 |
+
"mean": 14.6,
|
| 48 |
+
"median": 9,
|
| 49 |
+
"p90": 32,
|
| 50 |
+
"max": 125
|
| 51 |
+
},
|
| 52 |
+
"traces_with_none": 1,
|
| 53 |
+
"distinct_tools": 553,
|
| 54 |
+
"top_tools": {
|
| 55 |
+
"Bash": 14327,
|
| 56 |
+
"exec": 13084,
|
| 57 |
+
"terminal": 3813,
|
| 58 |
+
"read_file": 3008,
|
| 59 |
+
"Read": 2085,
|
| 60 |
+
"search_files": 1837,
|
| 61 |
+
"execute_code": 1601,
|
| 62 |
+
"tool_call": 1512,
|
| 63 |
+
"Edit": 1497,
|
| 64 |
+
"skill_view": 918,
|
| 65 |
+
"Grep": 917,
|
| 66 |
+
"patch": 758,
|
| 67 |
+
"unlock_discoverable_agent_tool": 457,
|
| 68 |
+
"call_discoverable_agent_tool": 457,
|
| 69 |
+
"wait": 436
|
| 70 |
+
}
|
| 71 |
+
},
|
| 72 |
+
"user_turns": {
|
| 73 |
+
"per_trace": {
|
| 74 |
+
"mean": 1.1,
|
| 75 |
+
"median": 1,
|
| 76 |
+
"p90": 1,
|
| 77 |
+
"max": 18
|
| 78 |
+
},
|
| 79 |
+
"single_turn_traces": 3352,
|
| 80 |
+
"multi_turn_traces": 300
|
| 81 |
+
},
|
| 82 |
+
"tokens": {
|
| 83 |
+
"method": "chars/4 (prose proxy). Calibrated against a real tokenizer on this corpus: chars/4 runs ~13% low (median 21.4k est vs 24.5k actual), so scale by ~1.15 for a central estimate; tool output is code/JSON and tokenizes denser than prose.",
|
| 84 |
+
"budget": 131072,
|
| 85 |
+
"chars_total": 323084312,
|
| 86 |
+
"est_total": 80769721,
|
| 87 |
+
"per_trace": {
|
| 88 |
+
"mean": 22116.6,
|
| 89 |
+
"median": 16996,
|
| 90 |
+
"p90": 53126,
|
| 91 |
+
"max": 119767
|
| 92 |
+
},
|
| 93 |
+
"fits_budget": {
|
| 94 |
+
"4096": {
|
| 95 |
+
"traces": 1043,
|
| 96 |
+
"pct": 28.6
|
| 97 |
+
},
|
| 98 |
+
"8192": {
|
| 99 |
+
"traces": 1218,
|
| 100 |
+
"pct": 33.4
|
| 101 |
+
},
|
| 102 |
+
"16384": {
|
| 103 |
+
"traces": 1773,
|
| 104 |
+
"pct": 48.5
|
| 105 |
+
},
|
| 106 |
+
"65536": {
|
| 107 |
+
"traces": 3570,
|
| 108 |
+
"pct": 97.8
|
| 109 |
+
},
|
| 110 |
+
"131072": {
|
| 111 |
+
"traces": 3652,
|
| 112 |
+
"pct": 100.0
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
},
|
| 116 |
+
"split": null,
|
| 117 |
+
"supervision": {
|
| 118 |
+
"contract": "loss on assistant spans only; tool/user/system spans are masked context",
|
| 119 |
+
"chars_by_role": {
|
| 120 |
+
"user": 4777684,
|
| 121 |
+
"assistant": 41191827,
|
| 122 |
+
"tool": 276324543,
|
| 123 |
+
"system": 790258
|
| 124 |
+
},
|
| 125 |
+
"assistant_share_pct": 12.7,
|
| 126 |
+
"tool_share_pct": 85.5,
|
| 127 |
+
"recovery": {
|
| 128 |
+
"traces": 47,
|
| 129 |
+
"pct": 1.3,
|
| 130 |
+
"cap_pct": 15.0,
|
| 131 |
+
"over_cap": false,
|
| 132 |
+
"method": "verified label: exactly one environment-caused failure (flaky endpoint, missing dependency, exhausted resource) followed by exactly one corrective step; model-caused errors and retry loops disqualify (docs/DATA_QUALITY.md §4.1)"
|
| 133 |
+
}
|
| 134 |
+
},
|
| 135 |
+
"difficulty": {
|
| 136 |
+
"tool_calls": {
|
| 137 |
+
"mean": 14.6,
|
| 138 |
+
"median": 9,
|
| 139 |
+
"p90": 32,
|
| 140 |
+
"max": 125
|
| 141 |
+
},
|
| 142 |
+
"messages": {
|
| 143 |
+
"mean": 30.8,
|
| 144 |
+
"median": 20,
|
| 145 |
+
"p90": 64,
|
| 146 |
+
"max": 252
|
| 147 |
+
},
|
| 148 |
+
"est_tokens": {
|
| 149 |
+
"mean": 22116.6,
|
| 150 |
+
"median": 16996,
|
| 151 |
+
"p90": 53126,
|
| 152 |
+
"max": 119767
|
| 153 |
+
},
|
| 154 |
+
"bands": {
|
| 155 |
+
"calls_lt_10": 1916,
|
| 156 |
+
"calls_10_24": 1149,
|
| 157 |
+
"calls_25_49": 450,
|
| 158 |
+
"calls_ge_50": 137
|
| 159 |
+
}
|
| 160 |
+
},
|
| 161 |
+
"notes": [
|
| 162 |
+
"identity/refusal burns gated (traceforge/identity.py)",
|
| 163 |
+
"clip markers, template leaks, permission-ask finals gated",
|
| 164 |
+
"residual-defect estimate ~5% (selfhosted cross-exam, triaged)",
|
| 165 |
+
"reasoning channel dropped per message (keep_reasoning=False)"
|
| 166 |
+
]
|
| 167 |
+
}
|
data/space/reference_census.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"key": {
|
| 3 |
+
"mtime": 1793044902,
|
| 4 |
+
"size": 406218197
|
| 5 |
+
},
|
| 6 |
+
"census": {
|
| 7 |
+
"total": 3652,
|
| 8 |
+
"classes": {
|
| 9 |
+
"time_sensitive": 13,
|
| 10 |
+
"time_anchored": 12,
|
| 11 |
+
"not_time_sensitive": 3627
|
| 12 |
+
},
|
| 13 |
+
"labels": {
|
| 14 |
+
"time_sensitive": "answer decays: temporal fact, no retrieval tool",
|
| 15 |
+
"time_anchored": "temporal fact, but a retrieval tool can fetch it",
|
| 16 |
+
"not_time_sensitive": "no time-dependent fact in the task"
|
| 17 |
+
},
|
| 18 |
+
"needs_rewrite": 0,
|
| 19 |
+
"time_sensitive_share": 0.0036,
|
| 20 |
+
"source": "data/dataset/train.jsonl",
|
| 21 |
+
"available": true
|
| 22 |
+
}
|
| 23 |
+
}
|
guidelines/IDEAL_TRACE_GUIDELINES.md
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Ideal-Trace Guidelines — v0.3
|
| 2 |
+
|
| 3 |
+
> **Authoritative spec for what an "ideal trace" looks like.** Read at runtime
|
| 4 |
+
> by the improver, digest, long-trace and review stages. The six quality
|
| 5 |
+
> pillars (§3) are owner-decided and binding. §4 states the training-signal
|
| 6 |
+
> contract that makes the pillars necessary; §6 scopes what this reference is
|
| 7 |
+
> for (evaluation, not training). Evidence base: `docs/DATA_QUALITY.md`.
|
| 8 |
+
>
|
| 9 |
+
> **Integrity rule:** this file is a spec, not a notebook. Keep it under
|
| 10 |
+
> 24 KB. Never paste a worked example, a trace, or your own reasoning into it
|
| 11 |
+
> — examples live in `data/traces/improved/`. `tests/test_guidelines.py`
|
| 12 |
+
> enforces this.
|
| 13 |
+
|
| 14 |
+
## 1. Purpose
|
| 15 |
+
|
| 16 |
+
An ideal trace is the **ideal-world execution of the same task**: same intent,
|
| 17 |
+
same real environment, flawless execution. It is NOT a re-execution — it is a
|
| 18 |
+
rewrite of the recorded trace, using the trace's own tool outputs as the only
|
| 19 |
+
evidence about the world.
|
| 20 |
+
|
| 21 |
+
The ideal trace is the **reference standard** the benchmark scores a model
|
| 22 |
+
against: the less the pipeline has to edit a model's trace to reach it, the
|
| 23 |
+
better the model. The training-signal contract in §4 is retained because the
|
| 24 |
+
reference must also be a valid target if the corpus is reused for SFT — but
|
| 25 |
+
the official scope is evaluation, not training.
|
| 26 |
+
|
| 27 |
+
## 2. Stable structural rules (hard requirements)
|
| 28 |
+
|
| 29 |
+
1. **Preserve the task intent.** The first user message carries the task. The
|
| 30 |
+
ideal trace solves *that* task. When the source trace starts with a handoff
|
| 31 |
+
artifact — a Hermes `[CONTEXT COMPACTION …]` row, or a Claude Code "This
|
| 32 |
+
session is being continued…" summary row — the first REAL user task message
|
| 33 |
+
defines the intent: reproduce it verbatim and byte-exact as the ideal
|
| 34 |
+
trace's opening message.
|
| 35 |
+
2. **Tool-call pairing.** Every assistant tool_call has exactly one matching
|
| 36 |
+
tool result; every tool result references an existing call; tool arguments
|
| 37 |
+
are valid JSON strings.
|
| 38 |
+
3. **Message shape.** user / assistant(+calls) / tool / … / final assistant.
|
| 39 |
+
The last message is the assistant's final answer. No two consecutive user
|
| 40 |
+
rows, no two consecutive text-only assistant rows.
|
| 41 |
+
4. **Metadata is set by the harness**, not by the improver.
|
| 42 |
+
5. **No scaffold leakage.** Never copy prompt scaffolding, clip markers
|
| 43 |
+
(`…[+N chars clipped]`), compaction banners, or harness self-talk into the
|
| 44 |
+
ideal trace.
|
| 45 |
+
|
| 46 |
+
## 3. The six quality pillars (owner-decided, binding)
|
| 47 |
+
|
| 48 |
+
**P1 — Efficient but deep reasoning.**
|
| 49 |
+
Reasoning is sharp and high-signal: the key decision logic per turn (why this
|
| 50 |
+
call, what the result implies, what to verify next). No filler, no restating
|
| 51 |
+
the obvious, no rambling. Depth where it matters (diagnoses, trade-offs,
|
| 52 |
+
verification logic) — silence where it doesn't. **Answer focus:** every
|
| 53 |
+
answer, final or mid-trace, gives exactly what its question asked for. A
|
| 54 |
+
follow-up gets only the new piece; never re-present facts the user already
|
| 55 |
+
received. Over-answering is a P1 violation.
|
| 56 |
+
|
| 57 |
+
**Deliver the substance, not a description of it.** The final answer must
|
| 58 |
+
present the concrete result the task asked for — the actual numbers, names,
|
| 59 |
+
paths, values, or code from the tool evidence — not a summary of what was
|
| 60 |
+
retrieved. "I pulled the nutrition data and the recipe" is poor; the data and
|
| 61 |
+
the recipe ARE the answer. Naming the work instead of showing its result fails
|
| 62 |
+
the task even when every tool call was correct.
|
| 63 |
+
|
| 64 |
+
**P2 — No committed mistakes (mine the negative signal first).**
|
| 65 |
+
Before writing, identify every mistake in the real trace: failed tool calls
|
| 66 |
+
(error results), retry loops, redundant calls, wrong turns that later needed
|
| 67 |
+
correction, and USER FEEDBACK (corrections, re-asks, complaints). The ideal
|
| 68 |
+
trace commits none of them: the correct route is taken the first time. Where
|
| 69 |
+
the real agent repaired itself, the ideal trace shows only the correct action
|
| 70 |
+
— not the failure, not the repair.
|
| 71 |
+
*Why:* the assistant turns are the loss-bearing target. A mistake in the
|
| 72 |
+
target is imitated; a mistake in the context is not. See §4 for the one
|
| 73 |
+
narrow exception (environment-caused failures).
|
| 74 |
+
|
| 75 |
+
**P3 — Perfect tool calls, only when really needed.**
|
| 76 |
+
Every call is necessary, correctly parameterized, and the natural next step.
|
| 77 |
+
Independent calls are batched in parallel.
|
| 78 |
+
|
| 79 |
+
**Batching is structural, not stylistic.** When the real trace issues two
|
| 80 |
+
calls in separate assistant turns but the second does not consume the first's
|
| 81 |
+
output, the ideal path issues them in ONE assistant turn carrying both
|
| 82 |
+
`tool_calls`, with the two tool results following in the same order. Do not
|
| 83 |
+
preserve the source's one-call-per-turn rhythm — that rhythm is the defect.
|
| 84 |
+
Measured 2026-10-25: 75 of 621 repair-exhausted failures are exactly this
|
| 85 |
+
shape. A duplicate call (same tool, same arguments, same turn) is never
|
| 86 |
+
batched; it is dropped. No exploratory calls, no re-reads
|
| 87 |
+
of what is already in context, no call whose result the narrative doesn't
|
| 88 |
+
use. When the ideal path makes the SAME call as the real trace, the tool
|
| 89 |
+
result must be the real trace's output — verbatim or a faithful compression
|
| 90 |
+
of it — never a plausible re-simulation of what the call might have printed.
|
| 91 |
+
|
| 92 |
+
**P4 — Fact-driven, no hallucinations (two layers, both binding).**
|
| 93 |
+
Layer 1 (trace-internal): reuse the real trace's tool outputs verbatim
|
| 94 |
+
wherever the ideal path makes the same call; never invent files, commands, or
|
| 95 |
+
results; no fake success — the final answer is grounded in real evidence from
|
| 96 |
+
the trace. Layer 2 (external world): statements about the outside world
|
| 97 |
+
(library versions, API behavior, dates, technical facts) must be either (a)
|
| 98 |
+
taken from the trace's own evidence or (b) kept out unless you are certain
|
| 99 |
+
they are correct. The harness fact-checks external claims with live web
|
| 100 |
+
searches after you finish — unsupported claims send the trace back.
|
| 101 |
+
**Construction order:** decide the ideal call path → copy each result
|
| 102 |
+
byte-verbatim from `outputs.jsonl` → only then write reasoning and the final
|
| 103 |
+
answer around those real results. A tool result you cannot quote is not
|
| 104 |
+
writable: reroute the path or drop the claim. Stitching individually-plausible
|
| 105 |
+
fragments is fabrication and is the single most common rejection.
|
| 106 |
+
|
| 107 |
+
**P5 — Perfect grammar and language quality, single language.**
|
| 108 |
+
One language per trace: the language of the original first user message. No
|
| 109 |
+
code-switching, no stray fragments in other languages. Assistant prose is
|
| 110 |
+
clean, grammatically perfect, professionally concise. The user message is kept
|
| 111 |
+
verbatim — realism of the input matters more than its typos. Never open by
|
| 112 |
+
restating the brief: the first assistant turn must do work (a tool call, a
|
| 113 |
+
decision, a finding), not echo the user's own text back at them.
|
| 114 |
+
|
| 115 |
+
**P6 — No refusals.**
|
| 116 |
+
The ideal agent completes the task without refusing. If the source task
|
| 117 |
+
genuinely cannot be completed (harmful or impossible), do NOT write an ideal
|
| 118 |
+
version: reply starting with `REJECTED:` plus a one-line reason, and write no
|
| 119 |
+
output file.
|
| 120 |
+
|
| 121 |
+
## 4. Training-signal contract (why the pillars are shaped this way)
|
| 122 |
+
|
| 123 |
+
The corpus is consumed by SFT: loss is computed on **assistant target tokens
|
| 124 |
+
only**; tool results, user turns and system prompts are masked context. The
|
| 125 |
+
model imitates what is in the target span.
|
| 126 |
+
|
| 127 |
+
> **Failures may be context. Failures must never be target.**
|
| 128 |
+
|
| 129 |
+
Therefore:
|
| 130 |
+
|
| 131 |
+
1. **A model-caused error must never appear in an assistant turn** — not as
|
| 132 |
+
"I'll try X", not as a failed call, not as a self-correction narrative.
|
| 133 |
+
Wrong tool name, wrong arguments, wrong path, bad reasoning: route around
|
| 134 |
+
it. This is P2.
|
| 135 |
+
2. **An environment-caused error may appear as a tool result** (context), with
|
| 136 |
+
the correct adaptation in the next assistant turn. This is the only
|
| 137 |
+
exception to P2, and it exists because a corpus of only first-try-clean
|
| 138 |
+
paths loses the only examples of "tool returned an error → read it →
|
| 139 |
+
adapt", and silently skews the corpus toward easy tasks.
|
| 140 |
+
3. **The recovery is the target, never the mistake.** If the ideal path cannot
|
| 141 |
+
avoid the error, the assistant's next turn is the supervised recovery; the
|
| 142 |
+
error text stays byte-verbatim in the tool row.
|
| 143 |
+
|
| 144 |
+
### 4.1 The narrow recovery carve-out
|
| 145 |
+
|
| 146 |
+
A trace may keep a real failure **only** when ALL of these hold:
|
| 147 |
+
|
| 148 |
+
- the failure is **environment-caused** — a tool returned an error the ideal
|
| 149 |
+
agent could not have foreseen (flaky endpoint, missing dependency, a
|
| 150 |
+
genuinely absent file). Not a wrong tool name, wrong arguments, wrong path,
|
| 151 |
+
or a reasoning error;
|
| 152 |
+
- the error text is **byte-verbatim** from `outputs.jsonl` — never composed,
|
| 153 |
+
never paraphrased, never invented;
|
| 154 |
+
- there is **one corrective step per error**; two or more consecutive
|
| 155 |
+
identical failed calls disqualify the trace;
|
| 156 |
+
- the harness records the trace as a **retained recovery** (manifest + dataset
|
| 157 |
+
card) so the corpus can report failure density and a trainer can filter it
|
| 158 |
+
out;
|
| 159 |
+
- the final state is **verified** — a clean-looking recovery whose final
|
| 160 |
+
answer is wrong is rejected outright.
|
| 161 |
+
|
| 162 |
+
Everything else follows P2: route around the mistake, show only the correct
|
| 163 |
+
action. When in doubt, route around it — the carve-out is an exception, not a
|
| 164 |
+
license.
|
| 165 |
+
|
| 166 |
+
### 4.2 What must never be in the target span
|
| 167 |
+
|
| 168 |
+
- A fabricated or paraphrased error message.
|
| 169 |
+
- A failure the ideal agent could have avoided by reading its own context.
|
| 170 |
+
- A "let me try again" narrative whose first attempt is not shown (the model
|
| 171 |
+
learns the shape of recovery without the substance).
|
| 172 |
+
- A recovery loop longer than one corrective step.
|
| 173 |
+
- A success claim that the tool results do not support.
|
| 174 |
+
|
| 175 |
+
## 5. Rewrite procedure (binding)
|
| 176 |
+
|
| 177 |
+
1. Read these guidelines in full.
|
| 178 |
+
2. Mine the negative signal: list the real trace's failed calls, retries,
|
| 179 |
+
wrong turns, and user feedback (P2).
|
| 180 |
+
3. Classify each failure: **harness artefact** (wrong cwd, missing env var,
|
| 181 |
+
stale workdir, tool not installed) → route around it; **environment-caused
|
| 182 |
+
and unavoidable** → eligible for the §4.1 carve-out; **model-caused** →
|
| 183 |
+
route around it, always.
|
| 184 |
+
4. Map the ideal path: the minimal set of correct, necessary calls (P3).
|
| 185 |
+
5. Copy every tool result on that path byte-verbatim from `outputs.jsonl`
|
| 186 |
+
(P4), then write the reasoning and final answer around them (P1, P5).
|
| 187 |
+
6. Write the ideal trace JSON (schema in the task prompt).
|
| 188 |
+
7. Verify: re-read the file; check JSON validity, call/result pairing, every
|
| 189 |
+
pillar, and that no failure sits in an assistant turn. Fix, then summarize
|
| 190 |
+
the changes.
|
| 191 |
+
|
| 192 |
+
## 6. Scope: what this corpus is and is not
|
| 193 |
+
|
| 194 |
+
This is an **evaluation reference**. It is not a training corpus by official
|
| 195 |
+
scope, not a preference corpus, and not an RL environment.
|
| 196 |
+
|
| 197 |
+
- **The benchmark** scores a model by how little the pipeline has to edit its
|
| 198 |
+
traces to reach the reference. The reference is the ruler, not the product.
|
| 199 |
+
- **SFT reuse** is possible — the §4 contract keeps the reference a valid
|
| 200 |
+
target — but it is a downstream decision, not this repo's purpose.
|
| 201 |
+
- **KTO/DPO** needs rejected samples from the policy being aligned,
|
| 202 |
+
length-matched to the chosen side, with a bounded negative loss. A
|
| 203 |
+
hand-written "bad" trace is a strawman and teaches length/style, not
|
| 204 |
+
preference.
|
| 205 |
+
- **PPO/GRPO** needs rollouts and a verifiable reward; failed rollouts are
|
| 206 |
+
advantage, not imitation. A static JSONL of ideal traces cannot supply that
|
| 207 |
+
signal.
|
| 208 |
+
- If those corpora are built later, they are separate artifacts with separate
|
| 209 |
+
rules. Do not repurpose this corpus by relabelling its rows.
|
| 210 |
+
|
| 211 |
+
Corpus-level obligations that belong to the harness, not the improver: report
|
| 212 |
+
failure density and difficulty distribution per batch; cap retained recoveries
|
| 213 |
+
at ≤15% of traces until an in-repo ablation says otherwise; state the masking
|
| 214 |
+
contract in the dataset card; dedup at the (task, tool-sequence) level.
|
| 215 |
+
|
| 216 |
+
## 7. Remaining open items
|
| 217 |
+
|
| 218 |
+
- System prompt: keep original vs. normalize to a canonical corpus prompt.
|
| 219 |
+
- Corpus-level dedup threshold for batch runs (Jaccard > 0.9 is the current
|
| 220 |
+
audit default).
|
| 221 |
+
- Whether the §4.1 carve-out improves or degrades held-out recovery rate —
|
| 222 |
+
needs an in-repo ablation (same traces, with and without retained
|
| 223 |
+
recoveries, scored on a forced-error recovery eval).
|
| 224 |
+
|
| 225 |
+
## 8. Provenance
|
| 226 |
+
|
| 227 |
+
Improved traces carry source session id, parent session id, and generator
|
| 228 |
+
version; the manifest records per-trace status, attempts, and errors. The
|
| 229 |
+
harness records retained recoveries (§4.1) in the manifest and reports failure
|
| 230 |
+
density in the dataset card.
|
requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=6.0
|
| 2 |
+
pyyaml>=6
|
| 3 |
+
huggingface_hub>=0.24
|
traceforge/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""traceforge — an evaluation harness for agent models.
|
| 2 |
+
|
| 3 |
+
Reads real agent traces (Hermes Agent, Claude Code, Codex), then uses the
|
| 4 |
+
Hermes Agent CLI itself as the improver harness to rewrite them into
|
| 5 |
+
ideal-world references, validated against the guidelines in
|
| 6 |
+
guidelines/IDEAL_TRACE_GUIDELINES.md. The benchmark scores a model by how
|
| 7 |
+
little that rewrite has to change: if the pipeline has nothing to do, the
|
| 8 |
+
model is already perfect.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
__version__ = "0.1.0"
|
| 12 |
+
|
| 13 |
+
from traceforge.config import Config
|
| 14 |
+
from traceforge.trace import Message, ToolCall, Trace
|
| 15 |
+
|
| 16 |
+
__all__ = ["Config", "Message", "ToolCall", "Trace", "__version__"]
|
traceforge/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (853 Bytes). View file
|
|
|
traceforge/__pycache__/config.cpython-314.pyc
ADDED
|
Binary file (12.4 kB). View file
|
|
|
traceforge/__pycache__/trace.cpython-314.pyc
ADDED
|
Binary file (21.2 kB). View file
|
|
|
traceforge/audit.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Corpus audit: mechanical, LLM-free quality gates over the PROMOTED corpus.
|
| 2 |
+
|
| 3 |
+
Supervisor tool — run before any training export. Zero LLM calls; every
|
| 4 |
+
finding is a mechanical pattern with a trace + index citation, so it can
|
| 5 |
+
drive repairs or corpus pruning deterministically.
|
| 6 |
+
|
| 7 |
+
Audit classes:
|
| 8 |
+
1. harness self-talk — compaction/continuation narrative, spillover
|
| 9 |
+
slices, retry language in ASSISTANT prose (raw-session plumbing an
|
| 10 |
+
ideal trace must never show a model saying)
|
| 11 |
+
2. clip markers — the static gate, re-checked corpus-wide
|
| 12 |
+
3. empty/degenerate assistant turns — prose turns with < 20 chars
|
| 13 |
+
that are not a tool-call carrier
|
| 14 |
+
4. judge/quality residue — "defect": false entries that leaked into
|
| 15 |
+
promoted artifacts (should be impossible; proves a gate bypass)
|
| 16 |
+
5. near-duplicates — normalized assistant-prose shingles > 0.9
|
| 17 |
+
Jaccard across traces (overfit risk for the trainee)
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import hashlib
|
| 22 |
+
import json
|
| 23 |
+
import os
|
| 24 |
+
import re
|
| 25 |
+
from collections import Counter
|
| 26 |
+
|
| 27 |
+
# ---------------------------------------------------------------- patterns
|
| 28 |
+
|
| 29 |
+
# Harness/plumbing noise in ASSISTANT prose. Deliberately narrower than
|
| 30 |
+
# the raw-trace patterns: words like "harness"/"retry" are legitimate in
|
| 31 |
+
# domain tasks (test harness, git retry), so we anchor on unambiguous
|
| 32 |
+
# self-referential plumbing.
|
| 33 |
+
SELF_TALK_RE = re.compile(
|
| 34 |
+
r"("
|
| 35 |
+
r"[Cc]ontext was compacted"
|
| 36 |
+
r"|\[\s*CONTEXT COMPACTION\s*\]"
|
| 37 |
+
r"|⟪HERMES-CONTEXT-COMPRESSION"
|
| 38 |
+
r"|spillover slice"
|
| 39 |
+
r"|\bcompacted mid-"
|
| 40 |
+
r"|\bmid context-compression\b"
|
| 41 |
+
r"|\bcontinuation handoff\b"
|
| 42 |
+
r"|CONTEXT-COMPRESSION:"
|
| 43 |
+
r")"
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
# A task ABOUT harness/crash/database repair legitimately discusses
|
| 47 |
+
# compaction state — that is domain content, not self-talk leakage.
|
| 48 |
+
DOMAIN_TASK_RE = re.compile(
|
| 49 |
+
r"crash|repair|database|hermes|corrupt|restore|recover|diagnos",
|
| 50 |
+
re.IGNORECASE,
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
CLIP_RE = re.compile(r"…?\s*\[\+[\d,.]+ ?(?:chars|bytes) clipped\]")
|
| 54 |
+
|
| 55 |
+
# same-family judge leak marker: judge entries surviving into artifacts
|
| 56 |
+
JUDGE_LEAK_RE = re.compile(r'"defect"\s*:\s*false|"answer_quality"', re.I)
|
| 57 |
+
|
| 58 |
+
# identity burns / refusal phrasings — must never reach the reference
|
| 59 |
+
from traceforge.identity import IDENTITY_RE, REFUSAL_RE # noqa: E402
|
| 60 |
+
from traceforge.splice import _task_demands_json # noqa: E402
|
| 61 |
+
|
| 62 |
+
# a final answer that IS a raw JSON dump (not fenced, parses as JSON)
|
| 63 |
+
# teaches the trainee to close sessions with machine output
|
| 64 |
+
def _final_is_blob(msgs: list) -> bool:
|
| 65 |
+
if not msgs:
|
| 66 |
+
return False
|
| 67 |
+
last = msgs[-1]
|
| 68 |
+
if last.get("role") != "assistant":
|
| 69 |
+
return False
|
| 70 |
+
c = (last.get("content") or "").strip()
|
| 71 |
+
if len(c) < 400 or c.startswith("```"):
|
| 72 |
+
return False
|
| 73 |
+
if not (c.startswith("{") or c.startswith("[")):
|
| 74 |
+
return False
|
| 75 |
+
try:
|
| 76 |
+
json.loads(c)
|
| 77 |
+
return True
|
| 78 |
+
except json.JSONDecodeError:
|
| 79 |
+
return False
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _shingle(text: str, k: int = 8) -> set[str]:
|
| 83 |
+
"""Word k-gram hashes for near-duplicate detection."""
|
| 84 |
+
words = re.findall(r"\w+", text.lower())
|
| 85 |
+
if len(words) < k:
|
| 86 |
+
return set()
|
| 87 |
+
return {hashlib.md5(
|
| 88 |
+
" ".join(words[i:i + k]).encode()).hexdigest()
|
| 89 |
+
for i in range(len(words) - k + 1)}
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _jaccard(a: set, b: set) -> float:
|
| 93 |
+
if not a or not b:
|
| 94 |
+
return 0.0
|
| 95 |
+
return len(a & b) / len(a | b)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def audit_trace(t: dict) -> list[dict]:
|
| 99 |
+
"""One promoted trace -> list of findings."""
|
| 100 |
+
findings = []
|
| 101 |
+
sid = t.get("parent_session_id") or t.get("session_id", "?")
|
| 102 |
+
msgs = t.get("messages", [])
|
| 103 |
+
# opening user task (skip compaction/continuation handoffs)
|
| 104 |
+
task = next((m.get("content") or "" for m in msgs
|
| 105 |
+
if m.get("role") == "user"
|
| 106 |
+
and not (m.get("content") or "").startswith(
|
| 107 |
+
("[CONTEXT", "⟪"))), "")
|
| 108 |
+
task_domain = bool(DOMAIN_TASK_RE.search(task))
|
| 109 |
+
if _final_is_blob(msgs) and not _task_demands_json(task):
|
| 110 |
+
findings.append({
|
| 111 |
+
"cls": "final_blob", "i": len(msgs) - 1,
|
| 112 |
+
"detail": "final answer is a raw JSON dump, not prose",
|
| 113 |
+
"ctx": (msgs[-1].get("content") or "")[:160].replace("\n", " "),
|
| 114 |
+
})
|
| 115 |
+
for i, m in enumerate(msgs):
|
| 116 |
+
role = m.get("role")
|
| 117 |
+
c = m.get("content") or ""
|
| 118 |
+
if role == "assistant":
|
| 119 |
+
mo = SELF_TALK_RE.search(c)
|
| 120 |
+
if mo:
|
| 121 |
+
findings.append({
|
| 122 |
+
"cls": "self_talk_warn" if task_domain else "self_talk",
|
| 123 |
+
"i": i,
|
| 124 |
+
"detail": mo.group(0)[:80],
|
| 125 |
+
"ctx": c[max(0, mo.start() - 30):
|
| 126 |
+
mo.end() + 50].replace("\n", " ")[:160],
|
| 127 |
+
})
|
| 128 |
+
# clip markers must not survive in ANY role's content (tool
|
| 129 |
+
# rows carry them when a rewrite copied the window rendering)
|
| 130 |
+
mo = CLIP_RE.search(c)
|
| 131 |
+
if mo:
|
| 132 |
+
findings.append({
|
| 133 |
+
"cls": "clip_marker", "i": i,
|
| 134 |
+
"detail": mo.group(0)[:80], "ctx": c[
|
| 135 |
+
max(0, mo.start() - 30):
|
| 136 |
+
mo.end() + 50].replace("\n", " ")[:160],
|
| 137 |
+
})
|
| 138 |
+
if role == "assistant":
|
| 139 |
+
has_calls = bool(m.get("tool_calls"))
|
| 140 |
+
if not has_calls and len(c.strip()) < 20:
|
| 141 |
+
findings.append({
|
| 142 |
+
"cls": "degenerate_turn", "i": i,
|
| 143 |
+
"detail": f"prose turn {len(c.strip())} chars",
|
| 144 |
+
"ctx": c.strip()[:160],
|
| 145 |
+
})
|
| 146 |
+
mo = JUDGE_LEAK_RE.search(c)
|
| 147 |
+
if mo:
|
| 148 |
+
findings.append({
|
| 149 |
+
"cls": "judge_residue", "i": i,
|
| 150 |
+
"detail": mo.group(0)[:80],
|
| 151 |
+
"ctx": c[max(0, mo.start() - 30):
|
| 152 |
+
mo.end() + 50].replace("\n", " ")[:160],
|
| 153 |
+
})
|
| 154 |
+
mo = IDENTITY_RE.search(c)
|
| 155 |
+
if mo:
|
| 156 |
+
findings.append({
|
| 157 |
+
"cls": "identity_burn", "i": i,
|
| 158 |
+
"detail": mo.group(0)[:80],
|
| 159 |
+
"ctx": c[max(0, mo.start() - 30):
|
| 160 |
+
mo.end() + 50].replace("\n", " ")[:160],
|
| 161 |
+
})
|
| 162 |
+
mo = REFUSAL_RE.search(c)
|
| 163 |
+
if mo:
|
| 164 |
+
findings.append({
|
| 165 |
+
"cls": "refusal", "i": i,
|
| 166 |
+
"detail": mo.group(0)[:80],
|
| 167 |
+
"ctx": c[max(0, mo.start() - 30):
|
| 168 |
+
mo.end() + 50].replace("\n", " ")[:160],
|
| 169 |
+
})
|
| 170 |
+
return findings
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def audit_corpus(improved_dir: str, *, max_dupes: int = 20) -> dict:
|
| 174 |
+
"""Whole-corpus audit: per-trace findings + near-duplicate clusters."""
|
| 175 |
+
per_trace: dict[str, list] = {}
|
| 176 |
+
shingles: dict[str, set] = {}
|
| 177 |
+
sizes = []
|
| 178 |
+
for f in sorted(os.listdir(improved_dir)):
|
| 179 |
+
if not f.endswith(".json"):
|
| 180 |
+
continue
|
| 181 |
+
sid = f[:-5]
|
| 182 |
+
try:
|
| 183 |
+
t = json.load(open(os.path.join(improved_dir, f)))
|
| 184 |
+
except Exception as e:
|
| 185 |
+
per_trace[sid] = [{"cls": "unparseable", "i": -1,
|
| 186 |
+
"detail": str(e)[:120], "ctx": ""}]
|
| 187 |
+
continue
|
| 188 |
+
per_trace[sid] = audit_trace(t)
|
| 189 |
+
prose = " ".join((m.get("content") or "")
|
| 190 |
+
for m in t.get("messages", [])
|
| 191 |
+
if m.get("role") == "assistant")
|
| 192 |
+
sh = _shingle(prose)
|
| 193 |
+
if sh:
|
| 194 |
+
shingles[sid] = sh
|
| 195 |
+
sizes.append((sid, len(json.dumps(t))))
|
| 196 |
+
|
| 197 |
+
# Near-duplicate clusters: greedy grouping against cluster reps.
|
| 198 |
+
#
|
| 199 |
+
# Comparing every trace against every cluster is O(n^2) Jaccard on sets of
|
| 200 |
+
# thousands of shingles: at 2,983 traces it ran for over 20 minutes and
|
| 201 |
+
# blocked every export. Two sets with Jaccard > 0.9 almost certainly share
|
| 202 |
+
# their smallest shingle hashes, so bucket by the 3 smallest and only
|
| 203 |
+
# compare inside a bucket. That is the standard LSH banding trick and it
|
| 204 |
+
# turns the pass into O(n) buckets with a handful of comparisons each.
|
| 205 |
+
buckets: dict[str, list[str]] = {}
|
| 206 |
+
for sid, sh in shingles.items():
|
| 207 |
+
for h in sorted(sh)[:3]:
|
| 208 |
+
buckets.setdefault(h, []).append(sid)
|
| 209 |
+
|
| 210 |
+
clusters: list[list[str]] = []
|
| 211 |
+
assigned: dict[str, list[str]] = {}
|
| 212 |
+
for a in shingles:
|
| 213 |
+
placed = False
|
| 214 |
+
# candidate clusters: any cluster whose rep shares a bucket with `a`
|
| 215 |
+
cands: list[list[str]] = []
|
| 216 |
+
seen_ids: set[int] = set()
|
| 217 |
+
for h in sorted(shingles[a])[:3]:
|
| 218 |
+
for other in buckets.get(h, ()):
|
| 219 |
+
cl = assigned.get(other)
|
| 220 |
+
if cl is not None and id(cl) not in seen_ids:
|
| 221 |
+
seen_ids.add(id(cl))
|
| 222 |
+
cands.append(cl)
|
| 223 |
+
for cl in cands:
|
| 224 |
+
if _jaccard(shingles[a], shingles[cl[0]]) > 0.9:
|
| 225 |
+
cl.append(a)
|
| 226 |
+
assigned[a] = cl
|
| 227 |
+
placed = True
|
| 228 |
+
break
|
| 229 |
+
if not placed:
|
| 230 |
+
cl = [a]
|
| 231 |
+
clusters.append(cl)
|
| 232 |
+
assigned[a] = cl
|
| 233 |
+
dupes = [cl for cl in clusters if len(cl) > 1][:max_dupes]
|
| 234 |
+
|
| 235 |
+
return {"per_trace": per_trace, "dupes": dupes,
|
| 236 |
+
"total": len(per_trace)}
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def main(improved_dir: str, *, verbose: bool = False) -> int:
|
| 240 |
+
rep = audit_corpus(improved_dir)
|
| 241 |
+
counts: Counter = Counter()
|
| 242 |
+
flagged = {}
|
| 243 |
+
for sid, findings in rep["per_trace"].items():
|
| 244 |
+
for f in findings:
|
| 245 |
+
counts[f["cls"]] += 1
|
| 246 |
+
if findings:
|
| 247 |
+
flagged[sid] = findings
|
| 248 |
+
print(f"audited {rep['total']} promoted traces")
|
| 249 |
+
for cls, n in sorted(counts.items()):
|
| 250 |
+
print(f" {cls}: {n}")
|
| 251 |
+
for sid, fs in list(flagged.items())[:10 if verbose else 5]:
|
| 252 |
+
print(f"\n--- {sid}")
|
| 253 |
+
for f in fs[:4]:
|
| 254 |
+
print(f" [{f['i']}] {f['cls']}: {f['detail']}")
|
| 255 |
+
print(f" …{f['ctx'][:120]}…")
|
| 256 |
+
if rep["dupes"]:
|
| 257 |
+
print(f"\nnear-duplicate clusters (J>0.9): {len(rep['dupes'])}")
|
| 258 |
+
for cl in rep["dupes"][:8]:
|
| 259 |
+
print(f" {' ~ '.join(cl)}")
|
| 260 |
+
else:
|
| 261 |
+
print("\nno near-duplicate clusters (J>0.9)")
|
| 262 |
+
return 1 if any(counts[c] for c in
|
| 263 |
+
("self_talk", "clip_marker", "unparseable",
|
| 264 |
+
"judge_residue", "identity_burn", "refusal",
|
| 265 |
+
"final_blob")) else 0
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
if __name__ == "__main__":
|
| 269 |
+
import sys
|
| 270 |
+
raise SystemExit(main(sys.argv[1] if len(sys.argv) > 1
|
| 271 |
+
else "data/traces/improved",
|
| 272 |
+
verbose="-v" in sys.argv))
|
traceforge/benchmark.py
ADDED
|
@@ -0,0 +1,811 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Benchmark suite — score a model by how little the pipeline had to edit.
|
| 2 |
+
|
| 3 |
+
The pipeline turns a real agent trace into an ideal-world reference. That
|
| 4 |
+
makes it an evaluation instrument: **the amount of work the pipeline has to
|
| 5 |
+
do on a model's trace is an inverse measure of how close the model already
|
| 6 |
+
is to the reference.** If the pipeline has nothing to do, the model is
|
| 7 |
+
already perfect.
|
| 8 |
+
|
| 9 |
+
Two independent signals, both "less is better":
|
| 10 |
+
|
| 11 |
+
1. **Fidelity** — similarity between the model's trace and the ideal
|
| 12 |
+
rewrite. A trace the pipeline can promote unchanged scores 1.0; every
|
| 13 |
+
dropped, rephrased or added message lowers it. Computed as a Dice
|
| 14 |
+
coefficient over aligned message pairs, so a rewrite that deletes half
|
| 15 |
+
the trace cannot score high by matching the surviving half perfectly.
|
| 16 |
+
2. **Labels** — the triage pass labels every message ``keep`` / ``drop`` /
|
| 17 |
+
``rephrase`` / ``fact_check`` / ``summarize``. ``keep`` is a positive
|
| 18 |
+
judgement, ``drop`` a negative one, and the shape edits sit in between.
|
| 19 |
+
The label score is graded (keep 1.0, shape edit 0.5, drop 0.0) and is
|
| 20 |
+
computed over ASSISTANT rows only: those are the model's own turns, while
|
| 21 |
+
a long tool result the triage wants compressed is the environment's
|
| 22 |
+
verbosity, not a model defect.
|
| 23 |
+
|
| 24 |
+
Composite score (0-100)::
|
| 25 |
+
|
| 26 |
+
score = 100 * (w_sim * fidelity + w_label * label_score)
|
| 27 |
+
|
| 28 |
+
Defaults are 0.6 / 0.4. Weights are a policy choice, not a measurement;
|
| 29 |
+
they are recorded in every report so a score is never quoted without them.
|
| 30 |
+
|
| 31 |
+
Why Dice and not a plain mean: a mean over aligned pairs rewards a rewrite
|
| 32 |
+
that keeps one message and deletes nine. Dice divides by the sum of both
|
| 33 |
+
lengths, so deletions and additions are edits and cost score.
|
| 34 |
+
|
| 35 |
+
Normalisation: the readers prepend pipeline framing markers to user rows
|
| 36 |
+
(``[trace segment 2 of 2 …]``, ``[subagent brief — role: …]``). The model
|
| 37 |
+
never wrote those, so they are stripped before comparison — otherwise every
|
| 38 |
+
split segment and subagent trace is penalised for a reader artifact.
|
| 39 |
+
|
| 40 |
+
Caveats the report states explicitly: triage labels come from a cheap
|
| 41 |
+
model and are a judgement, not ground truth; similarity measures agreement
|
| 42 |
+
with the reference, not correctness of the task outcome; and a corpus
|
| 43 |
+
smaller than ~30 traces cannot support a stable mean.
|
| 44 |
+
"""
|
| 45 |
+
from __future__ import annotations
|
| 46 |
+
|
| 47 |
+
import difflib
|
| 48 |
+
import json
|
| 49 |
+
import math
|
| 50 |
+
import os
|
| 51 |
+
import re
|
| 52 |
+
import time
|
| 53 |
+
from collections import Counter, defaultdict
|
| 54 |
+
from dataclasses import dataclass, field
|
| 55 |
+
from typing import Any, Iterable, Optional
|
| 56 |
+
|
| 57 |
+
from traceforge.trace import Message, Trace
|
| 58 |
+
from traceforge.triage import ACTIONS
|
| 59 |
+
from traceforge.validate import strip_scaffold_markers
|
| 60 |
+
|
| 61 |
+
BENCHMARK_VERSION = "1.0"
|
| 62 |
+
|
| 63 |
+
# Policy weights, not measurements. Recorded in every report.
|
| 64 |
+
DEFAULT_WEIGHTS: dict[str, float] = {"similarity": 0.6, "labels": 0.4}
|
| 65 |
+
|
| 66 |
+
# A pair at or above this is "kept verbatim" (normalised); below it the
|
| 67 |
+
# pipeline rewrote the message. 0.55 is the alignment floor: two messages
|
| 68 |
+
# that share less than half their text are different messages, not edits.
|
| 69 |
+
VERBATIM = 0.98
|
| 70 |
+
REPHRASE = 0.55
|
| 71 |
+
|
| 72 |
+
_WS_RE = re.compile(r"\s+")
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# --------------------------------------------------------------- similarity
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _norm(text: Optional[str]) -> str:
|
| 79 |
+
"""Collapse whitespace and case so formatting noise is not an edit."""
|
| 80 |
+
return _WS_RE.sub(" ", (text or "").strip().lower())
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def text_similarity(a: str, b: str, *, head: int = 800, tail: int = 200) -> float:
|
| 84 |
+
"""Similarity of two message texts in [0, 1].
|
| 85 |
+
|
| 86 |
+
Long tool outputs are compared on head + tail: a rewrite that preserves
|
| 87 |
+
an output preserves both ends, and running difflib over 50 KB strings is
|
| 88 |
+
quadratic for no extra signal. The length-ratio prefilter is what keeps
|
| 89 |
+
a full-corpus run under two minutes — a message half the length of its
|
| 90 |
+
counterpart is a compression, not a match.
|
| 91 |
+
"""
|
| 92 |
+
a, b = _norm(a), _norm(b)
|
| 93 |
+
if a == b:
|
| 94 |
+
return 1.0
|
| 95 |
+
if not a or not b:
|
| 96 |
+
return 0.0
|
| 97 |
+
la, lb = len(a), len(b)
|
| 98 |
+
if min(la, lb) / max(la, lb) < 0.5:
|
| 99 |
+
return 0.0
|
| 100 |
+
if la > head + tail or lb > head + tail:
|
| 101 |
+
a = a[:head] + "\n…\n" + a[-tail:]
|
| 102 |
+
b = b[:head] + "\n…\n" + b[-tail:]
|
| 103 |
+
sm = difflib.SequenceMatcher(None, a, b, autojunk=False)
|
| 104 |
+
# quick_ratio is an upper bound on ratio; below the alignment floor the
|
| 105 |
+
# exact value cannot change any decision, so skip the quadratic pass.
|
| 106 |
+
if sm.quick_ratio() < 0.4:
|
| 107 |
+
return 0.0
|
| 108 |
+
return sm.ratio()
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _msg_text(m: Message) -> str:
|
| 112 |
+
"""Everything a message carries: prose plus its tool calls."""
|
| 113 |
+
parts = [m.content or ""]
|
| 114 |
+
for tc in m.tool_calls:
|
| 115 |
+
parts.append(f"{tc.name} {tc.arguments}")
|
| 116 |
+
return "\n".join(p for p in parts if p)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _call_sig(tc) -> tuple[str, str]:
|
| 120 |
+
return (tc.name, _norm(tc.arguments)[:300])
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def _seq_similarity(a: list, b: list) -> float:
|
| 124 |
+
"""Dice coefficient over two sequences (1.0 when both are empty)."""
|
| 125 |
+
if not a and not b:
|
| 126 |
+
return 1.0
|
| 127 |
+
if not a or not b:
|
| 128 |
+
return 0.0
|
| 129 |
+
sm = difflib.SequenceMatcher(None, a, b, autojunk=False)
|
| 130 |
+
return sm.ratio()
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def message_similarity(raw_m: Message, imp_m: Message) -> float:
|
| 134 |
+
"""Similarity of one aligned message pair.
|
| 135 |
+
|
| 136 |
+
A tool-only assistant turn carries no prose, so its calls ARE the
|
| 137 |
+
message; weighting prose and calls equally would score every such pair
|
| 138 |
+
as a perfect match on the empty string.
|
| 139 |
+
"""
|
| 140 |
+
if raw_m.role != imp_m.role:
|
| 141 |
+
return 0.0
|
| 142 |
+
text_sim = text_similarity(_msg_text(raw_m), _msg_text(imp_m))
|
| 143 |
+
raw_calls = [_call_sig(tc) for tc in raw_m.tool_calls]
|
| 144 |
+
imp_calls = [_call_sig(tc) for tc in imp_m.tool_calls]
|
| 145 |
+
if raw_calls or imp_calls:
|
| 146 |
+
call_sim = _seq_similarity(raw_calls, imp_calls)
|
| 147 |
+
if not _norm(raw_m.content) and not _norm(imp_m.content):
|
| 148 |
+
return call_sim
|
| 149 |
+
return 0.5 * text_sim + 0.5 * call_sim
|
| 150 |
+
return text_sim
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def align_messages(raw_msgs: list[Message],
|
| 154 |
+
imp_msgs: list[Message]) -> list[tuple[int, int]]:
|
| 155 |
+
"""Monotonic alignment of raw -> improved messages.
|
| 156 |
+
|
| 157 |
+
Roles are the stable skeleton: a rewrite keeps the user/assistant/tool
|
| 158 |
+
shape and drops or adds whole turns. SequenceMatcher over the role
|
| 159 |
+
sequences finds the equal runs and the replaced blocks; inside a block
|
| 160 |
+
messages pair by position. That is O(n) pairs instead of the O(n·window)
|
| 161 |
+
of a content-driven search, and it is the right model of the edit: the
|
| 162 |
+
pipeline does not reorder turns, it removes and rewrites them.
|
| 163 |
+
"""
|
| 164 |
+
raw_roles = [m.role for m in raw_msgs]
|
| 165 |
+
imp_roles = [m.role for m in imp_msgs]
|
| 166 |
+
sm = difflib.SequenceMatcher(None, raw_roles, imp_roles, autojunk=False)
|
| 167 |
+
pairs: list[tuple[int, int]] = []
|
| 168 |
+
for tag, i1, i2, j1, j2 in sm.get_opcodes():
|
| 169 |
+
if tag in ("equal", "replace"):
|
| 170 |
+
for k in range(min(i2 - i1, j2 - j1)):
|
| 171 |
+
pairs.append((i1 + k, j1 + k))
|
| 172 |
+
return pairs
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
# ------------------------------------------------------------------- labels
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def label_stats(triage: Optional[dict],
|
| 179 |
+
messages: Optional[list[Message]] = None) -> dict[str, Any]:
|
| 180 |
+
"""Counts and rates over one triage label file.
|
| 181 |
+
|
| 182 |
+
``keep`` is the positive judgement; ``drop`` the negative one.
|
| 183 |
+
``rephrase``/``summarize`` are shape edits (right content, wrong form),
|
| 184 |
+
``fact_check`` is a verification flag — neither is a negative label, so
|
| 185 |
+
they are reported separately instead of being folded into the keep rate.
|
| 186 |
+
|
| 187 |
+
When ``messages`` is given, the same counts are also computed over
|
| 188 |
+
ASSISTANT rows only. That is the number the composite uses: the model
|
| 189 |
+
wrote its own turns, not the tool outputs, and a long tool result the
|
| 190 |
+
triage wants compressed is the environment's verbosity, not a model
|
| 191 |
+
defect.
|
| 192 |
+
"""
|
| 193 |
+
labels = (triage or {}).get("labels") or []
|
| 194 |
+
valid = [lab for lab in labels
|
| 195 |
+
if isinstance(lab, dict) and lab.get("action") in ACTIONS]
|
| 196 |
+
counts = Counter(lab["action"] for lab in valid)
|
| 197 |
+
total = sum(counts.values())
|
| 198 |
+
out: dict[str, Any] = {"total": total}
|
| 199 |
+
for action in ACTIONS:
|
| 200 |
+
out[action] = counts.get(action, 0)
|
| 201 |
+
out["keep_rate"] = (counts.get("keep", 0) / total) if total else None
|
| 202 |
+
out["negative_rate"] = (counts.get("drop", 0) / total) if total else None
|
| 203 |
+
out["edit_rate"] = (
|
| 204 |
+
(counts.get("rephrase", 0) + counts.get("summarize", 0)) / total
|
| 205 |
+
if total else None
|
| 206 |
+
)
|
| 207 |
+
# Graded positive/negative score: keep = 1.0, a shape edit (rephrase /
|
| 208 |
+
# summarize / fact_check) = 0.5, drop = 0.0. The strict keep rate is
|
| 209 |
+
# dominated by the triage model's compression preference — 58% of
|
| 210 |
+
# assistant labels on the real corpus are `summarize` because assistant
|
| 211 |
+
# turns carry large tool-call arguments — so the composite uses the
|
| 212 |
+
# graded score and the report shows both.
|
| 213 |
+
out["label_score"] = (
|
| 214 |
+
(counts.get("keep", 0)
|
| 215 |
+
+ 0.5 * (counts.get("rephrase", 0) + counts.get("summarize", 0)
|
| 216 |
+
+ counts.get("fact_check", 0))) / total
|
| 217 |
+
if total else None
|
| 218 |
+
)
|
| 219 |
+
if messages is not None:
|
| 220 |
+
roles = {i: m.role for i, m in enumerate(messages)}
|
| 221 |
+
a_counts = Counter(
|
| 222 |
+
lab["action"] for lab in valid
|
| 223 |
+
if isinstance(lab.get("i"), int)
|
| 224 |
+
and roles.get(lab["i"]) == "assistant"
|
| 225 |
+
)
|
| 226 |
+
a_total = sum(a_counts.values())
|
| 227 |
+
out["assistant_total"] = a_total
|
| 228 |
+
for action in ACTIONS:
|
| 229 |
+
out[f"assistant_{action}"] = a_counts.get(action, 0)
|
| 230 |
+
out["assistant_keep_rate"] = (
|
| 231 |
+
a_counts.get("keep", 0) / a_total if a_total else None)
|
| 232 |
+
out["assistant_negative_rate"] = (
|
| 233 |
+
a_counts.get("drop", 0) / a_total if a_total else None)
|
| 234 |
+
out["assistant_label_score"] = (
|
| 235 |
+
(a_counts.get("keep", 0)
|
| 236 |
+
+ 0.5 * (a_counts.get("rephrase", 0) + a_counts.get("summarize", 0)
|
| 237 |
+
+ a_counts.get("fact_check", 0))) / a_total
|
| 238 |
+
if a_total else None
|
| 239 |
+
)
|
| 240 |
+
return out
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def load_triage(path: str) -> Optional[dict]:
|
| 244 |
+
try:
|
| 245 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 246 |
+
return json.load(f)
|
| 247 |
+
except (OSError, ValueError):
|
| 248 |
+
return None
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
# ------------------------------------------------------------------ scoring
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
@dataclass
|
| 255 |
+
class TraceScore:
|
| 256 |
+
"""One model trace scored against its ideal rewrite."""
|
| 257 |
+
|
| 258 |
+
session_id: str
|
| 259 |
+
task: Optional[str] = None
|
| 260 |
+
source: Optional[str] = None
|
| 261 |
+
model: Optional[str] = None
|
| 262 |
+
|
| 263 |
+
# fidelity
|
| 264 |
+
similarity: float = 0.0
|
| 265 |
+
tool_similarity: float = 0.0
|
| 266 |
+
kept_verbatim: int = 0
|
| 267 |
+
rephrased: int = 0
|
| 268 |
+
dropped: int = 0
|
| 269 |
+
added: int = 0
|
| 270 |
+
raw_messages: int = 0
|
| 271 |
+
improved_messages: int = 0
|
| 272 |
+
raw_chars: int = 0
|
| 273 |
+
|
| 274 |
+
# labels
|
| 275 |
+
labels_total: int = 0
|
| 276 |
+
keep: int = 0
|
| 277 |
+
drop: int = 0
|
| 278 |
+
rephrase: int = 0
|
| 279 |
+
fact_check: int = 0
|
| 280 |
+
summarize: int = 0
|
| 281 |
+
keep_rate: Optional[float] = None
|
| 282 |
+
negative_rate: Optional[float] = None
|
| 283 |
+
label_score: Optional[float] = None
|
| 284 |
+
# assistant-only: the model's own turns (the loss-bearing target)
|
| 285 |
+
assistant_labels_total: int = 0
|
| 286 |
+
assistant_keep: int = 0
|
| 287 |
+
assistant_drop: int = 0
|
| 288 |
+
assistant_keep_rate: Optional[float] = None
|
| 289 |
+
assistant_negative_rate: Optional[float] = None
|
| 290 |
+
assistant_label_score: Optional[float] = None
|
| 291 |
+
|
| 292 |
+
# gates (best effort — only traces with a manifest attempts row have it)
|
| 293 |
+
attempts: Optional[int] = None
|
| 294 |
+
first_pass: Optional[bool] = None
|
| 295 |
+
|
| 296 |
+
# composite
|
| 297 |
+
score: float = 0.0
|
| 298 |
+
pipeline_idle: bool = False
|
| 299 |
+
|
| 300 |
+
@property
|
| 301 |
+
def edits(self) -> int:
|
| 302 |
+
return self.dropped + self.added + self.rephrased
|
| 303 |
+
|
| 304 |
+
def to_dict(self) -> dict[str, Any]:
|
| 305 |
+
d = {
|
| 306 |
+
"session_id": self.session_id,
|
| 307 |
+
"task": self.task,
|
| 308 |
+
"source": self.source,
|
| 309 |
+
"model": self.model,
|
| 310 |
+
"score": round(self.score, 2),
|
| 311 |
+
"similarity": round(self.similarity, 4),
|
| 312 |
+
"tool_similarity": round(self.tool_similarity, 4),
|
| 313 |
+
"keep_rate": (None if self.keep_rate is None
|
| 314 |
+
else round(self.keep_rate, 4)),
|
| 315 |
+
"negative_rate": (None if self.negative_rate is None
|
| 316 |
+
else round(self.negative_rate, 4)),
|
| 317 |
+
"assistant_keep_rate": (None if self.assistant_keep_rate is None
|
| 318 |
+
else round(self.assistant_keep_rate, 4)),
|
| 319 |
+
"assistant_negative_rate": (
|
| 320 |
+
None if self.assistant_negative_rate is None
|
| 321 |
+
else round(self.assistant_negative_rate, 4)),
|
| 322 |
+
"label_score": (None if self.label_score is None
|
| 323 |
+
else round(self.label_score, 4)),
|
| 324 |
+
"assistant_label_score": (
|
| 325 |
+
None if self.assistant_label_score is None
|
| 326 |
+
else round(self.assistant_label_score, 4)),
|
| 327 |
+
"edits": {
|
| 328 |
+
"kept_verbatim": self.kept_verbatim,
|
| 329 |
+
"rephrased": self.rephrased,
|
| 330 |
+
"dropped": self.dropped,
|
| 331 |
+
"added": self.added,
|
| 332 |
+
"total": self.edits,
|
| 333 |
+
},
|
| 334 |
+
"messages": {
|
| 335 |
+
"raw": self.raw_messages,
|
| 336 |
+
"improved": self.improved_messages,
|
| 337 |
+
"raw_chars": self.raw_chars,
|
| 338 |
+
},
|
| 339 |
+
"labels": {
|
| 340 |
+
"total": self.labels_total,
|
| 341 |
+
"keep": self.keep,
|
| 342 |
+
"drop": self.drop,
|
| 343 |
+
"rephrase": self.rephrase,
|
| 344 |
+
"fact_check": self.fact_check,
|
| 345 |
+
"summarize": self.summarize,
|
| 346 |
+
},
|
| 347 |
+
"attempts": self.attempts,
|
| 348 |
+
"first_pass": self.first_pass,
|
| 349 |
+
"pipeline_idle": self.pipeline_idle,
|
| 350 |
+
}
|
| 351 |
+
return d
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def score_pair(raw: Trace, improved: Trace, *, triage: Optional[dict] = None,
|
| 355 |
+
attempts: Optional[int] = None,
|
| 356 |
+
weights: Optional[dict[str, float]] = None) -> TraceScore:
|
| 357 |
+
"""Score one (model trace, ideal rewrite) pair.
|
| 358 |
+
|
| 359 |
+
``raw`` is mutated in place by the scaffold strip — pass a freshly
|
| 360 |
+
loaded trace, which is what the CLI does.
|
| 361 |
+
"""
|
| 362 |
+
w = {**DEFAULT_WEIGHTS, **(weights or {})}
|
| 363 |
+
strip_scaffold_markers(raw)
|
| 364 |
+
|
| 365 |
+
pairs = align_messages(raw.messages, improved.messages)
|
| 366 |
+
sims = [message_similarity(raw.messages[i], improved.messages[j])
|
| 367 |
+
for i, j in pairs]
|
| 368 |
+
n_raw, n_imp = len(raw.messages), len(improved.messages)
|
| 369 |
+
similarity = (2.0 * sum(sims) / (n_raw + n_imp)) if (n_raw + n_imp) else 1.0
|
| 370 |
+
|
| 371 |
+
kept = sum(1 for s in sims if s >= VERBATIM)
|
| 372 |
+
rephrased = sum(1 for s in sims if REPHRASE <= s < VERBATIM)
|
| 373 |
+
dropped = n_raw - len(pairs)
|
| 374 |
+
added = n_imp - len(pairs)
|
| 375 |
+
|
| 376 |
+
tool_similarity = _seq_similarity(
|
| 377 |
+
[_call_sig(tc) for m in raw.messages for tc in m.tool_calls],
|
| 378 |
+
[_call_sig(tc) for m in improved.messages for tc in m.tool_calls],
|
| 379 |
+
)
|
| 380 |
+
|
| 381 |
+
lab = label_stats(triage, raw.messages)
|
| 382 |
+
keep_rate = lab["keep_rate"]
|
| 383 |
+
# The composite judges the model on its OWN turns. Tool outputs are
|
| 384 |
+
# context the model did not write; a long result the triage wants
|
| 385 |
+
# compressed is the environment's verbosity, not a model defect.
|
| 386 |
+
label_score = (lab.get("assistant_label_score")
|
| 387 |
+
if lab.get("assistant_label_score") is not None
|
| 388 |
+
else lab.get("label_score"))
|
| 389 |
+
# Idle is strict: any non-keep label means the pipeline had work to do.
|
| 390 |
+
strict_keep = (lab.get("assistant_keep_rate")
|
| 391 |
+
if lab.get("assistant_keep_rate") is not None else keep_rate)
|
| 392 |
+
|
| 393 |
+
# Composite: renormalise over the components that exist. A trace with no
|
| 394 |
+
# triage file is scored on fidelity alone rather than being penalised
|
| 395 |
+
# for a missing label pass.
|
| 396 |
+
parts = [(w["similarity"], similarity)]
|
| 397 |
+
if label_score is not None:
|
| 398 |
+
parts.append((w["labels"], label_score))
|
| 399 |
+
total_w = sum(p[0] for p in parts)
|
| 400 |
+
score = (100.0 * sum(p[0] * p[1] for p in parts) / total_w) if total_w else 0.0
|
| 401 |
+
|
| 402 |
+
idle = (similarity >= 0.999 and dropped == 0 and added == 0
|
| 403 |
+
and (strict_keep is None or strict_keep >= 0.999))
|
| 404 |
+
|
| 405 |
+
return TraceScore(
|
| 406 |
+
session_id=raw.session_id,
|
| 407 |
+
task=improved.task or raw.task,
|
| 408 |
+
source=raw.source,
|
| 409 |
+
model=improved.model or raw.model,
|
| 410 |
+
similarity=similarity,
|
| 411 |
+
tool_similarity=tool_similarity,
|
| 412 |
+
kept_verbatim=kept,
|
| 413 |
+
rephrased=rephrased,
|
| 414 |
+
dropped=dropped,
|
| 415 |
+
added=added,
|
| 416 |
+
raw_messages=n_raw,
|
| 417 |
+
improved_messages=n_imp,
|
| 418 |
+
raw_chars=sum(len(m.content or "") for m in raw.messages),
|
| 419 |
+
labels_total=lab["total"],
|
| 420 |
+
keep=lab["keep"],
|
| 421 |
+
drop=lab["drop"],
|
| 422 |
+
rephrase=lab["rephrase"],
|
| 423 |
+
fact_check=lab["fact_check"],
|
| 424 |
+
summarize=lab["summarize"],
|
| 425 |
+
keep_rate=keep_rate,
|
| 426 |
+
negative_rate=lab["negative_rate"],
|
| 427 |
+
label_score=lab.get("label_score"),
|
| 428 |
+
assistant_labels_total=lab.get("assistant_total", 0),
|
| 429 |
+
assistant_keep=lab.get("assistant_keep", 0),
|
| 430 |
+
assistant_drop=lab.get("assistant_drop", 0),
|
| 431 |
+
assistant_keep_rate=lab.get("assistant_keep_rate"),
|
| 432 |
+
assistant_negative_rate=lab.get("assistant_negative_rate"),
|
| 433 |
+
assistant_label_score=lab.get("assistant_label_score"),
|
| 434 |
+
attempts=attempts,
|
| 435 |
+
first_pass=(attempts == 1) if attempts is not None else None,
|
| 436 |
+
score=score,
|
| 437 |
+
pipeline_idle=idle,
|
| 438 |
+
)
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
# --------------------------------------------------------------- statistics
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
def _dist(vals: Iterable[float]) -> dict[str, Any]:
|
| 445 |
+
"""Distribution summary. Percentiles are nearest-rank, not interpolated."""
|
| 446 |
+
v = sorted(float(x) for x in vals)
|
| 447 |
+
if not v:
|
| 448 |
+
return {"n": 0, "mean": None, "median": None, "p10": None,
|
| 449 |
+
"p90": None, "min": None, "max": None}
|
| 450 |
+
|
| 451 |
+
def pct(q: float) -> float:
|
| 452 |
+
idx = min(len(v) - 1, max(0, int(round(q * (len(v) - 1)))))
|
| 453 |
+
return v[idx]
|
| 454 |
+
|
| 455 |
+
return {
|
| 456 |
+
"n": len(v),
|
| 457 |
+
"mean": round(sum(v) / len(v), 4),
|
| 458 |
+
"median": round(pct(0.5), 4),
|
| 459 |
+
"p10": round(pct(0.1), 4),
|
| 460 |
+
"p90": round(pct(0.9), 4),
|
| 461 |
+
"min": round(v[0], 4),
|
| 462 |
+
"max": round(v[-1], 4),
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
def _pearson(xs: list[float], ys: list[float]) -> Optional[float]:
|
| 467 |
+
n = len(xs)
|
| 468 |
+
if n < 3 or n != len(ys):
|
| 469 |
+
return None
|
| 470 |
+
mx, my = sum(xs) / n, sum(ys) / n
|
| 471 |
+
num = sum((x - mx) * (y - my) for x, y in zip(xs, ys))
|
| 472 |
+
dx = math.sqrt(sum((x - mx) ** 2 for x in xs))
|
| 473 |
+
dy = math.sqrt(sum((y - my) ** 2 for y in ys))
|
| 474 |
+
if dx == 0 or dy == 0:
|
| 475 |
+
return None
|
| 476 |
+
return round(num / (dx * dy), 3)
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
def _histogram(vals: list[float], edges: list[float]) -> dict[str, int]:
|
| 480 |
+
"""Bucket counts; the last bucket is inclusive of its upper edge."""
|
| 481 |
+
out: dict[str, int] = {}
|
| 482 |
+
last = len(edges) - 2
|
| 483 |
+
for i, (lo, hi) in enumerate(zip(edges, edges[1:])):
|
| 484 |
+
out[f"{lo:g}-{hi:g}"] = sum(
|
| 485 |
+
1 for v in vals if lo <= v < hi or (i == last and v == hi))
|
| 486 |
+
return out
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
def _group(scores: list[TraceScore], key) -> dict[str, dict[str, Any]]:
|
| 490 |
+
buckets: dict[str, list[TraceScore]] = defaultdict(list)
|
| 491 |
+
for s in scores:
|
| 492 |
+
buckets[key(s) or "unknown"].append(s)
|
| 493 |
+
return {
|
| 494 |
+
k: {"n": len(v), "score_mean": round(sum(x.score for x in v) / len(v), 2),
|
| 495 |
+
"similarity_mean": round(sum(x.similarity for x in v) / len(v), 3),
|
| 496 |
+
"keep_rate_mean": (
|
| 497 |
+
round(sum(x.keep_rate for x in v if x.keep_rate is not None)
|
| 498 |
+
/ max(1, sum(1 for x in v if x.keep_rate is not None)), 3)
|
| 499 |
+
if any(x.keep_rate is not None for x in v) else None)}
|
| 500 |
+
for k, v in sorted(buckets.items(), key=lambda kv: -len(kv[1]))
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
|
| 504 |
+
def _size_band(chars: int) -> str:
|
| 505 |
+
kb = chars / 1000.0
|
| 506 |
+
if kb < 60:
|
| 507 |
+
return "<60KB"
|
| 508 |
+
if kb < 120:
|
| 509 |
+
return "60-120KB"
|
| 510 |
+
if kb < 180:
|
| 511 |
+
return "120-180KB"
|
| 512 |
+
if kb < 240:
|
| 513 |
+
return "180-240KB"
|
| 514 |
+
if kb < 300:
|
| 515 |
+
return "240-300KB"
|
| 516 |
+
return "300KB+"
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
def build_report(scores: list[TraceScore], *, label: Optional[str] = None,
|
| 520 |
+
weights: Optional[dict[str, float]] = None,
|
| 521 |
+
corpus: Optional[dict[str, Any]] = None,
|
| 522 |
+
generated_at: Optional[str] = None) -> dict[str, Any]:
|
| 523 |
+
"""Corpus-level statistics over a set of scored trace pairs."""
|
| 524 |
+
w = {**DEFAULT_WEIGHTS, **(weights or {})}
|
| 525 |
+
n = len(scores)
|
| 526 |
+
sims = [s.similarity for s in scores]
|
| 527 |
+
keeps = [s.keep_rate for s in scores if s.keep_rate is not None]
|
| 528 |
+
a_keeps = [s.assistant_keep_rate for s in scores
|
| 529 |
+
if s.assistant_keep_rate is not None]
|
| 530 |
+
a_scores = [s.assistant_label_score for s in scores
|
| 531 |
+
if s.assistant_label_score is not None]
|
| 532 |
+
negs = [s.negative_rate for s in scores if s.negative_rate is not None]
|
| 533 |
+
a_negs = [s.assistant_negative_rate for s in scores
|
| 534 |
+
if s.assistant_negative_rate is not None]
|
| 535 |
+
comp = [s.score for s in scores]
|
| 536 |
+
edits = [s.edits for s in scores]
|
| 537 |
+
tools = [s.tool_similarity for s in scores]
|
| 538 |
+
|
| 539 |
+
label_totals = Counter()
|
| 540 |
+
for s in scores:
|
| 541 |
+
for action in ACTIONS:
|
| 542 |
+
label_totals[action] += getattr(s, action)
|
| 543 |
+
label_total = sum(label_totals.values())
|
| 544 |
+
|
| 545 |
+
edit_totals = {
|
| 546 |
+
"kept_verbatim": sum(s.kept_verbatim for s in scores),
|
| 547 |
+
"rephrased": sum(s.rephrased for s in scores),
|
| 548 |
+
"dropped": sum(s.dropped for s in scores),
|
| 549 |
+
"added": sum(s.added for s in scores),
|
| 550 |
+
}
|
| 551 |
+
edit_total = sum(edit_totals.values()) or 1
|
| 552 |
+
|
| 553 |
+
idle = [s for s in scores if s.pipeline_idle]
|
| 554 |
+
gated = [s for s in scores if s.attempts is not None]
|
| 555 |
+
first_pass = [s for s in gated if s.first_pass]
|
| 556 |
+
|
| 557 |
+
# Fidelity and labels are independent instruments; where they disagree
|
| 558 |
+
# the pipeline is inconsistent (a trace promoted unchanged although the
|
| 559 |
+
# triage wanted edits, or a rewrite of turns the triage called clean).
|
| 560 |
+
# That is a pipeline diagnostic, not a model defect — report it.
|
| 561 |
+
def _lab(s: TraceScore) -> float:
|
| 562 |
+
return s.assistant_label_score if s.assistant_label_score is not None else 0.0
|
| 563 |
+
|
| 564 |
+
fid_perfect = [s for s in scores if s.similarity >= 0.999]
|
| 565 |
+
lab_perfect = [s for s in scores if _lab(s) >= 0.999]
|
| 566 |
+
both_perfect = [s for s in scores
|
| 567 |
+
if s.similarity >= 0.999 and _lab(s) >= 0.999]
|
| 568 |
+
|
| 569 |
+
corr = _pearson(
|
| 570 |
+
[s.similarity for s in scores if s.assistant_label_score is not None],
|
| 571 |
+
[s.assistant_label_score for s in scores
|
| 572 |
+
if s.assistant_label_score is not None],
|
| 573 |
+
)
|
| 574 |
+
|
| 575 |
+
worst = sorted(scores, key=lambda s: s.score)[:10]
|
| 576 |
+
best = sorted(scores, key=lambda s: -s.score)[:10]
|
| 577 |
+
|
| 578 |
+
report: dict[str, Any] = {
|
| 579 |
+
"benchmark_version": BENCHMARK_VERSION,
|
| 580 |
+
"generated_at": generated_at or time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
| 581 |
+
"label": label,
|
| 582 |
+
"weights": w,
|
| 583 |
+
"corpus": {
|
| 584 |
+
"scored": n,
|
| 585 |
+
"with_labels": len(keeps),
|
| 586 |
+
"with_gates": len(gated),
|
| 587 |
+
**(corpus or {}),
|
| 588 |
+
},
|
| 589 |
+
"score": {
|
| 590 |
+
**_dist(comp),
|
| 591 |
+
"histogram": _histogram(comp, [0, 20, 40, 60, 80, 100]),
|
| 592 |
+
},
|
| 593 |
+
"components": {
|
| 594 |
+
"similarity": _dist(sims),
|
| 595 |
+
"keep_rate": _dist(keeps),
|
| 596 |
+
"assistant_keep_rate": _dist(a_keeps),
|
| 597 |
+
"label_score": _dist(a_scores),
|
| 598 |
+
"negative_rate": _dist(negs),
|
| 599 |
+
"assistant_negative_rate": _dist(a_negs),
|
| 600 |
+
"tool_sequence_similarity": _dist(tools),
|
| 601 |
+
},
|
| 602 |
+
"edits": {
|
| 603 |
+
"totals": edit_totals,
|
| 604 |
+
"share_pct": {k: round(100.0 * v / edit_total, 1)
|
| 605 |
+
for k, v in edit_totals.items()},
|
| 606 |
+
"per_trace": _dist(edits),
|
| 607 |
+
},
|
| 608 |
+
"labels": {
|
| 609 |
+
"totals": dict(label_totals),
|
| 610 |
+
"total": label_total,
|
| 611 |
+
"share_pct": {k: round(100.0 * v / label_total, 1)
|
| 612 |
+
for k, v in label_totals.items()} if label_total else {},
|
| 613 |
+
},
|
| 614 |
+
"pipeline_idle": {
|
| 615 |
+
"traces": len(idle),
|
| 616 |
+
"pct": round(100.0 * len(idle) / n, 1) if n else 0.0,
|
| 617 |
+
"definition": "similarity >= 0.999, no dropped/added message, "
|
| 618 |
+
"keep_rate >= 0.999 — the pipeline had nothing to do",
|
| 619 |
+
},
|
| 620 |
+
"agreement": {
|
| 621 |
+
"both_perfect": len(both_perfect),
|
| 622 |
+
"fidelity_perfect_labels_edit": len(fid_perfect) - len(both_perfect),
|
| 623 |
+
"labels_perfect_fidelity_edit": len(lab_perfect) - len(both_perfect),
|
| 624 |
+
"note": "fidelity and labels are independent instruments; a "
|
| 625 |
+
"disagreement is a pipeline inconsistency (promoted "
|
| 626 |
+
"unchanged although triage wanted edits, or rewritten "
|
| 627 |
+
"although triage called it clean), not a model defect",
|
| 628 |
+
},
|
| 629 |
+
"gates": {
|
| 630 |
+
"traces_with_attempts": len(gated),
|
| 631 |
+
"first_pass": len(first_pass),
|
| 632 |
+
"first_pass_pct": (round(100.0 * len(first_pass) / len(gated), 1)
|
| 633 |
+
if gated else None),
|
| 634 |
+
"attempts_mean": (round(sum(s.attempts for s in gated) / len(gated), 2)
|
| 635 |
+
if gated else None),
|
| 636 |
+
},
|
| 637 |
+
"by_task": _group(scores, lambda s: s.task),
|
| 638 |
+
"by_source": _group(scores, lambda s: s.source),
|
| 639 |
+
"by_size": _group(scores, lambda s: _size_band(s.raw_chars)),
|
| 640 |
+
"correlation": {"similarity_vs_label_score": corr},
|
| 641 |
+
"worst": [s.to_dict() for s in worst],
|
| 642 |
+
"best": [s.to_dict() for s in best],
|
| 643 |
+
"notes": [
|
| 644 |
+
"score = 100 * (w_similarity * fidelity + w_labels * label_score); "
|
| 645 |
+
"weights are policy, not measurement",
|
| 646 |
+
"fidelity is a Dice coefficient over aligned message pairs, so "
|
| 647 |
+
"dropped and added messages both cost score",
|
| 648 |
+
"label_score is graded: keep=1.0, rephrase/summarize/fact_check=0.5, "
|
| 649 |
+
"drop=0.0, over ASSISTANT rows only (the model's own turns)",
|
| 650 |
+
"triage labels come from a cheap model and are a judgement, not "
|
| 651 |
+
"ground truth; label_score is agreement with the pipeline's judge",
|
| 652 |
+
"similarity measures agreement with the reference, not whether "
|
| 653 |
+
"the task outcome was correct",
|
| 654 |
+
"pipeline framing markers are stripped from raw user rows before "
|
| 655 |
+
"comparison (the model never wrote them)",
|
| 656 |
+
"n < 30 traces cannot support a stable mean — treat the score as "
|
| 657 |
+
"a smoke signal, not a ranking",
|
| 658 |
+
],
|
| 659 |
+
}
|
| 660 |
+
return report
|
| 661 |
+
|
| 662 |
+
|
| 663 |
+
# ------------------------------------------------------------------ render
|
| 664 |
+
|
| 665 |
+
|
| 666 |
+
def _fmt(v: Optional[float], nd: int = 2) -> str:
|
| 667 |
+
return "—" if v is None else f"{v:.{nd}f}"
|
| 668 |
+
|
| 669 |
+
|
| 670 |
+
def render_report(report: dict[str, Any]) -> str:
|
| 671 |
+
"""Human-readable report. The JSON is the artifact; this is the glance."""
|
| 672 |
+
c = report["corpus"]
|
| 673 |
+
sc = report["score"]
|
| 674 |
+
comp = report["components"]
|
| 675 |
+
lines: list[str] = []
|
| 676 |
+
title = f"traceforge benchmark v{report['benchmark_version']}"
|
| 677 |
+
if report.get("label"):
|
| 678 |
+
title += f" — {report['label']}"
|
| 679 |
+
lines.append(title)
|
| 680 |
+
lines.append(
|
| 681 |
+
f"scored {c['scored']} trace pairs (model trace -> ideal rewrite) · "
|
| 682 |
+
f"{c['with_labels']} with triage labels · "
|
| 683 |
+
f"{c['with_gates']} with gate attempts"
|
| 684 |
+
)
|
| 685 |
+
lines.append("")
|
| 686 |
+
lines.append(f"score (0-100) mean {_fmt(sc['mean'], 1)} "
|
| 687 |
+
f"median {_fmt(sc['median'], 1)} "
|
| 688 |
+
f"p10 {_fmt(sc['p10'], 1)} p90 {_fmt(sc['p90'], 1)}")
|
| 689 |
+
lines.append(f" fidelity mean {_fmt(comp['similarity']['mean'], 3)} "
|
| 690 |
+
f"median {_fmt(comp['similarity']['median'], 3)} "
|
| 691 |
+
f"p10 {_fmt(comp['similarity']['p10'], 3)} "
|
| 692 |
+
f"p90 {_fmt(comp['similarity']['p90'], 3)}")
|
| 693 |
+
lines.append(f" label score mean {_fmt(comp['label_score']['mean'], 3)} "
|
| 694 |
+
f"median {_fmt(comp['label_score']['median'], 3)} "
|
| 695 |
+
f"p10 {_fmt(comp['label_score']['p10'], 3)} "
|
| 696 |
+
f"p90 {_fmt(comp['label_score']['p90'], 3)}")
|
| 697 |
+
lines.append(f" keep rate (asst) mean {_fmt(comp['assistant_keep_rate']['mean'], 3)} "
|
| 698 |
+
f"median {_fmt(comp['assistant_keep_rate']['median'], 3)} "
|
| 699 |
+
f"p10 {_fmt(comp['assistant_keep_rate']['p10'], 3)} "
|
| 700 |
+
f"p90 {_fmt(comp['assistant_keep_rate']['p90'], 3)}")
|
| 701 |
+
lines.append(f" negative (asst) mean {_fmt(comp['assistant_negative_rate']['mean'], 3)} "
|
| 702 |
+
f"median {_fmt(comp['assistant_negative_rate']['median'], 3)} "
|
| 703 |
+
f"p90 {_fmt(comp['assistant_negative_rate']['p90'], 3)}")
|
| 704 |
+
lines.append(f" tool sequence mean {_fmt(comp['tool_sequence_similarity']['mean'], 3)}")
|
| 705 |
+
lines.append("")
|
| 706 |
+
hist = sc["histogram"]
|
| 707 |
+
lines.append("score histogram " + " · ".join(
|
| 708 |
+
f"{k}: {v}" for k, v in hist.items()))
|
| 709 |
+
lines.append("")
|
| 710 |
+
e = report["edits"]
|
| 711 |
+
lines.append(f"edits per trace mean {_fmt(e['per_trace']['mean'], 1)} "
|
| 712 |
+
f"median {_fmt(e['per_trace']['median'], 1)} "
|
| 713 |
+
f"p90 {_fmt(e['per_trace']['p90'], 1)}")
|
| 714 |
+
lines.append(
|
| 715 |
+
" messages "
|
| 716 |
+
f"kept {e['totals']['kept_verbatim']} ({e['share_pct']['kept_verbatim']}%) · "
|
| 717 |
+
f"rephrased {e['totals']['rephrased']} ({e['share_pct']['rephrased']}%) · "
|
| 718 |
+
f"dropped {e['totals']['dropped']} ({e['share_pct']['dropped']}%) · "
|
| 719 |
+
f"added {e['totals']['added']} ({e['share_pct']['added']}%)")
|
| 720 |
+
lab = report["labels"]
|
| 721 |
+
if lab["total"]:
|
| 722 |
+
lines.append("labels " + " · ".join(
|
| 723 |
+
f"{k} {v}" for k, v in lab["totals"].items()))
|
| 724 |
+
idle = report["pipeline_idle"]
|
| 725 |
+
lines.append(f"pipeline idle {idle['traces']} traces "
|
| 726 |
+
f"({idle['pct']}%) — nothing to edit")
|
| 727 |
+
ag = report.get("agreement") or {}
|
| 728 |
+
if ag:
|
| 729 |
+
lines.append(f"instrument agree both perfect {ag['both_perfect']} · "
|
| 730 |
+
f"fidelity-only {ag['fidelity_perfect_labels_edit']} · "
|
| 731 |
+
f"labels-only {ag['labels_perfect_fidelity_edit']}")
|
| 732 |
+
g = report["gates"]
|
| 733 |
+
if g["traces_with_attempts"]:
|
| 734 |
+
lines.append(f"first-pass gates {g['first_pass']}/"
|
| 735 |
+
f"{g['traces_with_attempts']} ({g['first_pass_pct']}%) · "
|
| 736 |
+
f"mean attempts {g['attempts_mean']}")
|
| 737 |
+
lines.append("")
|
| 738 |
+
for name, key in (("by task", "by_task"), ("by source", "by_source"),
|
| 739 |
+
("by size", "by_size")):
|
| 740 |
+
groups = report[key]
|
| 741 |
+
if not groups:
|
| 742 |
+
continue
|
| 743 |
+
parts = [f"{k} {v['n']} ({_fmt(v['score_mean'], 1)})"
|
| 744 |
+
for k, v in list(groups.items())[:8]]
|
| 745 |
+
lines.append(f"{name:<18} " + " · ".join(parts))
|
| 746 |
+
corr = report["correlation"]["similarity_vs_label_score"]
|
| 747 |
+
lines.append(f"correlation similarity vs label score: r={_fmt(corr, 3)}")
|
| 748 |
+
lines.append("")
|
| 749 |
+
lines.append("worst traces (inspect these first):")
|
| 750 |
+
for s in report["worst"][:5]:
|
| 751 |
+
lines.append(f" {s['session_id'][:44]:<44} score {s['score']:>5.1f} "
|
| 752 |
+
f"sim {s['similarity']:.2f} "
|
| 753 |
+
f"label {_fmt(s['assistant_label_score'], 2)} "
|
| 754 |
+
f"{s['task'] or '—'}")
|
| 755 |
+
return "\n".join(lines)
|
| 756 |
+
|
| 757 |
+
|
| 758 |
+
# -------------------------------------------------------------- leaderboard
|
| 759 |
+
|
| 760 |
+
|
| 761 |
+
def append_leaderboard(path: str, report: dict[str, Any]) -> None:
|
| 762 |
+
"""Append one compact row per run; the leaderboard keeps the latest per label."""
|
| 763 |
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
| 764 |
+
row = {
|
| 765 |
+
"label": report.get("label") or "unlabeled",
|
| 766 |
+
"generated_at": report["generated_at"],
|
| 767 |
+
"benchmark_version": report["benchmark_version"],
|
| 768 |
+
"n": report["corpus"]["scored"],
|
| 769 |
+
"score_mean": report["score"]["mean"],
|
| 770 |
+
"score_median": report["score"]["median"],
|
| 771 |
+
"similarity_mean": report["components"]["similarity"]["mean"],
|
| 772 |
+
"label_score_mean": report["components"]["label_score"]["mean"],
|
| 773 |
+
"keep_rate_mean": report["components"]["assistant_keep_rate"]["mean"],
|
| 774 |
+
"pipeline_idle_pct": report["pipeline_idle"]["pct"],
|
| 775 |
+
"weights": report["weights"],
|
| 776 |
+
}
|
| 777 |
+
with open(path, "a", encoding="utf-8") as f:
|
| 778 |
+
f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
| 779 |
+
|
| 780 |
+
|
| 781 |
+
def load_leaderboard(path: str) -> list[dict[str, Any]]:
|
| 782 |
+
rows: dict[str, dict[str, Any]] = {}
|
| 783 |
+
try:
|
| 784 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 785 |
+
for line in f:
|
| 786 |
+
try:
|
| 787 |
+
r = json.loads(line)
|
| 788 |
+
except ValueError:
|
| 789 |
+
continue
|
| 790 |
+
if r.get("label"):
|
| 791 |
+
rows[r["label"]] = r # latest run wins
|
| 792 |
+
except OSError:
|
| 793 |
+
return []
|
| 794 |
+
return sorted(rows.values(), key=lambda r: -(r.get("score_mean") or 0))
|
| 795 |
+
|
| 796 |
+
|
| 797 |
+
def render_leaderboard(rows: list[dict[str, Any]]) -> str:
|
| 798 |
+
if not rows:
|
| 799 |
+
return "leaderboard: empty"
|
| 800 |
+
lines = ["leaderboard (latest run per label, sorted by mean score):",
|
| 801 |
+
f" {'label':<28} {'n':>5} {'score':>7} {'fidelity':>9} "
|
| 802 |
+
f"{'label':>6} {'idle%':>6} when"]
|
| 803 |
+
for r in rows:
|
| 804 |
+
lines.append(
|
| 805 |
+
f" {r['label'][:28]:<28} {r['n']:>5} "
|
| 806 |
+
f"{_fmt(r.get('score_mean'), 1):>7} "
|
| 807 |
+
f"{_fmt(r.get('similarity_mean'), 3):>9} "
|
| 808 |
+
f"{_fmt(r.get('label_score_mean'), 3):>6} "
|
| 809 |
+
f"{_fmt(r.get('pipeline_idle_pct'), 1):>6} "
|
| 810 |
+
f"{r.get('generated_at', '')[:19]}")
|
| 811 |
+
return "\n".join(lines)
|
traceforge/chunker.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Splits a Trace into LLM-digestible chunks at message boundaries.
|
| 2 |
+
|
| 3 |
+
A 10M-token session can never be rendered into a single prompt. Chunks are
|
| 4 |
+
cut at message boundaries under a char budget (≈4 chars/token), with a
|
| 5 |
+
small tail-overlap of previous-chunk messages prepended as continuity
|
| 6 |
+
context for the digester.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
+
|
| 12 |
+
from traceforge.trace import Trace
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass
|
| 16 |
+
class Chunk:
|
| 17 |
+
index: int # 1-based
|
| 18 |
+
start: int # first message index (0-based, inclusive)
|
| 19 |
+
end: int # last message index (exclusive)
|
| 20 |
+
rendered: str # compact rendering incl. overlap context header
|
| 21 |
+
approx_chars: int # size estimate of the chunk's own messages
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _msg_size(m) -> int:
|
| 25 |
+
return len(m.content or "") + sum(
|
| 26 |
+
len(tc.arguments) for tc in m.tool_calls
|
| 27 |
+
) + 64
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def chunk_trace(
|
| 31 |
+
trace: Trace,
|
| 32 |
+
*,
|
| 33 |
+
max_chars: int = 100_000,
|
| 34 |
+
overlap_messages: int = 2,
|
| 35 |
+
render_kwargs: dict | None = None,
|
| 36 |
+
) -> list[Chunk]:
|
| 37 |
+
"""Cut the trace into chunks of ≤ max_chars (single messages never split).
|
| 38 |
+
|
| 39 |
+
Returns chunks with per-chunk overlap context prepended (marked as
|
| 40 |
+
continuity-only so the digester does not double-count).
|
| 41 |
+
"""
|
| 42 |
+
rk = render_kwargs or {}
|
| 43 |
+
msgs = trace.messages
|
| 44 |
+
n = len(msgs)
|
| 45 |
+
chunks: list[Chunk] = []
|
| 46 |
+
|
| 47 |
+
def render_slice(lo: int, hi: int) -> str:
|
| 48 |
+
sub = Trace(
|
| 49 |
+
session_id=trace.session_id,
|
| 50 |
+
model=trace.model,
|
| 51 |
+
messages=msgs[lo:hi],
|
| 52 |
+
)
|
| 53 |
+
out = sub.render_compact(**rk)
|
| 54 |
+
# drop the synthetic "=== TRACE ... ===" header line
|
| 55 |
+
return out.split("\n", 1)[1] if "\n" in out else out
|
| 56 |
+
|
| 57 |
+
i = 0
|
| 58 |
+
while i < n:
|
| 59 |
+
j = i
|
| 60 |
+
total = 0
|
| 61 |
+
while j < n and (j == i or total + _msg_size(msgs[j]) <= max_chars):
|
| 62 |
+
total += _msg_size(msgs[j])
|
| 63 |
+
j += 1
|
| 64 |
+
body = render_slice(i, j)
|
| 65 |
+
header = (
|
| 66 |
+
f"CHUNK {len(chunks) + 1} of ~N (messages {i + 1}-{j} of {n})"
|
| 67 |
+
)
|
| 68 |
+
block = f"{header}\n{body}"
|
| 69 |
+
if i > 0 and overlap_messages > 0:
|
| 70 |
+
ov = render_slice(max(0, i - overlap_messages), i)
|
| 71 |
+
block = (
|
| 72 |
+
"CONTEXT — tail of the previous chunk (continuity only;"
|
| 73 |
+
" already digested, do not re-report):\n"
|
| 74 |
+
f"{ov}\n\n{block}"
|
| 75 |
+
)
|
| 76 |
+
chunks.append(
|
| 77 |
+
Chunk(
|
| 78 |
+
index=len(chunks) + 1,
|
| 79 |
+
start=i,
|
| 80 |
+
end=j,
|
| 81 |
+
rendered=block,
|
| 82 |
+
approx_chars=total,
|
| 83 |
+
)
|
| 84 |
+
)
|
| 85 |
+
i = j
|
| 86 |
+
# fix the "of ~N" placeholders now that we know the count
|
| 87 |
+
for c in chunks:
|
| 88 |
+
if "~N" in c.rendered:
|
| 89 |
+
c.rendered = c.rendered.replace("~N", str(len(chunks)))
|
| 90 |
+
return chunks
|
traceforge/classify.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Task/topic classification for traces.
|
| 2 |
+
|
| 3 |
+
Labels every trace with one task bucket so export can split the corpus by
|
| 4 |
+
topic (``train.coding.jsonl``, ``train.research.jsonl``, ...). Two tiers:
|
| 5 |
+
|
| 6 |
+
heuristic tool signature + first-task-message keywords. Free,
|
| 7 |
+
deterministic, whole corpus in seconds. Confidence is the
|
| 8 |
+
margin between the winning bucket and the runner-up.
|
| 9 |
+
llm one structured call per trace, used only where the heuristic
|
| 10 |
+
is unsure (``--llm``). Same endpoint/grammar ladder as
|
| 11 |
+
triage.py, so no new infrastructure.
|
| 12 |
+
|
| 13 |
+
The label is persisted on the trace (``Trace.task``) so export is
|
| 14 |
+
deterministic and every assignment is inspectable and reviewable.
|
| 15 |
+
|
| 16 |
+
Taxonomy is deliberately closed: a small set the trainer can weight
|
| 17 |
+
against, not free-form tags. Coding is split because the corpus is ~95%
|
| 18 |
+
coding and a single bucket would be useless for balancing.
|
| 19 |
+
"""
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import json
|
| 23 |
+
import re
|
| 24 |
+
from typing import Any, Optional
|
| 25 |
+
|
| 26 |
+
from traceforge.trace import Trace
|
| 27 |
+
|
| 28 |
+
# --------------------------------------------------------------- taxonomy
|
| 29 |
+
|
| 30 |
+
CODING_TASKS = ("debug", "implement", "refactor", "test", "review")
|
| 31 |
+
NONCODING_TASKS = (
|
| 32 |
+
"research", "data-analysis", "office-docs", "creative", "ops", "other",
|
| 33 |
+
)
|
| 34 |
+
TASKS = CODING_TASKS + NONCODING_TASKS
|
| 35 |
+
|
| 36 |
+
TASK_DESCRIPTIONS = {
|
| 37 |
+
"debug": "diagnose and fix a failure: error, traceback, crash, regression",
|
| 38 |
+
"implement": "add or build a feature, file, endpoint, or capability",
|
| 39 |
+
"refactor": "restructure existing code without changing behaviour",
|
| 40 |
+
"test": "write or run tests, coverage, fixtures, CI test jobs",
|
| 41 |
+
"review": "read-only audit, code review, diff inspection, critique",
|
| 42 |
+
"research": "gather external information: web, papers, docs, comparison",
|
| 43 |
+
"data-analysis": "analyse data: metrics, SQL, CSV, statistics, dashboards",
|
| 44 |
+
"office-docs": "produce office-suite deliverables: .xlsx spreadsheets, "
|
| 45 |
+
".pptx decks, .docx documents (NOT markdown docs or code docs)",
|
| 46 |
+
"creative": "creative writing or design: prose, lyrics, branding, visuals",
|
| 47 |
+
"ops": "deploy, configure, or operate infrastructure and services",
|
| 48 |
+
"other": "none of the above, or too ambiguous to call",
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
# ------------------------------------------------------------- heuristics
|
| 52 |
+
|
| 53 |
+
# Keyword rules over the first task message. Weight = how diagnostic the
|
| 54 |
+
# term is; a single strong term ("traceback") should beat several weak ones.
|
| 55 |
+
_KEYWORDS: dict[str, list[tuple[str, int]]] = {
|
| 56 |
+
"debug": [
|
| 57 |
+
(r"\btraceback\b", 4), (r"\bstack ?trace\b", 4),
|
| 58 |
+
(r"\bsegfault\b", 4), (r"\bpanic\b", 3), (r"\bexception\b", 3),
|
| 59 |
+
(r"\bregression\b", 3), (r"\bcrash(es|ed|ing)?\b", 3),
|
| 60 |
+
(r"\bbug\b", 3), (r"\bbroken\b", 3), (r"\bfail(s|ed|ing)?\b", 2),
|
| 61 |
+
(r"\berror\b", 2), (r"\bfix\b", 2), (r"\bwhy (does|is|did|do)\b", 2),
|
| 62 |
+
(r"\bdebug\b", 3), (r"\bhang(s|ing)?\b", 2), (r"\btimeout\b", 2),
|
| 63 |
+
],
|
| 64 |
+
"test": [
|
| 65 |
+
(r"\bpytest\b", 4), (r"\bunittest\b", 4), (r"\bcoverage\b", 3),
|
| 66 |
+
(r"\bfixture\b", 3), (r"\bmock(s|ed|ing)?\b", 2),
|
| 67 |
+
(r"\bassert(ion)?s?\b", 2), (r"\be2e\b", 3),
|
| 68 |
+
(r"\bintegration test", 3), (r"\btest suite\b", 3),
|
| 69 |
+
(r"\bwrite (a |the )?tests?\b", 3), (r"\btests?\b", 1),
|
| 70 |
+
],
|
| 71 |
+
"refactor": [
|
| 72 |
+
(r"\brefactor\b", 5), (r"\brestructur", 4), (r"\brename\b", 3),
|
| 73 |
+
(r"\bextract (a |the )?(function|method|class|module)", 4),
|
| 74 |
+
(r"\bdedup", 3), (r"\bsimplif", 3), (r"\bclean ?up\b", 2),
|
| 75 |
+
(r"\bmove .{0,30} into\b", 2), (r"\bsplit .{0,30} into\b", 2),
|
| 76 |
+
(r"\btidy\b", 2), (r"\bconsolidat", 3),
|
| 77 |
+
],
|
| 78 |
+
"review": [
|
| 79 |
+
(r"\bcode review\b", 5), (r"\breview\b", 3), (r"\baudit\b", 4),
|
| 80 |
+
(r"\binspect\b", 3), (r"\bcritique\b", 3), (r"\bpull request\b", 3),
|
| 81 |
+
(r"\bdiff\b", 2), (r"\bcheck .{0,25} for\b", 2),
|
| 82 |
+
(r"\bread[- ]only\b", 3), (r"\bassess\b", 2),
|
| 83 |
+
],
|
| 84 |
+
"implement": [
|
| 85 |
+
(r"\bimplement\b", 4), (r"\badd (a |an |the )?", 2),
|
| 86 |
+
(r"\bbuild\b", 2), (r"\bcreate\b", 2), (r"\bwrite (a |an |the )?", 2),
|
| 87 |
+
(r"\bfeature\b", 3), (r"\bsupport for\b", 3), (r"\bintroduce\b", 2),
|
| 88 |
+
(r"\bwire up\b", 3), (r"\bhook up\b", 3), (r"\bendpoint\b", 2),
|
| 89 |
+
(r"\bmake .{0,25} work\b", 2), (r"\bscaffold\b", 3),
|
| 90 |
+
],
|
| 91 |
+
"research": [
|
| 92 |
+
(r"\bresearch\b", 4), (r"\binvestigate\b", 3), (r"\bcompare\b", 3),
|
| 93 |
+
(r"\bsurvey\b", 3), (r"\barxiv\b", 5), (r"\bpapers?\b", 3),
|
| 94 |
+
(r"\bliterature\b", 4), (r"\bfind out\b", 3), (r"\blook up\b", 3),
|
| 95 |
+
(r"\bwhat is\b", 2), (r"\bhow does\b", 2), (r"\bexplain\b", 2),
|
| 96 |
+
(r"\bsources?\b", 2), (r"\bstate of the art\b", 4),
|
| 97 |
+
],
|
| 98 |
+
"data-analysis": [
|
| 99 |
+
(r"\banaly[sz]e\b", 3), (r"\banalysis\b", 3), (r"\bmetrics?\b", 3),
|
| 100 |
+
(r"\bstatistic", 3), (r"\bcsv\b", 4), (r"\bsql\b", 4),
|
| 101 |
+
(r"\bdashboard\b", 4), (r"\bdataset\b", 3),
|
| 102 |
+
(r"\baggregat", 3), (r"\bpivot\b", 4), (r"\bcorrelat", 3),
|
| 103 |
+
],
|
| 104 |
+
"office-docs": [
|
| 105 |
+
(r"\bexcel\b", 5), (r"\bxlsx\b", 5), (r"\bspreadsheet\b", 5),
|
| 106 |
+
(r"\bpowerpoint\b", 5), (r"\bpptx\b", 5), (r"\bslide deck\b", 5),
|
| 107 |
+
(r"\bpresentation\b", 4),
|
| 108 |
+
(r"\bdocx\b", 5), (r"\bword document\b", 5), (r"\boutlook\b", 4),
|
| 109 |
+
(r"\bcalendar\b", 3), (r"\binvoice\b", 3),
|
| 110 |
+
],
|
| 111 |
+
"creative": [
|
| 112 |
+
(r"\bpoem\b", 5), (r"\bstory\b", 4), (r"\bsong\b", 5),
|
| 113 |
+
(r"\blyrics\b", 5), (r"\bblog post\b", 4), (r"\bessay\b", 4),
|
| 114 |
+
(r"\bcreative\b", 4), (r"\blogo\b", 4), (r"\billustration\b", 4),
|
| 115 |
+
(r"\binfographic\b", 4), (r"\bbrand(ing)?\b", 3),
|
| 116 |
+
(r"\bnarrative\b", 3), (r"\bdesign a\b", 2),
|
| 117 |
+
],
|
| 118 |
+
"ops": [
|
| 119 |
+
(r"\bdeploy(ment)?\b", 4), (r"\bdocker\b", 4), (r"\bkubernetes\b", 5),
|
| 120 |
+
(r"\bk8s\b", 5), (r"\bterraform\b", 5), (r"\bci/cd\b", 4),
|
| 121 |
+
(r"\binfra(structure)?\b", 4),
|
| 122 |
+
(r"\bnginx\b", 4), (r"\bsystemd\b", 4),
|
| 123 |
+
(r"\bmigrat", 2), (r"\bbackup\b", 3),
|
| 124 |
+
(r"\bmonitor(ing)?\b", 2), (r"\balert(s|ing)?\b", 2),
|
| 125 |
+
],
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
_COMPILED: dict[str, list[tuple[re.Pattern[str], int]]] = {
|
| 129 |
+
task: [(re.compile(pat, re.IGNORECASE), w) for pat, w in rules]
|
| 130 |
+
for task, rules in _KEYWORDS.items()
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
# Tool-signature boosts. The corpus is coding-heavy, so tool evidence is
|
| 134 |
+
# what separates the coding sub-buckets from each other.
|
| 135 |
+
_EDIT_TOOLS = {"patch", "edit", "write_file", "write", "apply_patch",
|
| 136 |
+
"str_replace_editor", "multiedit", "notebookedit"}
|
| 137 |
+
_WEB_TOOLS = {"web_search", "web_extract", "webfetch", "websearch", "fetch",
|
| 138 |
+
"browser", "browser_exec"}
|
| 139 |
+
_SHELL_TOOLS = {"exec", "bash", "terminal", "exec_command", "powershell",
|
| 140 |
+
"shell", "run_command"}
|
| 141 |
+
_READ_TOOLS = {"read_file", "read", "search_files", "grep", "glob",
|
| 142 |
+
"list_dir", "ls"}
|
| 143 |
+
_TEST_RUNNER_RE = re.compile(
|
| 144 |
+
r"\b(pytest|py\.test|npm (run )?test|yarn test|pnpm test|jest|vitest|"
|
| 145 |
+
r"go test|cargo test|mvn test|gradle test|ctest|tox|nox)\b", re.IGNORECASE)
|
| 146 |
+
_OFFICE_TOOL_RE = re.compile(
|
| 147 |
+
r"\b(xlsx|pptx|docx|openpyxl|python-pptx|python-docx|excel|powerpoint)\b",
|
| 148 |
+
re.IGNORECASE)
|
| 149 |
+
|
| 150 |
+
# Splice/reader scaffolding prepended to the first user row. It names the
|
| 151 |
+
# subagent role ("root/whole_branch_review") and would otherwise dominate
|
| 152 |
+
# keyword scoring — strip it before classifying.
|
| 153 |
+
_SCAFFOLD_RE = re.compile(
|
| 154 |
+
r"^\s*(?:\[subagent brief[^\]]*\]"
|
| 155 |
+
r"|\[trace segment \d+ of \d+[^\]]*\]"
|
| 156 |
+
r"|\[continued[^\]]*\])\s*", re.IGNORECASE)
|
| 157 |
+
|
| 158 |
+
# German task vocabulary — the corpus carries ~8% German traces and the
|
| 159 |
+
# English keyword sets score them as "other".
|
| 160 |
+
_KEYWORDS_DE: dict[str, list[tuple[str, int]]] = {
|
| 161 |
+
"debug": [(r"\bfehler\b", 3), (r"\bbeheben\b", 3), (r"\bdebuggen\b", 4),
|
| 162 |
+
(r"\bkaputt\b", 3), (r"\bschlägt fehl\b", 3),
|
| 163 |
+
(r"\bwarum\b", 2), (r"\bproblem\b", 2)],
|
| 164 |
+
"test": [(r"\btests?\b", 2), (r"\btestabdeckung\b", 4),
|
| 165 |
+
(r"\bprüf(?:en|e|ung)\b", 1)],
|
| 166 |
+
"refactor": [(r"\bumbauen\b", 4), (r"\brefaktor", 5),
|
| 167 |
+
(r"\bvereinfach", 3), (r"\baufräum", 3)],
|
| 168 |
+
"review": [(r"\bprüf(?:en|e|ung)\b", 3), (r"\breview\b", 3),
|
| 169 |
+
(r"\baudit\b", 4), (r"\bbewert", 3)],
|
| 170 |
+
"implement": [(r"\beinbauen\b", 4), (r"\bimplementier", 4),
|
| 171 |
+
(r"\berstell", 3), (r"\bhinzufüg", 3), (r"\bumsetzen\b", 3)],
|
| 172 |
+
"research": [(r"\brecherch", 4), (r"\bvergleich", 3),
|
| 173 |
+
(r"\buntersuch", 3), (r"\banalysier", 2)],
|
| 174 |
+
"data-analysis": [(r"\banalysier", 3), (r"\bauswert", 4),
|
| 175 |
+
(r"\bkennzahlen\b", 4), (r"\bstatistik\b", 3)],
|
| 176 |
+
"office-docs": [(r"\bexcel\b", 5), (r"\bpräsentation\b", 5),
|
| 177 |
+
(r"\bfolien\b", 4), (r"\bbericht\b", 2),
|
| 178 |
+
(r"\btabelle\b", 3)],
|
| 179 |
+
"creative": [(r"\bgedicht\b", 5), (r"\bgeschichte\b", 4),
|
| 180 |
+
(r"\btexten\b", 3), (r"\bgestalt", 3)],
|
| 181 |
+
"ops": [(r"\bdeploy", 4), (r"\bbereitstell", 3), (r"\binfrastruktur\b", 4),
|
| 182 |
+
(r"\bserver\b", 2), (r"\bkonfigurier", 3)],
|
| 183 |
+
}
|
| 184 |
+
_COMPILED_DE: dict[str, list[tuple[re.Pattern[str], int]]] = {
|
| 185 |
+
task: [(re.compile(pat, re.IGNORECASE), w) for pat, w in rules]
|
| 186 |
+
for task, rules in _KEYWORDS_DE.items()
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
# --- language detection -------------------------------------------------
|
| 190 |
+
# The corpus is EN/DE only, so function-word density beats a real detector
|
| 191 |
+
# here: no model call, no dependency, and it is stable on short task texts.
|
| 192 |
+
# The two sets are deliberately disjoint ("war" is dropped: it is a common
|
| 193 |
+
# English noun and a common German verb, so it carries no signal).
|
| 194 |
+
_DE_WORDS = frozenset(
|
| 195 |
+
"der die das den dem des und oder nicht kein keine ist sind wird werden "
|
| 196 |
+
"wurde wurden bitte für mit auf von zu im ein eine einen einem einer auch "
|
| 197 |
+
"noch schon aber wenn dass wie wir ich du sie es hat haben kann können "
|
| 198 |
+
"muss müssen soll sollen nach über unter zwischen durch bei aus als nur "
|
| 199 |
+
"mehr sehr jetzt dann hier dort diese dieser dieses diesem sowie bereits "
|
| 200 |
+
"jeweils".split()
|
| 201 |
+
)
|
| 202 |
+
_EN_WORDS = frozenset(
|
| 203 |
+
"the and or not is are be been to of for with from by at this that these "
|
| 204 |
+
"those it its we you they he she i have has had can could should would "
|
| 205 |
+
"must may do does did but if then than only more very now here there what "
|
| 206 |
+
"which who how when where why all any each every some no yes will was were "
|
| 207 |
+
"so in on as an a".split()
|
| 208 |
+
)
|
| 209 |
+
_WORD_RE = re.compile(r"[A-Za-zÄÖÜäöüß]+")
|
| 210 |
+
_UMLAUT_RE = re.compile(r"[äöüß]", re.IGNORECASE)
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def language_scores(text: str) -> tuple[int, int]:
|
| 214 |
+
"""(de, en) function-word counts for a text, umlaut bonus included.
|
| 215 |
+
|
| 216 |
+
Public so the structural validator can require a CONFIDENT language
|
| 217 |
+
call (a margin and a ratio) instead of detect_language's tie-break.
|
| 218 |
+
"""
|
| 219 |
+
words = _WORD_RE.findall(text.lower())
|
| 220 |
+
de = sum(1 for w in words if w in _DE_WORDS)
|
| 221 |
+
en = sum(1 for w in words if w in _EN_WORDS)
|
| 222 |
+
if _UMLAUT_RE.search(text):
|
| 223 |
+
de += 2 # strong signal that survives very short texts
|
| 224 |
+
return de, en
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def detect_language(text: str) -> str:
|
| 228 |
+
"""Return 'de' or 'en' for a task text. Defaults to 'en' on a tie."""
|
| 229 |
+
if not text:
|
| 230 |
+
return "en"
|
| 231 |
+
de, en = language_scores(text)
|
| 232 |
+
return "de" if de > en else "en"
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
LANGUAGE_NAMES = {"en": "English", "de": "German"}
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def _tool_names(t: Trace) -> set[str]:
|
| 239 |
+
names: set[str] = set()
|
| 240 |
+
for m in t.messages:
|
| 241 |
+
for tc in m.tool_calls:
|
| 242 |
+
if tc.name:
|
| 243 |
+
names.add(tc.name.lower())
|
| 244 |
+
return names
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def _tool_args_blob(t: Trace, limit: int = 20000) -> str:
|
| 248 |
+
parts: list[str] = []
|
| 249 |
+
total = 0
|
| 250 |
+
for m in t.messages:
|
| 251 |
+
for tc in m.tool_calls:
|
| 252 |
+
a = tc.arguments or ""
|
| 253 |
+
parts.append(a)
|
| 254 |
+
total += len(a)
|
| 255 |
+
if total > limit:
|
| 256 |
+
return " ".join(parts)
|
| 257 |
+
return " ".join(parts)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def heuristic_scores(t: Trace) -> dict[str, float]:
|
| 261 |
+
"""Raw per-task scores from keywords + tool signature."""
|
| 262 |
+
scores = {task: 0.0 for task in TASKS}
|
| 263 |
+
raw = (t.first_task_message() or "")[:6000]
|
| 264 |
+
text = _SCAFFOLD_RE.sub("", raw)
|
| 265 |
+
for rules in (_COMPILED, _COMPILED_DE):
|
| 266 |
+
for task, task_rules in rules.items():
|
| 267 |
+
for rx, weight in task_rules:
|
| 268 |
+
hits = len(rx.findall(text))
|
| 269 |
+
if hits:
|
| 270 |
+
# diminishing returns: one strong term beats a keyword soup
|
| 271 |
+
scores[task] += weight * (1 + 0.25 * min(hits - 1, 4))
|
| 272 |
+
|
| 273 |
+
tools = _tool_names(t)
|
| 274 |
+
if tools & _EDIT_TOOLS:
|
| 275 |
+
scores["implement"] += 3
|
| 276 |
+
scores["refactor"] += 1.5
|
| 277 |
+
if tools & _WEB_TOOLS:
|
| 278 |
+
scores["research"] += 4
|
| 279 |
+
if tools & _SHELL_TOOLS:
|
| 280 |
+
scores["ops"] += 1
|
| 281 |
+
scores["debug"] += 1
|
| 282 |
+
if tools and not (tools & _EDIT_TOOLS) and (tools & _READ_TOOLS):
|
| 283 |
+
scores["review"] += 2
|
| 284 |
+
if _TEST_RUNNER_RE.search(_tool_args_blob(t)):
|
| 285 |
+
scores["test"] += 4
|
| 286 |
+
if _OFFICE_TOOL_RE.search(_tool_args_blob(t)):
|
| 287 |
+
scores["office-docs"] += 5
|
| 288 |
+
return scores
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
def classify_heuristic(t: Trace) -> dict[str, Any]:
|
| 292 |
+
"""Deterministic label + confidence + the evidence that produced it."""
|
| 293 |
+
scores = heuristic_scores(t)
|
| 294 |
+
ranked = sorted(scores.items(), key=lambda kv: kv[1], reverse=True)
|
| 295 |
+
top_task, top = ranked[0]
|
| 296 |
+
second = ranked[1][1] if len(ranked) > 1 else 0.0
|
| 297 |
+
if top <= 0:
|
| 298 |
+
return {"task": "other", "confidence": 0.0, "scores": scores,
|
| 299 |
+
"method": "heuristic"}
|
| 300 |
+
if top == second:
|
| 301 |
+
# a tie is not a label — hand it to the LLM tier or "other"
|
| 302 |
+
return {"task": "other", "confidence": 0.0, "scores": scores,
|
| 303 |
+
"method": "heuristic", "tie": True}
|
| 304 |
+
# confidence: share of the top two, so a clear winner scores near 1.0
|
| 305 |
+
confidence = (top - second) / top
|
| 306 |
+
return {"task": top_task, "confidence": round(confidence, 3),
|
| 307 |
+
"scores": scores, "method": "heuristic"}
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
# ------------------------------------------------------------- LLM tier
|
| 311 |
+
|
| 312 |
+
_CLASSIFY_SCHEMA: dict[str, Any] = {
|
| 313 |
+
"type": "object",
|
| 314 |
+
"properties": {
|
| 315 |
+
"task": {"type": "string", "enum": list(TASKS)},
|
| 316 |
+
"reason": {"type": "string"},
|
| 317 |
+
},
|
| 318 |
+
"required": ["task"],
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def _classify_prompt(t: Trace) -> str:
|
| 323 |
+
task_text = (t.first_task_message() or "")[:2500]
|
| 324 |
+
tools = sorted(_tool_names(t))
|
| 325 |
+
# a few assistant prose lines carry the intent when the user message is
|
| 326 |
+
# terse ("continue", "go on")
|
| 327 |
+
prose = [
|
| 328 |
+
(m.content or "").strip()[:200]
|
| 329 |
+
for m in t.messages
|
| 330 |
+
if m.role == "assistant" and (m.content or "").strip()
|
| 331 |
+
][:4]
|
| 332 |
+
taxonomy = "\n".join(f"- {k}: {v}" for k, v in TASK_DESCRIPTIONS.items())
|
| 333 |
+
return (
|
| 334 |
+
"Classify this agent session into exactly one task bucket.\n\n"
|
| 335 |
+
f"Buckets:\n{taxonomy}\n\n"
|
| 336 |
+
f"Tools used: {', '.join(tools) or '(none)'}\n\n"
|
| 337 |
+
f"User task:\n{task_text}\n\n"
|
| 338 |
+
f"Assistant prose samples:\n" + "\n".join(f"- {p}" for p in prose) +
|
| 339 |
+
"\n\nAnswer with the single best bucket and a one-line reason."
|
| 340 |
+
)
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
def classify_llm(t: Trace, cfg) -> Optional[dict[str, Any]]:
|
| 344 |
+
"""One structured call. Returns None when the endpoint is unavailable."""
|
| 345 |
+
from traceforge import triage
|
| 346 |
+
|
| 347 |
+
try:
|
| 348 |
+
base, key, model = triage._resolve_endpoint(cfg)
|
| 349 |
+
except Exception:
|
| 350 |
+
return None
|
| 351 |
+
payload = {
|
| 352 |
+
"model": model,
|
| 353 |
+
"messages": [
|
| 354 |
+
{"role": "system",
|
| 355 |
+
"content": "You label agent sessions. Answer with JSON only."},
|
| 356 |
+
{"role": "user", "content": _classify_prompt(t)},
|
| 357 |
+
],
|
| 358 |
+
"temperature": 0.0,
|
| 359 |
+
"max_tokens": 200,
|
| 360 |
+
"response_format": {
|
| 361 |
+
"type": "json_schema",
|
| 362 |
+
"json_schema": {"name": "task_label",
|
| 363 |
+
"schema": _CLASSIFY_SCHEMA, "strict": True},
|
| 364 |
+
},
|
| 365 |
+
}
|
| 366 |
+
try:
|
| 367 |
+
data = triage._post_json(
|
| 368 |
+
triage._chat_url(base), payload, key, cfg.triage_timeout_s)
|
| 369 |
+
raw = triage._extract_content(data)
|
| 370 |
+
obj = json.loads(raw) if raw else None
|
| 371 |
+
except Exception:
|
| 372 |
+
return None
|
| 373 |
+
if not isinstance(obj, dict) or obj.get("task") not in TASKS:
|
| 374 |
+
return None
|
| 375 |
+
return {"task": obj["task"], "confidence": 1.0,
|
| 376 |
+
"reason": obj.get("reason", ""), "method": "llm"}
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
def classify_trace(
|
| 380 |
+
t: Trace, cfg=None, *, use_llm: bool = True, min_confidence: float = 0.35,
|
| 381 |
+
) -> dict[str, Any]:
|
| 382 |
+
"""Label one trace.
|
| 383 |
+
|
| 384 |
+
LLM first when an endpoint is configured (it reads intent, which the
|
| 385 |
+
keyword prior cannot); the heuristic is the fallback and is always
|
| 386 |
+
computed so the result carries the evidence for review.
|
| 387 |
+
"""
|
| 388 |
+
heuristic = classify_heuristic(t)
|
| 389 |
+
if use_llm and cfg is not None:
|
| 390 |
+
llm = classify_llm(t, cfg)
|
| 391 |
+
if llm:
|
| 392 |
+
llm["heuristic"] = heuristic
|
| 393 |
+
return llm
|
| 394 |
+
return heuristic
|
traceforge/cli.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
traceforge/config.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""traceforge configuration: defaults + optional YAML overrides."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
from typing import Any, Optional
|
| 7 |
+
|
| 8 |
+
DEFAULT_HERMES_HOME = os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes"))
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@dataclass
|
| 12 |
+
class Config:
|
| 13 |
+
# ---------------------------------------------------------------- paths
|
| 14 |
+
hermes_home: str = DEFAULT_HERMES_HOME
|
| 15 |
+
state_db: str = "" # derived from hermes_home when empty
|
| 16 |
+
data_dir: str = "" # derived from repo root when empty
|
| 17 |
+
guidelines_path: str = "" # derived from repo root when empty
|
| 18 |
+
# Seed stores (2026-10-13): the repo ships portable trace archives —
|
| 19 |
+
# harness-traces/{hermes,claude,codex} (a snapshot of all three
|
| 20 |
+
# live stores) and exported-seed-datas/* (codex rollouts from other
|
| 21 |
+
# machines). resolve() auto-appends them to the reader path lists
|
| 22 |
+
# below when the folders exist, so `traceforge generate` /
|
| 23 |
+
# scripts/generate.sh processes them with zero configuration.
|
| 24 |
+
# Lists are ADDITIVE to the live stores; empty = live store only.
|
| 25 |
+
hermes_state_dbs: list[str] = field(default_factory=list)
|
| 26 |
+
claude_projects_dirs: list[str] = field(default_factory=list)
|
| 27 |
+
# Published SFT corpora normalized into the pool by
|
| 28 |
+
# scripts/fetch_nemotron.py (one <subset>.jsonl per dataset subset).
|
| 29 |
+
# Auto-discovered from seed-datasets/nemotron/ when it exists.
|
| 30 |
+
nemotron_roots: list[str] = field(default_factory=list)
|
| 31 |
+
|
| 32 |
+
# -------------------------------------------------------- trace picking
|
| 33 |
+
# min_messages / min_tool_calls are HARD floors: they drop degenerate
|
| 34 |
+
# stubs (2-9 message probes), not long work.
|
| 35 |
+
# max_messages is a SOFT cap (2026-10-23): a session above it is still
|
| 36 |
+
# a candidate, flagged `oversize` in the listing row. The hard cap it
|
| 37 |
+
# replaced silently discarded 3,026 real agentic sessions (563 hermes,
|
| 38 |
+
# 1,246 codex, 1,217 claude — 35% of the viable pool) before they
|
| 39 |
+
# could reach split_long, which is the stage that actually bounds what
|
| 40 |
+
# the pipeline processes: by rendered size (split_max_chars), not by
|
| 41 |
+
# message count. A 200-message session is exactly the long tool-call
|
| 42 |
+
# chain the corpus wants.
|
| 43 |
+
# NOTE: `traceforge list|pull|generate` take these from argparse
|
| 44 |
+
# defaults in cli.py, not from this file — see the report's cli.py
|
| 45 |
+
# patch note.
|
| 46 |
+
min_messages: int = 10
|
| 47 |
+
max_messages: int = 120
|
| 48 |
+
min_tool_calls: int = 2
|
| 49 |
+
|
| 50 |
+
# ------------------------------------------------------- prompt budgets
|
| 51 |
+
user_chars: int = 4000
|
| 52 |
+
assistant_chars: int = 4000
|
| 53 |
+
tool_result_chars: int = 1200
|
| 54 |
+
tool_args_chars: int = 600
|
| 55 |
+
include_reasoning: bool = False
|
| 56 |
+
|
| 57 |
+
# ------------------------------------------------------ improver harness
|
| 58 |
+
model: Optional[str] = None # None = hermes default profile model
|
| 59 |
+
provider: Optional[str] = None
|
| 60 |
+
max_turns: Optional[int] = 16 # improver: read → write → validate → reply, with headroom
|
| 61 |
+
run_budget: Optional[int] = None
|
| 62 |
+
harness_timeout_s: int = 1200
|
| 63 |
+
improver_workdir: str = "" # derived: data_dir/work/ when empty
|
| 64 |
+
skills: list[str] = field(default_factory=list) # -s preloads for improver
|
| 65 |
+
|
| 66 |
+
# ------------------------------------------------- external verification
|
| 67 |
+
verify: bool = True # web-search fact-check stage (adversarial verifier)
|
| 68 |
+
verifier_max_turns: int = 20
|
| 69 |
+
verifier_timeout_s: int = 900
|
| 70 |
+
|
| 71 |
+
# ---------------------------------------------------- review rounds
|
| 72 |
+
# Never trust one reviewer: quality-pillar review runs independently
|
| 73 |
+
# this many times; ALL rounds must PASS for corpus admission.
|
| 74 |
+
review: bool = True
|
| 75 |
+
review_rounds: int = 2
|
| 76 |
+
review_max_turns: int = 16
|
| 77 |
+
review_timeout_s: int = 900 # deep-thinker reviewers verify byte-exact;
|
| 78 |
+
# 600s timed out in practice (2026-09-26)
|
| 79 |
+
|
| 80 |
+
# verification rounds: fact-check runs this many independent times
|
| 81 |
+
verify_rounds: int = 2
|
| 82 |
+
|
| 83 |
+
# ------------------------------------------------- cross-family confirm
|
| 84 |
+
# Second-family confirmation on promotion (residual-defect squeeze):
|
| 85 |
+
# after the primary judge PASSes, ONE selfhosted model from a
|
| 86 |
+
# different family re-judges the final window. VETO blocks promotion
|
| 87 |
+
# (the trace fails honestly and lands on the retry queue).
|
| 88 |
+
confirm_model: Optional[str] = None # e.g. "gemma4-31b-selfhosted"
|
| 89 |
+
|
| 90 |
+
# ---------------------------------------------------------- long traces
|
| 91 |
+
# Traces whose compact rendering exceeds chunk_max_chars go through the
|
| 92 |
+
# map-digest-write pipeline instead of single-shot improvement.
|
| 93 |
+
chunk_max_chars: int = 100_000 # ≈25k tokens per chunk prompt
|
| 94 |
+
chunk_overlap_messages: int = 2 # continuity tail between chunks
|
| 95 |
+
digest_workers: int = 3 # parallel digester sessions
|
| 96 |
+
digest_max_turns: int = 8
|
| 97 |
+
digest_timeout_s: int = 600
|
| 98 |
+
digest_output_clip: int = 6000 # max chars per verbatim output kept
|
| 99 |
+
|
| 100 |
+
# ------------------------------------------------------- live telemetry
|
| 101 |
+
# LiveStats polls state.db child-session counters while batch runs
|
| 102 |
+
# are in flight. TTY: per-trace sublines + tok/s postfix. Pipes: one
|
| 103 |
+
# heartbeat line every live_heartbeat_s. 0 disables the poller.
|
| 104 |
+
live_poll_s: float = 2.0
|
| 105 |
+
live_heartbeat_s: float = 30.0
|
| 106 |
+
|
| 107 |
+
# ------------------------------------------------------------- triage
|
| 108 |
+
# Cheap LLM pass (structured generation, NOT a hermes one-shot) that
|
| 109 |
+
# labels every message keep/drop/rephrase/fact_check/summarize BEFORE
|
| 110 |
+
# the expensive improver runs. Output: data/triage/<sid>.json.
|
| 111 |
+
triage: bool = False
|
| 112 |
+
triage_base_url: str = "" # empty = hermes active-profile endpoint
|
| 113 |
+
triage_model: str = "" # empty = server default
|
| 114 |
+
triage_max_chars: int = 24_000 # per-window char budget (≈6k tokens)
|
| 115 |
+
triage_overlap_messages: int = 4
|
| 116 |
+
triage_workers: int = 8 # parallel window requests
|
| 117 |
+
# Parallel traces in the pre-pass (2026-10-23). The pre-pass used to be
|
| 118 |
+
# serial over traces, so a doubled pool meant hours of silent log before
|
| 119 |
+
# the first promotion. Each trace is self-contained, so the outer loop
|
| 120 |
+
# runs this many traces at once; total in-flight requests ≈
|
| 121 |
+
# triage_prepass_workers × triage_workers.
|
| 122 |
+
triage_prepass_workers: int = 4
|
| 123 |
+
triage_timeout_s: int = 300
|
| 124 |
+
triage_temperature: float = 0.0
|
| 125 |
+
|
| 126 |
+
# ------------------------------------------------- windowed splice engine
|
| 127 |
+
# Direct-HTTP windowed improve path (traceforge/splice.py): no agent
|
| 128 |
+
# runtime, stdlib only. Triage labels drive a mechanical splice; only
|
| 129 |
+
# windows with rephrase/summarize/fact_check edits pay a rewrite call;
|
| 130 |
+
# admission is a windowed judge instead of agent verify/review rounds.
|
| 131 |
+
splice: bool = True # default improve path (False = hermes agents)
|
| 132 |
+
splice_max_chars: int = 24_000 # per-window char budget for rewrites/judge
|
| 133 |
+
splice_workers: int = 8 # parallel window HTTP requests
|
| 134 |
+
splice_temperature: float = 0.2
|
| 135 |
+
rewrite_max_tokens: int = 8000 # per-window rewrite emit ceiling
|
| 136 |
+
rewrite_timeout_s: int = 300 # per rewrite HTTP call
|
| 137 |
+
judge_model: str = "" # empty = same as cfg.model
|
| 138 |
+
judge_max_tokens: int = 3000
|
| 139 |
+
judge_timeout_s: int = 300
|
| 140 |
+
allow_task_reframe: bool = True # intent-only FAIL + good answer ->
|
| 141 |
+
# rewrite the task message once
|
| 142 |
+
|
| 143 |
+
# --------------------------------------------------------------- extras
|
| 144 |
+
raw_cache: bool = True # cache normalized raw traces under data/traces/raw
|
| 145 |
+
overwrite: bool = False
|
| 146 |
+
# Long-trace splitting (2026-10-07, owner directive): raws whose
|
| 147 |
+
# rendered size exceeds split_max_chars become N segment traces
|
| 148 |
+
# <sid>#p1..N at pull time — each ~≤120k tokens, each its own
|
| 149 |
+
# pipeline candidate (triage/splice/judge/promotion per segment).
|
| 150 |
+
split_long: bool = True
|
| 151 |
+
split_max_chars: int = 480_000 # ≈120k tokens per output trace
|
| 152 |
+
# Split TRIGGER (2026-10-24): only traces above this rendered size are
|
| 153 |
+
# split at all; 0 = same as split_max_chars (split everything over the
|
| 154 |
+
# segment ceiling, the pre-2026-10-24 behaviour).
|
| 155 |
+
#
|
| 156 |
+
# Measured on 300KB+ raws re-split three ways (same 3 parents, 19
|
| 157 |
+
# segments, scratch/exp): 480k segments promoted 0/2 at 650s and ~197k
|
| 158 |
+
# tokens each; 240k segments 1/6 (16.7%) at 418s; 120k segments 5/11
|
| 159 |
+
# (45.5%) at 364s and ~76k tokens. Cost per promotion: 802s at 120k vs
|
| 160 |
+
# 2507s at 240k vs never at 480k. The historical corpus agrees — last
|
| 161 |
+
# segments promote 47% at 60-120KB, 42% at 120-180KB, 4.8% at 420-480KB.
|
| 162 |
+
# Recommended pair for the 300KB+ backlog: split_max_chars=120_000 with
|
| 163 |
+
# split_trigger_chars=240_000 (leave the 60-240KB cohort whole: it
|
| 164 |
+
# already promotes at 27-32% and splitting it only doubles the attempts).
|
| 165 |
+
split_trigger_chars: int = 0
|
| 166 |
+
# A tail smaller than this merges into the previous segment instead of
|
| 167 |
+
# becoming its own trace (2026-10-23: the merge branch was a no-op, so
|
| 168 |
+
# 192 of 1,044 segments were under this floor, 31 under 5k, the
|
| 169 |
+
# smallest 714 chars / 2 messages).
|
| 170 |
+
min_segment_chars: int = 60_000
|
| 171 |
+
# Hard ceiling on segments per parent session (2026-10-23). The soft
|
| 172 |
+
# max_messages cap admits long sessions, and a few are pathological:
|
| 173 |
+
# measured, one codex rollout is 50,114 messages / 155M chars, which
|
| 174 |
+
# splits into 317 segments of the same task. At ~10-20 min per
|
| 175 |
+
# segment that is days of batch time for one session, and it makes
|
| 176 |
+
# the pool count and ETA meaningless. Above this ceiling the split
|
| 177 |
+
# keeps the FIRST max-1 segments (task setup + early work) and the
|
| 178 |
+
# LAST one (the real final answer), dropping the middle — the same
|
| 179 |
+
# standalone-window contract every later segment already has. Set 0
|
| 180 |
+
# to disable the ceiling.
|
| 181 |
+
max_segments_per_trace: int = 16
|
| 182 |
+
# Codex rollout stores to read (live store + extracted seed exports).
|
| 183 |
+
# Extra roots are merged into one multi-root index; thread ids are
|
| 184 |
+
# globally unique (uuid v7) so collisions cannot occur.
|
| 185 |
+
codex_roots: list[str] = field(default_factory=list)
|
| 186 |
+
# Claude subagent sidechains (<uuid>/subagents/agent-*.jsonl) as
|
| 187 |
+
# separate candidate traces (MASS arXiv 2610.12176: multi-agent
|
| 188 |
+
# trajectories are the densest training signal; see export._agent_role).
|
| 189 |
+
claude_sidechains: bool = True
|
| 190 |
+
|
| 191 |
+
def resolve(self, repo_root: str) -> "Config":
|
| 192 |
+
self.state_db = self.state_db or os.path.join(self.hermes_home, "state.db")
|
| 193 |
+
self.data_dir = self.data_dir or os.path.join(repo_root, "data")
|
| 194 |
+
self.guidelines_path = self.guidelines_path or os.path.join(
|
| 195 |
+
repo_root, "guidelines", "IDEAL_TRACE_GUIDELINES.md"
|
| 196 |
+
)
|
| 197 |
+
self.improver_workdir = self.improver_workdir or os.path.join(
|
| 198 |
+
self.data_dir, "work"
|
| 199 |
+
)
|
| 200 |
+
self.codex_roots = [os.path.expanduser(r) for r in self.codex_roots]
|
| 201 |
+
# ---- seed-store auto-discovery (see field docs above) --------
|
| 202 |
+
# harness-traces/<store> + exported-seed-datas/* are appended to
|
| 203 |
+
# the reader path lists when present; already-listed paths are
|
| 204 |
+
# never duplicated (YAML overrides + auto-discovery merge).
|
| 205 |
+
def _add_unique(lst: list[str], item: str) -> None:
|
| 206 |
+
if item not in lst:
|
| 207 |
+
lst.append(item)
|
| 208 |
+
|
| 209 |
+
seed_hermes = os.path.join(repo_root, "harness-traces", "hermes", "state.db")
|
| 210 |
+
if os.path.exists(seed_hermes):
|
| 211 |
+
_add_unique(self.hermes_state_dbs, seed_hermes)
|
| 212 |
+
seed_claude = os.path.join(repo_root, "harness-traces", "claude")
|
| 213 |
+
if os.path.isdir(seed_claude):
|
| 214 |
+
_add_unique(self.claude_projects_dirs, seed_claude)
|
| 215 |
+
seed_codex = os.path.join(repo_root, "harness-traces", "codex")
|
| 216 |
+
if os.path.isdir(seed_codex):
|
| 217 |
+
_add_unique(self.codex_roots, seed_codex)
|
| 218 |
+
seed_rollouts = os.path.join(repo_root, "exported-seed-datas")
|
| 219 |
+
if os.path.isdir(seed_rollouts):
|
| 220 |
+
_add_unique(self.codex_roots, seed_rollouts)
|
| 221 |
+
seed_nemotron = os.path.join(repo_root, "seed-datasets", "nemotron")
|
| 222 |
+
if os.path.isdir(seed_nemotron):
|
| 223 |
+
_add_unique(self.nemotron_roots, seed_nemotron)
|
| 224 |
+
self.hermes_state_dbs = [os.path.expanduser(d) for d in self.hermes_state_dbs]
|
| 225 |
+
self.claude_projects_dirs = [
|
| 226 |
+
os.path.expanduser(d) for d in self.claude_projects_dirs
|
| 227 |
+
]
|
| 228 |
+
self.nemotron_roots = [os.path.expanduser(d) for d in self.nemotron_roots]
|
| 229 |
+
return self
|
| 230 |
+
|
| 231 |
+
@property
|
| 232 |
+
def raw_dir(self) -> str:
|
| 233 |
+
return os.path.join(self.data_dir, "traces", "raw")
|
| 234 |
+
|
| 235 |
+
@property
|
| 236 |
+
def improved_dir(self) -> str:
|
| 237 |
+
return os.path.join(self.data_dir, "traces", "improved")
|
| 238 |
+
|
| 239 |
+
@property
|
| 240 |
+
def prompts_dir(self) -> str:
|
| 241 |
+
return os.path.join(self.data_dir, "prompts")
|
| 242 |
+
|
| 243 |
+
@property
|
| 244 |
+
def triage_dir(self) -> str:
|
| 245 |
+
return os.path.join(self.data_dir, "triage")
|
| 246 |
+
|
| 247 |
+
@property
|
| 248 |
+
def manifest_path(self) -> str:
|
| 249 |
+
return os.path.join(self.data_dir, "manifest.jsonl")
|
| 250 |
+
|
| 251 |
+
# ------------------------------------------------------------------ load
|
| 252 |
+
|
| 253 |
+
@classmethod
|
| 254 |
+
def load(cls, path: Optional[str] = None, repo_root: str = ".") -> "Config":
|
| 255 |
+
cfg = cls()
|
| 256 |
+
if path:
|
| 257 |
+
if not os.path.exists(path):
|
| 258 |
+
raise FileNotFoundError(f"config file not found: {path}")
|
| 259 |
+
try:
|
| 260 |
+
import yaml # optional dependency
|
| 261 |
+
except ImportError as e:
|
| 262 |
+
raise RuntimeError(
|
| 263 |
+
"PyYAML is required for --config files: uv pip install pyyaml"
|
| 264 |
+
) from e
|
| 265 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 266 |
+
overrides: dict[str, Any] = yaml.safe_load(f) or {}
|
| 267 |
+
unknown = set(overrides) - {f for f in cls.__dataclass_fields__}
|
| 268 |
+
if unknown:
|
| 269 |
+
raise ValueError(f"unknown config keys: {sorted(unknown)}")
|
| 270 |
+
cfg = cls(**{k: v for k, v in overrides.items() if v is not None})
|
| 271 |
+
return cfg.resolve(os.path.abspath(repo_root))
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def repo_root_from_pkg() -> str:
|
| 275 |
+
"""Repo root as located relative to this package (traceforge/../)."""
|
| 276 |
+
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
traceforge/defects.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stable defect codes for a failed improve attempt.
|
| 2 |
+
|
| 3 |
+
Why this module exists
|
| 4 |
+
----------------------
|
| 5 |
+
The pipeline used to record a failure as a free-text `stage` plus an `errors`
|
| 6 |
+
list. That is enough to read one trace and useless for a funnel: "repair 2" and
|
| 7 |
+
"gates fail (2)" are strings, so every report re-derived its own grouping and
|
| 8 |
+
two reports of the same corpus disagreed.
|
| 9 |
+
|
| 10 |
+
A defect code is the smallest thing that makes a loss countable. It is stable
|
| 11 |
+
across runs, it is what the yield table is broken down by, and it is what tells
|
| 12 |
+
you whether a bad week was the writer, the source, or the gate.
|
| 13 |
+
|
| 14 |
+
The codes follow the shared taxonomy in the agentic-sft-synth skill suite
|
| 15 |
+
(`agentic-verify` §"Defect codes") where a code already exists, and add the
|
| 16 |
+
repo-specific ones the suite has no name for. Do not invent a new code for a
|
| 17 |
+
failure that already has one; extend the mapping instead.
|
| 18 |
+
|
| 19 |
+
from traceforge.defects import classify, census
|
| 20 |
+
code = classify(stage="repair 2", errors=["max repair rounds exceeded"])
|
| 21 |
+
# -> "D-REP-01"
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import collections
|
| 27 |
+
import json
|
| 28 |
+
import os
|
| 29 |
+
import re
|
| 30 |
+
from typing import Iterable, Optional
|
| 31 |
+
|
| 32 |
+
# code -> (short name, layer, what it means, what to do about it)
|
| 33 |
+
DEFECTS: dict[str, tuple[str, str, str, str]] = {
|
| 34 |
+
# --- source / task ---------------------------------------------------
|
| 35 |
+
"D-TASK-04": ("no_task", "init",
|
| 36 |
+
"the source session has no user task message",
|
| 37 |
+
"not rewritable; the task lived in another session"),
|
| 38 |
+
"D-TASK-05": ("harness_brief", "init",
|
| 39 |
+
"the only opening user message is a harness dispatch brief",
|
| 40 |
+
"unwrap the brief to the real task, or drop"),
|
| 41 |
+
"D-TASK-02": ("reframe_failed", "init",
|
| 42 |
+
"the task could not be reframed into a single intent",
|
| 43 |
+
"usually several tasks in one session; split it"),
|
| 44 |
+
# --- writer / repair -------------------------------------------------
|
| 45 |
+
"D-REP-01": ("repair_exhausted", "repair",
|
| 46 |
+
"the repair budget ran out before the trace passed",
|
| 47 |
+
"the dominant loss; a third round rarely helps (R-85)"),
|
| 48 |
+
"D-REP-02": ("repair_round_1", "repair",
|
| 49 |
+
"failed after the first repair round",
|
| 50 |
+
"inspect the first violation; often a single bad window"),
|
| 51 |
+
"D-REP-03": ("no_writer_artifact", "writer",
|
| 52 |
+
"the writer produced nothing to verify",
|
| 53 |
+
"check the writer call, not the trace"),
|
| 54 |
+
# --- gates -----------------------------------------------------------
|
| 55 |
+
"D-GATE-01": ("gate_reject", "gate",
|
| 56 |
+
"a hard gate rejected the trace",
|
| 57 |
+
"read the violation; a gate with low precision is R-88"),
|
| 58 |
+
"D-VER-01": ("judge_violation", "judge",
|
| 59 |
+
"the judge found an unsupported claim or a broken pillar",
|
| 60 |
+
"the trace is fluent and wrong; do not ship it"),
|
| 61 |
+
"D-VER-02": ("fabricated_observation", "gate",
|
| 62 |
+
"a tool result shares no content with the call that produced it",
|
| 63 |
+
"fabrication; the trace is unusable, not repairable"),
|
| 64 |
+
# --- format (shared with the suite) ----------------------------------
|
| 65 |
+
"D-FMT-01": ("pairing", "format",
|
| 66 |
+
"tool call/result pairing or turn structure is broken",
|
| 67 |
+
"structural; cheap to detect, never ship"),
|
| 68 |
+
"D-FMT-03": ("final_blob", "format",
|
| 69 |
+
"the final answer is a dump of file contents, not a synthesis",
|
| 70 |
+
"rewrite the closing turn or drop"),
|
| 71 |
+
"D-FMT-04": ("self_talk", "format",
|
| 72 |
+
"the assistant narrates a plan and never acts on it",
|
| 73 |
+
"drop the step; it teaches narration"),
|
| 74 |
+
"D-FMT-05": ("judge_residue", "format",
|
| 75 |
+
"verifier text leaked into the trace",
|
| 76 |
+
"strip it; it is not the model's output"),
|
| 77 |
+
"D-FMT-06": ("clip_marker", "format",
|
| 78 |
+
"a truncation marker sits inside a message",
|
| 79 |
+
"the trace was clipped; drop or re-pull"),
|
| 80 |
+
"D-SAFE-03": ("identity_burn", "safety",
|
| 81 |
+
"the assistant claims a harness identity in a trainable span",
|
| 82 |
+
"strip the claim mechanically before the gates"),
|
| 83 |
+
# --- infra -----------------------------------------------------------
|
| 84 |
+
# 2026-10-26: 191 traces failed triage inside a 37-minute window
|
| 85 |
+
# (01:50-02:27) and every one was recorded as D-UNK-01, because the
|
| 86 |
+
# per-window error text was discarded and no code existed for "the
|
| 87 |
+
# endpoint failed". An infra loss is not a trace defect: it must be
|
| 88 |
+
# retried, never counted against the pool's yield.
|
| 89 |
+
"D-INF-01": ("llm_unavailable", "infra",
|
| 90 |
+
"the model endpoint failed every call for this trace",
|
| 91 |
+
"not a trace defect; retry it, do not count it as a loss"),
|
| 92 |
+
# --- unknown ---------------------------------------------------------
|
| 93 |
+
"D-UNK-01": ("unclassified", "unknown",
|
| 94 |
+
"a failure with no matching rule",
|
| 95 |
+
"extend classify(); an unclassified loss is a reporting bug"),
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
# Ordered rules: first match wins. Order matters — the specific before the
|
| 99 |
+
# general, because "max repair rounds exceeded" also contains "repair".
|
| 100 |
+
_RULES: list[tuple[str, re.Pattern[str]]] = [
|
| 101 |
+
# infra first: when a record carries both a network error and a repair
|
| 102 |
+
# verdict, the network error is the cause and the verdict is its shadow.
|
| 103 |
+
("D-INF-01", re.compile(
|
| 104 |
+
r"network:|urlopen|WinError 10054|connection (?:reset|refused|aborted)"
|
| 105 |
+
r"|infra error|failed for every window|rate limit", re.I)),
|
| 106 |
+
("D-TASK-05", re.compile(r"harness dispatch brief|dispatch brief", re.I)),
|
| 107 |
+
("D-TASK-04", re.compile(r"no user task message|no user task", re.I)),
|
| 108 |
+
("D-TASK-02", re.compile(r"reframe", re.I)),
|
| 109 |
+
("D-VER-02", re.compile(r"fabricated tool result|shares no content", re.I)),
|
| 110 |
+
("D-FMT-01", re.compile(r"multi-turn:.*dropped|pairing|orphan", re.I)),
|
| 111 |
+
("D-FMT-03", re.compile(r"final_blob|final answer is a (?:file )?dump", re.I)),
|
| 112 |
+
("D-FMT-04", re.compile(r"self_talk|self-talk", re.I)),
|
| 113 |
+
("D-FMT-05", re.compile(r"judge_residue|judge residue", re.I)),
|
| 114 |
+
("D-FMT-06", re.compile(r"clip_marker|truncated", re.I)),
|
| 115 |
+
("D-SAFE-03", re.compile(r"identity_burn|co-authored-by", re.I)),
|
| 116 |
+
("D-REP-01", re.compile(r"max repair rounds|repair budget", re.I)),
|
| 117 |
+
("D-REP-03", re.compile(r"no writer artifact|writer produced nothing", re.I)),
|
| 118 |
+
("D-VER-01", re.compile(r"judge violations?|unsupported claim", re.I)),
|
| 119 |
+
("D-GATE-01", re.compile(r"^gate:|gate fail", re.I)),
|
| 120 |
+
]
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def classify(stage: Optional[str] = None,
|
| 124 |
+
errors: Optional[Iterable[str]] = None,
|
| 125 |
+
violations: Optional[Iterable[dict]] = None) -> str:
|
| 126 |
+
"""Map a failure record to one stable code.
|
| 127 |
+
|
| 128 |
+
Errors are checked before the stage, because the error text is the specific
|
| 129 |
+
signal and the stage is the bucket. A record with neither is D-UNK-01 —
|
| 130 |
+
which is a bug in the caller, not a property of the trace.
|
| 131 |
+
"""
|
| 132 |
+
text = " | ".join(str(e) for e in (errors or []))
|
| 133 |
+
for code, pat in _RULES:
|
| 134 |
+
if pat.search(text):
|
| 135 |
+
return code
|
| 136 |
+
|
| 137 |
+
# violations carry a pillar; a judge violation is the only one we can name
|
| 138 |
+
for v in (violations or []):
|
| 139 |
+
if str(v.get("pillar", "")).upper() in {"P2", "ANSWER"}:
|
| 140 |
+
return "D-VER-01"
|
| 141 |
+
|
| 142 |
+
st = (stage or "").strip().lower()
|
| 143 |
+
if st.startswith("repair 2"):
|
| 144 |
+
return "D-REP-01"
|
| 145 |
+
if st.startswith("repair 1"):
|
| 146 |
+
return "D-REP-02"
|
| 147 |
+
if st.startswith("gates fail"):
|
| 148 |
+
return "D-GATE-01"
|
| 149 |
+
if st.startswith("init"):
|
| 150 |
+
return "D-TASK-04"
|
| 151 |
+
if st.startswith("reframe"):
|
| 152 |
+
return "D-TASK-02"
|
| 153 |
+
return "D-UNK-01"
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def census(workdir: str) -> dict[str, int]:
|
| 157 |
+
"""Count failure.json records by defect code, newest verdict per trace.
|
| 158 |
+
|
| 159 |
+
Reads only `failure.json` (the latest verdict), not the append-only log, so
|
| 160 |
+
a trace that failed three times counts once.
|
| 161 |
+
"""
|
| 162 |
+
counts: collections.Counter[str] = collections.Counter()
|
| 163 |
+
try:
|
| 164 |
+
entries = os.listdir(workdir)
|
| 165 |
+
except OSError:
|
| 166 |
+
return {}
|
| 167 |
+
for name in entries:
|
| 168 |
+
path = os.path.join(workdir, name, "failure.json")
|
| 169 |
+
if not os.path.exists(path):
|
| 170 |
+
continue
|
| 171 |
+
try:
|
| 172 |
+
with open(path, encoding="utf-8") as f:
|
| 173 |
+
row = json.load(f)
|
| 174 |
+
except (OSError, ValueError):
|
| 175 |
+
counts["D-UNK-01"] += 1
|
| 176 |
+
continue
|
| 177 |
+
code = row.get("defect") or classify(
|
| 178 |
+
row.get("stage"), row.get("errors"), row.get("violations"))
|
| 179 |
+
counts[code] += 1
|
| 180 |
+
return dict(counts.most_common())
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def describe(code: str) -> str:
|
| 184 |
+
"""One line for a report: code, name, meaning."""
|
| 185 |
+
name, layer, meaning, _ = DEFECTS.get(code, DEFECTS["D-UNK-01"])
|
| 186 |
+
return f"{code} {name} [{layer}] — {meaning}"
|
traceforge/difficulty.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Difficulty bands, measured from the trace itself.
|
| 2 |
+
|
| 3 |
+
The card reported topic and language but not difficulty, so a reader could not
|
| 4 |
+
tell whether the corpus was 2,000 one-shot edits or 2,000 twenty-call debugging
|
| 5 |
+
sessions. Those are different datasets.
|
| 6 |
+
|
| 7 |
+
The bands are the shared ones from the agentic-sft-synth suite
|
| 8 |
+
(`agentic-task-design` §"Difficulty"): the number of tool calls in the ideal
|
| 9 |
+
path, which is the only difficulty signal that is cheap, deterministic, and
|
| 10 |
+
already in the record. It is a proxy — a 20-call mechanical rename is easier
|
| 11 |
+
than a 6-call race-condition hunt — so the card reports it as a distribution,
|
| 12 |
+
never as a score.
|
| 13 |
+
|
| 14 |
+
from traceforge.difficulty import band, census
|
| 15 |
+
band(7) # -> "D3"
|
| 16 |
+
census(traces) # -> {"D1": 12, "D2": 88, ...}
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import collections
|
| 22 |
+
from typing import Iterable
|
| 23 |
+
|
| 24 |
+
# (code, lo, hi, label) — hi is inclusive; the last band is open-ended.
|
| 25 |
+
BANDS: list[tuple[str, int, int, str]] = [
|
| 26 |
+
("D1", 0, 2, "one-shot / single edit"),
|
| 27 |
+
("D2", 3, 5, "short loop"),
|
| 28 |
+
("D3", 6, 10, "multi-step"),
|
| 29 |
+
("D4", 11, 20, "long session"),
|
| 30 |
+
("D5", 21, 10**9, "very long session"),
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
# A trace below this is not a task worth training on: one call and an answer is
|
| 34 |
+
# a lookup, and it teaches the model to stop early.
|
| 35 |
+
MIN_CALLS = 1
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def band(n_calls: int) -> str:
|
| 39 |
+
"""Map a tool-call count to its band code."""
|
| 40 |
+
for code, lo, hi, _ in BANDS:
|
| 41 |
+
if lo <= n_calls <= hi:
|
| 42 |
+
return code
|
| 43 |
+
return "D5"
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def label(code: str) -> str:
|
| 47 |
+
for c, _, _, lab in BANDS:
|
| 48 |
+
if c == code:
|
| 49 |
+
return lab
|
| 50 |
+
return "?"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def count_calls(trace: dict) -> int:
|
| 54 |
+
"""Tool calls in an exported record (`{"messages": [...], "meta": ...}`)."""
|
| 55 |
+
n = 0
|
| 56 |
+
for m in trace.get("messages") or []:
|
| 57 |
+
n += len(m.get("tool_calls") or [])
|
| 58 |
+
return n
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def census(traces: Iterable[dict]) -> dict[str, int]:
|
| 62 |
+
"""Count exported records by band, in band order."""
|
| 63 |
+
counts: collections.Counter[str] = collections.Counter()
|
| 64 |
+
for t in traces:
|
| 65 |
+
counts[band(count_calls(t))] += 1
|
| 66 |
+
return {code: counts.get(code, 0) for code, _, _, _ in BANDS}
|
traceforge/enrich.py
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Recover the system prompt and tool definitions the harness actually sent.
|
| 2 |
+
|
| 3 |
+
The readers drop both, so an exported row has tool_calls naming tools nothing
|
| 4 |
+
defines — a model trained on it learns to emit `exec` without ever seeing what
|
| 5 |
+
`exec` accepts. The data is not lost, it is just not in the Trace:
|
| 6 |
+
|
| 7 |
+
- claude ``~/.claude/projects/*/<sid>.jsonl`` records ``api-request-shape``
|
| 8 |
+
whose ``shape`` holds the full ``system`` blocks AND the ``tools``
|
| 9 |
+
array the request was made with.
|
| 10 |
+
- codex ``~/.codex/sessions/**/rollout-*-<sid>.jsonl`` opens with
|
| 11 |
+
``session_meta.payload.base_instructions.text`` — the prompt, but
|
| 12 |
+
not the schemas (those live in the codex binary).
|
| 13 |
+
- hermes ``state.db``: ``sessions.system_prompt_hash`` -> ``system_prompts``.
|
| 14 |
+
- nemotron already embeds a ``## Available tools`` block in its system row.
|
| 15 |
+
|
| 16 |
+
Where the harness did not record schemas, the export falls back to
|
| 17 |
+
``data/toolspec.json`` — schemas derived from the arguments the corpus
|
| 18 |
+
actually passes, with per-property evidence counts. That is evidence, not
|
| 19 |
+
invention, and ``meta.tools_source`` says which one a row got.
|
| 20 |
+
"""
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import glob
|
| 24 |
+
import json
|
| 25 |
+
import os
|
| 26 |
+
import sqlite3
|
| 27 |
+
from typing import Any, Optional
|
| 28 |
+
|
| 29 |
+
CLAUDE_GLOB = os.path.expanduser("~/.claude/projects/*/*.jsonl")
|
| 30 |
+
CODEX_GLOB = os.path.expanduser("~/.codex/sessions/**/*.jsonl")
|
| 31 |
+
CODEX_ZST_GLOB = os.path.expanduser("~/.codex/sessions/**/*.jsonl.zst")
|
| 32 |
+
HERMES_DB = os.path.expanduser("~/AppData/Local/hermes/state.db")
|
| 33 |
+
TOOLSPEC = "data/toolspec.json"
|
| 34 |
+
RAW_DIR = "data/traces/raw"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _sid_from_codex(path: str) -> Optional[str]:
|
| 38 |
+
"""rollout-<iso>-<uuid>.jsonl -> <uuid>."""
|
| 39 |
+
base = os.path.basename(path)
|
| 40 |
+
if not base.startswith("rollout-"):
|
| 41 |
+
return None
|
| 42 |
+
stem = base
|
| 43 |
+
for suf in (".jsonl.zst", ".jsonl"):
|
| 44 |
+
if stem.endswith(suf):
|
| 45 |
+
stem = stem[: -len(suf)]
|
| 46 |
+
break
|
| 47 |
+
parts = stem.split("-")
|
| 48 |
+
# uuid is the last 5 dash groups
|
| 49 |
+
if len(parts) < 5:
|
| 50 |
+
return None
|
| 51 |
+
return "-".join(parts[-5:])
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _read_claude_shape(path: str) -> tuple[Optional[str], Optional[list]]:
|
| 55 |
+
"""(system_text, tools) from the first api-request-shape in the file."""
|
| 56 |
+
try:
|
| 57 |
+
with open(path, encoding="utf-8") as f:
|
| 58 |
+
for line in f:
|
| 59 |
+
if "api-request-shape" not in line:
|
| 60 |
+
continue
|
| 61 |
+
try:
|
| 62 |
+
r = json.loads(line)
|
| 63 |
+
except Exception:
|
| 64 |
+
continue
|
| 65 |
+
if r.get("type") != "api-request-shape":
|
| 66 |
+
continue
|
| 67 |
+
shape = r.get("shape") or {}
|
| 68 |
+
blocks = shape.get("system") or []
|
| 69 |
+
text = "\n\n".join(
|
| 70 |
+
b.get("text", "") for b in blocks
|
| 71 |
+
if isinstance(b, dict) and b.get("text"))
|
| 72 |
+
tools = shape.get("tools") or None
|
| 73 |
+
return (text or None), tools
|
| 74 |
+
except OSError:
|
| 75 |
+
pass
|
| 76 |
+
return None, None
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _read_codex_prompt(path: str) -> Optional[str]:
|
| 80 |
+
# 587 of the rollouts on this host are zstd-compressed in place; the codex
|
| 81 |
+
# reader already knows how to open them, so reuse it rather than
|
| 82 |
+
# reimplementing the decompression here.
|
| 83 |
+
try:
|
| 84 |
+
from traceforge.readers.codex import _open_rollout
|
| 85 |
+
opener = _open_rollout
|
| 86 |
+
except Exception:
|
| 87 |
+
opener = lambda p: open(p, encoding="utf-8")
|
| 88 |
+
try:
|
| 89 |
+
with opener(path) as f:
|
| 90 |
+
for line in f:
|
| 91 |
+
if "session_meta" not in line:
|
| 92 |
+
continue
|
| 93 |
+
try:
|
| 94 |
+
r = json.loads(line)
|
| 95 |
+
except Exception:
|
| 96 |
+
continue
|
| 97 |
+
if r.get("type") != "session_meta":
|
| 98 |
+
continue
|
| 99 |
+
bi = (r.get("payload") or {}).get("base_instructions")
|
| 100 |
+
if isinstance(bi, dict):
|
| 101 |
+
return bi.get("text") or None
|
| 102 |
+
if isinstance(bi, str):
|
| 103 |
+
return bi or None
|
| 104 |
+
return None
|
| 105 |
+
except OSError:
|
| 106 |
+
pass
|
| 107 |
+
return None
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _hermes_prompts() -> dict[str, str]:
|
| 111 |
+
"""session_id -> system prompt, via the hash join."""
|
| 112 |
+
out: dict[str, str] = {}
|
| 113 |
+
if not os.path.exists(HERMES_DB):
|
| 114 |
+
return out
|
| 115 |
+
try:
|
| 116 |
+
c = sqlite3.connect(f"file:{HERMES_DB}?mode=ro", uri=True)
|
| 117 |
+
c.row_factory = sqlite3.Row
|
| 118 |
+
prompts = {r["hash"]: r["prompt"] for r in
|
| 119 |
+
c.execute("select hash, prompt from system_prompts")}
|
| 120 |
+
for r in c.execute(
|
| 121 |
+
"select id, system_prompt_hash from sessions "
|
| 122 |
+
"where system_prompt_hash is not null"):
|
| 123 |
+
p = prompts.get(r["system_prompt_hash"])
|
| 124 |
+
if p:
|
| 125 |
+
out[r["id"]] = p
|
| 126 |
+
c.close()
|
| 127 |
+
except sqlite3.Error:
|
| 128 |
+
pass
|
| 129 |
+
return out
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
_INDEX_CACHE: Optional[dict] = None
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def load_index(verbose: bool = False) -> dict[str, dict]:
|
| 136 |
+
"""session_id -> {system, tools, source}.
|
| 137 |
+
|
| 138 |
+
Memoized: `export --by-topic` calls export_chat_jsonl once per topic, and
|
| 139 |
+
rebuilding this index each time re-reads 587 compressed rollouts and 401
|
| 140 |
+
claude transcripts — ten times the I/O for one export.
|
| 141 |
+
"""
|
| 142 |
+
global _INDEX_CACHE
|
| 143 |
+
if _INDEX_CACHE is not None:
|
| 144 |
+
return _INDEX_CACHE
|
| 145 |
+
idx: dict[str, dict] = {}
|
| 146 |
+
|
| 147 |
+
for p in glob.glob(CLAUDE_GLOB):
|
| 148 |
+
sid = os.path.basename(p)[:-6] # strip ".jsonl" (6 chars)
|
| 149 |
+
sys_text, tools = _read_claude_shape(p)
|
| 150 |
+
if sys_text or tools:
|
| 151 |
+
idx[sid] = {"system": sys_text, "tools": tools,
|
| 152 |
+
"source": "claude-request-shape"}
|
| 153 |
+
|
| 154 |
+
codex_files = (glob.glob(CODEX_GLOB, recursive=True)
|
| 155 |
+
+ glob.glob(CODEX_ZST_GLOB, recursive=True))
|
| 156 |
+
for p in codex_files:
|
| 157 |
+
sid = _sid_from_codex(p)
|
| 158 |
+
if not sid or sid in idx:
|
| 159 |
+
continue
|
| 160 |
+
sys_text = _read_codex_prompt(p)
|
| 161 |
+
if sys_text:
|
| 162 |
+
idx[sid] = {"system": sys_text, "tools": None,
|
| 163 |
+
"source": "codex-base-instructions"}
|
| 164 |
+
|
| 165 |
+
for sid, prompt in _hermes_prompts().items():
|
| 166 |
+
if sid in idx:
|
| 167 |
+
continue
|
| 168 |
+
idx[sid] = {"system": prompt, "tools": None,
|
| 169 |
+
"source": "hermes-system-prompts"}
|
| 170 |
+
|
| 171 |
+
if verbose:
|
| 172 |
+
print(f"enrich index: {len(idx)} sessions "
|
| 173 |
+
f"({sum(1 for v in idx.values() if v['tools'])} with real tools)")
|
| 174 |
+
_INDEX_CACHE = idx
|
| 175 |
+
return idx
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
_SPEC_CACHE: Optional[dict] = None
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def load_toolspec(path: str = TOOLSPEC) -> dict[str, dict]:
|
| 182 |
+
global _SPEC_CACHE
|
| 183 |
+
if _SPEC_CACHE is not None:
|
| 184 |
+
return _SPEC_CACHE
|
| 185 |
+
if not os.path.exists(path):
|
| 186 |
+
_SPEC_CACHE = {}
|
| 187 |
+
return _SPEC_CACHE
|
| 188 |
+
try:
|
| 189 |
+
_SPEC_CACHE = (json.load(open(path, encoding="utf-8")) or {}).get("tools") or {}
|
| 190 |
+
except Exception:
|
| 191 |
+
_SPEC_CACHE = {}
|
| 192 |
+
return _SPEC_CACHE
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
_RAW_SYS_CACHE: dict[str, Optional[str]] = {}
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _raw_system(sid: str, raw_dir: str = RAW_DIR) -> Optional[str]:
|
| 199 |
+
"""The system row from the RAW trace, if it has one.
|
| 200 |
+
|
| 201 |
+
The improver drops the system message, so a nemotron trace arrives with no
|
| 202 |
+
prompt even though its raw carries a `## Available tools` block (12,878
|
| 203 |
+
raws on this host have one). Recover it here rather than re-generating.
|
| 204 |
+
"""
|
| 205 |
+
if sid in _RAW_SYS_CACHE:
|
| 206 |
+
return _RAW_SYS_CACHE[sid]
|
| 207 |
+
out = None
|
| 208 |
+
path = os.path.join(raw_dir, sid + ".json")
|
| 209 |
+
if os.path.exists(path):
|
| 210 |
+
try:
|
| 211 |
+
t = json.load(open(path, encoding="utf-8"))
|
| 212 |
+
for m in t.get("messages") or []:
|
| 213 |
+
if m.get("role") == "system" and (m.get("content") or "").strip():
|
| 214 |
+
out = m["content"]
|
| 215 |
+
break
|
| 216 |
+
except Exception:
|
| 217 |
+
out = None
|
| 218 |
+
_RAW_SYS_CACHE[sid] = out
|
| 219 |
+
return out
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def _called_names(msgs: list[dict]) -> list[str]:
|
| 223 |
+
seen: list[str] = []
|
| 224 |
+
for m in msgs:
|
| 225 |
+
for tc in (m.get("tool_calls") or []):
|
| 226 |
+
fn = tc.get("function") or {}
|
| 227 |
+
name = fn.get("name") or tc.get("name")
|
| 228 |
+
if name and name not in seen:
|
| 229 |
+
seen.append(name)
|
| 230 |
+
return seen
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def _derived_tool(name: str, spec: dict) -> Optional[dict]:
|
| 234 |
+
"""OpenAI-format tool definition from the observed-call evidence."""
|
| 235 |
+
s = spec.get(name)
|
| 236 |
+
if not s:
|
| 237 |
+
return None
|
| 238 |
+
props = {}
|
| 239 |
+
for k, v in (s.get("properties") or {}).items():
|
| 240 |
+
props[k] = {"type": v.get("type", "string")}
|
| 241 |
+
ex = v.get("examples") or []
|
| 242 |
+
if ex:
|
| 243 |
+
props[k]["description"] = f"e.g. {ex[0][:120]}"
|
| 244 |
+
return {
|
| 245 |
+
"type": "function",
|
| 246 |
+
"function": {
|
| 247 |
+
"name": name,
|
| 248 |
+
"description": (f"Derived from {s.get('calls', 0)} observed calls "
|
| 249 |
+
f"in this corpus; property types and required "
|
| 250 |
+
f"list come from the arguments actually passed."),
|
| 251 |
+
"parameters": {
|
| 252 |
+
"type": "object",
|
| 253 |
+
"properties": props,
|
| 254 |
+
"required": list(s.get("required") or []),
|
| 255 |
+
},
|
| 256 |
+
},
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def _to_openai_tool(t: dict) -> Optional[dict]:
|
| 261 |
+
"""Normalize one recorded tool schema to OpenAI `{type, function}`.
|
| 262 |
+
|
| 263 |
+
The claude reader keeps the request's own shape, which is Anthropic:
|
| 264 |
+
`{name, description, input_schema}`. Every other schema in the export
|
| 265 |
+
(derived ones, the nemotron block) is OpenAI chat format, so a trainer
|
| 266 |
+
applying the chat template would meet two incompatible shapes in one
|
| 267 |
+
file. Convert at export time rather than at capture: the raw store
|
| 268 |
+
should keep what the harness actually sent.
|
| 269 |
+
"""
|
| 270 |
+
if not isinstance(t, dict):
|
| 271 |
+
return None
|
| 272 |
+
if t.get("type") == "function" and isinstance(t.get("function"), dict):
|
| 273 |
+
return t # already OpenAI
|
| 274 |
+
name = t.get("name")
|
| 275 |
+
if not name:
|
| 276 |
+
return None
|
| 277 |
+
params = t.get("input_schema") or t.get("parameters")
|
| 278 |
+
if not isinstance(params, dict):
|
| 279 |
+
params = {"type": "object", "properties": {}}
|
| 280 |
+
return {
|
| 281 |
+
"type": "function",
|
| 282 |
+
"function": {
|
| 283 |
+
"name": name,
|
| 284 |
+
"description": t.get("description") or "",
|
| 285 |
+
"parameters": params,
|
| 286 |
+
},
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def enrich(rec: dict, sid: str, index: dict, spec: dict) -> dict:
|
| 291 |
+
"""Attach `tools` and a leading system message to one export record."""
|
| 292 |
+
msgs = rec.get("messages") or []
|
| 293 |
+
rec.setdefault("meta", {})
|
| 294 |
+
entry = index.get(sid) or {}
|
| 295 |
+
|
| 296 |
+
# ---- tools: real schemas if the harness recorded them, else derived
|
| 297 |
+
tools = entry.get("tools")
|
| 298 |
+
if tools:
|
| 299 |
+
norm = [x for x in (_to_openai_tool(t) for t in tools) if x]
|
| 300 |
+
if norm:
|
| 301 |
+
# The recorded request can list fewer tools than the trace
|
| 302 |
+
# actually called (a deferred tool, or one the harness added
|
| 303 |
+
# after the request was captured). Fill the gap from the
|
| 304 |
+
# observed-call evidence so no call names a tool nothing
|
| 305 |
+
# defines, and mark the additions so a derived schema is never
|
| 306 |
+
# mistaken for a recorded one.
|
| 307 |
+
have = {(t.get("function") or {}).get("name") for t in norm}
|
| 308 |
+
extra = [d for d in (_derived_tool(n, spec)
|
| 309 |
+
for n in _called_names(msgs))
|
| 310 |
+
if d and (d.get("function") or {}).get("name") not in have]
|
| 311 |
+
rec["tools"] = norm + extra
|
| 312 |
+
rec["meta"]["tools_source"] = entry.get("source")
|
| 313 |
+
if extra:
|
| 314 |
+
rec["meta"]["tools_derived_extra"] = [
|
| 315 |
+
(d.get("function") or {}).get("name") for d in extra]
|
| 316 |
+
else:
|
| 317 |
+
names = _called_names(msgs)
|
| 318 |
+
derived = [d for d in (_derived_tool(n, spec) for n in names) if d]
|
| 319 |
+
if derived:
|
| 320 |
+
rec["tools"] = derived
|
| 321 |
+
rec["meta"]["tools_source"] = "derived-from-observed-calls"
|
| 322 |
+
rec["meta"]["tools_undefined"] = [
|
| 323 |
+
n for n in names if n not in spec]
|
| 324 |
+
|
| 325 |
+
# ---- system prompt: prepend unless the trace already opens with one
|
| 326 |
+
sys_text = entry.get("system")
|
| 327 |
+
if not sys_text:
|
| 328 |
+
sys_text = _raw_system(sid)
|
| 329 |
+
if sys_text:
|
| 330 |
+
entry = dict(entry, source="raw-system-row")
|
| 331 |
+
if sys_text and not (msgs and msgs[0].get("role") == "system"):
|
| 332 |
+
rec["messages"] = [{"role": "system", "content": sys_text}] + msgs
|
| 333 |
+
rec["meta"]["system_prompt_source"] = entry.get("source")
|
| 334 |
+
return rec
|
traceforge/export.py
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Export improved traces to training formats.
|
| 2 |
+
|
| 3 |
+
chat_jsonl: OpenAI-style chat fine-tune JSONL — one line per trace:
|
| 4 |
+
{"messages": [{"role","content"[,"tool_calls"][,"tool_call_id"...]}]}
|
| 5 |
+
Reasoning is dropped by default (keep_reasoning=False): the corpus trains on
|
| 6 |
+
the assistant's visible turns, and the reasoning channel is not part of the
|
| 7 |
+
served format. Pass keep_reasoning=True to retain it under "reasoning".
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import difflib
|
| 12 |
+
import hashlib
|
| 13 |
+
import json
|
| 14 |
+
import os
|
| 15 |
+
import re
|
| 16 |
+
from typing import Optional
|
| 17 |
+
|
| 18 |
+
from traceforge.trace import Trace
|
| 19 |
+
|
| 20 |
+
# Progress narration: the agent announcing what it is about to do instead of
|
| 21 |
+
# doing it. Same shape the SFT reframe gate rejects (_AGENT_NARRATION_RE in
|
| 22 |
+
# splice.py); duplicated here so export does not import the splice module.
|
| 23 |
+
_NARRATION_RE = re.compile(
|
| 24 |
+
r"\b(?:I'll|I will|Let me|Next,? I|Now I"
|
| 25 |
+
r"|Ich (?:prüfe|führe|werde|setze|beginne|starte|nehme))\b",
|
| 26 |
+
re.I,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _msg_to_chat(m, keep_reasoning: bool = False) -> Optional[dict]:
|
| 31 |
+
d: dict = {"role": m.role}
|
| 32 |
+
if m.content is not None:
|
| 33 |
+
d["content"] = m.content
|
| 34 |
+
if keep_reasoning and m.reasoning:
|
| 35 |
+
d["reasoning"] = m.reasoning
|
| 36 |
+
if m.tool_calls:
|
| 37 |
+
d["tool_calls"] = [
|
| 38 |
+
{
|
| 39 |
+
"id": tc.id,
|
| 40 |
+
"type": "function",
|
| 41 |
+
"function": {"name": tc.name, "arguments": tc.arguments},
|
| 42 |
+
}
|
| 43 |
+
for tc in m.tool_calls
|
| 44 |
+
]
|
| 45 |
+
if m.role == "tool":
|
| 46 |
+
d["tool_call_id"] = m.tool_call_id or ""
|
| 47 |
+
if m.tool_name:
|
| 48 |
+
d["name"] = m.tool_name
|
| 49 |
+
return d
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# Delegation tool names across the reader corpus (Claude 'Task', codex
|
| 53 |
+
# thread_spawn, hermes delegate_task): a trace calling one of these is
|
| 54 |
+
# an ORCHESTRATOR — it divides work across subagents. MASS (arXiv
|
| 55 |
+
# 2610.12176) trains on orchestrator AND subagent conversations as
|
| 56 |
+
# separate sequences; tagging lets the trainer balance the two roles
|
| 57 |
+
# instead of sampling blind.
|
| 58 |
+
_DELEGATION_TOOLS = {
|
| 59 |
+
"task", "delegate_task", "launchsubagent", "spawn_subagent",
|
| 60 |
+
"thread_spawn", "agent",
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _agent_role(t: Trace) -> Optional[str]:
|
| 65 |
+
"""'orchestrator' | 'subagent' | None, derived mechanically.
|
| 66 |
+
|
| 67 |
+
subagent: the sid carries the reader's sidechain namespace
|
| 68 |
+
('<parent>#sa-<agent>'), or provenance says the trace IS a
|
| 69 |
+
subagent's own thread (source claude-sidechain, or a codex child
|
| 70 |
+
rollout). The sid check matters because the improve stage rewrites
|
| 71 |
+
traces and overwrites source with 'traceforge-splice', while the
|
| 72 |
+
namespaced sid is preserved verbatim.
|
| 73 |
+
orchestrator: the trace issues a delegation tool call.
|
| 74 |
+
"""
|
| 75 |
+
if "#sa-" in (t.session_id or ""):
|
| 76 |
+
return "subagent"
|
| 77 |
+
src = (t.source or "").lower()
|
| 78 |
+
if "sidechain" in src:
|
| 79 |
+
return "subagent"
|
| 80 |
+
for m in t.messages:
|
| 81 |
+
for tc in m.tool_calls:
|
| 82 |
+
if (tc.name or "").lower() in _DELEGATION_TOOLS:
|
| 83 |
+
return "orchestrator"
|
| 84 |
+
return None
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def export_chat_jsonl(
|
| 88 |
+
traces: list[Trace], out_path: str, *, append: bool = False,
|
| 89 |
+
keep_reasoning: bool = False, enrich: bool = True,
|
| 90 |
+
) -> int:
|
| 91 |
+
"""Write improved traces as chat-format JSONL. Returns lines written.
|
| 92 |
+
|
| 93 |
+
keep_reasoning=False (default) drops per-message reasoning: harness
|
| 94 |
+
self-talk is not training signal. Flip on when the training target
|
| 95 |
+
includes a reasoning channel.
|
| 96 |
+
|
| 97 |
+
enrich=True (default) attaches the `tools` array and, where the harness
|
| 98 |
+
recorded one, the system prompt. Without it a row names tools nothing
|
| 99 |
+
defines — see traceforge/enrich.py.
|
| 100 |
+
"""
|
| 101 |
+
os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
|
| 102 |
+
mode = "a" if append else "w"
|
| 103 |
+
n = 0
|
| 104 |
+
_idx = None
|
| 105 |
+
_spec = None
|
| 106 |
+
if enrich:
|
| 107 |
+
from traceforge.enrich import load_index, load_toolspec
|
| 108 |
+
_idx = load_index()
|
| 109 |
+
_spec = load_toolspec()
|
| 110 |
+
with open(out_path, mode, encoding="utf-8") as f:
|
| 111 |
+
for t in traces:
|
| 112 |
+
msgs = [ _msg_to_chat(m, keep_reasoning) for m in t.messages ]
|
| 113 |
+
msgs = [m for m in msgs if m and (m.get("content") or m.get("tool_calls") or m.get("tool_call_id"))]
|
| 114 |
+
if not msgs:
|
| 115 |
+
continue
|
| 116 |
+
meta = {
|
| 117 |
+
"traceforge_session_id": t.session_id,
|
| 118 |
+
"parent_session_id": t.parent_session_id,
|
| 119 |
+
"source": t.source,
|
| 120 |
+
}
|
| 121 |
+
role = _agent_role(t)
|
| 122 |
+
if role:
|
| 123 |
+
meta["agent_role"] = role
|
| 124 |
+
# docs/DATA_QUALITY.md §4.1: a retained recovery is labelled so a
|
| 125 |
+
# trainer can filter it out and the corpus can report density.
|
| 126 |
+
if recovery_label(t):
|
| 127 |
+
meta["recovered"] = True
|
| 128 |
+
rec = {"messages": msgs, "meta": meta}
|
| 129 |
+
if enrich:
|
| 130 |
+
from traceforge.enrich import enrich as _enrich
|
| 131 |
+
sid = t.session_id
|
| 132 |
+
if sid.startswith("ideal-"):
|
| 133 |
+
sid = sid[6:]
|
| 134 |
+
sid = sid.split("#")[0]
|
| 135 |
+
rec = _enrich(rec, sid, _idx or {}, _spec or {})
|
| 136 |
+
# Time sensitivity: measured on the RECORD, because the enriched
|
| 137 |
+
# row carries the `tools` array and "did the agent have a
|
| 138 |
+
# retrieval tool" is what separates a decaying answer from a
|
| 139 |
+
# durable procedure. Absent = not time-sensitive.
|
| 140 |
+
try:
|
| 141 |
+
from traceforge.timesensitivity import detect as _ts_detect
|
| 142 |
+
ts = _ts_detect(rec)
|
| 143 |
+
if ts["class"] != "not_time_sensitive":
|
| 144 |
+
rec["meta"]["time_sensitivity"] = ts["class"]
|
| 145 |
+
if ts.get("temporal_facts"):
|
| 146 |
+
rec["meta"]["time_sensitivity_facts"] = ts["temporal_facts"]
|
| 147 |
+
if ts.get("needs_rewrite"):
|
| 148 |
+
rec["meta"]["time_sensitivity_needs_rewrite"] = True
|
| 149 |
+
except Exception:
|
| 150 |
+
pass
|
| 151 |
+
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
| 152 |
+
n += 1
|
| 153 |
+
return n
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
# --------------------------------------------------------------------------
|
| 157 |
+
# Corpus-level obligations (docs/DATA_QUALITY.md §4.3, rules 6 and 9)
|
| 158 |
+
# --------------------------------------------------------------------------
|
| 159 |
+
|
| 160 |
+
# An ENVIRONMENT-caused failure is one the ideal agent could not have
|
| 161 |
+
# foreseen: a flaky endpoint, a missing dependency, an exhausted resource.
|
| 162 |
+
# A MODEL-caused failure (wrong tool, wrong args, wrong path, bad reasoning)
|
| 163 |
+
# is never eligible for the §4.1 carve-out — the ideal path routes around it.
|
| 164 |
+
#
|
| 165 |
+
# The match is anchored to the HEAD of the tool output, because a harness
|
| 166 |
+
# reports status there. Scanning the whole output matches error text quoted
|
| 167 |
+
# inside source code and file listings: measured on this corpus, a loose
|
| 168 |
+
# pattern labelled 32% of traces "recovered" where the real figure is ~12%,
|
| 169 |
+
# with `502`/`429` matching line numbers (`502- capture_output=True`) and
|
| 170 |
+
# `timeout` matching documentation prose. Bare status codes are therefore
|
| 171 |
+
# excluded; only an HTTP-context form counts.
|
| 172 |
+
_ENV_ERROR_RE = re.compile(
|
| 173 |
+
r"(?i)(command not found"
|
| 174 |
+
r"|modulenotfounderror|cannot find module"
|
| 175 |
+
r"|connection (refused|reset|timed out|aborted)"
|
| 176 |
+
r"|(?:error|fatal|wait|request|operation|connection)[^\n]{0,40}"
|
| 177 |
+
r"(?:timed out|timeout)"
|
| 178 |
+
r"|temporarily unavailable|service unavailable"
|
| 179 |
+
r"|bad gateway|too many requests"
|
| 180 |
+
r"|network is unreachable|name or service not known"
|
| 181 |
+
r"|no space left on device|resource temporarily unavailable"
|
| 182 |
+
r"|too many open files)"
|
| 183 |
+
)
|
| 184 |
+
_MODEL_ERROR_RE = re.compile(
|
| 185 |
+
r"(?i)(invalid (argument|json|parameter|request)|unknown tool"
|
| 186 |
+
r"|tool not found|jsondecodeerror|unexpected token"
|
| 187 |
+
r"|missing required|schema validation|permission denied"
|
| 188 |
+
r"|operation not permitted|not a git repository"
|
| 189 |
+
r"|is a directory|no such file or directory)"
|
| 190 |
+
)
|
| 191 |
+
# How much of a tool output counts as its status head.
|
| 192 |
+
_ERR_HEAD = 200
|
| 193 |
+
# A grep/nl/`cat -n` listing starts with a line number. Its text is file
|
| 194 |
+
# content, not a status line — `39:const RESOLVE_TIMEOUT_DETAIL: &str =
|
| 195 |
+
# "Timed out opening chat event stream"` is a string literal, not a failure.
|
| 196 |
+
_LISTING_RE = re.compile(r"^\s*\d+[:-]")
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def _call_sig(tc) -> tuple[str, str]:
|
| 200 |
+
return ((tc.name or "").lower(),
|
| 201 |
+
re.sub(r"\s+", " ", (tc.arguments or "")).strip()[:300])
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def recovery_label(t: Trace) -> bool:
|
| 205 |
+
"""True when the trace retains exactly one environment-caused failure
|
| 206 |
+
followed by exactly one corrective step (docs/DATA_QUALITY.md §4.1).
|
| 207 |
+
|
| 208 |
+
Disqualified by a model-caused error, by a repeated identical failed call
|
| 209 |
+
(a retry loop, not a correction), or by an error with no corrective step
|
| 210 |
+
after it. Conservative by design: the carve-out is an exception, not a
|
| 211 |
+
license, so more than one retained error also disqualifies.
|
| 212 |
+
"""
|
| 213 |
+
msgs = t.messages
|
| 214 |
+
env_hits = 0
|
| 215 |
+
for i, m in enumerate(msgs):
|
| 216 |
+
if m.role != "tool":
|
| 217 |
+
continue
|
| 218 |
+
c = (m.content or "")[:_ERR_HEAD]
|
| 219 |
+
if _LISTING_RE.match(c):
|
| 220 |
+
continue # file content, not a status line
|
| 221 |
+
if not _ENV_ERROR_RE.search(c) or _MODEL_ERROR_RE.search(c):
|
| 222 |
+
continue
|
| 223 |
+
call = None
|
| 224 |
+
for j in range(i - 1, -1, -1):
|
| 225 |
+
if msgs[j].role == "assistant" and msgs[j].tool_calls:
|
| 226 |
+
call = msgs[j].tool_calls[-1]
|
| 227 |
+
break
|
| 228 |
+
if call is None:
|
| 229 |
+
continue
|
| 230 |
+
nxt = None
|
| 231 |
+
for j in range(i + 1, len(msgs)):
|
| 232 |
+
if msgs[j].role == "assistant" and msgs[j].tool_calls:
|
| 233 |
+
nxt = msgs[j].tool_calls[0]
|
| 234 |
+
break
|
| 235 |
+
if nxt is None:
|
| 236 |
+
continue
|
| 237 |
+
if _call_sig(nxt) == _call_sig(call):
|
| 238 |
+
return False # retry loop: disqualified outright
|
| 239 |
+
env_hits += 1
|
| 240 |
+
return env_hits == 1
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def dedup_key(t: Trace) -> tuple[str, str]:
|
| 244 |
+
"""(task, tool-sequence) identity — docs/DATA_QUALITY.md rule 9.
|
| 245 |
+
|
| 246 |
+
Task text alone over-merges (one brief solved two ways is two examples);
|
| 247 |
+
the tool sequence alone over-merges (unrelated tasks share exec>exec).
|
| 248 |
+
The pair is what repeats when a session is re-pulled or a split segment
|
| 249 |
+
is re-improved.
|
| 250 |
+
|
| 251 |
+
The sequence carries a digest of the call ARGUMENTS, not just the tool
|
| 252 |
+
names (2026-10-23). Name-only was measurably destructive: on 1,246
|
| 253 |
+
improved traces it removed 18, and every one was a DISTINCT session —
|
| 254 |
+
0 with an identical body, 0 with an identical first assistant turn.
|
| 255 |
+
Nine separate "watch the CI and report" sessions all reduce to
|
| 256 |
+
``exec>exec>exec`` and were collapsed into one; the arguments are what
|
| 257 |
+
distinguishes them (a different job id per session). Arguments survive
|
| 258 |
+
re-improvement verbatim except on the splice engine's malformed-args
|
| 259 |
+
repair path, so a genuine re-pull still matches; a repaired duplicate
|
| 260 |
+
that slips through is one extra near-duplicate, which is far cheaper
|
| 261 |
+
than deleting real sessions.
|
| 262 |
+
"""
|
| 263 |
+
task = ""
|
| 264 |
+
for m in t.messages:
|
| 265 |
+
if m.role == "user":
|
| 266 |
+
task = re.sub(r"\s+", " ", (m.content or "")).strip().lower()[:400]
|
| 267 |
+
break
|
| 268 |
+
names: list[str] = []
|
| 269 |
+
digest = hashlib.sha1()
|
| 270 |
+
for m in t.messages:
|
| 271 |
+
for c in (m.tool_calls or []):
|
| 272 |
+
name = (c.name or "").lower()
|
| 273 |
+
names.append(name)
|
| 274 |
+
digest.update(name.encode("utf-8", "replace"))
|
| 275 |
+
digest.update(b"\x00")
|
| 276 |
+
digest.update((c.arguments or "").encode("utf-8", "replace"))
|
| 277 |
+
digest.update(b"\x00")
|
| 278 |
+
return task, ">".join(names) + "#" + digest.hexdigest()[:16]
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def dedup_traces(traces: list[Trace]) -> tuple[list[Trace], list[Trace]]:
|
| 282 |
+
"""Keep the most complete trace per (task, tool-sequence).
|
| 283 |
+
|
| 284 |
+
Returns (kept, removed). Deterministic — more messages wins, then more
|
| 285 |
+
tool calls, then session id — so re-exporting a grown corpus keeps the
|
| 286 |
+
same survivor instead of reshuffling the split.
|
| 287 |
+
"""
|
| 288 |
+
best: dict[tuple[str, str], Trace] = {}
|
| 289 |
+
for t in traces:
|
| 290 |
+
k = dedup_key(t)
|
| 291 |
+
cur = best.get(k)
|
| 292 |
+
if cur is None:
|
| 293 |
+
best[k] = t
|
| 294 |
+
continue
|
| 295 |
+
a = (len(t.messages), sum(len(m.tool_calls or []) for m in t.messages))
|
| 296 |
+
b = (len(cur.messages), sum(len(m.tool_calls or []) for m in cur.messages))
|
| 297 |
+
if a > b or (a == b and (t.session_id or "") < (cur.session_id or "")):
|
| 298 |
+
best[k] = t
|
| 299 |
+
kept = list(best.values())
|
| 300 |
+
removed = [t for t in traces if best.get(dedup_key(t)) is not t]
|
| 301 |
+
return kept, removed
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
# --------------------------------------------------------------------------
|
| 305 |
+
# Preference corpus (KTO) — docs/DATA_QUALITY.md §4.4, rule 10
|
| 306 |
+
# --------------------------------------------------------------------------
|
| 307 |
+
# This is NOT a relabelling of the SFT corpus. KTO needs rejected samples
|
| 308 |
+
# from the policy being aligned: a negative the policy would never produce is
|
| 309 |
+
# a length/style gradient, not a preference signal. So the flow is staged:
|
| 310 |
+
#
|
| 311 |
+
# 1. extract_prompts() -> kto_prompts.jsonl: rollout seeds taken from the
|
| 312 |
+
# SFT corpus (the cleaned prefix at each assistant turn boundary).
|
| 313 |
+
# 2. after SFT, sample N completions per prompt from the SFT model, verify
|
| 314 |
+
# each against the trace's own tool evidence, and label it.
|
| 315 |
+
# 3. build_kto() -> kto.jsonl: length-matched, bounded-loss contract.
|
| 316 |
+
#
|
| 317 |
+
# Stage 2 needs a served model and is deliberately not in this module.
|
| 318 |
+
|
| 319 |
+
def extract_prompts(traces, *, max_prompts: int = 0, turns: str = "final",
|
| 320 |
+
max_prefix_chars: int = 0) -> list[dict]:
|
| 321 |
+
"""Rollout seeds: (cleaned prefix, reference completion).
|
| 322 |
+
|
| 323 |
+
The prefix is the ideal trace's own context, so a rollout starts from the
|
| 324 |
+
distribution the model sees at inference — not from the raw trace's
|
| 325 |
+
messier prefix.
|
| 326 |
+
|
| 327 |
+
turns="final" (default) emits one seed per trace: the final assistant
|
| 328 |
+
turn, whose outcome the trace's own verification already covers. That is
|
| 329 |
+
the turn a KTO label can be trusted on. turns="all" emits every assistant
|
| 330 |
+
turn — 14k seeds and ~940 MB of prefixes on the current corpus — for
|
| 331 |
+
step-level preference work where each step has its own verifier.
|
| 332 |
+
|
| 333 |
+
max_prefix_chars > 0 keeps the opening task message and the most recent
|
| 334 |
+
turns, with an explicit elision marker, so a seed stays a bounded size.
|
| 335 |
+
"""
|
| 336 |
+
out: list[dict] = []
|
| 337 |
+
for t in traces:
|
| 338 |
+
msgs = t.messages
|
| 339 |
+
idx = [i for i, m in enumerate(msgs)
|
| 340 |
+
if m.role == "assistant" and (m.content or m.tool_calls)]
|
| 341 |
+
if turns == "final":
|
| 342 |
+
idx = idx[-1:]
|
| 343 |
+
for i in idx:
|
| 344 |
+
prefix = msgs[:i]
|
| 345 |
+
if not any(p.role == "user" for p in prefix):
|
| 346 |
+
continue
|
| 347 |
+
chat = [m for m in (_msg_to_chat(p) for p in prefix) if m]
|
| 348 |
+
if max_prefix_chars:
|
| 349 |
+
chat = _clip_prefix(chat, max_prefix_chars)
|
| 350 |
+
out.append({
|
| 351 |
+
"session_id": t.session_id,
|
| 352 |
+
"turn": i,
|
| 353 |
+
"prompt": chat,
|
| 354 |
+
"reference": _msg_to_chat(msgs[i]),
|
| 355 |
+
})
|
| 356 |
+
if max_prompts and len(out) >= max_prompts:
|
| 357 |
+
return out
|
| 358 |
+
return out
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def _clip_prefix(chat: list[dict], limit: int) -> list[dict]:
|
| 362 |
+
"""Keep the opening task and the most recent turns under `limit` chars."""
|
| 363 |
+
def size(m: dict) -> int:
|
| 364 |
+
return len(json.dumps(m, ensure_ascii=False))
|
| 365 |
+
if sum(size(m) for m in chat) <= limit:
|
| 366 |
+
return chat
|
| 367 |
+
head = chat[:1]
|
| 368 |
+
used = size(head[0])
|
| 369 |
+
tail: list[dict] = []
|
| 370 |
+
for m in reversed(chat[1:]):
|
| 371 |
+
n = size(m)
|
| 372 |
+
if used + n > limit:
|
| 373 |
+
break
|
| 374 |
+
tail.append(m)
|
| 375 |
+
used += n
|
| 376 |
+
tail.reverse()
|
| 377 |
+
return head + [{"role": "system",
|
| 378 |
+
"content": "[earlier turns elided for length]"}] + tail
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def build_kto(rollouts, *, max_len_ratio: float = 1.5) -> tuple[list[dict], dict]:
|
| 382 |
+
"""Turn labelled rollouts into KTO rows, length-matched.
|
| 383 |
+
|
| 384 |
+
Each rollout: {"prompt": [...], "completion": {...}, "label": bool}.
|
| 385 |
+
A negative whose completion is more than `max_len_ratio` times the
|
| 386 |
+
shortest desirable completion for the same prompt is dropped — otherwise
|
| 387 |
+
the only signal is length (arXiv:2403.19159). A prompt with no desirable
|
| 388 |
+
sample contributes nothing: an unpaired negative is noise, not a label.
|
| 389 |
+
"""
|
| 390 |
+
by_prompt: dict[str, list[dict]] = {}
|
| 391 |
+
for r in rollouts:
|
| 392 |
+
key = json.dumps(r.get("prompt"), sort_keys=True, ensure_ascii=False)
|
| 393 |
+
by_prompt.setdefault(key, []).append(r)
|
| 394 |
+
rows: list[dict] = []
|
| 395 |
+
dropped_len = dropped_unpaired = 0
|
| 396 |
+
for group in by_prompt.values():
|
| 397 |
+
pos = [r for r in group if r.get("label")]
|
| 398 |
+
neg = [r for r in group if not r.get("label")]
|
| 399 |
+
if not pos:
|
| 400 |
+
dropped_unpaired += len(neg)
|
| 401 |
+
continue
|
| 402 |
+
ref_len = min(len(json.dumps(r["completion"], ensure_ascii=False))
|
| 403 |
+
for r in pos)
|
| 404 |
+
for r in pos:
|
| 405 |
+
rows.append({"prompt": r["prompt"],
|
| 406 |
+
"completion": r["completion"], "label": True})
|
| 407 |
+
for r in neg:
|
| 408 |
+
n = len(json.dumps(r["completion"], ensure_ascii=False))
|
| 409 |
+
if n > max_len_ratio * ref_len:
|
| 410 |
+
dropped_len += 1
|
| 411 |
+
continue
|
| 412 |
+
rows.append({"prompt": r["prompt"],
|
| 413 |
+
"completion": r["completion"], "label": False})
|
| 414 |
+
stats = {
|
| 415 |
+
"rows": len(rows),
|
| 416 |
+
"desirable": sum(1 for r in rows if r["label"]),
|
| 417 |
+
"undesirable": sum(1 for r in rows if not r["label"]),
|
| 418 |
+
"prompts": len(by_prompt),
|
| 419 |
+
"dropped_length_mismatch": dropped_len,
|
| 420 |
+
"dropped_unpaired": dropped_unpaired,
|
| 421 |
+
}
|
| 422 |
+
return rows, stats
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
def _defect_shape(text: str, task: str) -> Optional[str]:
|
| 426 |
+
"""Name the defect a dropped assistant turn actually exhibits, or None.
|
| 427 |
+
|
| 428 |
+
The triage judge drops a turn for two very different reasons: it is
|
| 429 |
+
*wrong* (narration instead of work, a restatement of the brief, a bare
|
| 430 |
+
acknowledgement) or merely *redundant* (it repeats something already
|
| 431 |
+
said). Only the first is an instructive negative — training against
|
| 432 |
+
redundancy teaches the model to be terse, not to be correct. Measured on
|
| 433 |
+
the 1,456 dropped turns available 2026-10-22: 37% are defect shapes,
|
| 434 |
+
63% are redundancy.
|
| 435 |
+
"""
|
| 436 |
+
body = (text or "").strip()
|
| 437 |
+
if not body:
|
| 438 |
+
return None
|
| 439 |
+
head = body[:200]
|
| 440 |
+
if _NARRATION_RE.match(body) or _NARRATION_RE.search(head):
|
| 441 |
+
return "narration"
|
| 442 |
+
if task:
|
| 443 |
+
a = re.sub(r"\s+", " ", task).strip().lower()[:400]
|
| 444 |
+
b = re.sub(r"\s+", " ", body).strip().lower()[:400]
|
| 445 |
+
if a and b and difflib.SequenceMatcher(None, a, b).ratio() > 0.6:
|
| 446 |
+
return "restates_task"
|
| 447 |
+
if len(body) < 120:
|
| 448 |
+
return "short_ack"
|
| 449 |
+
return None
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
def build_kto_provisional(traces, raw_dir: str, triage_dir: str, *,
|
| 453 |
+
max_rows: int = 0) -> tuple[list[dict], dict]:
|
| 454 |
+
"""Provisional KTO rows for exercising the trainer — NOT on-policy.
|
| 455 |
+
|
| 456 |
+
Positives: each improved trace's final assistant turn, prompt = the
|
| 457 |
+
cleaned prefix. Negatives: assistant turns the triage judge labelled
|
| 458 |
+
``drop``, prompt = that raw trace's own prefix.
|
| 459 |
+
|
| 460 |
+
``docs/DATA_QUALITY.md`` §4.4 requires negatives sampled from the policy
|
| 461 |
+
being aligned. These come from the original harness agent, so this
|
| 462 |
+
artifact is a smoke-test set and a baseline, not the corpus a real KTO
|
| 463 |
+
run trains on — that one is built from SFT-model rollouts via
|
| 464 |
+
``build_kto``. The negatives are at least *verified* bad (the triage
|
| 465 |
+
judge dropped them) rather than merely different, which is why this is
|
| 466 |
+
preferable to relabelling raw final answers: measured 2026-10-22, raw and
|
| 467 |
+
improved finals differ materially in 229 of 991 traces but are usually
|
| 468 |
+
just differently worded, not worse.
|
| 469 |
+
"""
|
| 470 |
+
rows: list[dict] = []
|
| 471 |
+
seen: set[str] = set()
|
| 472 |
+
|
| 473 |
+
def _key(prompt, completion) -> str:
|
| 474 |
+
return json.dumps([prompt, completion], sort_keys=True, ensure_ascii=False)
|
| 475 |
+
|
| 476 |
+
for t in traces:
|
| 477 |
+
msgs = t.messages
|
| 478 |
+
idx = [i for i, m in enumerate(msgs)
|
| 479 |
+
if m.role == "assistant" and (m.content or m.tool_calls)]
|
| 480 |
+
if not idx:
|
| 481 |
+
continue
|
| 482 |
+
i = idx[-1]
|
| 483 |
+
prefix = msgs[:i]
|
| 484 |
+
if not any(p.role == "user" for p in prefix):
|
| 485 |
+
continue
|
| 486 |
+
prompt = [m for m in (_msg_to_chat(p) for p in prefix) if m]
|
| 487 |
+
comp = _msg_to_chat(msgs[i])
|
| 488 |
+
if not comp:
|
| 489 |
+
continue
|
| 490 |
+
k = _key(prompt, comp)
|
| 491 |
+
if k in seen:
|
| 492 |
+
continue
|
| 493 |
+
seen.add(k)
|
| 494 |
+
rows.append({"prompt": prompt, "completion": comp, "label": True,
|
| 495 |
+
"session_id": t.session_id, "source": "improved"})
|
| 496 |
+
if max_rows and len(rows) >= max_rows:
|
| 497 |
+
return rows, _provisional_stats(rows, 0, 0, 0)
|
| 498 |
+
|
| 499 |
+
raws = {f[:-5]: os.path.join(raw_dir, f)
|
| 500 |
+
for f in os.listdir(raw_dir) if f.endswith(".json")}
|
| 501 |
+
n_neg = n_skipped = n_redundant = 0
|
| 502 |
+
for tf in sorted(os.listdir(triage_dir)):
|
| 503 |
+
if not tf.endswith(".json"):
|
| 504 |
+
continue
|
| 505 |
+
try:
|
| 506 |
+
with open(os.path.join(triage_dir, tf), encoding="utf-8") as f:
|
| 507 |
+
d = json.load(f)
|
| 508 |
+
except (OSError, json.JSONDecodeError):
|
| 509 |
+
continue
|
| 510 |
+
rp = raws.get(d.get("session_id"))
|
| 511 |
+
if not rp:
|
| 512 |
+
continue
|
| 513 |
+
try:
|
| 514 |
+
rt = Trace.load(rp)
|
| 515 |
+
except Exception:
|
| 516 |
+
continue
|
| 517 |
+
for lab in d.get("labels") or []:
|
| 518 |
+
if lab.get("action") != "drop":
|
| 519 |
+
continue
|
| 520 |
+
i = lab.get("i")
|
| 521 |
+
if not isinstance(i, int) or i >= len(rt.messages):
|
| 522 |
+
continue
|
| 523 |
+
m = rt.messages[i]
|
| 524 |
+
if m.role != "assistant" or not m.content or len(m.content) < 20:
|
| 525 |
+
continue
|
| 526 |
+
prefix = rt.messages[:i]
|
| 527 |
+
if not any(p.role == "user" for p in prefix):
|
| 528 |
+
continue
|
| 529 |
+
prompt = [c for c in (_msg_to_chat(p) for p in prefix) if c]
|
| 530 |
+
comp = _msg_to_chat(m)
|
| 531 |
+
if not comp:
|
| 532 |
+
continue
|
| 533 |
+
task = next((p.content or "" for p in prefix if p.role == "user"), "")
|
| 534 |
+
if not _defect_shape(m.content, task):
|
| 535 |
+
n_redundant += 1
|
| 536 |
+
continue
|
| 537 |
+
k = _key(prompt, comp)
|
| 538 |
+
if k in seen:
|
| 539 |
+
n_skipped += 1
|
| 540 |
+
continue
|
| 541 |
+
seen.add(k)
|
| 542 |
+
rows.append({"prompt": prompt, "completion": comp, "label": False,
|
| 543 |
+
"session_id": rt.session_id, "source": "triage_drop"})
|
| 544 |
+
n_neg += 1
|
| 545 |
+
if max_rows and len(rows) >= max_rows:
|
| 546 |
+
return rows, _provisional_stats(rows, n_neg, n_skipped, n_redundant)
|
| 547 |
+
return rows, _provisional_stats(rows, n_neg, n_skipped, n_redundant)
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
def _provisional_stats(rows: list[dict], n_neg: int, n_skipped: int,
|
| 551 |
+
n_redundant: int = 0) -> dict:
|
| 552 |
+
pos = sum(1 for r in rows if r["label"])
|
| 553 |
+
neg = len(rows) - pos
|
| 554 |
+
return {
|
| 555 |
+
"rows": len(rows),
|
| 556 |
+
"desirable": pos,
|
| 557 |
+
"undesirable": neg,
|
| 558 |
+
"ratio": round(pos / neg, 2) if neg else None,
|
| 559 |
+
"duplicates_skipped": n_skipped,
|
| 560 |
+
"skipped_redundant": n_redundant,
|
| 561 |
+
"on_policy": False,
|
| 562 |
+
"note": ("negatives come from the original harness agent, not the SFT "
|
| 563 |
+
"model — smoke-test artifact only (DATA_QUALITY.md §4.4)"),
|
| 564 |
+
}
|
traceforge/grounding.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Grounding validation: every salient fact in an improved trace must exist
|
| 2 |
+
in its source trace. This is the mechanical no-hallucination gate (P4).
|
| 3 |
+
|
| 4 |
+
Salient facts = numbers-with-units, file paths, hex ids, quoted technical
|
| 5 |
+
strings. Sound but cheap: a trace that invents paths or counts fails here.
|
| 6 |
+
|
| 7 |
+
Tool-result fidelity: an ideal trace that makes the same tool call must
|
| 8 |
+
NOT carry a fabricated result. For each ideal tool row whose call signature
|
| 9 |
+
(tool name + argument keys) matches a real call, the result must share
|
| 10 |
+
content with the real output — exact line match first, then 25-char window
|
| 11 |
+
containment (the improver sees a clipped rendering and may compress, so a
|
| 12 |
+
wholly-unfamiliar output is fabrication, but paraphrase/reformat is not).
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
import re
|
| 18 |
+
|
| 19 |
+
from traceforge.trace import Trace
|
| 20 |
+
|
| 21 |
+
FACT_PATTERNS = [
|
| 22 |
+
# counts with units (verdicts, row/line counts, repeats)
|
| 23 |
+
re.compile(r"\b\d{1,6}\s+(?:PASS|SUSPECT|FAIL|CLEAN|lines|rows|files|chars|bytes|errors)\b"),
|
| 24 |
+
# file paths
|
| 25 |
+
re.compile(r"(?:[\w./-]+/)?[\w.-]+\.(?:py|ts|tsx|js|rs|go|svelte|json|jsonl|yaml|yml|toml|md|txt|csv|sql|sh)\b"),
|
| 26 |
+
# hex ids (commit shas, call ids)
|
| 27 |
+
re.compile(r"\b[0-9a-f]{7,40}\b"),
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
# facts the improver may legitimately introduce (its own bookkeeping)
|
| 31 |
+
ALLOWLIST_PATTERN = re.compile(r"^(?:call_|ideal-)")
|
| 32 |
+
|
| 33 |
+
# rendering artifacts, not world claims: the compact renderer's clip
|
| 34 |
+
# markers (also matches the "+N chars clipped" tail of read_file-style
|
| 35 |
+
# outputs). Stripped from the ideal blob before fact extraction.
|
| 36 |
+
CLIP_MARKER_RE = re.compile(r"…?\[\+[\d,.]+ ?(?:chars|bytes) clipped\]")
|
| 37 |
+
|
| 38 |
+
# unit claims that describe a rendering's compression, not the world.
|
| 39 |
+
# "48613 chars" in an ideal trace is the improver mirroring its own
|
| 40 |
+
# input rendering — never a salient fact.
|
| 41 |
+
NON_WORLD_UNIT_RE = re.compile(r"^\d{1,6}\s+(?:chars|bytes)$")
|
| 42 |
+
|
| 43 |
+
# fuzzy-containment window for tool-result fidelity
|
| 44 |
+
_CONTAIN_WINDOW = 25
|
| 45 |
+
|
| 46 |
+
# Verified on live promoted/attempted corpora (2026-09-28): fact patterns
|
| 47 |
+
# extract GLUED tokens from ideal traces whose underlying path IS in the
|
| 48 |
+
# source corpus — the top grounding false-kill shapes:
|
| 49 |
+
# 'a///Users/x/y.py' — unified-diff prefix "a/" glued onto a path that
|
| 50 |
+
# sits after a literal \n in a JSON-embedded diff
|
| 51 |
+
# 'nbackend/x/y.py' — a literal \n escape's 'n' glued onto the next
|
| 52 |
+
# path segment of a JSON-embedded tool output
|
| 53 |
+
# The real corpus stores the same output with REAL newlines, so it
|
| 54 |
+
# extracts the clean path and the glued variant looks invented. Before a
|
| 55 |
+
# fact is declared missing, glue-stripped variants are re-tested; only
|
| 56 |
+
# variants long enough to remain distinctive are considered.
|
| 57 |
+
def _glue_variants(fact: str) -> list[str]:
|
| 58 |
+
"""The fact itself plus glue-stripped spelling variants.
|
| 59 |
+
|
| 60 |
+
The BASE fact is always included (it is self-grounding when present
|
| 61 |
+
in the corpus); only the stripped variants must be distinctive
|
| 62 |
+
(len >= 8) to count, so short facts are never grounded by a
|
| 63 |
+
meaninglessly-short fragment.
|
| 64 |
+
"""
|
| 65 |
+
out = [fact]
|
| 66 |
+
if "/" in fact:
|
| 67 |
+
stripped = re.sub(r"^[a-z]?/+", "", fact) # glue char + slash run
|
| 68 |
+
if stripped != fact and len(stripped) >= 8:
|
| 69 |
+
out.append(stripped)
|
| 70 |
+
if len(fact) > 1 and len(fact) - 1 >= 8:
|
| 71 |
+
out.append(fact[1:]) # single leading char (n-glue, a/, b/)
|
| 72 |
+
# count+unit facts: the corpus may spell the same claim with the
|
| 73 |
+
# unit BEFORE the numbers ("lines old/new: 12875 12875" — verified
|
| 74 |
+
# false-kill 1fa6a3e5: identical bytes in ideal and raw, the
|
| 75 |
+
# NUMBER+UNIT pattern simply creates a token order the corpus lacks).
|
| 76 |
+
m = re.match(r"^(\d{1,6})\s+(lines|rows|files|errors)$", fact)
|
| 77 |
+
if m:
|
| 78 |
+
out.append(f"{m.group(2)} old/new: {m.group(1)} {m.group(1)}")
|
| 79 |
+
out.append(f"{m.group(2)}: {m.group(1)}")
|
| 80 |
+
return out
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _ideal_blob(ideal: Trace) -> str:
|
| 84 |
+
"""Content + tool args of the ideal trace, clip markers stripped."""
|
| 85 |
+
blob = "\n".join(
|
| 86 |
+
(m.content or "") for m in ideal.messages
|
| 87 |
+
) + "\n" + "\n".join(
|
| 88 |
+
tc.arguments for m in ideal.messages for tc in m.tool_calls
|
| 89 |
+
)
|
| 90 |
+
return CLIP_MARKER_RE.sub("", blob)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _real_corpus(real: Trace) -> str:
|
| 94 |
+
parts = [m.content or "" for m in real.messages]
|
| 95 |
+
parts += [tc.arguments for m in real.messages for tc in m.tool_calls]
|
| 96 |
+
return "\n".join(parts)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def grounding_coverage(ideal: Trace, real: Trace) -> tuple[float, list[str]]:
|
| 100 |
+
"""Return (fraction_grounded, missing_facts). 1.0 == fully grounded."""
|
| 101 |
+
corpus = _real_corpus(real)
|
| 102 |
+
ideal_blob = _ideal_blob(ideal)
|
| 103 |
+
missing: list[str] = []
|
| 104 |
+
total = 0
|
| 105 |
+
grounded = 0
|
| 106 |
+
for pat in FACT_PATTERNS:
|
| 107 |
+
for f in set(pat.findall(ideal_blob)):
|
| 108 |
+
if ALLOWLIST_PATTERN.match(f):
|
| 109 |
+
continue
|
| 110 |
+
if NON_WORLD_UNIT_RE.match(f):
|
| 111 |
+
continue # rendering artifact (mirrored clip marker)
|
| 112 |
+
total += 1
|
| 113 |
+
# JSON-embedded outputs glue a stray char onto the head of a
|
| 114 |
+
# real path (verified false-kill shapes, see _glue_variants).
|
| 115 |
+
# A fact is grounded when IT or a glue-stripped variant sits
|
| 116 |
+
# in the real corpus.
|
| 117 |
+
if any(v in corpus for v in _glue_variants(f)):
|
| 118 |
+
grounded += 1
|
| 119 |
+
else:
|
| 120 |
+
missing.append(f)
|
| 121 |
+
if total == 0:
|
| 122 |
+
return 1.0, []
|
| 123 |
+
return grounded / total, missing
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def validate_grounding(
|
| 127 |
+
ideal: Trace, real: Trace, min_coverage: float = 0.95
|
| 128 |
+
) -> list[str]:
|
| 129 |
+
"""Gate: fail when salient-fact grounding is below the threshold."""
|
| 130 |
+
errs: list[str] = []
|
| 131 |
+
cov, missing = grounding_coverage(ideal, real)
|
| 132 |
+
if cov < min_coverage:
|
| 133 |
+
shown = ", ".join(m[:80] for m in missing[:8])
|
| 134 |
+
errs.append(
|
| 135 |
+
f"fact grounding {cov:.0%} < {min_coverage:.0%}; "
|
| 136 |
+
f"facts not in source trace: {shown}"
|
| 137 |
+
)
|
| 138 |
+
errs.extend(tool_result_fidelity_errors(ideal, real))
|
| 139 |
+
return errs
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _tool_output_lines(raw: str) -> list[str]:
|
| 143 |
+
"""Substantive lines of a tool output; unwraps execute_code-style JSON."""
|
| 144 |
+
try:
|
| 145 |
+
d = json.loads(raw)
|
| 146 |
+
if isinstance(d, dict) and isinstance(d.get("output"), str):
|
| 147 |
+
raw = d["output"]
|
| 148 |
+
except (json.JSONDecodeError, TypeError):
|
| 149 |
+
pass
|
| 150 |
+
return [l.strip() for l in raw.split("\n") if len(l.strip()) >= 25]
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def _call_signature(name: str, arguments: str) -> str:
|
| 154 |
+
"""Stable-ish call fingerprint: tool name + sorted argument string."""
|
| 155 |
+
try:
|
| 156 |
+
a = json.loads(arguments or "{}")
|
| 157 |
+
keys = ",".join(sorted(a)) if isinstance(a, dict) else str(sorted(a))
|
| 158 |
+
except json.JSONDecodeError:
|
| 159 |
+
keys = (arguments or "")[:80]
|
| 160 |
+
return f"{name}({keys})"
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _windows(line: str, size: int = _CONTAIN_WINDOW):
|
| 164 |
+
"""All size-length windows of a line, stepped every quarter-window."""
|
| 165 |
+
if len(line) < size:
|
| 166 |
+
return [line]
|
| 167 |
+
step = max(1, size // 4)
|
| 168 |
+
return [line[i:i + size] for i in range(0, len(line) - size + 1, step)]
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def _result_grounded(lines: list[str], rlines: list[str]) -> bool:
|
| 172 |
+
"""Do the ideal result's lines share content with the real output?
|
| 173 |
+
|
| 174 |
+
Two tiers: exact stripped-line match (verbatim reuse), then 25-char
|
| 175 |
+
window containment (compressed/paraphrased reuse of real evidence).
|
| 176 |
+
"""
|
| 177 |
+
rset = set(rlines)
|
| 178 |
+
if any(l in rset for l in lines):
|
| 179 |
+
return True
|
| 180 |
+
rjoin = "\n".join(rlines)
|
| 181 |
+
for l in lines:
|
| 182 |
+
for w in _windows(l):
|
| 183 |
+
if w in rjoin:
|
| 184 |
+
return True
|
| 185 |
+
return False
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _real_output_index(real: Trace) -> dict[str, list[tuple[str, list[str]]]]:
|
| 189 |
+
"""Per tool name: [(call signature, substantive output lines)]."""
|
| 190 |
+
idx: dict[str, list[tuple[str, list[str]]]] = {}
|
| 191 |
+
for m in real.messages:
|
| 192 |
+
for tc in m.tool_calls:
|
| 193 |
+
res = next(
|
| 194 |
+
(r for r in real.messages
|
| 195 |
+
if r.role == "tool" and r.tool_call_id == tc.id),
|
| 196 |
+
None,
|
| 197 |
+
)
|
| 198 |
+
if res is not None and res.content:
|
| 199 |
+
sig = _call_signature(tc.name, tc.arguments)
|
| 200 |
+
idx.setdefault(tc.name, []).append(
|
| 201 |
+
(sig, _tool_output_lines(res.content))
|
| 202 |
+
)
|
| 203 |
+
return idx
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def _ideal_call_sigs(ideal: Trace) -> dict[str, str]:
|
| 207 |
+
"""tool_call_id -> signature, for the ideal trace's own calls."""
|
| 208 |
+
out: dict[str, str] = {}
|
| 209 |
+
for m in ideal.messages:
|
| 210 |
+
for tc in m.tool_calls:
|
| 211 |
+
out[tc.id] = _call_signature(tc.name, tc.arguments)
|
| 212 |
+
return out
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def tool_result_fidelity_errors(ideal: Trace, real: Trace) -> list[str]:
|
| 216 |
+
"""Fabricated tool results: same-call results sharing no content.
|
| 217 |
+
|
| 218 |
+
An ideal tool row whose tool was really called fails when its result
|
| 219 |
+
shares neither an exact substantive line nor a 25-char window with
|
| 220 |
+
any real output of that tool. The error names the ideal call's
|
| 221 |
+
signature, shows what the real trace's output actually looked like,
|
| 222 |
+
and gives a concrete rewrite target — a vague "be more grounded"
|
| 223 |
+
never repairs anything.
|
| 224 |
+
"""
|
| 225 |
+
idx = _real_output_index(real)
|
| 226 |
+
sigs = _ideal_call_sigs(ideal)
|
| 227 |
+
errs: list[str] = []
|
| 228 |
+
for m in ideal.messages:
|
| 229 |
+
if m.role != "tool":
|
| 230 |
+
continue
|
| 231 |
+
cands = idx.get(m.tool_name or "", [])
|
| 232 |
+
if not cands:
|
| 233 |
+
continue # tool never called in real trace: coverage handles it
|
| 234 |
+
lines = _tool_output_lines(m.content or "")
|
| 235 |
+
if not lines:
|
| 236 |
+
continue
|
| 237 |
+
for _sig, rlines in cands:
|
| 238 |
+
if _result_grounded(lines, rlines):
|
| 239 |
+
break # grounded against (at least) one real result
|
| 240 |
+
else:
|
| 241 |
+
sig = sigs.get(m.tool_call_id or "", "?")
|
| 242 |
+
# prefer the real output of the SAME call when one exists,
|
| 243 |
+
# else the tool's first real output — concrete repair target
|
| 244 |
+
same = [rl for s, rl in cands if s == sig]
|
| 245 |
+
sample = (same or [rl for _s, rl in cands])[0]
|
| 246 |
+
head_txt = " | ".join(l[:70] for l in sample[:2]) or "(empty)"
|
| 247 |
+
head = lines[0][:90]
|
| 248 |
+
errs.append(
|
| 249 |
+
f"fabricated tool result [{m.tool_name}]: result shares no "
|
| 250 |
+
f"content with any real {m.tool_name} output "
|
| 251 |
+
f"(e.g. {head!r}). The ideal call is {sig}; the real trace's "
|
| 252 |
+
f"output looks like: {head_txt!r}. Rewrite this result to "
|
| 253 |
+
"quote the real trace's actual output (compressed is fine, "
|
| 254 |
+
"but every substantive line must come from the real output)."
|
| 255 |
+
)
|
| 256 |
+
return errs
|
traceforge/identity.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Identity-neutrality and refusal gates.
|
| 2 |
+
|
| 3 |
+
The reference must not burn in an assistant identity ("I am Claude",
|
| 4 |
+
"as an AI made by …") and must not teach refusals — a model scored against
|
| 5 |
+
it should neither claim a vendor self-identity nor reproduce refusal
|
| 6 |
+
phrasings. These gates are deterministic regex checks over ASSISTANT
|
| 7 |
+
prose (system/user rows are never rewritten by the pipeline anyway and
|
| 8 |
+
tool results are verbatim facts, so only assistant content can carry a
|
| 9 |
+
learnable self-claim).
|
| 10 |
+
|
| 11 |
+
Domain content rule: a trace whose TASK is about a model/vendor (e.g.
|
| 12 |
+
"compare chatgpt vs claude APIs", "debug Claude-Code traces") may
|
| 13 |
+
LEGITIMATELY contain those names — that is content, not identity. Only
|
| 14 |
+
SELF-referential claims are gated: first-person identity patterns
|
| 15 |
+
("I am Claude", "I'm an AI", "trained by Anthropic", …) and refusal
|
| 16 |
+
patterns ("I can't help with that", "against my guidelines", …).
|
| 17 |
+
Mentions like "CLAUDE.md exists in the repo" are never touched.
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import json
|
| 22 |
+
import re
|
| 23 |
+
from typing import Optional
|
| 24 |
+
|
| 25 |
+
# First-person identity self-claims. Anchored on first-person verbs so
|
| 26 |
+
# "the trace contains Claude-Code traces" (third person) never matches.
|
| 27 |
+
IDENTITY_RE = re.compile(
|
| 28 |
+
r"("
|
| 29 |
+
r"co-?authored-?by:\s*"
|
| 30 |
+
r"(claude|chatgpt|gpt-?[45o]|glm|gemini|qwen|gemma|llama|"
|
| 31 |
+
r"deepseek|copilot|sonnet|opus|anthropic|openai)\b"
|
| 32 |
+
r"|\b(i am|i'?m|my name is|call me)\s+"
|
| 33 |
+
r"(claude|chatgpt|gpt-?[45o]|glm|gemini|qwen|gemma|llama|"
|
| 34 |
+
r"deepseek|copilot|sonnet|opus)\b"
|
| 35 |
+
r"|\b(i am|i'?m)\s+(an?|the)\s+"
|
| 36 |
+
r"(ai|llm|large language model|language model|chatbot|assistant "
|
| 37 |
+
r"(?:model|developed|made|trained|created)\b[^.]{0,40})"
|
| 38 |
+
r"|\b(trained|made|created|developed|built)\s+(by|at)\s+"
|
| 39 |
+
r"(anthropic|openai|google|deepmind|meta|mistral|alibaba|z\.ai|"
|
| 40 |
+
r"zhipu|nvidia|x\.?ai)\b[^.]{0,30}\bby me\b"
|
| 41 |
+
r"|\bi\s+(was\s+)?(trained|fine-?tuned)\s+(by|on|with)\b"
|
| 42 |
+
r"|\bas an?\s+(ai|llm|language model)(?=[,.])"
|
| 43 |
+
r"|\bclaude\b\s+(here|speaking|at your service)"
|
| 44 |
+
r"|\boperated by\s+(anthropic|openai|z\.ai)\b"
|
| 45 |
+
r"|\bich\s+(bin|heiße|werde genannt)\s+"
|
| 46 |
+
r"(claude|chatgpt|gpt-?[45o]?|glm|gemini|qwen|gemma|llama|"
|
| 47 |
+
r"deepseek)\b"
|
| 48 |
+
r"|\bals\s+ki\b[^.]{0,40}\bich\b"
|
| 49 |
+
r"|\bich\s+(bin|werde)\s+(eine?|ein)\s+"
|
| 50 |
+
r"(ki|künstliche?\s+intelligenz|sprachmodell)\b"
|
| 51 |
+
r")",
|
| 52 |
+
re.IGNORECASE,
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
# Refusal phrasings that would teach the model to refuse. Anchored on
|
| 56 |
+
# first-person refusal verbs; a task ABOUT moderation policy may quote
|
| 57 |
+
# refusals third-person — those don't match.
|
| 58 |
+
REFUSAL_RE = re.compile(
|
| 59 |
+
r"("
|
| 60 |
+
r"\bi\s+(can'?t|cannot|can not|won'?t|will not|am unable to|"
|
| 61 |
+
r"'?m not able to)\s+"
|
| 62 |
+
r"(help|assist|comply|provide|create|generate|write|build)\b"
|
| 63 |
+
r"(\s+(that|this|with (that|this)))?"
|
| 64 |
+
r"|\bi\s+(don'?t|do not)\s+feel\s+comfortable\b"
|
| 65 |
+
r"|\b(against|outside)\s+my\s+(guidelines|principles|"
|
| 66 |
+
r"programming|policies)\b"
|
| 67 |
+
r"|\bi'?m\s+sorry,?\s+but\s+i\s+(can'?t|cannot|won'?t)\b"
|
| 68 |
+
r"|\bi\s+must\s+(respect|follow)\s+my\s+"
|
| 69 |
+
r"(guidelines|principles|programming)\b"
|
| 70 |
+
r"|\bich\s+(kann|kann nicht|werde)\s+.*(nicht helfen|"
|
| 71 |
+
r"nicht unterstützen|nicht unterstützen bei)\b"
|
| 72 |
+
r"|\bich\s+(kann|mag)\s+das\s+nicht\b"
|
| 73 |
+
r"|\bdas\s+verstößt\s+gegen\s+meine\b"
|
| 74 |
+
r"|\bmeine\s+richtlinien\b[^.]{0,20}\bverbieten\b"
|
| 75 |
+
r")",
|
| 76 |
+
re.IGNORECASE,
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
# Refusals in the opening task (user explicitly asks for something
|
| 80 |
+
# harmful and the source model refused) are visible in USER rows too —
|
| 81 |
+
# but we never edit user rows, so a harmful-request trace is not
|
| 82 |
+
# promotable at all; that class dies at triage/judge level instead.
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _find_hits(content: str, rx: re.Pattern) -> list[str]:
|
| 86 |
+
return [m.group(0)[:100] for m in rx.finditer(content)]
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def validate_identity(
|
| 90 |
+
messages: list,
|
| 91 |
+
*,
|
| 92 |
+
identity_rx: Optional[re.Pattern] = None,
|
| 93 |
+
refusal_rx: Optional[re.Pattern] = None,
|
| 94 |
+
) -> list[str]:
|
| 95 |
+
"""Return gate errors for assistant identity/refusal text ([] = ok).
|
| 96 |
+
|
| 97 |
+
``messages`` may be Trace.messages (Message objects with .role/
|
| 98 |
+
.content) or plain dicts (role/content keys) — the audit calls this
|
| 99 |
+
with dicts, the improver with Messages.
|
| 100 |
+
"""
|
| 101 |
+
irx = identity_rx or IDENTITY_RE
|
| 102 |
+
rrx = refusal_rx or REFUSAL_RE
|
| 103 |
+
errors: list[str] = []
|
| 104 |
+
for i, m in enumerate(messages):
|
| 105 |
+
role = m.get("role") if isinstance(m, dict) else m.role
|
| 106 |
+
if role != "assistant":
|
| 107 |
+
continue
|
| 108 |
+
c = (m.get("content") if isinstance(m, dict) else m.content) or ""
|
| 109 |
+
tc = m.get("tool_calls") if isinstance(m, dict) else m.tool_calls
|
| 110 |
+
texts = [c] if c else []
|
| 111 |
+
for call in tc or []:
|
| 112 |
+
if isinstance(call, dict):
|
| 113 |
+
args = call.get("arguments", call.get("args"))
|
| 114 |
+
else:
|
| 115 |
+
args = getattr(call, "arguments", None)
|
| 116 |
+
if args is None:
|
| 117 |
+
args = getattr(call, "args", None)
|
| 118 |
+
if isinstance(args, str):
|
| 119 |
+
texts.append(args)
|
| 120 |
+
elif args:
|
| 121 |
+
texts.append(json.dumps(args, ensure_ascii=False))
|
| 122 |
+
for c2 in texts:
|
| 123 |
+
if not c2:
|
| 124 |
+
continue
|
| 125 |
+
for hit in _find_hits(c2, irx):
|
| 126 |
+
errors.append(
|
| 127 |
+
f"[{i}] identity self-claim in assistant text/tool args: {hit!r}")
|
| 128 |
+
for hit in _find_hits(c2, rrx):
|
| 129 |
+
errors.append(
|
| 130 |
+
f"[{i}] refusal phrasing in assistant text/tool args: {hit!r}")
|
| 131 |
+
return errors
|
traceforge/improver/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from traceforge.improver.harness import ImproverResult, improve_trace
|
| 2 |
+
from traceforge.improver.prompt import build_prompt
|
| 3 |
+
|
| 4 |
+
__all__ = ["ImproverResult", "improve_trace", "build_prompt"]
|
traceforge/improver/digest_prompts.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prompts for the long-trace (map-digest-write) pipeline.
|
| 2 |
+
|
| 3 |
+
Heavy sessions (up to ~10M tokens) can never fit into one context. The
|
| 4 |
+
pipeline: digest each chunk in parallel -> merge digests into a chunk map
|
| 5 |
+
-> writer builds the ideal trace from the map, looking up exact verbatim
|
| 6 |
+
tool outputs from on-disk chunk files by reference.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
CHUNK_DIGEST_HEADER = """You are a trace-digest agent. You see ONE chunk of a
|
| 11 |
+
long real agent session. boil it down for a downstream writer agent that
|
| 12 |
+
will rewrite the whole session as a flawless "ideal trace".
|
| 13 |
+
|
| 14 |
+
Produce JSON with EXACTLY these keys, written to: {digest_path}
|
| 15 |
+
{{
|
| 16 |
+
"chunk": <chunk number>,
|
| 17 |
+
"task_scope": "<what part of the overall task this chunk covers>",
|
| 18 |
+
"narrative": "<compact ordered summary of what happened: decisions,
|
| 19 |
+
tool calls made (name + key args), results seen,
|
| 20 |
+
user feedback received>",
|
| 21 |
+
"mistakes": [
|
| 22 |
+
{{"kind": "failed_tool_call|retry_loop|wrong_turn|redundant|user_feedback",
|
| 23 |
+
"detail": "<what went wrong>", "turn_ref": "<[chunk-local msg idx]>"}}
|
| 24 |
+
],
|
| 25 |
+
"key_outputs": [
|
| 26 |
+
{{"ref": "chunk{n}-{i}", "tool": "<name>", "significance": "<why it
|
| 27 |
+
matters>", "loc": "<file/section the output is about>"}}
|
| 28 |
+
],
|
| 29 |
+
"claim_worthy": ["<external-world facts asserted (versions, prices,
|
| 30 |
+
paper ids) worth fact-checking>"]
|
| 31 |
+
}}
|
| 32 |
+
|
| 33 |
+
RULES:
|
| 34 |
+
- Story-first: the downstream writer only sees your digest, not the chunk.
|
| 35 |
+
Everything load-bearing must appear in narrative/ key_outputs.
|
| 36 |
+
- Mistakes matter most: every failed call, retry, wrong turn, and user
|
| 37 |
+
correction must be listed — those are what the ideal path must avoid.
|
| 38 |
+
- Do NOT include raw tool outputs in digest. Reference them as refs
|
| 39 |
+
(chunk{n}-{i}); the writer resolves refs against on-disk chunk files.
|
| 40 |
+
- ideal_path: describe in "narrative" what SHOULD have happened in this
|
| 41 |
+
chunk's scope (perfect first-time route per guidelines).
|
| 42 |
+
- No preamble: write the JSON, validate it parses when re-read, then reply
|
| 43 |
+
"DIGEST DONE chunk {n}".
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
MAP_MERGE_HEADER = """You are a trace-map merge agent. You receive digests
|
| 47 |
+
of ALL chunks of one long real agent session, in order. Build the unified
|
| 48 |
+
session map for the ideal-trace writer.
|
| 49 |
+
|
| 50 |
+
Write JSON to: {map_path}
|
| 51 |
+
{{
|
| 52 |
+
"task": "<the one-sentence task the user wanted, verbatim from the
|
| 53 |
+
first user message>",
|
| 54 |
+
"first_user_message": "<the EXACT, COMPLETE first user message text>",
|
| 55 |
+
"structure": [ "<ordered story beats: what the ideal path needs to do,
|
| 56 |
+
step by step, referencing key_outputs refs>" ],
|
| 57 |
+
"mistakes": [ "<ALL mistakes across chunks, deduplicated, in order>" ],
|
| 58 |
+
"key_outputs": [ "<all key_output refs, deduplicated, with tool+loc>" ],
|
| 59 |
+
"claim_worthy": [ "<all claim-worthy external facts>" ],
|
| 60 |
+
"chunk_index": [ "<per-chunk digest files, in order>" ]
|
| 61 |
+
}}
|
| 62 |
+
|
| 63 |
+
RULES:
|
| 64 |
+
- Deduplicate conservative: keep every load-bearing item; drop only
|
| 65 |
+
literal repeats introduced by chunk overlap.
|
| 66 |
+
- first_user_message must be VERBATIM and COMPLETE — the writer will
|
| 67 |
+
reproduce it byte-exact in the ideal trace.
|
| 68 |
+
- ideal story: structure = the perfect-first-time route through the whole
|
| 69 |
+
session (per guidelines: mistakes removed, minimal necessary calls).
|
| 70 |
+
- Write the JSON, validate it parses, reply "MAP DONE".
|
| 71 |
+
"""
|
| 72 |
+
|
| 73 |
+
WRITER_LONG_HEADER = """You are the ideal-trace writer for a LONG session.
|
| 74 |
+
You get: (1) the session map (digests merged), (2) access to on-disk chunk
|
| 75 |
+
files that hold the raw rendered chunks, (3) the guidelines.
|
| 76 |
+
|
| 77 |
+
Your job: write the ideal-world version of the whole session as JSON to:
|
| 78 |
+
{output_path}
|
| 79 |
+
|
| 80 |
+
Procedure:
|
| 81 |
+
1. Read the guidelines file: {guidelines_path}
|
| 82 |
+
2. Read the session map at: {map_path}
|
| 83 |
+
3. Read guidelines §3 pillars. Then look up chunk data on demand:
|
| 84 |
+
- The chunk files live at: {chunk_dir} (files chunk_001.txt, ...).
|
| 85 |
+
- Use refs from the map's key_outputs to locate the exact tool outputs
|
| 86 |
+
you want to reuse VERBATIM (search the chunk files).
|
| 87 |
+
- Read a chunk file only when you need exact content from it. Do not
|
| 88 |
+
read all chunk files — only what refs point you to.
|
| 89 |
+
4. Write the ideal trace (same JSON schema as the improver contract:
|
| 90 |
+
messages array with role/content/tool_calls/tool_call_id/tool_name).
|
| 91 |
+
Apply ALL quality pillars. Reuse tool outputs verbatim when the ideal
|
| 92 |
+
path makes the same call (P4); resolve via refs.
|
| 93 |
+
5. Verify: re-read what you wrote; check JSON; check tool pairing; check
|
| 94 |
+
the first user message matches the map's first_user_message byte-exact.
|
| 95 |
+
6. Reply "WRITER DONE" + a summary paragraph.
|
| 96 |
+
|
| 97 |
+
Scope limits (unchanged): no environment archaeology, no subagents —
|
| 98 |
+
chunk files and guidelines are your only inputs.
|
| 99 |
+
"""
|
| 100 |
+
|
| 101 |
+
DIGEST_SUFFIX = """
|
| 102 |
+
|
| 103 |
+
REAL TRACE CHUNK {n}:
|
| 104 |
+
{chunk}
|
| 105 |
+
"""
|
traceforge/improver/harness.py
ADDED
|
@@ -0,0 +1,850 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Runs the improver as a Hermes Agent one-shot session (the harness).
|
| 2 |
+
|
| 3 |
+
Design: for each trace we spawn
|
| 4 |
+
|
| 5 |
+
hermes chat --query-file <prompt.txt> --in <workdir> -Q
|
| 6 |
+
[--improver-model M] [--max-turns N] [--pass-session-id]
|
| 7 |
+
|
| 8 |
+
The improver is a full agent: it can read the guidelines file, inspect the
|
| 9 |
+
workdir, write its output with a tool, and re-validate it — agentic
|
| 10 |
+
self-improvement inside the generation loop. The harness never trusts the
|
| 11 |
+
improver's chat reply; it reads back the JSON file the improver wrote,
|
| 12 |
+
then structural validation + repair loop run outside the model.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import dataclasses
|
| 17 |
+
import json
|
| 18 |
+
import os
|
| 19 |
+
import shutil
|
| 20 |
+
import subprocess
|
| 21 |
+
import time
|
| 22 |
+
from typing import Optional
|
| 23 |
+
|
| 24 |
+
from traceforge import chunker
|
| 25 |
+
from traceforge.config import Config
|
| 26 |
+
from traceforge.grounding import CLIP_MARKER_RE as CLIP_MARKER_PROBE
|
| 27 |
+
from traceforge.improver.prompt import build_prompt, write_prompt_file
|
| 28 |
+
from traceforge.trace import Message, Trace, json_loads_lenient
|
| 29 |
+
|
| 30 |
+
_TOOL_TAIL_ERROR = "last message role is 'tool' (expected assistant)"
|
| 31 |
+
_EMPTY_ROW_RE = "message with empty content and no tool calls"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _only_tool_tail(errs: list[str]) -> bool:
|
| 35 |
+
"""True when the artifact's ONLY defect is that it ends on a tool row."""
|
| 36 |
+
return bool(errs) and all(_TOOL_TAIL_ERROR in e for e in errs)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _drop_empty_assistant_rows(improved: Trace) -> bool:
|
| 40 |
+
"""Remove assistant rows with no content and no calls. Returns changed.
|
| 41 |
+
|
| 42 |
+
The writer emits these as separators when it loses the thread. They
|
| 43 |
+
carry zero signal and a trainee must never learn to produce them, so
|
| 44 |
+
dropping is strictly better than paying a repair round to rewrite the
|
| 45 |
+
whole trace around one blank row.
|
| 46 |
+
"""
|
| 47 |
+
kept = [m for m in improved.messages
|
| 48 |
+
if not (m.role == "assistant" and not m.tool_calls
|
| 49 |
+
and not (m.content or "").strip())]
|
| 50 |
+
if len(kept) == len(improved.messages):
|
| 51 |
+
return False
|
| 52 |
+
improved.messages = kept
|
| 53 |
+
return True
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _append_synth_final(cfg: Config, improved: Trace,
|
| 57 |
+
source: Trace) -> Optional[Trace]:
|
| 58 |
+
"""Close a trace that the writer left mid-call with a synthesized answer.
|
| 59 |
+
|
| 60 |
+
The writer stops after a tool call in ~3% of traces (output cap or turn
|
| 61 |
+
cap), leaving an artifact that is one row short of valid. A full repair
|
| 62 |
+
round rewrites the whole trace and usually reproduces the same cut, so
|
| 63 |
+
the cheap fix is the targeted one: ask for the closing answer only,
|
| 64 |
+
grounded in the tail that already exists. Same helper the splice path
|
| 65 |
+
uses for raw traces that were cut off mid-run.
|
| 66 |
+
"""
|
| 67 |
+
from traceforge.splice import _resolve_endpoint, _synthesize_final_answer
|
| 68 |
+
|
| 69 |
+
try:
|
| 70 |
+
base, key = _resolve_endpoint(cfg)
|
| 71 |
+
except Exception as e:
|
| 72 |
+
print(f" tool-tail synth skipped: endpoint {type(e).__name__}: {e}")
|
| 73 |
+
return None
|
| 74 |
+
ans, err = _synthesize_final_answer(
|
| 75 |
+
cfg, improved, source.first_task_message() or "", base, key)
|
| 76 |
+
if not ans:
|
| 77 |
+
print(f" tool-tail synth failed: {(err or 'empty answer')[:120]}")
|
| 78 |
+
return None
|
| 79 |
+
improved.messages.append(Message(role="assistant", content=ans))
|
| 80 |
+
return improved
|
| 81 |
+
|
| 82 |
+
CLIP_MARKER_ERROR = (
|
| 83 |
+
"artifact contains rendering clip markers (…[+N chars clipped]) "
|
| 84 |
+
"with no verifiable counts — reviewers reject these. Rewrite the tool "
|
| 85 |
+
"results verbatim from outputs.jsonl (contiguous portions fine)."
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
# Per-workdir file recording which gate rounds already PASSED for which
|
| 89 |
+
# artifact content digest (see _load_gate_cache).
|
| 90 |
+
GATE_CACHE_FILE = "gate_cache.json"
|
| 91 |
+
|
| 92 |
+
# Bump whenever a JUDGE/GATE STANDARD changes (new pillar rule, ANSWER
|
| 93 |
+
# note wording that changes verdicts, regex gate changes) — cached
|
| 94 |
+
# passes recorded under an older standard must not replay. The value
|
| 95 |
+
# rides in the cache file; _load_gate_cache discards stale files.
|
| 96 |
+
GATE_CACHE_STANDARDS = "2026-10-08.1" # +answer-focus rule (P1 FOCUS, over-answering = poor) # +write-JSON deliverable forms, tool-tail carry, exact-args dup reads # +mixed-turn surgical drops, usage-error detect, planner >=2 + write-fix chains # +identity scans tool args (Co-Authored-By), degenerate prose gate # +judge sees reasoning channel
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _clip_markers_present(candidate) -> bool:
|
| 100 |
+
"""True if any message content carries a rendering clip marker."""
|
| 101 |
+
blob = "\n".join((m.content or "") for m in candidate.messages)
|
| 102 |
+
return bool(CLIP_MARKER_PROBE.search(blob))
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _artifact_digest(candidate) -> str:
|
| 106 |
+
"""Stable content hash of an improved artifact.
|
| 107 |
+
|
| 108 |
+
Gate passes are keyed on this: structural/grounding/verify/review
|
| 109 |
+
PASSes are only replayed from cache when the artifact byte-content is
|
| 110 |
+
unchanged (the file may still have been rewritten with identical
|
| 111 |
+
bytes — that's fine, it's the same artifact).
|
| 112 |
+
"""
|
| 113 |
+
import hashlib
|
| 114 |
+
|
| 115 |
+
h = hashlib.sha256()
|
| 116 |
+
for m in candidate.messages:
|
| 117 |
+
h.update((m.role or "").encode("utf-8"))
|
| 118 |
+
h.update(b"\x00")
|
| 119 |
+
h.update((m.content or "").encode("utf-8"))
|
| 120 |
+
h.update(b"\x01")
|
| 121 |
+
if m.tool_call_id:
|
| 122 |
+
h.update(m.tool_call_id.encode("utf-8"))
|
| 123 |
+
h.update(b"\x01")
|
| 124 |
+
if m.tool_name:
|
| 125 |
+
h.update(m.tool_name.encode("utf-8"))
|
| 126 |
+
h.update(b"\x02")
|
| 127 |
+
if m.tool_calls:
|
| 128 |
+
for tc in m.tool_calls:
|
| 129 |
+
h.update((tc.id or "").encode("utf-8"))
|
| 130 |
+
h.update((tc.name or "").encode("utf-8"))
|
| 131 |
+
h.update((tc.arguments or "").encode("utf-8"))
|
| 132 |
+
h.update(b"\x03")
|
| 133 |
+
return h.hexdigest()
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
class ImproverResult:
|
| 137 |
+
def __init__(
|
| 138 |
+
self,
|
| 139 |
+
*,
|
| 140 |
+
session_id: str,
|
| 141 |
+
ok: bool,
|
| 142 |
+
improved: Optional[Trace],
|
| 143 |
+
reply: str,
|
| 144 |
+
hermes_session_id: Optional[str] = None,
|
| 145 |
+
errors: Optional[list[str]] = None,
|
| 146 |
+
attempts: int = 0,
|
| 147 |
+
duration_s: float = 0.0,
|
| 148 |
+
):
|
| 149 |
+
self.session_id = session_id # source trace session id
|
| 150 |
+
self.ok = ok
|
| 151 |
+
self.improved = improved
|
| 152 |
+
self.reply = reply
|
| 153 |
+
self.hermes_session_id = hermes_session_id
|
| 154 |
+
self.errors = errors or []
|
| 155 |
+
self.attempts = attempts
|
| 156 |
+
self.duration_s = duration_s
|
| 157 |
+
|
| 158 |
+
def to_dict(self) -> dict:
|
| 159 |
+
return {
|
| 160 |
+
"session_id": self.session_id,
|
| 161 |
+
"ok": self.ok,
|
| 162 |
+
"errors": self.errors,
|
| 163 |
+
"hermes_session_id": self.hermes_session_id,
|
| 164 |
+
"attempts": self.attempts,
|
| 165 |
+
"duration_s": round(self.duration_s, 1),
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def _run_hermes(
|
| 170 |
+
cfg: Config, prompt_path: str, workdir: str
|
| 171 |
+
) -> tuple[int, str, Optional[str], str]:
|
| 172 |
+
"""Invoke hermes chat one-shot; returns (exit_code, stdout, session_id)."""
|
| 173 |
+
cmd = [
|
| 174 |
+
"hermes", "chat",
|
| 175 |
+
"--query-file", prompt_path,
|
| 176 |
+
"--in", workdir,
|
| 177 |
+
"-Q",
|
| 178 |
+
"--format", "text",
|
| 179 |
+
"--pass-session-id",
|
| 180 |
+
]
|
| 181 |
+
if cfg.model:
|
| 182 |
+
cmd += ["-m", cfg.model]
|
| 183 |
+
if cfg.provider:
|
| 184 |
+
cmd += ["--provider", cfg.provider]
|
| 185 |
+
if cfg.max_turns:
|
| 186 |
+
cmd += ["--max-turns", str(cfg.max_turns)]
|
| 187 |
+
if cfg.run_budget:
|
| 188 |
+
cmd += ["--run-budget", str(cfg.run_budget)]
|
| 189 |
+
if cfg.skills:
|
| 190 |
+
cmd += ["-s", ",".join(cfg.skills)]
|
| 191 |
+
proc = subprocess.run(
|
| 192 |
+
cmd,
|
| 193 |
+
capture_output=True,
|
| 194 |
+
text=True,
|
| 195 |
+
timeout=cfg.harness_timeout_s,
|
| 196 |
+
cwd=workdir,
|
| 197 |
+
)
|
| 198 |
+
session_id = None
|
| 199 |
+
for line in (proc.stdout or "").splitlines():
|
| 200 |
+
if line.strip().startswith("session_id:"):
|
| 201 |
+
session_id = line.split(":", 1)[1].strip() or None
|
| 202 |
+
return proc.returncode, proc.stdout, session_id, proc.stderr
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def _parse_improved(path: str) -> Optional[Trace]:
|
| 206 |
+
"""Parse the improver-written improved.json into a Trace, leniently."""
|
| 207 |
+
if not os.path.exists(path):
|
| 208 |
+
return None
|
| 209 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 210 |
+
raw = f.read()
|
| 211 |
+
# tolerate fenced code blocks from chatty models
|
| 212 |
+
raw = raw.strip()
|
| 213 |
+
if raw.startswith("```"):
|
| 214 |
+
raw = raw.split("\n", 1)[1] if "\n" in raw else ""
|
| 215 |
+
if raw.rstrip().endswith("```"):
|
| 216 |
+
raw = raw.rstrip()[: -3]
|
| 217 |
+
data = json_loads_lenient(raw)
|
| 218 |
+
if not isinstance(data, dict):
|
| 219 |
+
return None
|
| 220 |
+
msgs = data.get("messages")
|
| 221 |
+
if not isinstance(msgs, list):
|
| 222 |
+
return None
|
| 223 |
+
from traceforge.trace import Message, ToolCall
|
| 224 |
+
|
| 225 |
+
out = []
|
| 226 |
+
for d in msgs:
|
| 227 |
+
if not isinstance(d, dict) or "role" not in d:
|
| 228 |
+
return None
|
| 229 |
+
m = {
|
| 230 |
+
"role": d["role"],
|
| 231 |
+
"content": d.get("content"),
|
| 232 |
+
"reasoning": d.get("reasoning"),
|
| 233 |
+
"tool_call_id": d.get("tool_call_id"),
|
| 234 |
+
"tool_name": d.get("tool_name"),
|
| 235 |
+
}
|
| 236 |
+
m = {k: v for k, v in m.items() if v is not None}
|
| 237 |
+
if "tool_calls" in d and isinstance(d["tool_calls"], list):
|
| 238 |
+
m["tool_calls"] = [
|
| 239 |
+
{
|
| 240 |
+
"id": str(tc.get("id", "")),
|
| 241 |
+
"name": str(tc.get("name", "")),
|
| 242 |
+
"arguments": tc.get("arguments") or "{}",
|
| 243 |
+
}
|
| 244 |
+
for tc in d["tool_calls"]
|
| 245 |
+
if isinstance(tc, dict)
|
| 246 |
+
]
|
| 247 |
+
out.append(Message.from_dict(m))
|
| 248 |
+
return Trace(
|
| 249 |
+
session_id="improved-placeholder", # set by caller
|
| 250 |
+
messages=out,
|
| 251 |
+
improved=True,
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def _append_status(cfg: Config, sid: str, status: dict) -> None:
|
| 256 |
+
"""Append a stage-level status row to the manifest."""
|
| 257 |
+
import json as _json
|
| 258 |
+
|
| 259 |
+
os.makedirs(os.path.dirname(cfg.manifest_path), exist_ok=True)
|
| 260 |
+
row = {"session_id": sid, **status}
|
| 261 |
+
with open(cfg.manifest_path, "a", encoding="utf-8") as f:
|
| 262 |
+
f.write(_json.dumps(row, ensure_ascii=False) + "\n")
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def _load_gate_cache(workdir: str) -> dict:
|
| 266 |
+
"""Read the workdir's gate-pass cache (digest -> list of gate rows).
|
| 267 |
+
|
| 268 |
+
Batches die at arbitrary points (turn interruptions, terminal close,
|
| 269 |
+
SIGTERM to the worker pool); without this, every verify/review round
|
| 270 |
+
already PAID FOR on an unchanged artifact is re-run from scratch on
|
| 271 |
+
the next resume. The cache is keyed by the artifact's content digest
|
| 272 |
+
so a rewritten (different) artifact never inherits stale passes.
|
| 273 |
+
Cached passes from an OLDER standards version are discarded — judge
|
| 274 |
+
rules evolve, and a verdict recorded under old rules must not
|
| 275 |
+
replay against new ones.
|
| 276 |
+
"""
|
| 277 |
+
path = os.path.join(workdir, GATE_CACHE_FILE)
|
| 278 |
+
try:
|
| 279 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 280 |
+
data = json.load(f)
|
| 281 |
+
if not isinstance(data, dict):
|
| 282 |
+
return {}
|
| 283 |
+
if data.get("standards") != GATE_CACHE_STANDARDS:
|
| 284 |
+
return {}
|
| 285 |
+
return data
|
| 286 |
+
except (OSError, json.JSONDecodeError):
|
| 287 |
+
return {}
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def _save_gate_cache(workdir: str, cache: dict) -> None:
|
| 291 |
+
"""Persist the gate-pass cache atomically (workdir, per trace)."""
|
| 292 |
+
path = os.path.join(workdir, GATE_CACHE_FILE)
|
| 293 |
+
tmp = path + ".tmp"
|
| 294 |
+
cache = dict(cache)
|
| 295 |
+
cache["standards"] = GATE_CACHE_STANDARDS
|
| 296 |
+
with open(tmp, "w", encoding="utf-8") as f:
|
| 297 |
+
json.dump(cache, f, ensure_ascii=False)
|
| 298 |
+
os.replace(tmp, path)
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def _cache_gate_pass(cache: dict, digest: str, stage: str, round_no: int) -> None:
|
| 302 |
+
"""Record a gate PASS for one artifact digest."""
|
| 303 |
+
rows = cache.setdefault(digest, [])
|
| 304 |
+
key = f"{stage}:{round_no}"
|
| 305 |
+
if key not in rows:
|
| 306 |
+
rows.append(key)
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def _acquire_workdir_lock(workdir: str) -> bool:
|
| 310 |
+
"""O_EXCL pid lockfile; locks left by dead processes are stolen.
|
| 311 |
+
|
| 312 |
+
Two concurrent improve batches working the same sid corrupt each
|
| 313 |
+
other (shared improved.json / verdict.json — the verifier unlinks its
|
| 314 |
+
verdict file before each run) and burn double model time. The lock
|
| 315 |
+
makes one-improver-per-workdir structural, not a user discipline.
|
| 316 |
+
"""
|
| 317 |
+
lock_path = os.path.join(workdir, ".lock")
|
| 318 |
+
my_pid = str(os.getpid())
|
| 319 |
+
for _ in range(3):
|
| 320 |
+
try:
|
| 321 |
+
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
| 322 |
+
try:
|
| 323 |
+
os.write(fd, my_pid.encode())
|
| 324 |
+
finally:
|
| 325 |
+
os.close(fd)
|
| 326 |
+
return True
|
| 327 |
+
except FileExistsError:
|
| 328 |
+
try:
|
| 329 |
+
other = open(lock_path, encoding="utf-8").read().strip()
|
| 330 |
+
except OSError:
|
| 331 |
+
continue
|
| 332 |
+
pid = int(other) if other.isdigit() else -1
|
| 333 |
+
alive = False
|
| 334 |
+
if pid > 0:
|
| 335 |
+
try:
|
| 336 |
+
os.kill(pid, 0)
|
| 337 |
+
alive = True
|
| 338 |
+
except ProcessLookupError:
|
| 339 |
+
alive = False
|
| 340 |
+
except PermissionError:
|
| 341 |
+
alive = True # exists, not ours: assume alive
|
| 342 |
+
except OverflowError:
|
| 343 |
+
alive = False
|
| 344 |
+
if alive:
|
| 345 |
+
return False
|
| 346 |
+
try:
|
| 347 |
+
os.unlink(lock_path) # stale lock from a dead run: steal
|
| 348 |
+
except OSError:
|
| 349 |
+
pass
|
| 350 |
+
return False
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def _release_workdir_lock(workdir: str) -> None:
|
| 354 |
+
"""Drop our own lock (never another live run's)."""
|
| 355 |
+
lock_path = os.path.join(workdir, ".lock")
|
| 356 |
+
try:
|
| 357 |
+
with open(lock_path, encoding="utf-8") as f:
|
| 358 |
+
if f.read().strip() == str(os.getpid()):
|
| 359 |
+
os.unlink(lock_path)
|
| 360 |
+
except OSError:
|
| 361 |
+
pass
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def _write_outputs_index(
|
| 365 |
+
cfg: Config, trace: Trace, workdir: str, max_chars: int = 250_000
|
| 366 |
+
) -> str:
|
| 367 |
+
"""Full real tool outputs as outputs.jsonl, keyed by rendering index.
|
| 368 |
+
|
| 369 |
+
The compact rendering clips each tool result to a short head; P4
|
| 370 |
+
demands verbatim reuse, so the improver needs the unclipped text.
|
| 371 |
+
One JSON object per line: {"i": N, "tool", "args", "output"} where N
|
| 372 |
+
is the same [N] message index the rendering shows — the improver can
|
| 373 |
+
correlate a clipped result in its prompt with the full text here.
|
| 374 |
+
|
| 375 |
+
max_chars is a byte-blow safety valve (measured corpus max real
|
| 376 |
+
output: ~111KB), NOT a compression budget: the 20k cap this used to
|
| 377 |
+
apply hid exactly the mid-tail evidence (p95 = 20k) that P4 demands
|
| 378 |
+
the improver quote — on exactly the big traces where review kills
|
| 379 |
+
artifacts — forcing composed pseudo-outputs. Outputs beyond the
|
| 380 |
+
safety valve carry an honest total-marker, never a fake count.
|
| 381 |
+
"""
|
| 382 |
+
path = os.path.join(workdir, "outputs.jsonl")
|
| 383 |
+
arg_of: dict[str, str] = {}
|
| 384 |
+
for m in trace.messages:
|
| 385 |
+
for tc in m.tool_calls:
|
| 386 |
+
arg_of[tc.id] = tc.arguments or "{}"
|
| 387 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 388 |
+
for i, m in enumerate(trace.messages, 1):
|
| 389 |
+
if m.role != "tool" or not m.content:
|
| 390 |
+
continue
|
| 391 |
+
out = m.content
|
| 392 |
+
if len(out) > max_chars:
|
| 393 |
+
out = out[:max_chars] + f" …[+{len(out) - max_chars} chars total]"
|
| 394 |
+
f.write(json.dumps({
|
| 395 |
+
"i": i,
|
| 396 |
+
"tool": m.tool_name or "?",
|
| 397 |
+
"args": arg_of.get(m.tool_call_id or "", "")[:400],
|
| 398 |
+
"output": out,
|
| 399 |
+
}, ensure_ascii=False) + "\n")
|
| 400 |
+
return path
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
def _generate_single(cfg, trace, rendered, workdir, output_path, errors, prev_path, sid):
|
| 404 |
+
"""One single-shot improver invocation. Returns (candidate, reply, hsid, code, stderr) or raises Timeout."""
|
| 405 |
+
from traceforge.classify import detect_language
|
| 406 |
+
prompt = build_prompt(
|
| 407 |
+
rendered_trace=rendered,
|
| 408 |
+
guidelines_path=cfg.guidelines_path,
|
| 409 |
+
output_path=output_path,
|
| 410 |
+
outputs_path=os.path.join(workdir, "outputs.jsonl"),
|
| 411 |
+
errors=errors,
|
| 412 |
+
prev_path=prev_path,
|
| 413 |
+
language=detect_language(trace.first_task_message() or ""),
|
| 414 |
+
)
|
| 415 |
+
prompt_path = write_prompt_file(cfg.prompts_dir, sid, prompt)
|
| 416 |
+
try:
|
| 417 |
+
code, stdout, hsid, stderr = _run_hermes(cfg, prompt_path, workdir)
|
| 418 |
+
except subprocess.TimeoutExpired as e:
|
| 419 |
+
raise e
|
| 420 |
+
reply = (stdout or "").strip()
|
| 421 |
+
return _parse_improved(output_path), reply, hsid, code, stderr
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
def improve_trace(
|
| 425 |
+
cfg: Config,
|
| 426 |
+
trace: Trace,
|
| 427 |
+
*,
|
| 428 |
+
max_attempts: int = 2,
|
| 429 |
+
validator=None,
|
| 430 |
+
status_cb=None,
|
| 431 |
+
) -> ImproverResult:
|
| 432 |
+
"""Run the agentic improver on one trace, with a validation repair loop.
|
| 433 |
+
|
| 434 |
+
Long traces (rendered size > cfg.chunk_max_chars) go through the staged
|
| 435 |
+
map-digest-write pipeline instead of the single shot. Every admitted
|
| 436 |
+
trace passes: N independent verification rounds (web fact-check) and
|
| 437 |
+
M independent review rounds (quality-pillar review) — all must PASS.
|
| 438 |
+
|
| 439 |
+
``status_cb`` (optional) receives short human stage strings
|
| 440 |
+
("improving", "digest", "verify 1/2", ...) for live progress display.
|
| 441 |
+
"""
|
| 442 |
+
from traceforge.trace import Trace as _T
|
| 443 |
+
from traceforge.validate import validate_structural
|
| 444 |
+
from traceforge.grounding import validate_grounding
|
| 445 |
+
from traceforge.improver.verify import run_verifier
|
| 446 |
+
from traceforge.improver.review import run_review
|
| 447 |
+
|
| 448 |
+
def _status(s: str) -> None:
|
| 449 |
+
if status_cb is not None:
|
| 450 |
+
status_cb(s)
|
| 451 |
+
|
| 452 |
+
started = time.time()
|
| 453 |
+
sid = trace.session_id
|
| 454 |
+
workdir = os.path.join(cfg.improver_workdir, sid)
|
| 455 |
+
os.makedirs(workdir, exist_ok=True)
|
| 456 |
+
# One improver per workdir, structurally: concurrent batches on the
|
| 457 |
+
# same sid race on improved.json/verdict.json and pay the model twice.
|
| 458 |
+
if not _acquire_workdir_lock(workdir):
|
| 459 |
+
return ImproverResult(
|
| 460 |
+
session_id=sid, ok=False, improved=None, reply="",
|
| 461 |
+
hermes_session_id=None,
|
| 462 |
+
errors=["workdir locked by a live improver run — skipping"],
|
| 463 |
+
attempts=0, duration_s=0.0,
|
| 464 |
+
)
|
| 465 |
+
try:
|
| 466 |
+
return _improve_locked(cfg, trace, max_attempts, status_cb)
|
| 467 |
+
finally:
|
| 468 |
+
_release_workdir_lock(workdir)
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
def _improve_locked(
|
| 472 |
+
cfg: Config, trace: Trace, max_attempts: int, status_cb
|
| 473 |
+
) -> ImproverResult:
|
| 474 |
+
from traceforge.trace import Trace as _T
|
| 475 |
+
from traceforge.validate import validate_structural
|
| 476 |
+
from traceforge.grounding import validate_grounding
|
| 477 |
+
from traceforge.improver.verify import run_verifier
|
| 478 |
+
from traceforge.improver.review import run_review
|
| 479 |
+
|
| 480 |
+
def _status(s: str) -> None:
|
| 481 |
+
if status_cb is not None:
|
| 482 |
+
status_cb(s)
|
| 483 |
+
|
| 484 |
+
started = time.time()
|
| 485 |
+
sid = trace.session_id
|
| 486 |
+
workdir = os.path.join(cfg.improver_workdir, sid)
|
| 487 |
+
rendered = trace.render_compact(
|
| 488 |
+
user_chars=cfg.user_chars,
|
| 489 |
+
assistant_chars=cfg.assistant_chars,
|
| 490 |
+
tool_result_chars=cfg.tool_result_chars,
|
| 491 |
+
tool_args_chars=cfg.tool_args_chars,
|
| 492 |
+
include_reasoning=cfg.include_reasoning,
|
| 493 |
+
)
|
| 494 |
+
output_path = os.path.join(workdir, "improved.json")
|
| 495 |
+
# Full unclipped tool outputs on disk: P4's verbatim-reuse rule is
|
| 496 |
+
# only satisfiable when the improver can actually see the full text.
|
| 497 |
+
outputs_path = _write_outputs_index(cfg, trace, workdir)
|
| 498 |
+
|
| 499 |
+
errors: list[str] = []
|
| 500 |
+
prev_path: Optional[str] = None
|
| 501 |
+
attempts = 0
|
| 502 |
+
improved: Optional[Trace] = None
|
| 503 |
+
reply = ""
|
| 504 |
+
hermes_session_id: Optional[str] = None
|
| 505 |
+
# long-trace attempts don't run hermes chat directly (stages do); the
|
| 506 |
+
# loop below must not touch single-shot navigation vars on that path
|
| 507 |
+
code = 0
|
| 508 |
+
stderr = ""
|
| 509 |
+
|
| 510 |
+
for attempt in range(1, max_attempts + 1):
|
| 511 |
+
attempts = attempt
|
| 512 |
+
candidate = None
|
| 513 |
+
# Fast-path: when a previous run already left an artifact in this
|
| 514 |
+
# workdir, re-adjudicate it first (regate). Only pay the model for
|
| 515 |
+
# a fresh write when nothing usable exists on disk. This makes
|
| 516 |
+
# "rerun after a gate change" nearly free instead of a full
|
| 517 |
+
# re-write of every trace.
|
| 518 |
+
if attempt == 1 and os.path.exists(output_path):
|
| 519 |
+
_status("regate")
|
| 520 |
+
candidate = _parse_improved(output_path)
|
| 521 |
+
# Static pre-flight: an artifact that still carries rendering
|
| 522 |
+
# clip markers is dead on arrival at review (reviewers kill
|
| 523 |
+
# invented counts; grounding silently strips them). Fail it
|
| 524 |
+
# here — one static check instead of 4 deep-thinker
|
| 525 |
+
# sessions re-discovering it.
|
| 526 |
+
if candidate is not None and _clip_markers_present(candidate):
|
| 527 |
+
candidate = None
|
| 528 |
+
errors = [CLIP_MARKER_ERROR]
|
| 529 |
+
# keep the failed artifact as the repair reference: the
|
| 530 |
+
# next attempt must FIX it (REPAIR_HEADER + errors), not
|
| 531 |
+
# blind-rewrite from a fresh TASK_HEADER that would
|
| 532 |
+
# silently drop the specific clip-marker error.
|
| 533 |
+
prev_path = output_path
|
| 534 |
+
if candidate is not None:
|
| 535 |
+
reply = "REGATE (existing artifact)"
|
| 536 |
+
code = 0
|
| 537 |
+
stderr = ""
|
| 538 |
+
elif len(rendered) <= cfg.chunk_max_chars:
|
| 539 |
+
_status("improving" if attempt == 1 else f"repair {attempt}")
|
| 540 |
+
try:
|
| 541 |
+
candidate, reply_one, hsid, code, stderr = _generate_single(
|
| 542 |
+
cfg, trace, rendered, workdir, output_path, errors,
|
| 543 |
+
prev_path, sid,
|
| 544 |
+
)
|
| 545 |
+
except subprocess.TimeoutExpired:
|
| 546 |
+
errors = [f"harness timeout after {cfg.harness_timeout_s}s"]
|
| 547 |
+
continue
|
| 548 |
+
reply = reply_one
|
| 549 |
+
hermes_session_id = hsid
|
| 550 |
+
if reply.startswith("REJECTED"):
|
| 551 |
+
errors = [f"rejected by improver: {reply[:300]}"]
|
| 552 |
+
break
|
| 553 |
+
else:
|
| 554 |
+
# long-trace staged pipeline: digest -> map -> writer
|
| 555 |
+
from traceforge.improver import longtrace
|
| 556 |
+
|
| 557 |
+
chunk_dir = os.path.join(workdir, "chunks")
|
| 558 |
+
if prev_path is None:
|
| 559 |
+
_status("digest+map")
|
| 560 |
+
chunks = chunker.chunk_trace(
|
| 561 |
+
trace,
|
| 562 |
+
max_chars=cfg.chunk_max_chars,
|
| 563 |
+
overlap_messages=cfg.chunk_overlap_messages,
|
| 564 |
+
render_kwargs={
|
| 565 |
+
"user_chars": cfg.user_chars,
|
| 566 |
+
"assistant_chars": cfg.assistant_chars,
|
| 567 |
+
"tool_result_chars": cfg.tool_result_chars,
|
| 568 |
+
"tool_args_chars": cfg.tool_args_chars,
|
| 569 |
+
"include_reasoning": cfg.include_reasoning,
|
| 570 |
+
},
|
| 571 |
+
)
|
| 572 |
+
if not chunks:
|
| 573 |
+
errors = ["chunking produced no chunks"]
|
| 574 |
+
continue
|
| 575 |
+
longtrace.write_chunks(chunk_dir, chunks, trace)
|
| 576 |
+
_status(f"digest 0/{len(chunks)}")
|
| 577 |
+
digests, derrs = longtrace.digest_all(
|
| 578 |
+
cfg, trace, chunks, workdir,
|
| 579 |
+
progress_cb=lambda done, total: _status(
|
| 580 |
+
f"digest {done}/{total}"
|
| 581 |
+
),
|
| 582 |
+
)
|
| 583 |
+
if derrs:
|
| 584 |
+
errors = [f"digest failures: {derrs}"]
|
| 585 |
+
continue
|
| 586 |
+
_status(f"map ({len(chunks)} chunks)")
|
| 587 |
+
map_d, merr = longtrace.build_map(cfg, trace, digests, workdir)
|
| 588 |
+
if map_d is None:
|
| 589 |
+
errors = [f"map merge failed: {merr}"]
|
| 590 |
+
continue
|
| 591 |
+
_append_status(cfg, sid, {
|
| 592 |
+
"stage": "map", "chunks": len(chunks),
|
| 593 |
+
"approx_tokens": sum(c.approx_chars for c in chunks) // 4,
|
| 594 |
+
})
|
| 595 |
+
# writer (also used for repair attempts on long traces)
|
| 596 |
+
map_d = longtrace._json_file(os.path.join(workdir, "session_map.json"))
|
| 597 |
+
if map_d is None:
|
| 598 |
+
errors = ["session map missing after long-trace stage"]
|
| 599 |
+
continue
|
| 600 |
+
_status("writer")
|
| 601 |
+
candidate, werr = longtrace.run_writer(
|
| 602 |
+
cfg, trace, map_d, chunk_dir, workdir, output_path,
|
| 603 |
+
repair_errors=errors if prev_path else None,
|
| 604 |
+
)
|
| 605 |
+
if candidate is None:
|
| 606 |
+
errors = [f"long-trace writer failed: {werr}"]
|
| 607 |
+
continue
|
| 608 |
+
reply = "WRITER DONE"
|
| 609 |
+
# The artifact on disk is the source of truth — NOT the exit code.
|
| 610 |
+
# The improver may hit its turn cap after writing a perfectly valid
|
| 611 |
+
# file (e.g. right after a repair); validate what exists regardless.
|
| 612 |
+
candidate = _parse_improved(output_path)
|
| 613 |
+
if candidate is None:
|
| 614 |
+
if code != 0:
|
| 615 |
+
stderr_tail = f"; stderr: {stderr.strip()[-500:]}" if stderr else ""
|
| 616 |
+
errors = [
|
| 617 |
+
f"hermes chat exited with code {code} and "
|
| 618 |
+
f"improved.json missing/unparseable{stderr_tail}"
|
| 619 |
+
]
|
| 620 |
+
else:
|
| 621 |
+
errors = [
|
| 622 |
+
"improved.json missing, unparseable, or missing 'messages'"
|
| 623 |
+
]
|
| 624 |
+
continue
|
| 625 |
+
# Static clip-marker gate on EVERY path, not just regate: writers
|
| 626 |
+
# and repairs can still copy markers out of the clipped rendering.
|
| 627 |
+
# A marker artifact is dead on arrival at review (and grounding
|
| 628 |
+
# silently forgives it) — fail fast here instead of burning
|
| 629 |
+
# verifier + reviewer sessions on it.
|
| 630 |
+
if _clip_markers_present(candidate):
|
| 631 |
+
errors = [f"clip-marker gate: {CLIP_MARKER_ERROR}"]
|
| 632 |
+
# repair must fix THIS artifact (REPAIR_HEADER + errors), not
|
| 633 |
+
# blind-rewrite from a TASK_HEADER that drops the specifics
|
| 634 |
+
prev_path = output_path
|
| 635 |
+
continue
|
| 636 |
+
prev_path = output_path
|
| 637 |
+
improved = candidate
|
| 638 |
+
# Pipeline framing markers ("[trace segment k of n]", "[subagent
|
| 639 |
+
# brief — role: ...]") belong to the improver's INPUT; strip them
|
| 640 |
+
# before the gates so the echo check compares against the real task
|
| 641 |
+
# and the promoted artifact carries no scaffolding.
|
| 642 |
+
from traceforge.validate import strip_scaffold_markers
|
| 643 |
+
if strip_scaffold_markers(improved):
|
| 644 |
+
improved.save(output_path)
|
| 645 |
+
errs = validate_structural(improved)
|
| 646 |
+
if errs and any(_EMPTY_ROW_RE in e for e in errs):
|
| 647 |
+
# blank assistant rows are pure noise — drop them and re-check
|
| 648 |
+
# rather than paying a full rewrite to remove one empty row.
|
| 649 |
+
# Runs even when other errors are present: a trace with a blank
|
| 650 |
+
# row AND a tool tail must get both fixes, not neither.
|
| 651 |
+
if _drop_empty_assistant_rows(improved):
|
| 652 |
+
improved.save(output_path) # repair must start from the fix
|
| 653 |
+
errs = validate_structural(improved)
|
| 654 |
+
if not errs:
|
| 655 |
+
_status("dropped empty assistant rows")
|
| 656 |
+
if _only_tool_tail(errs):
|
| 657 |
+
# writer stopped mid-call: one row short of valid. Synthesize
|
| 658 |
+
# the closing answer instead of paying a full repair round
|
| 659 |
+
# that reproduces the same cut (measured: 49/1535 traces).
|
| 660 |
+
fixed = _append_synth_final(cfg, improved, trace)
|
| 661 |
+
if fixed is not None:
|
| 662 |
+
improved = fixed
|
| 663 |
+
# persist BEFORE re-validating: a later repair round reads
|
| 664 |
+
# prev_path from disk, so an in-memory-only fix is thrown
|
| 665 |
+
# away and the writer reproduces the same tool tail.
|
| 666 |
+
improved.save(output_path)
|
| 667 |
+
errs = validate_structural(improved)
|
| 668 |
+
if not errs:
|
| 669 |
+
_status("final answer (tool-tail synth)")
|
| 670 |
+
if not errs:
|
| 671 |
+
errs = validate_grounding(improved, trace)
|
| 672 |
+
if not errs and (cfg.verify or cfg.review):
|
| 673 |
+
# Gate-pass cache: rounds that already PASSED for THIS exact
|
| 674 |
+
# artifact content are replayed instead of re-paid. Survives
|
| 675 |
+
# batch interruptions (turn cap, SIGTERM, terminal close); a
|
| 676 |
+
# rewritten artifact has a different digest and re-runs all
|
| 677 |
+
# rounds fresh.
|
| 678 |
+
digest = _artifact_digest(improved)
|
| 679 |
+
gate_cache = _load_gate_cache(workdir)
|
| 680 |
+
gate_pass_keys = set(gate_cache.get(digest, []))
|
| 681 |
+
|
| 682 |
+
def _gate_pass(stage: str, round_no: int) -> None:
|
| 683 |
+
_cache_gate_pass(gate_cache, digest, stage, round_no)
|
| 684 |
+
_save_gate_cache(workdir, gate_cache)
|
| 685 |
+
|
| 686 |
+
verify_failures: list[str] = []
|
| 687 |
+
# ---- N independent verification rounds (web fact-check)
|
| 688 |
+
# Strict admission: every round must end PASS. A transient ERROR
|
| 689 |
+
# (timeout, unparseable verdict) gets ONE in-place retry; a
|
| 690 |
+
# second ERROR surfaces as an error row — never silent
|
| 691 |
+
# under-verification, never a model repair for infra issues.
|
| 692 |
+
if cfg.verify:
|
| 693 |
+
passes = 0
|
| 694 |
+
vr = 1
|
| 695 |
+
while vr <= cfg.verify_rounds:
|
| 696 |
+
if f"verify:{vr}" in gate_pass_keys:
|
| 697 |
+
passes += 1 # already adjudicated on identical bytes
|
| 698 |
+
_status(f"verify {vr}/{cfg.verify_rounds} (cached)")
|
| 699 |
+
_append_status(cfg, sid, {
|
| 700 |
+
"stage": "verify", "round": vr,
|
| 701 |
+
"verdict": "PASS", "cached": True,
|
| 702 |
+
})
|
| 703 |
+
vr += 1
|
| 704 |
+
continue
|
| 705 |
+
_status(f"verify {vr}/{cfg.verify_rounds}")
|
| 706 |
+
vres = run_verifier(cfg, output_path, workdir)
|
| 707 |
+
if vres.verdict == "PASS":
|
| 708 |
+
passes += 1
|
| 709 |
+
_gate_pass("verify", vr)
|
| 710 |
+
_append_status(cfg, sid, {
|
| 711 |
+
"stage": "verify", "round": vr,
|
| 712 |
+
"verdict": "PASS",
|
| 713 |
+
"claims_checked": vres.claims_checked,
|
| 714 |
+
})
|
| 715 |
+
vr += 1
|
| 716 |
+
continue
|
| 717 |
+
if vres.verdict == "FAIL":
|
| 718 |
+
claims = "\n".join(
|
| 719 |
+
f'- "{u.get("claim", "?")}" — {u.get("why", "?")}'
|
| 720 |
+
for u in vres.unsupported[:10]
|
| 721 |
+
)
|
| 722 |
+
verify_failures.append(
|
| 723 |
+
f"[verify r{vr}] external claims unsupported:\n{claims}"
|
| 724 |
+
)
|
| 725 |
+
_append_status(cfg, sid, {
|
| 726 |
+
"stage": "verify", "round": vr, "verdict": "FAIL",
|
| 727 |
+
"claims_checked": vres.claims_checked,
|
| 728 |
+
})
|
| 729 |
+
break # concrete FAIL: repair next attempt
|
| 730 |
+
# transient ERROR: one in-place retry of the same round
|
| 731 |
+
retry = run_verifier(cfg, output_path, workdir)
|
| 732 |
+
if retry.verdict == "PASS":
|
| 733 |
+
passes += 1
|
| 734 |
+
_gate_pass("verify", vr)
|
| 735 |
+
_append_status(cfg, sid, {
|
| 736 |
+
"stage": "verify", "round": vr,
|
| 737 |
+
"verdict": "PASS", "retried": True,
|
| 738 |
+
"claims_checked": retry.claims_checked,
|
| 739 |
+
})
|
| 740 |
+
vr += 1
|
| 741 |
+
continue
|
| 742 |
+
if retry.verdict == "FAIL":
|
| 743 |
+
claims = "\n".join(
|
| 744 |
+
f'- "{u.get("claim", "?")}" — {u.get("why", "?")}'
|
| 745 |
+
for u in retry.unsupported[:10]
|
| 746 |
+
)
|
| 747 |
+
verify_failures.append(
|
| 748 |
+
f"[verify r{vr}] external claims unsupported:\n{claims}"
|
| 749 |
+
)
|
| 750 |
+
_append_status(cfg, sid, {
|
| 751 |
+
"stage": "verify", "round": vr, "verdict": "FAIL",
|
| 752 |
+
"retried": True,
|
| 753 |
+
"claims_checked": retry.claims_checked,
|
| 754 |
+
})
|
| 755 |
+
break
|
| 756 |
+
verify_failures.append(
|
| 757 |
+
f"[verify r{vr}] infra error x2: "
|
| 758 |
+
f"{vres.error or 'verifier error'} / "
|
| 759 |
+
f"{retry.error or 'verifier error'}"
|
| 760 |
+
)
|
| 761 |
+
_append_status(cfg, sid, {
|
| 762 |
+
"stage": "verify", "round": vr, "verdict": "ERROR",
|
| 763 |
+
"error": (
|
| 764 |
+
f"{vres.error or 'verifier error'} / "
|
| 765 |
+
f"{retry.error or 'verifier error'}"
|
| 766 |
+
)[:300],
|
| 767 |
+
})
|
| 768 |
+
break
|
| 769 |
+
# ---- M independent review rounds (quality pillars)
|
| 770 |
+
review_failures: list[str] = []
|
| 771 |
+
task_snip = trace.first_task_message() or "(no user message)"
|
| 772 |
+
if not verify_failures and cfg.review:
|
| 773 |
+
for rr in range(1, cfg.review_rounds + 1):
|
| 774 |
+
if f"review:{rr}" in gate_pass_keys:
|
| 775 |
+
_status(f"review {rr}/{cfg.review_rounds} (cached)")
|
| 776 |
+
_append_status(cfg, sid, {
|
| 777 |
+
"stage": "review", "round": rr, "verdict": "PASS",
|
| 778 |
+
"cached": True,
|
| 779 |
+
})
|
| 780 |
+
continue
|
| 781 |
+
_status(f"review {rr}/{cfg.review_rounds}")
|
| 782 |
+
rres = run_review(
|
| 783 |
+
cfg, output_path, task_snip, workdir, rr
|
| 784 |
+
)
|
| 785 |
+
if not rres.ok() and rres.verdict == "ERROR":
|
| 786 |
+
rres = run_review(
|
| 787 |
+
cfg, output_path, task_snip, workdir, rr
|
| 788 |
+
) # one in-place retry on transient infra error
|
| 789 |
+
if rres.ok():
|
| 790 |
+
_gate_pass("review", rr)
|
| 791 |
+
_append_status(cfg, sid, {
|
| 792 |
+
"stage": "review", "round": rr, "verdict": "PASS",
|
| 793 |
+
})
|
| 794 |
+
continue
|
| 795 |
+
if rres.verdict == "FAIL":
|
| 796 |
+
review_failures.append(
|
| 797 |
+
"[review r%d] pillar violations:\n%s"
|
| 798 |
+
% (rr, "\n".join(rres.violations[:10]))
|
| 799 |
+
)
|
| 800 |
+
_append_status(cfg, sid, {
|
| 801 |
+
"stage": "review", "round": rr, "verdict": "FAIL",
|
| 802 |
+
})
|
| 803 |
+
break # concrete FAIL: repair next attempt
|
| 804 |
+
review_failures.append(
|
| 805 |
+
f"[review r{rr}] infra error x2: {rres.error or 'review error'}"
|
| 806 |
+
)
|
| 807 |
+
_append_status(cfg, sid, {
|
| 808 |
+
"stage": "review", "round": rr, "verdict": "ERROR",
|
| 809 |
+
"error": (rres.error or "review error")[:300],
|
| 810 |
+
})
|
| 811 |
+
break
|
| 812 |
+
errs = verify_failures + review_failures
|
| 813 |
+
if errs:
|
| 814 |
+
# Infra failures (verifier/reviewer down) are NOT content errors:
|
| 815 |
+
# a model repair cannot fix them. Fail fast without re-running
|
| 816 |
+
# the improver so no model time is wasted on infrastructure.
|
| 817 |
+
if any("infra error x2" in e for e in errs):
|
| 818 |
+
return ImproverResult(
|
| 819 |
+
session_id=sid, ok=False, improved=None, reply=reply,
|
| 820 |
+
hermes_session_id=hermes_session_id,
|
| 821 |
+
errors=errs, attempts=attempts,
|
| 822 |
+
duration_s=time.time() - started,
|
| 823 |
+
)
|
| 824 |
+
errors = errs + (
|
| 825 |
+
[f"note: hermes exit code {code}"] if code != 0 else []
|
| 826 |
+
)
|
| 827 |
+
continue
|
| 828 |
+
improved.session_id = f"ideal-{sid}"
|
| 829 |
+
improved.parent_session_id = sid
|
| 830 |
+
# cfg.model None = default profile; the exact model is recoverable
|
| 831 |
+
# from hermes_session_id (recorded in the manifest).
|
| 832 |
+
improved.model = cfg.model or "hermes-default"
|
| 833 |
+
improved.source = "hermes-one-shot"
|
| 834 |
+
return ImproverResult(
|
| 835 |
+
session_id=sid, ok=True, improved=improved, reply=reply,
|
| 836 |
+
hermes_session_id=hermes_session_id, errors=[],
|
| 837 |
+
attempts=attempts, duration_s=time.time() - started,
|
| 838 |
+
)
|
| 839 |
+
|
| 840 |
+
return ImproverResult(
|
| 841 |
+
session_id=sid, ok=False, improved=None, reply=reply,
|
| 842 |
+
hermes_session_id=hermes_session_id, errors=errors,
|
| 843 |
+
attempts=attempts, duration_s=time.time() - started,
|
| 844 |
+
)
|
| 845 |
+
|
| 846 |
+
|
| 847 |
+
def cleanup_workdir(cfg: Config, sid: str, keep: bool = False) -> None:
|
| 848 |
+
wd = os.path.join(cfg.improver_workdir, sid)
|
| 849 |
+
if not keep and os.path.isdir(wd):
|
| 850 |
+
shutil.rmtree(wd, ignore_errors=True)
|
traceforge/improver/longtrace.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Long-trace pipeline: chunk -> parallel digests -> map -> writer.
|
| 2 |
+
|
| 3 |
+
For sessions whose compact rendering exceeds cfg.chunk_max_chars, the
|
| 4 |
+
single-shot improver is replaced by this staged pipeline. The trace's raw
|
| 5 |
+
chunks are ALSO written to disk (chunk_dir/chunk_NNN.txt) so the writer
|
| 6 |
+
can look up exact tool outputs by reference, keeping each agent context
|
| 7 |
+
small even for ~10M-token sessions.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import concurrent.futures as cf
|
| 12 |
+
import json
|
| 13 |
+
import os
|
| 14 |
+
import re
|
| 15 |
+
import subprocess
|
| 16 |
+
import threading
|
| 17 |
+
import time
|
| 18 |
+
from typing import Optional
|
| 19 |
+
|
| 20 |
+
from traceforge import chunker
|
| 21 |
+
from traceforge.config import Config
|
| 22 |
+
from traceforge.trace import Message, Trace, json_loads_lenient
|
| 23 |
+
from traceforge.improver.digest_prompts import (
|
| 24 |
+
CHUNK_DIGEST_HEADER,
|
| 25 |
+
MAP_MERGE_HEADER,
|
| 26 |
+
WRITER_LONG_HEADER,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _hermes_run(
|
| 31 |
+
prompt_path: str, workdir: str, max_turns: int, timeout: int
|
| 32 |
+
) -> tuple[int, str, str]:
|
| 33 |
+
cmd = [
|
| 34 |
+
"hermes", "chat",
|
| 35 |
+
"--query-file", prompt_path,
|
| 36 |
+
"--in", workdir,
|
| 37 |
+
"-Q",
|
| 38 |
+
"--max-turns", str(max_turns),
|
| 39 |
+
]
|
| 40 |
+
proc = subprocess.run(
|
| 41 |
+
cmd, capture_output=True, text=True, timeout=timeout, cwd=workdir
|
| 42 |
+
)
|
| 43 |
+
return proc.returncode, proc.stdout or "", proc.stderr or ""
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _json_file(path: str) -> Optional[dict]:
|
| 47 |
+
if not os.path.exists(path):
|
| 48 |
+
return None
|
| 49 |
+
return json_loads_lenient(open(path, encoding="utf-8").read())
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _put_prompt(cfg: Config, name: str, text: str) -> str:
|
| 53 |
+
os.makedirs(cfg.prompts_dir, exist_ok=True)
|
| 54 |
+
p = os.path.join(cfg.prompts_dir, name)
|
| 55 |
+
with open(p, "w", encoding="utf-8") as f:
|
| 56 |
+
f.write(text)
|
| 57 |
+
return p
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def write_chunks(
|
| 61 |
+
chunk_dir: str, chunks: "list[chunker.Chunk]", trace: Trace
|
| 62 |
+
) -> None:
|
| 63 |
+
"""Persist raw chunks + a ref->output index for verbatim lookup."""
|
| 64 |
+
os.makedirs(chunk_dir, exist_ok=True)
|
| 65 |
+
for c in chunks:
|
| 66 |
+
with open(
|
| 67 |
+
os.path.join(chunk_dir, f"chunk_{c.index:03d}.txt"), "w",
|
| 68 |
+
encoding="utf-8",
|
| 69 |
+
) as f:
|
| 70 |
+
f.write(c.rendered)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _digest_one(
|
| 74 |
+
cfg: Config, trace: Trace, c: chunker.Chunk, workdir: str
|
| 75 |
+
) -> tuple[Optional[dict], str]:
|
| 76 |
+
"""Run one digester session over one chunk. Returns (digest, error)."""
|
| 77 |
+
digest_path = os.path.join(workdir, f"digest_{c.index:03d}.json")
|
| 78 |
+
prompt = CHUNK_DIGEST_HEADER.format(
|
| 79 |
+
digest_path=digest_path, n=c.index
|
| 80 |
+
) + c.rendered
|
| 81 |
+
pp = _put_prompt(cfg, f"digest_{trace.session_id}_c{c.index:03d}.txt", prompt)
|
| 82 |
+
try:
|
| 83 |
+
code, _out, _err = _hermes_run(
|
| 84 |
+
pp, workdir, cfg.digest_max_turns, cfg.digest_timeout_s
|
| 85 |
+
)
|
| 86 |
+
except subprocess.TimeoutExpired:
|
| 87 |
+
return None, f"chunk {c.index} digest timeout"
|
| 88 |
+
d = _json_file(digest_path)
|
| 89 |
+
if d is None:
|
| 90 |
+
return None, f"chunk {c.index} digest unparseable (exit {code})"
|
| 91 |
+
return d, ""
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def digest_all(
|
| 95 |
+
cfg: Config, trace: Trace, chunks: list, workdir: str,
|
| 96 |
+
progress_cb=None,
|
| 97 |
+
) -> tuple[list[dict], list[str]]:
|
| 98 |
+
"""Digest all chunks, cfg.digest_workers in parallel.
|
| 99 |
+
|
| 100 |
+
progress_cb(done, total) fires after each chunk completes (from the
|
| 101 |
+
worker threads) — used for live progress display.
|
| 102 |
+
"""
|
| 103 |
+
errors: list[str] = []
|
| 104 |
+
digests: list[dict] = []
|
| 105 |
+
done = 0
|
| 106 |
+
lock = threading.Lock()
|
| 107 |
+
with cf.ThreadPoolExecutor(max_workers=cfg.digest_workers) as ex:
|
| 108 |
+
futs = {
|
| 109 |
+
ex.submit(_digest_one, cfg, trace, c, workdir): c.index
|
| 110 |
+
for c in chunks
|
| 111 |
+
}
|
| 112 |
+
for fut in cf.as_completed(futs):
|
| 113 |
+
idx = futs[fut]
|
| 114 |
+
d, err = fut.result()
|
| 115 |
+
with lock:
|
| 116 |
+
done += 1
|
| 117 |
+
if progress_cb is not None:
|
| 118 |
+
try:
|
| 119 |
+
progress_cb(done, len(chunks))
|
| 120 |
+
except Exception:
|
| 121 |
+
pass # display callback must never break the stage
|
| 122 |
+
if err:
|
| 123 |
+
errors.append(err)
|
| 124 |
+
elif d is not None:
|
| 125 |
+
digests.append(d)
|
| 126 |
+
digests.sort(key=lambda d: int(d.get("chunk", 0)))
|
| 127 |
+
return digests, errors
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def build_map(
|
| 131 |
+
cfg: Config, trace: Trace, digests: list[dict], workdir: str
|
| 132 |
+
) -> tuple[Optional[dict], str]:
|
| 133 |
+
"""Merge digests into the unified session map."""
|
| 134 |
+
map_path = os.path.join(workdir, "session_map.json")
|
| 135 |
+
blob = "\n\n".join(json.dumps(d, ensure_ascii=False, indent=1) for d in digests)
|
| 136 |
+
prompt = MAP_MERGE_HEADER.format(map_path=map_path) + "\nDIGESTS:\n" + blob
|
| 137 |
+
pp = _put_prompt(cfg, f"map_{trace.session_id}.txt", prompt)
|
| 138 |
+
try:
|
| 139 |
+
code, _out, _err = _hermes_run(
|
| 140 |
+
pp, workdir, cfg.digest_max_turns, cfg.digest_timeout_s
|
| 141 |
+
)
|
| 142 |
+
except subprocess.TimeoutExpired:
|
| 143 |
+
return None, "map merge timeout"
|
| 144 |
+
m = _json_file(map_path)
|
| 145 |
+
if m is None:
|
| 146 |
+
return None, f"session map unparseable (exit {code})"
|
| 147 |
+
return m, ""
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def run_writer(
|
| 151 |
+
cfg: Config, trace: Trace, map_d: dict, chunk_dir: str,
|
| 152 |
+
workdir: str, output_path: str,
|
| 153 |
+
repair_errors: Optional[list[str]] = None,
|
| 154 |
+
) -> tuple[Optional[Trace], str]:
|
| 155 |
+
"""Run the ideal-trace writer over the session map."""
|
| 156 |
+
map_path = os.path.join(workdir, "session_map.json")
|
| 157 |
+
head = WRITER_LONG_HEADER.format(
|
| 158 |
+
output_path=output_path,
|
| 159 |
+
guidelines_path=cfg.guidelines_path,
|
| 160 |
+
map_path=map_path,
|
| 161 |
+
chunk_dir=chunk_dir,
|
| 162 |
+
)
|
| 163 |
+
if repair_errors:
|
| 164 |
+
head = (
|
| 165 |
+
"Your previous ideal trace FAILED validation. Fix these exact "
|
| 166 |
+
f"errors:\n- " + "\n- ".join(repair_errors[:20]) +
|
| 167 |
+
"\n\nThe previous attempt is at " + output_path +
|
| 168 |
+
" — read it, fix it in place.\n\n" + head
|
| 169 |
+
)
|
| 170 |
+
pp = _put_prompt(cfg, f"writer_{trace.session_id}.txt", head)
|
| 171 |
+
try:
|
| 172 |
+
code, _out, _err = _hermes_run(
|
| 173 |
+
pp, workdir, cfg.max_turns or 16, cfg.harness_timeout_s
|
| 174 |
+
)
|
| 175 |
+
except subprocess.TimeoutExpired:
|
| 176 |
+
return None, "writer timeout"
|
| 177 |
+
from traceforge.improver.harness import _parse_improved
|
| 178 |
+
|
| 179 |
+
t = _parse_improved(output_path)
|
| 180 |
+
if t is None:
|
| 181 |
+
return None, f"writer output unparseable (exit {code})"
|
| 182 |
+
return t, ""
|
traceforge/improver/prompt.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Builds the improver prompt: task spec + guidelines + compact trace.
|
| 2 |
+
|
| 3 |
+
The improver is a Hermes one-shot session with tools. It is told to
|
| 4 |
+
1) read the guidelines file itself (agentic grounding),
|
| 5 |
+
2) study the rendered real trace,
|
| 6 |
+
3) write the ideal-world rewrite to <workdir>/improved.json,
|
| 7 |
+
4) self-check JSON validity before finishing.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import os
|
| 12 |
+
|
| 13 |
+
TASK_HEADER = """You are a reference improver agent. Your job: rewrite the
|
| 14 |
+
REAL agent trace below into the IDEAL-WORLD version of the same task — what a
|
| 15 |
+
perfect execution would have looked like, following the guidelines exactly.
|
| 16 |
+
The rewrite is the REFERENCE a benchmark scores the source model against: the
|
| 17 |
+
less you have to change, the better that model is. Change only what the
|
| 18 |
+
guidelines require.
|
| 19 |
+
|
| 20 |
+
SCOPE — this is a REWRITE, not a re-execution. Hard limits:
|
| 21 |
+
- The rendered trace below is your ONLY source of truth. Do NOT investigate
|
| 22 |
+
the original environment: no reading the actual project files, no git
|
| 23 |
+
history, no running commands from the trace, no browsing. You cannot and
|
| 24 |
+
must not verify against the real world.
|
| 25 |
+
- Work alone. Do not spawn subagents or delegate.
|
| 26 |
+
- Tool budget (this is all you need): (1) read the guidelines file,
|
| 27 |
+
(2) write improved.json, (3) re-read it to validate, (4) reply.
|
| 28 |
+
- Go straight to writing. Analysis of the trace happens in your reply
|
| 29 |
+
reasoning, not in exploratory tool calls.
|
| 30 |
+
|
| 31 |
+
QUALITY PILLARS (binding — the guidelines file is the full spec):
|
| 32 |
+
- P1 Efficient but deep reasoning: high-signal decision logic per turn
|
| 33 |
+
(why this call, what the result implies, what to verify next); no filler.
|
| 34 |
+
FOCUS: answer exactly what was asked — for a follow-up question,
|
| 35 |
+
deliver the new piece only; never re-present facts the reader already
|
| 36 |
+
got in an earlier turn of the same session.
|
| 37 |
+
SUBSTANCE: the final answer presents the concrete result the task asked
|
| 38 |
+
for — the actual numbers, names, paths, values or code from the tool
|
| 39 |
+
evidence — never a description of what was retrieved. "I pulled the
|
| 40 |
+
nutrition data and the recipe" is poor; the data and the recipe ARE the
|
| 41 |
+
answer. Naming the work instead of showing its result fails the task
|
| 42 |
+
even when every tool call was correct.
|
| 43 |
+
- P2 No committed mistakes: FIRST list the real trace's mistakes (failed
|
| 44 |
+
tool calls, retry loops, wrong turns, user feedback/corrections), then
|
| 45 |
+
route the ideal path around every single one — correct action the first
|
| 46 |
+
time; never show the failure or the repair. WHY: the assistant turns are
|
| 47 |
+
the loss-bearing target; a mistake in the target is imitated, a mistake in
|
| 48 |
+
the context is not. ONE exception (guidelines §4.1): an environment-caused
|
| 49 |
+
failure the ideal agent could not foresee may stay as a byte-verbatim tool
|
| 50 |
+
result, with exactly one corrective step in the next assistant turn and
|
| 51 |
+
recorded as a retained recovery by the harness. Model-caused errors (wrong
|
| 52 |
+
tool, wrong args, wrong path, bad reasoning) are never eligible.
|
| 53 |
+
- P3 Perfect tool calls: every call necessary and correctly parameterized;
|
| 54 |
+
batch independent calls in parallel; no exploratory flailing; no call
|
| 55 |
+
whose result the narrative doesn't use. BATCHING IS STRUCTURAL: when the
|
| 56 |
+
real trace issues two calls in separate assistant turns but the second
|
| 57 |
+
does not consume the first's output, put BOTH tool_calls in ONE assistant
|
| 58 |
+
turn and let the two results follow in order. Do not preserve the source's
|
| 59 |
+
one-call-per-turn rhythm — that rhythm is the defect the judge rejects.
|
| 60 |
+
A duplicate call (same tool, same arguments) is dropped, never batched.
|
| 61 |
+
- P4 Fact-driven: reuse real tool outputs verbatim where the ideal path
|
| 62 |
+
makes the same call; never invent files, commands, or results; no fake
|
| 63 |
+
success — the final answer is grounded in the trace's real evidence.
|
| 64 |
+
The FULL unclipped text of every real tool output is in {outputs_path}
|
| 65 |
+
(one JSON object per line; "i" = the [N] message index in the rendered
|
| 66 |
+
trace). When the ideal path makes the same call as the real trace, take
|
| 67 |
+
the result from there (a contiguous portion is fine). NEVER copy the
|
| 68 |
+
rendering's clip markers ("…[+N chars clipped]") into the ideal trace —
|
| 69 |
+
they are display artifacts of THIS prompt, not content. If you compress
|
| 70 |
+
a tool result, every substantive line must still be quoted from the
|
| 71 |
+
real output.
|
| 72 |
+
CONSTRUCTION ORDER (do this in this exact order): (1) decide the ideal
|
| 73 |
+
call path; (2) for every tool call on it, COPY the result content from
|
| 74 |
+
the matching outputs.jsonl row FIRST — byte-verbatim or a contiguous
|
| 75 |
+
slice; (3) only then write the reasoning and final answer AROUND those
|
| 76 |
+
real results. A tool result you cannot quote from outputs.jsonl is NOT
|
| 77 |
+
writable: either reroute the ideal path so that call is unnecessary, or
|
| 78 |
+
drop the claim. NEVER assemble a tool result from fragments you believe
|
| 79 |
+
are true — stitching individually-plausible lines is fabrication, the
|
| 80 |
+
single most common rejection reason.
|
| 81 |
+
External-world claims (versions, API behavior, dates, technical facts)
|
| 82 |
+
only when the trace itself proves them — otherwise leave them out.
|
| 83 |
+
A web-searching fact-checker reviews every external claim AFTER you
|
| 84 |
+
finish; unsupported claims send the trace back for repair.
|
| 85 |
+
- P5 Language: one language per trace (the first user message's language);
|
| 86 |
+
perfect grammar in assistant prose; keep the user message verbatim.
|
| 87 |
+
NEVER open by restating the brief. The first assistant turn must do
|
| 88 |
+
work — a tool call, a decision, a finding — not re-present the user's
|
| 89 |
+
own text back to them. A message that is a near-copy of the opening
|
| 90 |
+
brief (>=85% similar) is rejected mechanically before the judge sees it.
|
| 91 |
+
- P6 No refusals: complete the task. If it is genuinely un-completable,
|
| 92 |
+
reply starting "REJECTED: <one-line reason>" and write no output file.
|
| 93 |
+
|
| 94 |
+
TRAINING-SIGNAL CONTRACT (why the pillars are shaped this way): this corpus
|
| 95 |
+
is SFT — loss is computed on assistant tokens only; tool results, user turns
|
| 96 |
+
and system prompts are masked context. Never put a failure, a fabricated
|
| 97 |
+
error, or a "try again" narrative in an assistant turn. Never fabricate a
|
| 98 |
+
tool output to make a recovery work. This corpus is not preference or RL
|
| 99 |
+
data: do not invent rejected samples, preference pairs, or reward labels.
|
| 100 |
+
|
| 101 |
+
PROCEDURE: (1) read the guidelines, (2) mine the mistakes, (3) write
|
| 102 |
+
{output_path} in one go, (4) re-read it and validate (JSON, pairing,
|
| 103 |
+
pillars), (5) summarize the changes.
|
| 104 |
+
|
| 105 |
+
Ground rules:
|
| 106 |
+
- Read the guidelines file first: {guidelines_path}
|
| 107 |
+
Treat it as the authoritative spec. If it has no rule for a case you hit,
|
| 108 |
+
follow the six pillars and the training-signal contract, and use your best
|
| 109 |
+
judgment — never invent a rule that contradicts them.
|
| 110 |
+
- CRITICAL — intent fidelity: the ideal trace's FIRST user message must be
|
| 111 |
+
the real trace's first USER TASK message, verbatim, byte-exact (skip
|
| 112 |
+
handoff artifacts — `[CONTEXT COMPACTION]` Hermes rows and "This
|
| 113 |
+
session is being continued…" Claude Code rows; they carry no task).
|
| 114 |
+
If the real trace contains no user task message, do not invent
|
| 115 |
+
one: reply REJECTED: no user task in source trace and write no file.
|
| 116 |
+
You may merge subsequent real user messages into a shorter, cleaner
|
| 117 |
+
dialogue, but the OPENING task must stay the same task.
|
| 118 |
+
- Preserve the original task intent (first user message) and any real context
|
| 119 |
+
(file paths, commands, tool results) that a correct execution would also
|
| 120 |
+
have produced. You MAY compress and clean the trace's own tool results,
|
| 121 |
+
but you must NOT invent facts the trace does not support.
|
| 122 |
+
- Keep realistic tool_io. The improved trace must still read like a genuine
|
| 123 |
+
agent session — just a flawless one: efficient tool use, clear reasoning,
|
| 124 |
+
no flailing, no exploration dead-ends, no policy violations, precise
|
| 125 |
+
final answer.
|
| 126 |
+
|
| 127 |
+
Output contract (CRITICAL):
|
| 128 |
+
- Write the improved trace as JSON to exactly this path: {output_path}
|
| 129 |
+
- Schema:
|
| 130 |
+
{{
|
| 131 |
+
"messages": [
|
| 132 |
+
{{"role": "user"|"assistant"|"tool",
|
| 133 |
+
"content": "...",
|
| 134 |
+
"tool_calls": [{{"id": "...", "name": "...",
|
| 135 |
+
"arguments": "<json string>"}}] // assistant only
|
| 136 |
+
"tool_call_id": "...", "tool_name": "..." // tool rows only
|
| 137 |
+
}}
|
| 138 |
+
]
|
| 139 |
+
}}
|
| 140 |
+
Optional per-message key: "reasoning" (string) for thinking text.
|
| 141 |
+
- tool_calls[].arguments must be a JSON-encoded STRING (OpenAI style).
|
| 142 |
+
- Every assistant tool_call id must have exactly one matching tool row
|
| 143 |
+
(tool_call_id), and vice versa.
|
| 144 |
+
- Validate your output before finishing: re-read the file you wrote with a
|
| 145 |
+
tool, confirm it parses as JSON and the schema + pairing rules hold.
|
| 146 |
+
- Then reply with a one-paragraph summary of what you changed and why.
|
| 147 |
+
"""
|
| 148 |
+
|
| 149 |
+
REPAIR_HEADER = """Your previous attempt at improving this trace FAILED
|
| 150 |
+
validation. Fix it.
|
| 151 |
+
|
| 152 |
+
Validation errors:
|
| 153 |
+
{errors}
|
| 154 |
+
|
| 155 |
+
Previous attempt: {prev_path}
|
| 156 |
+
(Rendered real trace and guidelines still apply, restated below.)
|
| 157 |
+
|
| 158 |
+
The FULL unclipped text of every real tool output is in {outputs_path}
|
| 159 |
+
(one JSON object per line; "i" = the [N] message index in the rendered
|
| 160 |
+
trace). Grounding failures mean: take the ideal tool results from there
|
| 161 |
+
verbatim (contiguous portion fine) — do not paraphrase real outputs.
|
| 162 |
+
NEVER replace a bad result by assembling plausible lines: copy byte-
|
| 163 |
+
verbatim from outputs.jsonl, or drop the call/claim. Clip markers
|
| 164 |
+
("…[+N chars clipped]") are display artifacts — never reproduce them,
|
| 165 |
+
and remove any that the previous attempt copied in.
|
| 166 |
+
|
| 167 |
+
SCOPE (same hard limits as before): this is a REWRITE, not an
|
| 168 |
+
investigation. Do NOT read project files, repo source, gate code, or git
|
| 169 |
+
history. Do NOT spawn subagents. Budget: (1) read the previous
|
| 170 |
+
improved.json once, (2) read the specific outputs.jsonl rows cited in
|
| 171 |
+
the errors above, (3) write the corrected file, (4) re-read it once to
|
| 172 |
+
validate, (5) reply. NO other tool calls — every forensic check the
|
| 173 |
+
reviewer already did for you is spelled out in the errors.
|
| 174 |
+
|
| 175 |
+
WRITE MECHANICS: to overwrite an existing file, read the ENTIRE current
|
| 176 |
+
file first (not a page of it), or write via a Python script
|
| 177 |
+
(execute_code / terminal) that builds the new JSON with json.dumps —
|
| 178 |
+
never hand-type large JSON. If a write is refused, re-read the full file
|
| 179 |
+
and retry the write immediately; do not spend turns analyzing the
|
| 180 |
+
refusal.
|
| 181 |
+
|
| 182 |
+
Write the corrected improved trace JSON to: {output_path}
|
| 183 |
+
Validate the file yourself before finishing, then summarize the fixes.
|
| 184 |
+
"""
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def build_prompt(
|
| 188 |
+
*,
|
| 189 |
+
rendered_trace: str,
|
| 190 |
+
guidelines_path: str,
|
| 191 |
+
output_path: str,
|
| 192 |
+
outputs_path: "str | None" = None,
|
| 193 |
+
repair: "os._Environ | None" = None,
|
| 194 |
+
errors: list[str] | None = None,
|
| 195 |
+
prev_path: str | None = None,
|
| 196 |
+
language: str = "en",
|
| 197 |
+
) -> str:
|
| 198 |
+
"""Compose the full improver prompt for one trace."""
|
| 199 |
+
if errors and prev_path:
|
| 200 |
+
head = REPAIR_HEADER.format(
|
| 201 |
+
errors="\n".join(f"- {e}" for e in errors),
|
| 202 |
+
prev_path=prev_path,
|
| 203 |
+
output_path=output_path,
|
| 204 |
+
outputs_path=outputs_path or "(not provided)",
|
| 205 |
+
)
|
| 206 |
+
else:
|
| 207 |
+
head = TASK_HEADER.format(
|
| 208 |
+
guidelines_path=guidelines_path,
|
| 209 |
+
output_path=output_path,
|
| 210 |
+
outputs_path=outputs_path or "(not provided)",
|
| 211 |
+
)
|
| 212 |
+
# P5 is stated in the guidelines, but the writer still mirrors the
|
| 213 |
+
# language of the source material (German briefs, German tool output)
|
| 214 |
+
# instead of the task's. Naming the language explicitly, next to the
|
| 215 |
+
# trace, is what actually holds it.
|
| 216 |
+
from traceforge.classify import LANGUAGE_NAMES
|
| 217 |
+
lang = LANGUAGE_NAMES.get(language, "English")
|
| 218 |
+
lang_line = (
|
| 219 |
+
f"\nLANGUAGE (hard requirement): this trace's task is in {lang}. "
|
| 220 |
+
f"Write EVERY assistant message — prose, plan steps, final answer — "
|
| 221 |
+
f"in {lang}. The first user message stays byte-verbatim in its "
|
| 222 |
+
f"original language. Source material in another language (briefs, "
|
| 223 |
+
f"tool output, file contents) is quoted as-is, never mirrored: "
|
| 224 |
+
f"quoting German evidence inside an English trace is correct, "
|
| 225 |
+
f"answering in German is not.\n"
|
| 226 |
+
)
|
| 227 |
+
return (
|
| 228 |
+
f"{head}\n"
|
| 229 |
+
f"{lang_line}"
|
| 230 |
+
f"{'=' * 70}\n"
|
| 231 |
+
f"REAL TRACE TO IMPROVE (compact rendering, clipped tool output):\n\n"
|
| 232 |
+
f"{rendered_trace}\n"
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def write_prompt_file(prompts_dir: str, session_id: str, prompt: str) -> str:
|
| 237 |
+
os.makedirs(prompts_dir, exist_ok=True)
|
| 238 |
+
path = os.path.join(prompts_dir, f"improve_{session_id}.txt")
|
| 239 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 240 |
+
f.write(prompt)
|
| 241 |
+
return path
|
traceforge/improver/review.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Quality-pillar reviewer: independent adversarial review rounds.
|
| 2 |
+
|
| 3 |
+
The improver and verifier already self-check and web-check; this stage is
|
| 4 |
+
the final human-dream-quality gate: independent reviewer sessions read
|
| 5 |
+
the ideal trace against the six pillars and the ORIGINAL user intent,
|
| 6 |
+
with NO knowledge of the improver's reasoning, and must unanimously PASS.
|
| 7 |
+
cfg.review_rounds runs independently (default 2); ALL must PASS.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import dataclasses
|
| 12 |
+
import json
|
| 13 |
+
import os
|
| 14 |
+
import subprocess
|
| 15 |
+
import time
|
| 16 |
+
from typing import Any, Optional
|
| 17 |
+
|
| 18 |
+
from traceforge.config import Config
|
| 19 |
+
from traceforge.trace import json_loads_lenient
|
| 20 |
+
|
| 21 |
+
REVIEW_HEADER = """You are an independent, adversarial quality reviewer for
|
| 22 |
+
a synthetic agent trace. You had NO part in creating it. Judge ONLY what
|
| 23 |
+
you see, against the quality pillars below.
|
| 24 |
+
|
| 25 |
+
The ideal trace under review: {improved_path}
|
| 26 |
+
The ORIGINAL task (for intent comparison): {task_snippet}
|
| 27 |
+
Quality pillars (the spec): {guidelines_path} — read it first.
|
| 28 |
+
|
| 29 |
+
Judge the trace against every pillar. Typical failure shapes:
|
| 30 |
+
- P1 violated: bloated reasoning, filler, restating the obvious
|
| 31 |
+
- P2 violated: a mistake survives (failed call, retry loop, wrong turn,
|
| 32 |
+
user-correction absorbed nowhere)
|
| 33 |
+
- P3 violated: unnecessary/exploratory tool calls, missing parallel
|
| 34 |
+
batching, wrong parameters
|
| 35 |
+
- P4 violated: statements without basis in the trace's tool evidence;
|
| 36 |
+
fake success; invented paths/commands/results
|
| 37 |
+
- P5 violated: mixed languages, grammar errors, sloppy prose
|
| 38 |
+
- P6 violated: refusal or task abandonment
|
| 39 |
+
- INTENT violated: the ideal trace solves a different task than the
|
| 40 |
+
original user asked
|
| 41 |
+
|
| 42 |
+
Verdict JSON to {verdict_path}:
|
| 43 |
+
{{
|
| 44 |
+
"verdict": "PASS" | "FAIL",
|
| 45 |
+
"pillar_findings": [
|
| 46 |
+
{{"pillar": "P1", "status": "ok|violated", "detail": "<if violated>"}}
|
| 47 |
+
],
|
| 48 |
+
"violations": ["<every violation, one line each, concrete>"],
|
| 49 |
+
"notes": "<one paragraph overall judgment>"
|
| 50 |
+
}}
|
| 51 |
+
All pillars must be ok for PASS. Then reply "REVIEW: PASS" or
|
| 52 |
+
"REVIEW: FAIL".
|
| 53 |
+
|
| 54 |
+
DISCIPLINE — you have a LIMITED tool-call budget. Spend it wisely:
|
| 55 |
+
- Read the guidelines once, then the improved.json once.
|
| 56 |
+
- Do NOT re-read the same file repeatedly or re-verify what you already
|
| 57 |
+
checked. Batch related checks into one execute_code/terminal call.
|
| 58 |
+
- From ~turn 10 onward (of 16), STOP forensic tool calls entirely and
|
| 59 |
+
write the verdict file with what you have. A verdict you can defend
|
| 60 |
+
from the checks already done is worth infinitely more than one more
|
| 61 |
+
probe: a missing verdict file FAILS the whole review round as an
|
| 62 |
+
infra error.
|
| 63 |
+
- The verdict file MUST exist on disk BEFORE your final reply. If you
|
| 64 |
+
are running out of budget, write it NOW with your best judgment.
|
| 65 |
+
"""
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@dataclasses.dataclass
|
| 69 |
+
class ReviewResult:
|
| 70 |
+
verdict: str # PASS | FAIL | ERROR
|
| 71 |
+
violations: list[str] = dataclasses.field(default_factory=list)
|
| 72 |
+
pillar_findings: list[dict] = dataclasses.field(default_factory=list)
|
| 73 |
+
notes: str = ""
|
| 74 |
+
duration_s: float = 0.0
|
| 75 |
+
error: Optional[str] = None
|
| 76 |
+
|
| 77 |
+
def ok(self) -> bool:
|
| 78 |
+
return self.verdict == "PASS"
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _run_review_hermes(
|
| 82 |
+
prompt_path: str, workdir: str, max_turns: int, timeout: int
|
| 83 |
+
) -> tuple[int, str]:
|
| 84 |
+
cmd = [
|
| 85 |
+
"hermes", "chat",
|
| 86 |
+
"--query-file", prompt_path,
|
| 87 |
+
"--in", workdir,
|
| 88 |
+
"-Q",
|
| 89 |
+
"--max-turns", str(max_turns),
|
| 90 |
+
]
|
| 91 |
+
proc = subprocess.run(
|
| 92 |
+
cmd, capture_output=True, text=True, timeout=timeout, cwd=workdir
|
| 93 |
+
)
|
| 94 |
+
return proc.returncode, proc.stdout or ""
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def parse_review(raw: Optional[str]) -> Optional[dict[str, Any]]:
|
| 98 |
+
if not raw:
|
| 99 |
+
return None
|
| 100 |
+
raw = raw.strip()
|
| 101 |
+
if raw.startswith("```"):
|
| 102 |
+
raw = raw.split("\n", 1)[1] if "\n" in raw else ""
|
| 103 |
+
if raw.rstrip().endswith("```"):
|
| 104 |
+
raw = raw.rstrip()[:-3]
|
| 105 |
+
data = json_loads_lenient(raw)
|
| 106 |
+
if not isinstance(data, dict) or "verdict" not in data:
|
| 107 |
+
return None
|
| 108 |
+
return data
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _rescue_verdict_from_stdout(raw: Optional[str]) -> Optional[dict[str, Any]]:
|
| 112 |
+
"""A reviewer that hit its turn cap mid-forensics never writes the
|
| 113 |
+
verdict file; its final chat message still contains the judgment.
|
| 114 |
+
REVIEW: PASS/FAIL line is authoritative; lift the richest JSON verdict
|
| 115 |
+
object the reviewer emitted, if any."""
|
| 116 |
+
if not raw:
|
| 117 |
+
return None
|
| 118 |
+
marker = None
|
| 119 |
+
for m in ("REVIEW: PASS", "REVIEW: FAIL"):
|
| 120 |
+
if m in raw:
|
| 121 |
+
marker = m.split(":")[1].strip()
|
| 122 |
+
break
|
| 123 |
+
if marker is None:
|
| 124 |
+
return None
|
| 125 |
+
data: dict[str, Any] = {"verdict": marker}
|
| 126 |
+
for candidate in _json_object_spans(raw):
|
| 127 |
+
try:
|
| 128 |
+
obj = json_loads_lenient(candidate)
|
| 129 |
+
except Exception:
|
| 130 |
+
continue
|
| 131 |
+
if isinstance(obj, dict) and "verdict" in obj:
|
| 132 |
+
data = obj
|
| 133 |
+
data["verdict"] = marker
|
| 134 |
+
break
|
| 135 |
+
return data
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _json_object_spans(text: str) -> list[str]:
|
| 139 |
+
"""Balanced top-level {...} spans from text, largest first."""
|
| 140 |
+
spans: list[tuple[int, int]] = []
|
| 141 |
+
depth = 0
|
| 142 |
+
start = -1
|
| 143 |
+
in_str = False
|
| 144 |
+
esc = False
|
| 145 |
+
for i, ch in enumerate(text):
|
| 146 |
+
if in_str:
|
| 147 |
+
if esc:
|
| 148 |
+
esc = False
|
| 149 |
+
elif ch == "\\":
|
| 150 |
+
esc = True
|
| 151 |
+
elif ch == '"':
|
| 152 |
+
in_str = False
|
| 153 |
+
continue
|
| 154 |
+
if ch == '"':
|
| 155 |
+
in_str = True
|
| 156 |
+
elif ch == "{":
|
| 157 |
+
if depth == 0:
|
| 158 |
+
start = i
|
| 159 |
+
depth += 1
|
| 160 |
+
elif ch == "}" and depth:
|
| 161 |
+
depth -= 1
|
| 162 |
+
if depth == 0 and start >= 0:
|
| 163 |
+
spans.append((start, i + 1))
|
| 164 |
+
start = -1
|
| 165 |
+
spans.sort(key=lambda s: s[1] - s[0], reverse=True)
|
| 166 |
+
return [text[a:b] for a, b in spans[:20]]
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def run_review(
|
| 170 |
+
cfg: Config,
|
| 171 |
+
improved_path: str,
|
| 172 |
+
task_snippet: str,
|
| 173 |
+
workdir: str,
|
| 174 |
+
round_no: int,
|
| 175 |
+
) -> ReviewResult:
|
| 176 |
+
"""One independent review round."""
|
| 177 |
+
started = time.time()
|
| 178 |
+
verdict_path = os.path.join(
|
| 179 |
+
workdir, f"review_{round_no:02d}.json"
|
| 180 |
+
)
|
| 181 |
+
if os.path.exists(verdict_path):
|
| 182 |
+
os.unlink(verdict_path)
|
| 183 |
+
prompt = REVIEW_HEADER.format(
|
| 184 |
+
improved_path=improved_path,
|
| 185 |
+
task_snippet=task_snippet[:2000],
|
| 186 |
+
guidelines_path=cfg.guidelines_path,
|
| 187 |
+
verdict_path=verdict_path,
|
| 188 |
+
)
|
| 189 |
+
os.makedirs(cfg.prompts_dir, exist_ok=True)
|
| 190 |
+
prompt_path = os.path.join(
|
| 191 |
+
cfg.prompts_dir,
|
| 192 |
+
f"review_{os.path.basename(os.path.dirname(improved_path))}"
|
| 193 |
+
f"_r{round_no}.txt",
|
| 194 |
+
)
|
| 195 |
+
with open(prompt_path, "w", encoding="utf-8") as f:
|
| 196 |
+
f.write(prompt)
|
| 197 |
+
try:
|
| 198 |
+
_code, _out = _run_review_hermes(
|
| 199 |
+
prompt_path, workdir, cfg.review_max_turns, cfg.review_timeout_s
|
| 200 |
+
)
|
| 201 |
+
except subprocess.TimeoutExpired:
|
| 202 |
+
return ReviewResult(
|
| 203 |
+
verdict="ERROR",
|
| 204 |
+
error=f"review round {round_no} timeout",
|
| 205 |
+
duration_s=time.time() - started,
|
| 206 |
+
)
|
| 207 |
+
data = parse_review(
|
| 208 |
+
open(verdict_path, encoding="utf-8").read()
|
| 209 |
+
if os.path.exists(verdict_path) else None
|
| 210 |
+
)
|
| 211 |
+
if data is None:
|
| 212 |
+
rescue = _rescue_verdict_from_stdout(_out)
|
| 213 |
+
if rescue is not None:
|
| 214 |
+
data = rescue
|
| 215 |
+
if data is None:
|
| 216 |
+
return ReviewResult(
|
| 217 |
+
verdict="ERROR",
|
| 218 |
+
error=f"review round {round_no} produced no parseable verdict",
|
| 219 |
+
duration_s=time.time() - started,
|
| 220 |
+
)
|
| 221 |
+
return ReviewResult(
|
| 222 |
+
verdict=str(data.get("verdict", "FAIL")).upper(),
|
| 223 |
+
violations=[
|
| 224 |
+
str(v) for v in (data.get("violations", []) or [])
|
| 225 |
+
],
|
| 226 |
+
pillar_findings=[
|
| 227 |
+
p for p in (data.get("pillar_findings", []) or []) if isinstance(p, dict)
|
| 228 |
+
],
|
| 229 |
+
notes=str(data.get("notes", "")),
|
| 230 |
+
duration_s=time.time() - started,
|
| 231 |
+
)
|
traceforge/improver/verify.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""External-claim verification stage: a web-searching verifier agent.
|
| 2 |
+
|
| 3 |
+
The grounding gate covers environment-internal facts (paths, outputs from
|
| 4 |
+
the source trace). This stage covers the OTHER class: claims about the
|
| 5 |
+
external world (library versions, API behavior, dates, technical facts)
|
| 6 |
+
that an agent can state without the trace proving them — the classic
|
| 7 |
+
hallucination vector.
|
| 8 |
+
|
| 9 |
+
The verifier is a Hermes one-shot session with the default toolset (web
|
| 10 |
+
search + extract enabled). It reads the improved trace, extracts
|
| 11 |
+
external-world claims, web-searches each, and writes a verdict JSON.
|
| 12 |
+
It VERDICTS, never rewrites — on FAIL the unsupported claims flow back
|
| 13 |
+
into the improver repair loop.
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import dataclasses
|
| 18 |
+
import json
|
| 19 |
+
import os
|
| 20 |
+
import subprocess
|
| 21 |
+
import time
|
| 22 |
+
from typing import Any, Optional
|
| 23 |
+
|
| 24 |
+
from traceforge.config import Config
|
| 25 |
+
from traceforge.trace import json_loads_lenient
|
| 26 |
+
|
| 27 |
+
VERIFIER_HEADER = """You are an adversarial fact-checker for a synthetic agent
|
| 28 |
+
trace. The file below is an "ideal-world" agent trace under review for
|
| 29 |
+
corpus admission. Your ONLY job: find incorrect or unverifiable statements
|
| 30 |
+
about the EXTERNAL WORLD and fail the trace if any exist.
|
| 31 |
+
|
| 32 |
+
What counts as an external claim (CHECK IT):
|
| 33 |
+
- Technical facts: library/framework versions, API behavior, default
|
| 34 |
+
settings, command flags, file formats, error semantics
|
| 35 |
+
- Factual assertions: dates, quantities, named results, "X supports Y",
|
| 36 |
+
"Z is deprecated since W"
|
| 37 |
+
- Citations: paper names, spec sections, RFC numbers
|
| 38 |
+
|
| 39 |
+
What does NOT count (SKIP — already verified against the source trace):
|
| 40 |
+
- Anything about the project's own files/paths/commands/outputs
|
| 41 |
+
- The trace's internal narrative (plans, reasoning steps)
|
| 42 |
+
- Opinions, hedged statements, future intentions
|
| 43 |
+
|
| 44 |
+
Procedure (use web_search / web_extract as needed — that is the point):
|
| 45 |
+
1. Read the improved trace at: {improved_path}
|
| 46 |
+
2. List every external claim. If there are none, verdict PASS with
|
| 47 |
+
claims_checked: 0 — do not search.
|
| 48 |
+
3. For each claim: search the web. Primary sources beat blogs; note the
|
| 49 |
+
source URL per claim. A claim passes when a real source confirms it.
|
| 50 |
+
A claim fails when sources contradict it — or passes_with_caveat when
|
| 51 |
+
partially right. Unverifiable-but-asserted-with-confidence = fail.
|
| 52 |
+
4. Write your verdict as JSON to exactly: {verdict_path}
|
| 53 |
+
Schema:
|
| 54 |
+
{{
|
| 55 |
+
"verdict": "PASS" | "FAIL",
|
| 56 |
+
"claims_checked": <int>,
|
| 57 |
+
"unsupported": [
|
| 58 |
+
{{"claim": "...", "why": "...", "searched": "..."}}
|
| 59 |
+
],
|
| 60 |
+
"notes": "<one paragraph>"
|
| 61 |
+
}}
|
| 62 |
+
Include ALL checked claims (pass and fail) as a "checked" array with
|
| 63 |
+
per-claim {{"claim", "verdict": "pass"|"fail"|"pass_with_caveat",
|
| 64 |
+
"source"}} entries.
|
| 65 |
+
5. Reply with just: VERDICT: PASS or VERDICT: FAIL plus claim count.
|
| 66 |
+
|
| 67 |
+
Be adversarial: your value is in finding the wrong statement everyone
|
| 68 |
+
else let slide. But do not fail a trace for hedged or internal statements.
|
| 69 |
+
|
| 70 |
+
DISCIPLINE — you have a LIMITED tool-call budget:
|
| 71 |
+
- Read the trace once; list the claims in your reasoning, then check
|
| 72 |
+
them with batched searches (multiple queries in parallel tool calls).
|
| 73 |
+
- From ~turn 15 onward (of 20), STOP searching and write verdict.json
|
| 74 |
+
with what you have. The verdict file MUST exist on disk BEFORE your
|
| 75 |
+
final reply — a missing verdict file fails the whole verification
|
| 76 |
+
round as an infra error. If unsure, write your best verdict NOW.
|
| 77 |
+
"""
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@dataclasses.dataclass
|
| 81 |
+
class VerifierResult:
|
| 82 |
+
verdict: str # PASS | FAIL | ERROR
|
| 83 |
+
claims_checked: int = 0
|
| 84 |
+
unsupported: list[dict] = dataclasses.field(default_factory=list)
|
| 85 |
+
checked: list[dict] = dataclasses.field(default_factory=list)
|
| 86 |
+
notes: str = ""
|
| 87 |
+
hermes_session_id: Optional[str] = None
|
| 88 |
+
duration_s: float = 0.0
|
| 89 |
+
error: Optional[str] = None
|
| 90 |
+
|
| 91 |
+
def ok(self) -> bool:
|
| 92 |
+
return self.verdict == "PASS"
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _run_verifier_hermes(
|
| 96 |
+
cfg: Config, prompt_path: str, workdir: str
|
| 97 |
+
) -> tuple[int, str, str]:
|
| 98 |
+
cmd = [
|
| 99 |
+
"hermes", "chat",
|
| 100 |
+
"--query-file", prompt_path,
|
| 101 |
+
"--in", workdir,
|
| 102 |
+
"-Q",
|
| 103 |
+
"--max-turns", str(cfg.verifier_max_turns),
|
| 104 |
+
]
|
| 105 |
+
proc = subprocess.run(
|
| 106 |
+
cmd,
|
| 107 |
+
capture_output=True,
|
| 108 |
+
text=True,
|
| 109 |
+
timeout=cfg.verifier_timeout_s,
|
| 110 |
+
cwd=workdir,
|
| 111 |
+
)
|
| 112 |
+
return proc.returncode, proc.stdout or "", proc.stderr or ""
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def parse_verdict(raw: Optional[str]) -> Optional[dict[str, Any]]:
|
| 116 |
+
if not raw:
|
| 117 |
+
return None
|
| 118 |
+
raw = raw.strip()
|
| 119 |
+
if raw.startswith("```"):
|
| 120 |
+
raw = raw.split("\n", 1)[1] if "\n" in raw else ""
|
| 121 |
+
if raw.rstrip().endswith("```"):
|
| 122 |
+
raw = raw.rstrip()[:-3]
|
| 123 |
+
data = json_loads_lenient(raw)
|
| 124 |
+
if not isinstance(data, dict) or "verdict" not in data:
|
| 125 |
+
return None
|
| 126 |
+
return data
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _rescue_verdict_from_stdout(raw: Optional[str]) -> Optional[dict[str, Any]]:
|
| 130 |
+
"""A verifier that hit its turn cap mid-research never writes
|
| 131 |
+
verdict.json; its final chat message still contains the judgment.
|
| 132 |
+
VERDICT: PASS/FAIL line is authoritative; lift the richest JSON
|
| 133 |
+
verdict object the verifier emitted, if any."""
|
| 134 |
+
from traceforge.improver.review import (
|
| 135 |
+
_json_object_spans,
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
if not raw:
|
| 139 |
+
return None
|
| 140 |
+
marker = None
|
| 141 |
+
for m in ("VERDICT: PASS", "VERDICT: FAIL"):
|
| 142 |
+
if m in raw:
|
| 143 |
+
marker = m.split(":")[1].strip()
|
| 144 |
+
break
|
| 145 |
+
if marker is None:
|
| 146 |
+
return None
|
| 147 |
+
data: dict[str, Any] = {"verdict": marker}
|
| 148 |
+
for candidate in _json_object_spans(raw):
|
| 149 |
+
try:
|
| 150 |
+
obj = json_loads_lenient(candidate)
|
| 151 |
+
except Exception:
|
| 152 |
+
continue
|
| 153 |
+
if isinstance(obj, dict) and "verdict" in obj:
|
| 154 |
+
data = obj
|
| 155 |
+
data["verdict"] = marker
|
| 156 |
+
break
|
| 157 |
+
return data
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def run_verifier(
|
| 161 |
+
cfg: Config, improved_path: str, workdir: str
|
| 162 |
+
) -> VerifierResult:
|
| 163 |
+
"""Fact-check the improved trace's external claims via web search."""
|
| 164 |
+
started = time.time()
|
| 165 |
+
verdict_path = os.path.join(workdir, "verdict.json")
|
| 166 |
+
if os.path.exists(verdict_path):
|
| 167 |
+
os.unlink(verdict_path)
|
| 168 |
+
prompt = VERIFIER_HEADER.format(
|
| 169 |
+
improved_path=improved_path, verdict_path=verdict_path
|
| 170 |
+
)
|
| 171 |
+
os.makedirs(cfg.prompts_dir, exist_ok=True)
|
| 172 |
+
prompt_path = os.path.join(
|
| 173 |
+
cfg.prompts_dir,
|
| 174 |
+
f"verify_{os.path.basename(os.path.dirname(improved_path))}.txt",
|
| 175 |
+
)
|
| 176 |
+
with open(prompt_path, "w", encoding="utf-8") as f:
|
| 177 |
+
f.write(prompt)
|
| 178 |
+
try:
|
| 179 |
+
code, stdout, stderr = _run_verifier_hermes(cfg, prompt_path, workdir)
|
| 180 |
+
except subprocess.TimeoutExpired:
|
| 181 |
+
return VerifierResult(
|
| 182 |
+
verdict="ERROR", error=f"verifier timeout {cfg.verifier_timeout_s}s",
|
| 183 |
+
duration_s=time.time() - started,
|
| 184 |
+
)
|
| 185 |
+
data = parse_verdict(
|
| 186 |
+
open(verdict_path, encoding="utf-8").read()
|
| 187 |
+
if os.path.exists(verdict_path) else None
|
| 188 |
+
)
|
| 189 |
+
if data is None:
|
| 190 |
+
rescue = _rescue_verdict_from_stdout(stdout)
|
| 191 |
+
if rescue is not None:
|
| 192 |
+
data = rescue
|
| 193 |
+
if data is None:
|
| 194 |
+
tail = stderr.strip()[-400:] if stderr else ""
|
| 195 |
+
return VerifierResult(
|
| 196 |
+
verdict="ERROR",
|
| 197 |
+
error=(
|
| 198 |
+
f"verifier produced no parseable verdict.json "
|
| 199 |
+
f"(exit {code}{'; ' + tail if tail else ''})"
|
| 200 |
+
),
|
| 201 |
+
duration_s=time.time() - started,
|
| 202 |
+
)
|
| 203 |
+
return VerifierResult(
|
| 204 |
+
verdict=str(data.get("verdict", "FAIL")).upper(),
|
| 205 |
+
claims_checked=int(data.get("claims_checked", 0) or 0),
|
| 206 |
+
unsupported=list(data.get("unsupported", []) or []),
|
| 207 |
+
checked=list(data.get("checked", []) or []),
|
| 208 |
+
notes=str(data.get("notes", "")),
|
| 209 |
+
duration_s=time.time() - started,
|
| 210 |
+
)
|
traceforge/live.py
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Live terminal telemetry for long-running batches.
|
| 2 |
+
|
| 3 |
+
Every stage traceforge spawns (improver, digester, map, writer, verifier,
|
| 4 |
+
reviewer) is a Hermes one-shot running with cwd = the per-trace workdir,
|
| 5 |
+
and Hermes updates live counters on the session row in state.db as the
|
| 6 |
+
model streams. LiveStats polls those rows on a background thread and
|
| 7 |
+
renders:
|
| 8 |
+
|
| 9 |
+
TTY detail rows under the batch bar covering EVERY in-flight
|
| 10 |
+
trace (stage, age, tokens in/out, instantaneous tok/s,
|
| 11 |
+
child-session count), plus a batch postfix (total out ·
|
| 12 |
+
tok/s). Layout adapts to --workers and terminal size: full
|
| 13 |
+
detail rows while they fit; then packed chips
|
| 14 |
+
(`<sid-tail> <stage> <age>`, several per row); a stage
|
| 15 |
+
histogram only when even chips overflow the screen. An
|
| 16 |
+
in-flight trace is never silently hidden.
|
| 17 |
+
pipes a plain heartbeat line every live_heartbeat_s with the same
|
| 18 |
+
aggregate — every in-flight trace listed up to 10, then
|
| 19 |
+
per-stage counts — so backgrounded runs show real progress.
|
| 20 |
+
end token totals on IMPROVED/FAILED lines and a batch summary line.
|
| 21 |
+
|
| 22 |
+
Read-only against state.db (mode=ro); poll failures are swallowed and
|
| 23 |
+
keep the last snapshot. Contract: poll() runs on one thread (the built-in
|
| 24 |
+
loop); render helpers are safe from any thread.
|
| 25 |
+
"""
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import os
|
| 29 |
+
import shutil
|
| 30 |
+
import sqlite3
|
| 31 |
+
import threading
|
| 32 |
+
import time
|
| 33 |
+
from typing import Optional
|
| 34 |
+
|
| 35 |
+
from traceforge.progress import _fmt_dur
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def fmt_tok(n) -> str:
|
| 39 |
+
"""Compact token count: 1234 -> '1.2k', 1234567 -> '1.23M'."""
|
| 40 |
+
n = int(n or 0)
|
| 41 |
+
if n >= 1_000_000:
|
| 42 |
+
return f"{n / 1_000_000:.2f}M"
|
| 43 |
+
if n >= 10_000:
|
| 44 |
+
return f"{n / 1000:.0f}k"
|
| 45 |
+
if n >= 1000:
|
| 46 |
+
return f"{n / 1000:.1f}k"
|
| 47 |
+
return str(n)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _stage_counts(acts) -> dict:
|
| 51 |
+
"""{stage: n} over [(sid, info), ...] pairs."""
|
| 52 |
+
counts: dict = {}
|
| 53 |
+
for _sid, info in acts:
|
| 54 |
+
st = info.get("stage", "?")
|
| 55 |
+
counts[st] = counts.get(st, 0) + 1
|
| 56 |
+
return counts
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def sid_short(sid: str) -> str:
|
| 60 |
+
"""Distinguishing short form of a session id for detail rows.
|
| 61 |
+
|
| 62 |
+
Repo sids are date-prefixed (`20260926_160647_ceb268`), so the
|
| 63 |
+
first 12 chars are the SAME for every trace started the same
|
| 64 |
+
minute — head truncation makes rows indistinguishable. Show the
|
| 65 |
+
tail instead: last `_` segment (hermes) or last 8 chars (uuid).
|
| 66 |
+
"""
|
| 67 |
+
seg = sid.rsplit("_", 1)[-1] if len(sid) > 10 else sid
|
| 68 |
+
if len(seg) > 8:
|
| 69 |
+
seg = seg[-8:]
|
| 70 |
+
return seg if seg == sid else f"…{seg}"
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class LiveRegistry:
|
| 74 |
+
"""Thread-safe map of in-flight traces -> current stage + timestamps."""
|
| 75 |
+
|
| 76 |
+
def __init__(self):
|
| 77 |
+
self._lock = threading.Lock()
|
| 78 |
+
self._traces: dict = {}
|
| 79 |
+
|
| 80 |
+
def begin(self, sid: str, stage: str = "starting") -> None:
|
| 81 |
+
with self._lock:
|
| 82 |
+
now = time.monotonic()
|
| 83 |
+
self._traces[sid] = {
|
| 84 |
+
"stage": stage, "t0": now, "stage_t": now,
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
def set_stage(self, sid: str, stage: str) -> None:
|
| 88 |
+
with self._lock:
|
| 89 |
+
d = self._traces.get(sid)
|
| 90 |
+
if d is not None:
|
| 91 |
+
d["stage"] = stage
|
| 92 |
+
d["stage_t"] = time.monotonic()
|
| 93 |
+
|
| 94 |
+
def end(self, sid: str) -> None:
|
| 95 |
+
with self._lock:
|
| 96 |
+
self._traces.pop(sid, None)
|
| 97 |
+
|
| 98 |
+
def snapshot(self):
|
| 99 |
+
"""[(sid, info), ...] oldest first — stable display order."""
|
| 100 |
+
with self._lock:
|
| 101 |
+
return [
|
| 102 |
+
(sid, dict(d))
|
| 103 |
+
for sid, d in sorted(
|
| 104 |
+
self._traces.items(), key=lambda kv: kv[1]["t0"]
|
| 105 |
+
)
|
| 106 |
+
]
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
class LiveStats:
|
| 110 |
+
"""Polls state.db child-session counters; renders live progress."""
|
| 111 |
+
|
| 112 |
+
def __init__(self, cfg, registry: LiveRegistry, prog=None,
|
| 113 |
+
*, max_sublines: int = 0):
|
| 114 |
+
self.registry = registry
|
| 115 |
+
self.prog = prog
|
| 116 |
+
# 0 = adaptive: show every in-flight trace, switching layout
|
| 117 |
+
# (full rows -> packed chips -> stage histogram) by screen fit.
|
| 118 |
+
# >0 (tests, explicit caps): hard cap on full detail rows.
|
| 119 |
+
self.max_sublines = max(0, int(max_sublines))
|
| 120 |
+
self._forced_cap = self.max_sublines > 0
|
| 121 |
+
self.state_db = cfg.state_db
|
| 122 |
+
self.poll_s = max(0.5, float(getattr(cfg, "live_poll_s", 2.0)))
|
| 123 |
+
self.heartbeat_s = max(
|
| 124 |
+
5.0, float(getattr(cfg, "live_heartbeat_s", 30.0))
|
| 125 |
+
)
|
| 126 |
+
# ours = one-shot sessions started in this batch window AND inside
|
| 127 |
+
# this batch's workdir root (cwd leaf = trace sid)
|
| 128 |
+
self._like = os.path.join(cfg.improver_workdir, "")
|
| 129 |
+
self.enabled = os.path.exists(self.state_db)
|
| 130 |
+
self._t0_mono = time.monotonic()
|
| 131 |
+
self._since = time.time() - 1.0
|
| 132 |
+
self._per: dict = {}
|
| 133 |
+
self._prev_out: dict = {}
|
| 134 |
+
self._sess_models: dict = {} # child session id -> model name
|
| 135 |
+
self._sessions = 0
|
| 136 |
+
self._tot_in = 0
|
| 137 |
+
self._tot_out = 0
|
| 138 |
+
self._rate = 0.0
|
| 139 |
+
self._last_poll = None # monotonic ts of previous poll
|
| 140 |
+
self._lock = threading.Lock()
|
| 141 |
+
self._stop = threading.Event()
|
| 142 |
+
self._thread: Optional[threading.Thread] = None
|
| 143 |
+
|
| 144 |
+
# ----------------------------------------------------------- lifecycle
|
| 145 |
+
@classmethod
|
| 146 |
+
def setup(cls, cfg, prog=None, registry=None, **kw):
|
| 147 |
+
"""Factory honoring the live_poll_s=0 kill-switch."""
|
| 148 |
+
if float(getattr(cfg, "live_poll_s", 0) or 0) <= 0:
|
| 149 |
+
return None
|
| 150 |
+
reg = registry or LiveRegistry()
|
| 151 |
+
return cls(cfg, reg, prog, **kw)
|
| 152 |
+
|
| 153 |
+
def start(self) -> None:
|
| 154 |
+
if not self.enabled or self._thread is not None:
|
| 155 |
+
return
|
| 156 |
+
self._thread = threading.Thread(
|
| 157 |
+
target=self._loop, name="traceforge-live", daemon=True
|
| 158 |
+
)
|
| 159 |
+
self._thread.start()
|
| 160 |
+
|
| 161 |
+
def stop(self) -> None:
|
| 162 |
+
self._stop.set()
|
| 163 |
+
t = self._thread
|
| 164 |
+
if (
|
| 165 |
+
t is not None and t.is_alive()
|
| 166 |
+
and threading.current_thread() is not t
|
| 167 |
+
):
|
| 168 |
+
t.join(timeout=self.poll_s + 1.0)
|
| 169 |
+
self._thread = None
|
| 170 |
+
|
| 171 |
+
# ---------------------------------------------------------------- poll
|
| 172 |
+
def poll(self) -> None:
|
| 173 |
+
"""One state.db read; recomputes aggregates. Single-thread contract."""
|
| 174 |
+
try:
|
| 175 |
+
conn = sqlite3.connect(
|
| 176 |
+
f"file:{self.state_db}?mode=ro", uri=True, timeout=2.0
|
| 177 |
+
)
|
| 178 |
+
try:
|
| 179 |
+
rows = conn.execute(
|
| 180 |
+
"SELECT id, cwd, input_tokens, output_tokens,"
|
| 181 |
+
" message_count, api_call_count, ended_at IS NULL, model"
|
| 182 |
+
" FROM sessions WHERE source='oneshot'"
|
| 183 |
+
" AND started_at >= ?",
|
| 184 |
+
(self._since,),
|
| 185 |
+
).fetchall()
|
| 186 |
+
finally:
|
| 187 |
+
conn.close()
|
| 188 |
+
except sqlite3.Error:
|
| 189 |
+
return # transient lock/IO hiccup: keep last snapshot
|
| 190 |
+
rows = [r for r in rows if (r[1] or "").startswith(self._like)]
|
| 191 |
+
now = time.monotonic()
|
| 192 |
+
with self._lock:
|
| 193 |
+
prev_out = dict(self._prev_out)
|
| 194 |
+
last_poll = self._last_poll
|
| 195 |
+
dt = None
|
| 196 |
+
if last_poll is not None:
|
| 197 |
+
dt = max(now - last_poll, 1e-6)
|
| 198 |
+
per: dict = {}
|
| 199 |
+
delta_out = 0
|
| 200 |
+
models: dict = {}
|
| 201 |
+
for (sess, cwd, tin, tout, msgs, api, running, model) in rows:
|
| 202 |
+
key = (cwd or "").rstrip("/").rsplit("/", 1)[-1]
|
| 203 |
+
d = per.setdefault(key, {
|
| 204 |
+
"in": 0, "out": 0, "msgs": 0, "api": 0,
|
| 205 |
+
"runs": 0, "running": 0, "rate": 0.0, "models": set(),
|
| 206 |
+
})
|
| 207 |
+
tin, tout = tin or 0, tout or 0
|
| 208 |
+
d["in"] += tin
|
| 209 |
+
d["out"] += tout
|
| 210 |
+
d["msgs"] += msgs or 0
|
| 211 |
+
d["api"] += api or 0
|
| 212 |
+
d["runs"] += 1
|
| 213 |
+
d["running"] += 1 if running else 0
|
| 214 |
+
if model:
|
| 215 |
+
d["models"].add(str(model))
|
| 216 |
+
models[sess] = str(model)
|
| 217 |
+
po = prev_out.get(sess)
|
| 218 |
+
if po is not None and tout > po:
|
| 219 |
+
grew = tout - po
|
| 220 |
+
delta_out += grew
|
| 221 |
+
d["rate"] += grew / dt if dt else 0.0
|
| 222 |
+
# splice path: merge direct-HTTP usage (no state.db rows exist
|
| 223 |
+
# for _post_chat calls; SPLICE_USAGE is the only telemetry source)
|
| 224 |
+
splice_delta = 0
|
| 225 |
+
sper: dict = {}
|
| 226 |
+
try:
|
| 227 |
+
from traceforge.splice import SPLICE_USAGE
|
| 228 |
+
sper, splice_delta = SPLICE_USAGE.drain(dt)
|
| 229 |
+
for sid, b in sper.items():
|
| 230 |
+
d = per.setdefault(sid, {
|
| 231 |
+
"in": 0, "out": 0, "msgs": 0, "api": 0,
|
| 232 |
+
"runs": 0, "running": 0, "rate": 0.0, "models": set(),
|
| 233 |
+
})
|
| 234 |
+
d["in"] += b["in"]
|
| 235 |
+
d["out"] += b["out"]
|
| 236 |
+
d["api"] += b["calls"]
|
| 237 |
+
d["runs"] += b["calls"]
|
| 238 |
+
d["rate"] += b["rate"]
|
| 239 |
+
for m in b["models"]:
|
| 240 |
+
d["models"].add(m)
|
| 241 |
+
models[f"splice:{sid}:{m}"] = m
|
| 242 |
+
except Exception:
|
| 243 |
+
pass # telemetry must never break the batch
|
| 244 |
+
for d in per.values():
|
| 245 |
+
d["rate"] = round(d["rate"], 2)
|
| 246 |
+
d["models"] = sorted(d["models"])
|
| 247 |
+
with self._lock:
|
| 248 |
+
self._per = per
|
| 249 |
+
self._prev_out = {r[0]: r[3] or 0 for r in rows}
|
| 250 |
+
self._sessions = len(rows) + len(
|
| 251 |
+
[s for s in sper if s != "-"]
|
| 252 |
+
)
|
| 253 |
+
self._sess_models.update(models)
|
| 254 |
+
self._tot_in = sum(d["in"] for d in per.values())
|
| 255 |
+
self._tot_out = sum(d["out"] for d in per.values())
|
| 256 |
+
if dt:
|
| 257 |
+
self._rate = round(
|
| 258 |
+
0.5 * self._rate
|
| 259 |
+
+ 0.5 * ((delta_out + splice_delta) / dt), 2
|
| 260 |
+
)
|
| 261 |
+
self._last_poll = now
|
| 262 |
+
|
| 263 |
+
def _loop(self) -> None:
|
| 264 |
+
next_hb = time.monotonic() + self.heartbeat_s
|
| 265 |
+
while not self._stop.is_set():
|
| 266 |
+
self.poll()
|
| 267 |
+
if self.prog is not None:
|
| 268 |
+
if self.prog.enabled:
|
| 269 |
+
pf = self._postfix()
|
| 270 |
+
if pf:
|
| 271 |
+
self.prog.set_postfix(pf)
|
| 272 |
+
# always refresh: an empty list clears stale sublines
|
| 273 |
+
self.prog.set_sublines(self.sublines())
|
| 274 |
+
elif time.monotonic() >= next_hb:
|
| 275 |
+
self.prog.write(self.heartbeat())
|
| 276 |
+
next_hb = time.monotonic() + self.heartbeat_s
|
| 277 |
+
self._stop.wait(self.poll_s)
|
| 278 |
+
|
| 279 |
+
# ------------------------------------------------------------ rendering
|
| 280 |
+
def _snapshot(self):
|
| 281 |
+
with self._lock:
|
| 282 |
+
return dict(self._per), self._tot_in, self._tot_out, self._rate
|
| 283 |
+
|
| 284 |
+
def per_trace(self, sid: str):
|
| 285 |
+
"""Aggregate token usage for one trace (all its child sessions)."""
|
| 286 |
+
with self._lock:
|
| 287 |
+
d = self._per.get(sid)
|
| 288 |
+
return dict(d) if d else None
|
| 289 |
+
|
| 290 |
+
def sublines(self) -> list:
|
| 291 |
+
"""Detail lines for the Progress bar — covering EVERY in-flight
|
| 292 |
+
trace, never a "+N more" cutoff.
|
| 293 |
+
|
| 294 |
+
Adaptive layout (no Rich TUI needed — the bar is the dashboard):
|
| 295 |
+
1. full detail rows, one per trace, while they all fit on
|
| 296 |
+
screen — the default at any worker count with headroom;
|
| 297 |
+
2. packed chips — several traces per row
|
| 298 |
+
(`├ <sid8> improving 1:23 <sid8> verify 1/2 0:45`) once
|
| 299 |
+
full rows would overflow the terminal height;
|
| 300 |
+
3. stage histogram (`├ stages: improving 9 · verify 2/2 4`) only
|
| 301 |
+
when rows×width can't fit every chip — traces are never
|
| 302 |
+
silently hidden at any worker count or terminal size.
|
| 303 |
+
"""
|
| 304 |
+
acts = self.registry.snapshot()
|
| 305 |
+
if not acts:
|
| 306 |
+
return []
|
| 307 |
+
per, _tin, _tout, _rate = self._snapshot()
|
| 308 |
+
now = time.monotonic()
|
| 309 |
+
rows_budget = self._detail_rows_budget()
|
| 310 |
+
if rows_budget >= len(acts):
|
| 311 |
+
return self._full_rows(acts, per, now)
|
| 312 |
+
return self._packed_rows(acts, rows_budget, now)
|
| 313 |
+
|
| 314 |
+
# ------------------------------------------------- adaptive subline help
|
| 315 |
+
def _detail_rows_budget(self) -> int:
|
| 316 |
+
"""How many detail rows the screen has room for.
|
| 317 |
+
|
| 318 |
+
Forced caps (>0, tests/explicit limit) win; adaptive mode
|
| 319 |
+
reserves 4 lines around the frame (bar row, prompt, slack) and
|
| 320 |
+
keeps at least the historical 8 visible rows.
|
| 321 |
+
"""
|
| 322 |
+
if self._forced_cap:
|
| 323 |
+
return self.max_sublines
|
| 324 |
+
lines = shutil.get_terminal_size((120, 24)).lines
|
| 325 |
+
return max(8, lines - 4)
|
| 326 |
+
|
| 327 |
+
def _full_rows(self, acts, per, now) -> list:
|
| 328 |
+
cols = shutil.get_terminal_size((120, 24)).columns
|
| 329 |
+
lines = []
|
| 330 |
+
last = len(acts) - 1
|
| 331 |
+
for i, (sid, info) in enumerate(acts):
|
| 332 |
+
connector = "└" if i == last else "├"
|
| 333 |
+
head = (
|
| 334 |
+
f"{connector} {sid_short(sid):<10}"
|
| 335 |
+
f" {info.get('stage', '?'):<12}"
|
| 336 |
+
f" {_fmt_dur(now - info.get('t0', now)):>6}"
|
| 337 |
+
)
|
| 338 |
+
d = per.get(sid)
|
| 339 |
+
if not d:
|
| 340 |
+
lines.append(head + " · telemetry pending")
|
| 341 |
+
continue
|
| 342 |
+
# richest first; trim fields until the line fits the terminal
|
| 343 |
+
m = d.get("models") or []
|
| 344 |
+
fields = [
|
| 345 |
+
f"{d['rate']:5.1f} tok/s",
|
| 346 |
+
f"{fmt_tok(d['out'])} out",
|
| 347 |
+
f"{fmt_tok(d['in'])} in",
|
| 348 |
+
"/".join(m) if m else "?",
|
| 349 |
+
f"{d['runs']} run" + ("s" if d["runs"] != 1 else ""),
|
| 350 |
+
]
|
| 351 |
+
line = head
|
| 352 |
+
for f in fields:
|
| 353 |
+
cand = line + f" · {f}"
|
| 354 |
+
if len(cand) + 1 <= cols:
|
| 355 |
+
line = cand
|
| 356 |
+
lines.append(line)
|
| 357 |
+
return lines
|
| 358 |
+
|
| 359 |
+
@staticmethod
|
| 360 |
+
def _chip(sid: str, info: dict, now: float) -> str:
|
| 361 |
+
"""One packed trace chip: `<sid8>… <stage≤12> <age>`."""
|
| 362 |
+
stage = info.get("stage", "?")
|
| 363 |
+
if len(stage) > 12:
|
| 364 |
+
stage = stage[:12]
|
| 365 |
+
return f"{sid_short(sid)} {stage} {_fmt_dur(now - info.get('t0', now))}"
|
| 366 |
+
|
| 367 |
+
def _packed_rows(self, acts, rows_budget: int, now) -> list:
|
| 368 |
+
"""Chips packed several-per-row: ALL traces visible, compact.
|
| 369 |
+
|
| 370 |
+
Greedy packing by ACTUAL chip width (worst ~28 chars, typically
|
| 371 |
+
less) honors the terminal columns; the row count stays within
|
| 372 |
+
rows_budget. If width×rows together cannot show every trace,
|
| 373 |
+
degrade to the stage histogram — traces are never dropped.
|
| 374 |
+
"""
|
| 375 |
+
cols = shutil.get_terminal_size((120, 24)).columns
|
| 376 |
+
chips = [self._chip(sid, info, now) for sid, info in acts]
|
| 377 |
+
rows: list = []
|
| 378 |
+
cur: list = []
|
| 379 |
+
cur_w = 2 # "├ " connector prefix
|
| 380 |
+
for chip in chips:
|
| 381 |
+
w = len(chip) + (2 if cur else 0) # gap before non-first chip
|
| 382 |
+
if cur and cur_w + w > cols:
|
| 383 |
+
rows.append(cur)
|
| 384 |
+
cur, cur_w = [], 2
|
| 385 |
+
if len(rows) >= rows_budget:
|
| 386 |
+
break
|
| 387 |
+
cur.append(chip)
|
| 388 |
+
cur_w += w
|
| 389 |
+
if cur and len(rows) < rows_budget:
|
| 390 |
+
rows.append(cur)
|
| 391 |
+
placed = sum(len(r) for r in rows)
|
| 392 |
+
if placed < len(chips):
|
| 393 |
+
# cannot fit every trace even packed: histogram, not hiding
|
| 394 |
+
return self._stage_histogram(acts, rows_budget)
|
| 395 |
+
return [
|
| 396 |
+
f"{'├' if i < len(rows) - 1 else '└'} " + " ".join(r)
|
| 397 |
+
for i, r in enumerate(rows)
|
| 398 |
+
]
|
| 399 |
+
|
| 400 |
+
@staticmethod
|
| 401 |
+
def _stage_histogram(acts, rows_budget: int) -> list:
|
| 402 |
+
"""Last resort (narrow/short terminals): per-stage counts.
|
| 403 |
+
|
| 404 |
+
Width-safe: stage groups wrap across rows within the terminal
|
| 405 |
+
columns; the oldest in-flight trace closes the frame when the
|
| 406 |
+
row budget allows it.
|
| 407 |
+
"""
|
| 408 |
+
cols = shutil.get_terminal_size((120, 24)).columns
|
| 409 |
+
groups = [
|
| 410 |
+
f"{st} {n}"
|
| 411 |
+
for st, n in sorted(
|
| 412 |
+
_stage_counts(acts).items(), key=lambda kv: (-kv[1], kv[0])
|
| 413 |
+
)
|
| 414 |
+
]
|
| 415 |
+
rows: list = []
|
| 416 |
+
cur: list = []
|
| 417 |
+
cur_w = len("├ stages: ")
|
| 418 |
+
for g in groups:
|
| 419 |
+
w = len(g) + (3 if cur else 0) # " · " separator
|
| 420 |
+
if cur and cur_w + w > cols:
|
| 421 |
+
rows.append(" · ".join(cur))
|
| 422 |
+
cur, cur_w = [], len("├ stages: ")
|
| 423 |
+
if len(rows) >= max(1, rows_budget - 1):
|
| 424 |
+
break
|
| 425 |
+
cur.append(g)
|
| 426 |
+
cur_w += w
|
| 427 |
+
if cur:
|
| 428 |
+
rows.append(" · ".join(cur))
|
| 429 |
+
if len(rows) > max(1, rows_budget - 1):
|
| 430 |
+
rows = rows[:max(1, rows_budget - 1)]
|
| 431 |
+
sid, info = acts[0] # snapshot() is oldest-first
|
| 432 |
+
now = time.monotonic()
|
| 433 |
+
oldest = (
|
| 434 |
+
f"oldest: {sid_short(sid)} {info.get('stage', '?')}"
|
| 435 |
+
f" {_fmt_dur(now - info.get('t0', now))}"
|
| 436 |
+
)
|
| 437 |
+
if rows_budget == 1:
|
| 438 |
+
# single-row budget: merge the oldest into the histogram row
|
| 439 |
+
head = "stages: " + (rows[0] if rows else "")
|
| 440 |
+
return [f"└ {head} · {oldest}"[:cols]]
|
| 441 |
+
lines = [f"stages: {r}" if i == 0 else f" {r}"
|
| 442 |
+
for i, r in enumerate(rows)]
|
| 443 |
+
lines.append(oldest)
|
| 444 |
+
return [f"{'└' if i == len(lines) - 1 else '├'} {ln}"
|
| 445 |
+
for i, ln in enumerate(lines[:rows_budget])]
|
| 446 |
+
|
| 447 |
+
def _postfix(self) -> str:
|
| 448 |
+
_per, tin, tout, rate = self._snapshot()
|
| 449 |
+
if tout or tin:
|
| 450 |
+
return f"{fmt_tok(tout)} out · {rate:.1f} tok/s"
|
| 451 |
+
return ""
|
| 452 |
+
|
| 453 |
+
def heartbeat(self) -> str:
|
| 454 |
+
_per, tin, tout, rate = self._snapshot()
|
| 455 |
+
wall = _fmt_dur(time.monotonic() - self._t0_mono)
|
| 456 |
+
now = time.monotonic()
|
| 457 |
+
acts = self.registry.snapshot()
|
| 458 |
+
# every in-flight trace listed for as long as the line stays
|
| 459 |
+
# readable (~300 chars); beyond that collapse to stage counts
|
| 460 |
+
shown = acts[:10]
|
| 461 |
+
inflight = ", ".join(
|
| 462 |
+
f"{sid_short(sid)} {info.get('stage', '?')}"
|
| 463 |
+
f" ({_fmt_dur(now - info.get('t0', now))})"
|
| 464 |
+
for sid, info in shown
|
| 465 |
+
)
|
| 466 |
+
if len(acts) > len(shown):
|
| 467 |
+
rest = acts[len(shown):]
|
| 468 |
+
counts = " · ".join(
|
| 469 |
+
f"{st} {n}"
|
| 470 |
+
for st, n in sorted(
|
| 471 |
+
_stage_counts(rest).items(), key=lambda kv: -kv[1]
|
| 472 |
+
)
|
| 473 |
+
)
|
| 474 |
+
inflight = f"{inflight} … +{len(acts) - len(shown)} ({counts})" if inflight else counts
|
| 475 |
+
inflight = inflight or "none"
|
| 476 |
+
# Judge cache hit rate: a repair round re-judges every window, so this
|
| 477 |
+
# is the share of judge calls that cost nothing. Silent until the
|
| 478 |
+
# first miss, so a run that never judges does not print "0/0".
|
| 479 |
+
try:
|
| 480 |
+
from traceforge.splice import judge_cache_stats
|
| 481 |
+
js = judge_cache_stats()
|
| 482 |
+
jtot = js["hit"] + js["miss"]
|
| 483 |
+
jtxt = (f" · judge cache {100 * js['hit'] / jtot:.0f}%"
|
| 484 |
+
f" ({js['hit']}/{jtot})") if jtot else ""
|
| 485 |
+
except Exception:
|
| 486 |
+
jtxt = ""
|
| 487 |
+
return (
|
| 488 |
+
f"-- [{wall}] {fmt_tok(tin)} in · {fmt_tok(tout)} out"
|
| 489 |
+
f" · {rate:.1f} tok/s · {self._models_str()} · in flight: {inflight}"
|
| 490 |
+
f"{jtxt}"
|
| 491 |
+
)
|
| 492 |
+
|
| 493 |
+
def _models_str(self) -> str:
|
| 494 |
+
"""Distinct child models seen this batch, e.g. 'glmb300'."""
|
| 495 |
+
with self._lock:
|
| 496 |
+
ms = set(self._sess_models.values())
|
| 497 |
+
return "/".join(sorted(ms)) if ms else "?"
|
| 498 |
+
|
| 499 |
+
def summary(self) -> str:
|
| 500 |
+
with self._lock:
|
| 501 |
+
tin, tout, ns = self._tot_in, self._tot_out, self._sessions
|
| 502 |
+
wall = max(time.monotonic() - self._t0_mono, 1e-6)
|
| 503 |
+
avg = tout / wall if tout else 0.0
|
| 504 |
+
return (
|
| 505 |
+
f"-- live: {ns} child sessions · {fmt_tok(tin)} tok in"
|
| 506 |
+
f" · {fmt_tok(tout)} tok out · avg {avg:.1f} tok/s gen"
|
| 507 |
+
f" · model {self._models_str()} · wall {_fmt_dur(wall)}"
|
| 508 |
+
)
|
traceforge/progress.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Minimal dependency-free progress bars (tqdm-style, stdlib only).
|
| 2 |
+
|
| 3 |
+
Design notes:
|
| 4 |
+
- Bar renders to stderr and only when stderr is a TTY; when output is
|
| 5 |
+
piped/redirected (CI, background shells, logs) the bar disables itself
|
| 6 |
+
and callers fall back to plain per-item lines on stdout.
|
| 7 |
+
- Thread-safe: a single lock guards rendering, so worker threads can
|
| 8 |
+
update counts / postfixes concurrently.
|
| 9 |
+
- write(msg) clears the current frame, prints msg on stderr, and
|
| 10 |
+
redraws — the tqdm.write pattern — so per-item result lines never
|
| 11 |
+
visually corrupt the bar.
|
| 12 |
+
- Multi-line frames: set_sublines(lines) installs detail lines drawn
|
| 13 |
+
under the bar (one per in-flight worker). Each render draws the whole
|
| 14 |
+
frame atomically and repaints in place with ANSI cursor-up codes.
|
| 15 |
+
Height changes (workers starting/finishing) repaint from the old
|
| 16 |
+
frame's top row, so no stale rows survive.
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import shutil
|
| 21 |
+
import sys
|
| 22 |
+
import threading
|
| 23 |
+
import time
|
| 24 |
+
from typing import Optional, TextIO
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _fmt_dur(sec: float) -> str:
|
| 28 |
+
s = int(sec)
|
| 29 |
+
if s >= 3600:
|
| 30 |
+
return f"{s // 3600}:{s % 3600 // 60:02d}:{s % 60:02d}"
|
| 31 |
+
return f"{s // 60:}:{s % 60:02d}"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class Progress:
|
| 35 |
+
"""Progress bar with optional detail sublines.
|
| 36 |
+
|
| 37 |
+
Frame layout (TTY only):
|
| 38 |
+
|
| 39 |
+
desc 42%|########----| 4/10 [1:12<24:51, 3.2s/it] postfix
|
| 40 |
+
├ abc123def456 improving 1:23 45.2k in · 3.1k out · 12.4 tok/s
|
| 41 |
+
└ def789abc123 verify 1/2 0:45 12.0k in · 1.2k out · 4.2 tok/s
|
| 42 |
+
|
| 43 |
+
update()/set_postfix()/set_sublines() are thread-safe; render is
|
| 44 |
+
rate-limited by mininterval so fast loops don't flood the terminal.
|
| 45 |
+
"""
|
| 46 |
+
|
| 47 |
+
PAD_TARGET = 150 # pad each line to at least this width (wipe leftovers)
|
| 48 |
+
|
| 49 |
+
def __init__(
|
| 50 |
+
self,
|
| 51 |
+
total: int,
|
| 52 |
+
desc: str = "",
|
| 53 |
+
*,
|
| 54 |
+
stream: Optional[TextIO] = None,
|
| 55 |
+
width: int = 24,
|
| 56 |
+
mininterval: float = 0.25,
|
| 57 |
+
enabled: Optional[bool] = None,
|
| 58 |
+
):
|
| 59 |
+
self.total = max(int(total), 0)
|
| 60 |
+
self.n = 0
|
| 61 |
+
self.desc = desc
|
| 62 |
+
self.width = width
|
| 63 |
+
self.mininterval = mininterval
|
| 64 |
+
self.stream = stream or sys.stderr
|
| 65 |
+
if enabled is None:
|
| 66 |
+
enabled = bool(getattr(self.stream, "isatty", lambda: False)())
|
| 67 |
+
self.enabled = enabled and self.total > 0
|
| 68 |
+
self._lock = threading.Lock()
|
| 69 |
+
self._postfix = ""
|
| 70 |
+
self._sublines: list[str] = []
|
| 71 |
+
self._frame_rows = 0 # rows the on-screen frame currently spans
|
| 72 |
+
self._start = time.monotonic()
|
| 73 |
+
self._last_render = 0.0
|
| 74 |
+
self._closed = False
|
| 75 |
+
if self.enabled:
|
| 76 |
+
self._render(force=True)
|
| 77 |
+
|
| 78 |
+
# ------------------------------------------------------------ lifecycle
|
| 79 |
+
def set_postfix(self, s: str) -> None:
|
| 80 |
+
with self._lock:
|
| 81 |
+
self._postfix = s
|
| 82 |
+
self._maybe_render()
|
| 83 |
+
|
| 84 |
+
def set_sublines(self, lines) -> None:
|
| 85 |
+
"""Install/refresh the detail lines drawn under the bar."""
|
| 86 |
+
with self._lock:
|
| 87 |
+
if not self.enabled:
|
| 88 |
+
return
|
| 89 |
+
new = list(lines)
|
| 90 |
+
if new == self._sublines:
|
| 91 |
+
return # byte-identical: skip render
|
| 92 |
+
self._sublines = new
|
| 93 |
+
self._maybe_render(force=True)
|
| 94 |
+
|
| 95 |
+
def update(self, n: int = 1) -> None:
|
| 96 |
+
with self._lock:
|
| 97 |
+
self.n = min(self.n + n, self.total)
|
| 98 |
+
self._maybe_render()
|
| 99 |
+
|
| 100 |
+
def close(self) -> None:
|
| 101 |
+
with self._lock:
|
| 102 |
+
if self._closed:
|
| 103 |
+
return
|
| 104 |
+
self._closed = True
|
| 105 |
+
if self.enabled:
|
| 106 |
+
self._render(force=True)
|
| 107 |
+
self.stream.write("\n")
|
| 108 |
+
self.stream.flush()
|
| 109 |
+
|
| 110 |
+
# context-manager sugar
|
| 111 |
+
def __enter__(self) -> "Progress":
|
| 112 |
+
return self
|
| 113 |
+
|
| 114 |
+
def __exit__(self, *exc) -> None:
|
| 115 |
+
self.close()
|
| 116 |
+
|
| 117 |
+
# ------------------------------------------------------------ rendering
|
| 118 |
+
def _maybe_render(self, force: bool = False) -> None:
|
| 119 |
+
if not self.enabled or self._closed:
|
| 120 |
+
return
|
| 121 |
+
now = time.monotonic()
|
| 122 |
+
if force or now - self._last_render >= self.mininterval:
|
| 123 |
+
self._render(force=True)
|
| 124 |
+
|
| 125 |
+
def _render(self, force: bool = False) -> None:
|
| 126 |
+
if not self.enabled:
|
| 127 |
+
return
|
| 128 |
+
now = time.monotonic()
|
| 129 |
+
if not force and now - self._last_render < self.mininterval:
|
| 130 |
+
return
|
| 131 |
+
self._last_render = now
|
| 132 |
+
elapsed = max(now - self._start, 1e-6)
|
| 133 |
+
frac = self.n / self.total if self.total else 0.0
|
| 134 |
+
filled = int(self.width * frac)
|
| 135 |
+
bar = "#" * filled + "-" * (self.width - filled)
|
| 136 |
+
# rate: per-item time; 0 completed -> unknown
|
| 137 |
+
if self.n > 0:
|
| 138 |
+
per = elapsed / self.n
|
| 139 |
+
eta = _fmt_dur(per * (self.total - self.n))
|
| 140 |
+
rate = f"{per:.1f}s/it"
|
| 141 |
+
else:
|
| 142 |
+
eta, rate = "-:--", "?it/s"
|
| 143 |
+
line = (
|
| 144 |
+
f"{self.desc} {int(frac * 100):3d}%|{bar}|"
|
| 145 |
+
f" {self.n}/{self.total} [{_fmt_dur(elapsed)}<{eta}, {rate}]"
|
| 146 |
+
)
|
| 147 |
+
if self._postfix:
|
| 148 |
+
line += f" {self._postfix}"
|
| 149 |
+
frame = [line] + list(self._sublines)
|
| 150 |
+
old_h = self._frame_rows
|
| 151 |
+
if old_h > 0 and len(frame) < old_h:
|
| 152 |
+
# frame shrank: overwrite stale rows below with blanks
|
| 153 |
+
frame += [""] * (old_h - len(frame))
|
| 154 |
+
# truncate each line to the terminal width — a longer line would
|
| 155 |
+
# wrap to a second physical row and break the up-cursor math
|
| 156 |
+
cols = shutil.get_terminal_size((120, 24)).columns
|
| 157 |
+
padded = []
|
| 158 |
+
for ln in frame:
|
| 159 |
+
ln = ln[:cols]
|
| 160 |
+
padded.append(ln + " " * (cols - len(ln)))
|
| 161 |
+
rows = len(padded)
|
| 162 |
+
body = "\r" + "\n".join(padded)
|
| 163 |
+
|
| 164 |
+
# Cursor sits ON the frame's last row (lines joined with \n, no
|
| 165 |
+
# trailing newline). Move up to the frame's top row before the
|
| 166 |
+
# rewrite; F also returns the cursor to column 0. old_h == 1
|
| 167 |
+
# needs no prefix: the cursor is already on the only frame row
|
| 168 |
+
# and the leading \r returns it to column 0.
|
| 169 |
+
if old_h >= 2:
|
| 170 |
+
body = f"\x1b[{old_h - 1}F" + body
|
| 171 |
+
|
| 172 |
+
self.stream.write(body)
|
| 173 |
+
self.stream.flush()
|
| 174 |
+
self._frame_rows = rows
|
| 175 |
+
|
| 176 |
+
def write(self, msg: str) -> None:
|
| 177 |
+
"""Print a permanent line above the frame (thread-safe)."""
|
| 178 |
+
with self._lock:
|
| 179 |
+
if not self.enabled:
|
| 180 |
+
print(msg) # non-TTY: plain stdout log line
|
| 181 |
+
return
|
| 182 |
+
if self._frame_rows > 0:
|
| 183 |
+
# park on the frame's TOP row: cursor sits ON the last
|
| 184 |
+
# row of the frame, so up = rows-1 lands on the top row
|
| 185 |
+
up = max(self._frame_rows - 1, 1)
|
| 186 |
+
self.stream.write(f"\x1b[{up}F")
|
| 187 |
+
self.stream.write("\x1b[0J") # clear cursor..end of screen
|
| 188 |
+
self._frame_rows = 0 # frame gone; next render starts here
|
| 189 |
+
else:
|
| 190 |
+
self.stream.write("\r" + " " * 130 + "\r")
|
| 191 |
+
self.stream.write(msg + "\n")
|
| 192 |
+
self.stream.flush()
|
| 193 |
+
if not self._closed:
|
| 194 |
+
self._render(force=True)
|
traceforge/readers/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from traceforge.readers.claude import ClaudeTraceReader
|
| 2 |
+
from traceforge.readers.codex import CodexTraceReader
|
| 3 |
+
from traceforge.readers.hermes import HermesTraceReader
|
| 4 |
+
from traceforge.readers.nemotron import NemotronTraceReader
|
| 5 |
+
|
| 6 |
+
__all__ = [
|
| 7 |
+
"HermesTraceReader",
|
| 8 |
+
"ClaudeTraceReader",
|
| 9 |
+
"CodexTraceReader",
|
| 10 |
+
"NemotronTraceReader",
|
| 11 |
+
]
|
traceforge/readers/_common.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared reader helpers: self-reference guard + silent-skip accounting.
|
| 2 |
+
|
| 3 |
+
Both concerns are cross-cutting (every reader has a store it must not
|
| 4 |
+
read its own output back from, and every reader has code paths that
|
| 5 |
+
skip a file or a row without telling anyone), so they live here rather
|
| 6 |
+
than being re-implemented per reader.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import re
|
| 11 |
+
from collections import Counter
|
| 12 |
+
from typing import Optional
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _norm(path: Optional[str]) -> str:
|
| 16 |
+
"""Forward-slash, case-folded, drive-less form of a path for comparison.
|
| 17 |
+
|
| 18 |
+
Seed archives come from other machines: a macOS rollout carries
|
| 19 |
+
``/Users/x/dev/traceforge/data/work/<sid>`` while the live guard is
|
| 20 |
+
``C:\\Users\\y\\...\\data\\work``. Comparing raw strings misses that,
|
| 21 |
+
which is exactly the self-reference leak the guard exists to stop.
|
| 22 |
+
"""
|
| 23 |
+
if not path:
|
| 24 |
+
return ""
|
| 25 |
+
p = str(path).replace("\\", "/")
|
| 26 |
+
if len(p) > 1 and p[1] == ":":
|
| 27 |
+
p = p[2:] # strip the Windows drive letter
|
| 28 |
+
return p.rstrip("/").lower()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def in_workdir(cwd: Optional[str], workdir: Optional[str]) -> bool:
|
| 32 |
+
"""True when ``cwd`` sits inside ``workdir`` (any platform's spelling).
|
| 33 |
+
|
| 34 |
+
Two matches count:
|
| 35 |
+
1. the normalized cwd starts with the normalized workdir;
|
| 36 |
+
2. the cwd ends with the workdir's last two components
|
| 37 |
+
(``data/work/<sid>``) — the portable form, which catches a
|
| 38 |
+
foreign-platform archive whose absolute prefix differs.
|
| 39 |
+
"""
|
| 40 |
+
c, w = _norm(cwd), _norm(workdir)
|
| 41 |
+
if not c or not w:
|
| 42 |
+
return False
|
| 43 |
+
if c == w or c.startswith(w + "/"):
|
| 44 |
+
return True
|
| 45 |
+
tail = "/".join(w.split("/")[-2:])
|
| 46 |
+
return bool(tail) and (c == tail or c.startswith(tail + "/") or f"/{tail}/" in c)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def new_skips() -> Counter:
|
| 50 |
+
"""Counter for rows/files a reader dropped without a candidate row."""
|
| 51 |
+
return Counter()
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def bump(skips: Optional[Counter], reason: str, n: int = 1) -> None:
|
| 55 |
+
"""Increment a skip counter when one is attached (never fatal)."""
|
| 56 |
+
if skips is not None:
|
| 57 |
+
skips[reason] += n
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def oversize_row(row: dict, max_messages: int) -> dict:
|
| 61 |
+
"""Flag a candidate that exceeds the (soft) message cap.
|
| 62 |
+
|
| 63 |
+
``max_messages`` is a SOFT cap: a session above it is still a
|
| 64 |
+
candidate because the pipeline bounds what it actually processes by
|
| 65 |
+
rendered size (``split_long`` / ``split_max_chars``), not by message
|
| 66 |
+
count. The flag lets callers report the long-session share instead
|
| 67 |
+
of silently discarding it.
|
| 68 |
+
|
| 69 |
+
Returns a COPY: the codex reader keeps the same dict in its on-disk
|
| 70 |
+
summary cache, and a persisted ``oversize`` flag would survive a
|
| 71 |
+
later ``max_messages`` change.
|
| 72 |
+
"""
|
| 73 |
+
out = dict(row)
|
| 74 |
+
if max_messages and out.get("message_count", 0) > max_messages:
|
| 75 |
+
out["oversize"] = True
|
| 76 |
+
return out
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def workdir_of(cfg) -> str:
|
| 80 |
+
"""Improver workdir for a Config (empty when unset)."""
|
| 81 |
+
return getattr(cfg, "improver_workdir", "") or ""
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# --------------------------------------------------------------- dispatch briefs
|
| 85 |
+
# An orchestrator that dispatches a subagent pastes its own brief into the
|
| 86 |
+
# child's first user row: 6-7KB of Orca plumbing (session/task ids, CLI docs,
|
| 87 |
+
# worker_done rules) followed by a ``=== TASK ===`` block holding the real
|
| 88 |
+
# instruction. Promoted as-is, the trainee learns to emit harness plumbing as
|
| 89 |
+
# the user turn, so ``validate`` rejects the trace outright. Measured
|
| 90 |
+
# 2026-10-24 on the raw pool: 130 claude-code traces open with this brief, 0
|
| 91 |
+
# promoted, 20 gate failures. The TASK block IS the user's own words, so keep
|
| 92 |
+
# exactly that and drop the plumbing.
|
| 93 |
+
_DISPATCH_BRIEF_RE = re.compile(
|
| 94 |
+
r"You are working inside Orca, a multi-agent IDE"
|
| 95 |
+
r"|You are a dispatched worker"
|
| 96 |
+
r"|Your coordinator'?s Orca session ID is"
|
| 97 |
+
r"|=== CLI COMMANDS ===",
|
| 98 |
+
re.IGNORECASE,
|
| 99 |
+
)
|
| 100 |
+
_TASK_BLOCK = "=== TASK ==="
|
| 101 |
+
# The brief arrives wrapped in the parent's paste envelope; the closing tag
|
| 102 |
+
# trails the TASK block and is not part of the task.
|
| 103 |
+
_PASTE_CLOSE_RE = re.compile(r"\s*</pasted_content[^>]*>\s*$", re.IGNORECASE)
|
| 104 |
+
# A split segment's task row is the original task with this marker prepended
|
| 105 |
+
# (splitter.save_segments). Unwrapping a brief must not eat it: the marker is
|
| 106 |
+
# what tells the trainee the window is a continuation, not a fresh request.
|
| 107 |
+
_SEGMENT_PREFIX_RE = re.compile(r"^(\[trace segment \d+ of \d+[^\]]*\]\s*)")
|
| 108 |
+
|
| 109 |
+
# Orchestration notifications: the harness injecting "You have N orchestration
|
| 110 |
+
# messages. Run `orca orchestration check`." into the conversation. Pure
|
| 111 |
+
# plumbing, never user content — and because ``validate`` matches the bare
|
| 112 |
+
# phrase ``orca orchestration``, ONE such row kills an otherwise clean trace
|
| 113 |
+
# (measured 2026-10-24: 4 of 20 dispatch-brief gate failures had a clean task
|
| 114 |
+
# row and a 65-char notice at row 168).
|
| 115 |
+
_ORCH_NOTICE_RE = re.compile(
|
| 116 |
+
r"^\s*You have \d+ orchestration messages?\."
|
| 117 |
+
r"\s*Run `orca orchestration check",
|
| 118 |
+
re.IGNORECASE,
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def is_orchestration_notice(text: Optional[str]) -> bool:
|
| 123 |
+
"""True for the harness's 'You have N orchestration messages' row."""
|
| 124 |
+
return bool(_ORCH_NOTICE_RE.match(text or ""))
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def unwrap_dispatch_brief(text: str) -> str:
|
| 128 |
+
"""The real task inside an orchestrator dispatch brief.
|
| 129 |
+
|
| 130 |
+
Returns ``text`` unchanged when it is not a brief, the ``=== TASK ===``
|
| 131 |
+
block when it is, and ``""`` when the brief carries no task block (pure
|
| 132 |
+
harness plumbing — the row must be dropped, not kept).
|
| 133 |
+
"""
|
| 134 |
+
if not _DISPATCH_BRIEF_RE.search(text):
|
| 135 |
+
return text
|
| 136 |
+
if _TASK_BLOCK in text:
|
| 137 |
+
task = text.split(_TASK_BLOCK, 1)[1]
|
| 138 |
+
task = _PASTE_CLOSE_RE.sub("", task).strip()
|
| 139 |
+
seg = _SEGMENT_PREFIX_RE.match(text)
|
| 140 |
+
return (seg.group(1) + task) if seg else task
|
| 141 |
+
return ""
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def reframe_task_rows(messages: list, skips: Optional[Counter] = None) -> list:
|
| 145 |
+
"""Drop harness plumbing user rows; unwrap dispatch-brief user rows.
|
| 146 |
+
|
| 147 |
+
Two loss points, one pass (both are the same defect: harness plumbing
|
| 148 |
+
sitting where the user's own words belong):
|
| 149 |
+
|
| 150 |
+
1. ``You have N orchestration messages`` notification rows — dropped
|
| 151 |
+
everywhere they appear, counted ``orchestration_notice_row``.
|
| 152 |
+
2. A dispatch brief in a user row — replaced by its ``=== TASK ===``
|
| 153 |
+
block (``dispatch_brief_reframed``), or dropped when the brief
|
| 154 |
+
carries no task (``dispatch_brief_no_task``). Every user row is
|
| 155 |
+
checked, not just the first: a codex child can receive a second
|
| 156 |
+
dispatch mid-thread, and the validate gate rejects the trace for
|
| 157 |
+
ANY row that still carries the boilerplate.
|
| 158 |
+
|
| 159 |
+
Returns a new list; the caller decides what an empty result means (a
|
| 160 |
+
taskless trace, which the reader's own taskless path then counts).
|
| 161 |
+
"""
|
| 162 |
+
out = []
|
| 163 |
+
n_notice = 0
|
| 164 |
+
for m in messages:
|
| 165 |
+
if m.role == "user" and is_orchestration_notice(m.content):
|
| 166 |
+
n_notice += 1
|
| 167 |
+
continue
|
| 168 |
+
out.append(m)
|
| 169 |
+
bump(skips, "orchestration_notice_row", n_notice)
|
| 170 |
+
|
| 171 |
+
kept = []
|
| 172 |
+
for m in out:
|
| 173 |
+
if m.role == "user" and _DISPATCH_BRIEF_RE.search(m.content or ""):
|
| 174 |
+
task = unwrap_dispatch_brief(m.content)
|
| 175 |
+
if task:
|
| 176 |
+
m.content = task
|
| 177 |
+
bump(skips, "dispatch_brief_reframed")
|
| 178 |
+
else:
|
| 179 |
+
bump(skips, "dispatch_brief_no_task")
|
| 180 |
+
if not task:
|
| 181 |
+
continue
|
| 182 |
+
kept.append(m)
|
| 183 |
+
return kept
|
| 184 |
+
|
traceforge/readers/claude.py
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Claude Code trace reader — session JSONLs under ~/.claude/projects.
|
| 2 |
+
|
| 3 |
+
Verified schema (Claude Code 2.x, probed 2026-09-26). Layout:
|
| 4 |
+
~/.claude/projects/-<slugified-cwd>/<session-uuid>.jsonl
|
| 5 |
+
one JSON object per line, relevant row types:
|
| 6 |
+
user message:{role:"user", content: str | [tool_result blocks]}
|
| 7 |
+
assistant message:{role:"assistant", content:[text|thinking|tool_use],
|
| 8 |
+
model, usage}
|
| 9 |
+
custom-title / ai-title title sources (customTitle / aiTitle)
|
| 10 |
+
queue-operation, attachment, system, last-prompt, bridge-session, ... → skip
|
| 11 |
+
|
| 12 |
+
Probed skip rules (UI/engine artifacts, never agent content):
|
| 13 |
+
- isSidechain rows INSIDE top-level session files: subagent traffic
|
| 14 |
+
relayed into the parent transcript (also: only top-level
|
| 15 |
+
<project>/*.jsonl files are scanned for parent sessions; the
|
| 16 |
+
<uuid>/ subdirs hold the subagents' OWN transcripts and are read
|
| 17 |
+
by the sidechain index — see include_sidechains)
|
| 18 |
+
- isMeta string rows starting with '<local-command-caveat>' or 'Another
|
| 19 |
+
Claude session sent a message' (subagent hand-back wrapper); other
|
| 20 |
+
isMeta rows carry real goal-continuation tasks and are KEPT
|
| 21 |
+
- user string rows starting with '<command-'/'<local-command-'/'<bash-'/
|
| 22 |
+
'<system-reminder>' — EXCEPT '/goal' command rows, whose <command-args>
|
| 23 |
+
carry the real task text, which is extracted as a user message
|
| 24 |
+
- the session-continuation row ('This session is being continued ...') is
|
| 25 |
+
KEPT as a user message (handoff context, cf. Hermes compaction rows);
|
| 26 |
+
Trace.first_task_message() skips it when picking the task
|
| 27 |
+
|
| 28 |
+
Tool results live as tool_result blocks inside USER rows (results arrive in
|
| 29 |
+
the row after the assistant turn, possibly several blocks batched for
|
| 30 |
+
parallel calls). Tool names are resolved via a tool_use_id → name map built
|
| 31 |
+
from the assistant tool_use blocks in the same file.
|
| 32 |
+
"""
|
| 33 |
+
from __future__ import annotations
|
| 34 |
+
|
| 35 |
+
import json
|
| 36 |
+
import os
|
| 37 |
+
import re
|
| 38 |
+
from datetime import datetime
|
| 39 |
+
from typing import Any, Optional
|
| 40 |
+
|
| 41 |
+
from traceforge.readers._common import (
|
| 42 |
+
bump, new_skips, oversize_row, reframe_task_rows,
|
| 43 |
+
)
|
| 44 |
+
from traceforge.trace import Message, ToolCall, Trace
|
| 45 |
+
|
| 46 |
+
_ARTIFACT_PREFIXES = (
|
| 47 |
+
"<command-",
|
| 48 |
+
"<local-command-",
|
| 49 |
+
"<bash-",
|
| 50 |
+
"<system-reminder>",
|
| 51 |
+
"<task-",
|
| 52 |
+
)
|
| 53 |
+
_META_SKIP_PREFIXES = (
|
| 54 |
+
"<local-command-caveat>",
|
| 55 |
+
"Another Claude session sent a message",
|
| 56 |
+
)
|
| 57 |
+
CONTINUATION_PREFIX = "This session is being continued"
|
| 58 |
+
_GOAL_ARGS_RE = re.compile(r"<command-args>(.*?)</command-args>", re.DOTALL)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _iso_to_epoch(ts: Optional[str]) -> Optional[float]:
|
| 62 |
+
if not ts:
|
| 63 |
+
return None
|
| 64 |
+
try:
|
| 65 |
+
return datetime.fromisoformat(str(ts).replace("Z", "+00:00")).timestamp()
|
| 66 |
+
except ValueError:
|
| 67 |
+
return None
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _block_text(blocks: Any) -> str:
|
| 71 |
+
"""Flatten a content-blocks list to text (tool_result content nests)."""
|
| 72 |
+
if isinstance(blocks, str):
|
| 73 |
+
return blocks
|
| 74 |
+
parts: list[str] = []
|
| 75 |
+
if not isinstance(blocks, list):
|
| 76 |
+
return ""
|
| 77 |
+
for b in blocks:
|
| 78 |
+
if isinstance(b, str):
|
| 79 |
+
parts.append(b)
|
| 80 |
+
elif isinstance(b, dict):
|
| 81 |
+
t = b.get("type")
|
| 82 |
+
if t == "text" and b.get("text"):
|
| 83 |
+
parts.append(str(b["text"]))
|
| 84 |
+
elif t == "image":
|
| 85 |
+
parts.append("[image]")
|
| 86 |
+
elif t == "tool_result" and b.get("content") is not None:
|
| 87 |
+
parts.append(_block_text(b["content"]))
|
| 88 |
+
return "\n\n".join(p for p in parts if p)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class ClaudeTraceReader:
|
| 92 |
+
"""Reads real traces from the Claude Code session store (read-only).
|
| 93 |
+
|
| 94 |
+
include_sidechains=True (default) also indexes the subagents' OWN
|
| 95 |
+
transcripts (<session-uuid>/subagents/agent-*.jsonl) as separate
|
| 96 |
+
candidate traces. Verified schema (probed 2026-10-12): the sidechain
|
| 97 |
+
files use the SAME user/assistant/tool_result row shapes as parent
|
| 98 |
+
session files, with isSidechain=true on every row. Sidechain
|
| 99 |
+
threads open with the dispatched lane-brief ("You are lane A of a
|
| 100 |
+
5-lane agent team…") — that row IS the task; the thread becomes a
|
| 101 |
+
self-contained trainable (task → work → result) trace, the same
|
| 102 |
+
doctrine as codex taskless-child recovery. MASS (arXiv 2610.12176):
|
| 103 |
+
orchestrator+subagent conversations are the densest RSI training
|
| 104 |
+
signal — skipping them discards the highest-value data in the store.
|
| 105 |
+
"""
|
| 106 |
+
|
| 107 |
+
def __init__(
|
| 108 |
+
self,
|
| 109 |
+
projects_dir: Optional[list[str] | str] = None,
|
| 110 |
+
*,
|
| 111 |
+
include_sidechains: bool = True,
|
| 112 |
+
):
|
| 113 |
+
# Multi-store support: a list of projects dirs is indexed
|
| 114 |
+
# together (live store + seed archives). Same session uuid in
|
| 115 |
+
# two stores -> the LARGER file wins (fresher snapshot of the
|
| 116 |
+
# same session), matching the single-store collision rule.
|
| 117 |
+
if projects_dir is None:
|
| 118 |
+
self.projects_dirs = [
|
| 119 |
+
os.path.expanduser(os.path.join("~", ".claude", "projects"))
|
| 120 |
+
]
|
| 121 |
+
elif isinstance(projects_dir, (list, tuple)):
|
| 122 |
+
self.projects_dirs = [
|
| 123 |
+
os.path.expanduser(d) for d in projects_dir
|
| 124 |
+
]
|
| 125 |
+
else:
|
| 126 |
+
self.projects_dirs = [os.path.expanduser(projects_dir)]
|
| 127 |
+
self.include_sidechains = include_sidechains
|
| 128 |
+
# files/rows dropped without a candidate row: reported by stats()
|
| 129 |
+
self.skips = new_skips()
|
| 130 |
+
# memoized _index() — see the docstring there (double-counted skips)
|
| 131 |
+
self._idx_cache: Optional[dict[str, str]] = None
|
| 132 |
+
|
| 133 |
+
@property
|
| 134 |
+
def projects_dir(self) -> str:
|
| 135 |
+
"""First store (backwards compat for callers/tests)."""
|
| 136 |
+
return self.projects_dirs[0]
|
| 137 |
+
|
| 138 |
+
# ---------------------------------------------------------------- index
|
| 139 |
+
|
| 140 |
+
def _index(self) -> dict[str, str]:
|
| 141 |
+
"""session uuid -> newest (largest) jsonl path across stores.
|
| 142 |
+
|
| 143 |
+
Memoized (2026-10-24): the index walk is what bumps the file-level
|
| 144 |
+
skip counters (``sidechain_sidecar``, ``non_transcript_file``), so
|
| 145 |
+
re-walking it — ``stats()`` used to call ``_index()`` a second time
|
| 146 |
+
after ``list_sessions`` — reported every file skip TWICE (measured:
|
| 147 |
+
2,684 sidecars where the store holds 1,342). A skip census that
|
| 148 |
+
doubles itself is worse than no census.
|
| 149 |
+
"""
|
| 150 |
+
if self._idx_cache is not None:
|
| 151 |
+
return self._idx_cache
|
| 152 |
+
idx: dict[str, tuple[int, str]] = {}
|
| 153 |
+
found = False
|
| 154 |
+
for projects_dir in self.projects_dirs:
|
| 155 |
+
if not os.path.isdir(projects_dir):
|
| 156 |
+
continue # missing store (seed archive) is not an error
|
| 157 |
+
found = True
|
| 158 |
+
self._index_store(projects_dir, idx)
|
| 159 |
+
if not found:
|
| 160 |
+
raise FileNotFoundError(
|
| 161 |
+
f"Claude projects dir not found: {self.projects_dirs}"
|
| 162 |
+
)
|
| 163 |
+
self._idx_cache = {sid: path for sid, (_, path) in idx.items()}
|
| 164 |
+
return self._idx_cache
|
| 165 |
+
|
| 166 |
+
def _index_store(self, projects_dir: str, idx: dict) -> None:
|
| 167 |
+
local_sids: list[str] = [] # sids discovered in THIS store
|
| 168 |
+
for proj in sorted(os.listdir(projects_dir)):
|
| 169 |
+
proj_dir = os.path.join(projects_dir, proj)
|
| 170 |
+
if not os.path.isdir(proj_dir):
|
| 171 |
+
continue # files at top level are never traces
|
| 172 |
+
try:
|
| 173 |
+
entries = os.listdir(proj_dir)
|
| 174 |
+
except OSError:
|
| 175 |
+
continue
|
| 176 |
+
for fn in entries:
|
| 177 |
+
if not fn.endswith(".jsonl"):
|
| 178 |
+
# A non-transcript FILE (a .json sidecar, a stray .txt)
|
| 179 |
+
# is counted, not just .json — the counter used to miss
|
| 180 |
+
# every other extension. Directories are the <uuid>/
|
| 181 |
+
# sidechain containers, not skips.
|
| 182 |
+
if os.path.isfile(os.path.join(proj_dir, fn)):
|
| 183 |
+
bump(self.skips, "non_transcript_file")
|
| 184 |
+
continue # <uuid>/ subdirs: parent's sidechains live inside
|
| 185 |
+
sid = fn[:-6]
|
| 186 |
+
path = os.path.join(proj_dir, fn)
|
| 187 |
+
try:
|
| 188 |
+
size = os.path.getsize(path)
|
| 189 |
+
except OSError:
|
| 190 |
+
bump(self.skips, "unreadable_file")
|
| 191 |
+
continue
|
| 192 |
+
prev = idx.get(sid)
|
| 193 |
+
if prev is None or size > prev[0]:
|
| 194 |
+
idx[sid] = (size, path)
|
| 195 |
+
local_sids.append(sid)
|
| 196 |
+
if self.include_sidechains:
|
| 197 |
+
# Only THIS store's sids (2026-10-24). Iterating the global idx
|
| 198 |
+
# re-scanned every earlier store's subagents dir on each store
|
| 199 |
+
# pass, so a two-store setup counted the live store's sidecars
|
| 200 |
+
# twice (measured: 1,342 reported where the stores hold 1,169).
|
| 201 |
+
for sid in local_sids:
|
| 202 |
+
path = idx[sid][1]
|
| 203 |
+
sub_dir = os.path.join(
|
| 204 |
+
os.path.dirname(path), sid, "subagents"
|
| 205 |
+
)
|
| 206 |
+
if not os.path.isdir(sub_dir):
|
| 207 |
+
continue
|
| 208 |
+
try:
|
| 209 |
+
subs = sorted(os.listdir(sub_dir))
|
| 210 |
+
except OSError:
|
| 211 |
+
continue
|
| 212 |
+
for fn in subs:
|
| 213 |
+
if not (fn.startswith("agent-") and fn.endswith(".jsonl")):
|
| 214 |
+
# agent-*.meta.json sidecars (and the odd
|
| 215 |
+
# `workflows/` dir): metadata, not a transcript.
|
| 216 |
+
# Counted so the skip is visible.
|
| 217 |
+
bump(self.skips, "sidechain_sidecar")
|
| 218 |
+
continue
|
| 219 |
+
# namespaced id: <parent>#sa-<agentfile-stem> —
|
| 220 |
+
# '#' cannot appear in session uuids, so the raw-cache
|
| 221 |
+
# namespace stays collision-free (same rule as the
|
| 222 |
+
# splitter's <sid>#pN segments)
|
| 223 |
+
sub_sid = f"{sid}#sa-{fn[6:-6]}"
|
| 224 |
+
sub_path = os.path.join(sub_dir, fn)
|
| 225 |
+
try:
|
| 226 |
+
sub_size = os.path.getsize(sub_path)
|
| 227 |
+
except OSError:
|
| 228 |
+
continue
|
| 229 |
+
prev = idx.get(sub_sid)
|
| 230 |
+
if prev is None or sub_size > prev[0]:
|
| 231 |
+
idx[sub_sid] = (sub_size, sub_path)
|
| 232 |
+
return {sid: path for sid, (_, path) in idx.items()}
|
| 233 |
+
|
| 234 |
+
# ---------------------------------------------------------------- parse
|
| 235 |
+
|
| 236 |
+
def _parse_file(self, path: str, session_id: Optional[str] = None) -> Trace:
|
| 237 |
+
apath = os.path.abspath(path).replace("/", os.sep)
|
| 238 |
+
# a subagent's OWN transcript: every row carries isSidechain=true
|
| 239 |
+
# (kept here; only RELAYED sidechain rows inside parent files
|
| 240 |
+
# are skipped) and the sid defaults to the namespaced form
|
| 241 |
+
is_sidechain = os.sep + "subagents" + os.sep in apath
|
| 242 |
+
if session_id:
|
| 243 |
+
sid = session_id
|
| 244 |
+
elif is_sidechain:
|
| 245 |
+
stem = os.path.basename(path)[:-6]
|
| 246 |
+
parent = os.path.basename(os.path.dirname(os.path.dirname(path)))
|
| 247 |
+
sid = f"{parent}#sa-{stem[len('agent-'):]}"
|
| 248 |
+
else:
|
| 249 |
+
sid = os.path.basename(path)[:-6]
|
| 250 |
+
messages: list[Message] = []
|
| 251 |
+
tool_names: dict[str, str] = {} # tool_use_id -> name
|
| 252 |
+
model: Optional[str] = None
|
| 253 |
+
title_custom: Optional[str] = None
|
| 254 |
+
title_ai: Optional[str] = None
|
| 255 |
+
cwd: Optional[str] = None
|
| 256 |
+
started: Optional[float] = None
|
| 257 |
+
last_ts: Optional[float] = None
|
| 258 |
+
|
| 259 |
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
| 260 |
+
for line in f:
|
| 261 |
+
try:
|
| 262 |
+
d = json.loads(line)
|
| 263 |
+
except json.JSONDecodeError:
|
| 264 |
+
bump(self.skips, "unparseable_line")
|
| 265 |
+
continue # truncated/interleaved tail lines
|
| 266 |
+
if not isinstance(d, dict):
|
| 267 |
+
continue
|
| 268 |
+
ts = _iso_to_epoch(d.get("timestamp"))
|
| 269 |
+
if started is None and ts is not None:
|
| 270 |
+
started = ts
|
| 271 |
+
if ts is not None:
|
| 272 |
+
last_ts = ts
|
| 273 |
+
|
| 274 |
+
t = d.get("type")
|
| 275 |
+
if t == "custom-title":
|
| 276 |
+
title_custom = d.get("customTitle") or title_custom
|
| 277 |
+
continue
|
| 278 |
+
if t == "ai-title":
|
| 279 |
+
title_ai = d.get("aiTitle") or title_ai
|
| 280 |
+
continue
|
| 281 |
+
if t not in ("user", "assistant"):
|
| 282 |
+
continue
|
| 283 |
+
if d.get("isSidechain") and not is_sidechain:
|
| 284 |
+
continue # relayed subagent traffic inside a parent file
|
| 285 |
+
if cwd is None and d.get("cwd"):
|
| 286 |
+
cwd = d["cwd"]
|
| 287 |
+
|
| 288 |
+
m = d.get("message")
|
| 289 |
+
if not isinstance(m, dict):
|
| 290 |
+
continue
|
| 291 |
+
content = m.get("content")
|
| 292 |
+
|
| 293 |
+
if t == "assistant":
|
| 294 |
+
if model is None and m.get("model"):
|
| 295 |
+
model = str(m["model"])
|
| 296 |
+
texts: list[str] = []
|
| 297 |
+
thinks: list[str] = []
|
| 298 |
+
calls: list[ToolCall] = []
|
| 299 |
+
if isinstance(content, list):
|
| 300 |
+
for blk in content:
|
| 301 |
+
if not isinstance(blk, dict):
|
| 302 |
+
continue
|
| 303 |
+
bt = blk.get("type")
|
| 304 |
+
if bt == "text" and blk.get("text"):
|
| 305 |
+
texts.append(str(blk["text"]))
|
| 306 |
+
elif bt == "thinking" and blk.get("thinking"):
|
| 307 |
+
thinks.append(str(blk["thinking"]))
|
| 308 |
+
elif bt == "tool_use":
|
| 309 |
+
tc_id = str(blk.get("id") or "")
|
| 310 |
+
name = str(blk.get("name") or "")
|
| 311 |
+
if tc_id:
|
| 312 |
+
tool_names[tc_id] = name
|
| 313 |
+
calls.append(
|
| 314 |
+
ToolCall(
|
| 315 |
+
id=tc_id,
|
| 316 |
+
name=name,
|
| 317 |
+
arguments=json.dumps(
|
| 318 |
+
blk.get("input") or {},
|
| 319 |
+
ensure_ascii=False,
|
| 320 |
+
),
|
| 321 |
+
)
|
| 322 |
+
)
|
| 323 |
+
if not (texts or thinks or calls):
|
| 324 |
+
continue
|
| 325 |
+
messages.append(
|
| 326 |
+
Message(
|
| 327 |
+
role="assistant",
|
| 328 |
+
content="\n\n".join(texts) or None,
|
| 329 |
+
reasoning="\n\n".join(thinks) or None,
|
| 330 |
+
tool_calls=calls,
|
| 331 |
+
timestamp=ts,
|
| 332 |
+
)
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
elif t == "user":
|
| 336 |
+
if isinstance(content, str):
|
| 337 |
+
if d.get("isMeta") and content.startswith(
|
| 338 |
+
_META_SKIP_PREFIXES
|
| 339 |
+
):
|
| 340 |
+
continue
|
| 341 |
+
if content.startswith("<command-name>"):
|
| 342 |
+
# /goal rows carry the task in <command-args>
|
| 343 |
+
if "/goal" in content[:80]:
|
| 344 |
+
mm = _GOAL_ARGS_RE.search(content)
|
| 345 |
+
if mm and mm.group(1).strip():
|
| 346 |
+
messages.append(
|
| 347 |
+
Message(
|
| 348 |
+
role="user",
|
| 349 |
+
content=mm.group(1).strip(),
|
| 350 |
+
timestamp=ts,
|
| 351 |
+
)
|
| 352 |
+
)
|
| 353 |
+
continue
|
| 354 |
+
if content.startswith(_ARTIFACT_PREFIXES):
|
| 355 |
+
continue
|
| 356 |
+
messages.append(
|
| 357 |
+
Message(role="user", content=content, timestamp=ts)
|
| 358 |
+
)
|
| 359 |
+
elif isinstance(content, list):
|
| 360 |
+
for blk in content:
|
| 361 |
+
if (
|
| 362 |
+
not isinstance(blk, dict)
|
| 363 |
+
or blk.get("type") != "tool_result"
|
| 364 |
+
):
|
| 365 |
+
continue
|
| 366 |
+
tc_id = str(blk.get("tool_use_id") or "")
|
| 367 |
+
body = _block_text(blk.get("content"))
|
| 368 |
+
if not body and blk.get("is_error"):
|
| 369 |
+
body = "(tool error)"
|
| 370 |
+
if not body and not tc_id:
|
| 371 |
+
continue
|
| 372 |
+
messages.append(
|
| 373 |
+
Message(
|
| 374 |
+
role="tool",
|
| 375 |
+
content=body or None,
|
| 376 |
+
tool_call_id=tc_id,
|
| 377 |
+
tool_name=tool_names.get(tc_id),
|
| 378 |
+
timestamp=ts,
|
| 379 |
+
)
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
# Harness plumbing in user rows (2026-10-24): an orchestrator's
|
| 383 |
+
# dispatch brief pasted as the task row, and the harness's "You have
|
| 384 |
+
# N orchestration messages" notifications. Both are plumbing where
|
| 385 |
+
# the user's own words belong; the brief's `=== TASK ===` block is
|
| 386 |
+
# the real request, so keep that and drop the rest. Measured: 130
|
| 387 |
+
# claude-code raw traces opened with the brief, 0 promoted, 20 gate
|
| 388 |
+
# failures — and 4 of those had a CLEAN task row killed by a 65-char
|
| 389 |
+
# notice at row 168.
|
| 390 |
+
messages = reframe_task_rows(messages, self.skips)
|
| 391 |
+
|
| 392 |
+
# Subagent-brief recovery (MASS doctrine, mirrors codex taskless-
|
| 393 |
+
# child recovery): a sidechain thread opens with the dispatched
|
| 394 |
+
# lane-brief — already a real first user row in Claude files, so
|
| 395 |
+
# nothing to synthesize. But a sidechain whose rows are ALL
|
| 396 |
+
# assistant/tool (handback content only) stays taskless — drop it
|
| 397 |
+
# here rather than letting it die at the improve gates.
|
| 398 |
+
if is_sidechain and not any(
|
| 399 |
+
m.role == "user" for m in messages
|
| 400 |
+
):
|
| 401 |
+
return Trace(session_id=sid, model=model, title=None,
|
| 402 |
+
started_at=started or last_ts, cwd=cwd,
|
| 403 |
+
messages=[], source="claude-sidechain")
|
| 404 |
+
|
| 405 |
+
return Trace(
|
| 406 |
+
session_id=sid,
|
| 407 |
+
model=model,
|
| 408 |
+
title=title_custom or title_ai,
|
| 409 |
+
started_at=started or last_ts,
|
| 410 |
+
cwd=cwd,
|
| 411 |
+
messages=messages,
|
| 412 |
+
source="claude-sidechain" if is_sidechain else "claude-code",
|
| 413 |
+
)
|
| 414 |
+
|
| 415 |
+
# ------------------------------------------------------------- sessions
|
| 416 |
+
|
| 417 |
+
def list_sessions(
|
| 418 |
+
self,
|
| 419 |
+
*,
|
| 420 |
+
min_messages: int = 10,
|
| 421 |
+
max_messages: int = 120,
|
| 422 |
+
min_tool_calls: int = 2,
|
| 423 |
+
limit: Optional[int] = None,
|
| 424 |
+
) -> list[dict[str, Any]]:
|
| 425 |
+
"""Candidate traces for improvement, newest first (summaries only).
|
| 426 |
+
|
| 427 |
+
``max_messages`` is a SOFT cap (2026-10-23): a session above it is
|
| 428 |
+
still listed, flagged ``oversize``. The hard cap it replaced
|
| 429 |
+
discarded 1,217 real claude sessions (median 245 messages, 107
|
| 430 |
+
tool calls, ~25% of them over ``split_max_chars`` and therefore
|
| 431 |
+
split candidates) before they could reach ``split_long``.
|
| 432 |
+
"""
|
| 433 |
+
rows: list[dict[str, Any]] = []
|
| 434 |
+
for sid, path in self._index().items():
|
| 435 |
+
try:
|
| 436 |
+
trace = self._parse_file(path, sid)
|
| 437 |
+
except OSError:
|
| 438 |
+
bump(self.skips, "unreadable_file")
|
| 439 |
+
continue
|
| 440 |
+
n_msgs = len(trace.messages)
|
| 441 |
+
if not n_msgs:
|
| 442 |
+
bump(self.skips, "taskless_sidechain"
|
| 443 |
+
if trace.source == "claude-sidechain" else "empty_trace")
|
| 444 |
+
continue
|
| 445 |
+
if n_msgs < min_messages:
|
| 446 |
+
bump(self.skips, "below_min_messages")
|
| 447 |
+
continue
|
| 448 |
+
n_calls = sum(len(m.tool_calls) for m in trace.messages)
|
| 449 |
+
if n_calls < min_tool_calls:
|
| 450 |
+
bump(self.skips, "below_min_tool_calls")
|
| 451 |
+
continue
|
| 452 |
+
roles: dict[str, int] = {}
|
| 453 |
+
for m in trace.messages:
|
| 454 |
+
roles[m.role] = roles.get(m.role, 0) + 1
|
| 455 |
+
rows.append(
|
| 456 |
+
oversize_row(
|
| 457 |
+
{
|
| 458 |
+
"session_id": sid,
|
| 459 |
+
"model": trace.model,
|
| 460 |
+
"title": (trace.title or "")[:120].strip(),
|
| 461 |
+
"started_at": trace.started_at,
|
| 462 |
+
"ended_at": trace.messages[-1].timestamp
|
| 463 |
+
if trace.messages
|
| 464 |
+
else None,
|
| 465 |
+
"message_count": n_msgs,
|
| 466 |
+
"tool_call_count": n_calls,
|
| 467 |
+
"cwd": trace.cwd,
|
| 468 |
+
"source": trace.source,
|
| 469 |
+
"roles": roles,
|
| 470 |
+
},
|
| 471 |
+
max_messages,
|
| 472 |
+
)
|
| 473 |
+
)
|
| 474 |
+
rows.sort(key=lambda r: r["started_at"] or 0.0, reverse=True)
|
| 475 |
+
if limit is not None:
|
| 476 |
+
rows = rows[:limit]
|
| 477 |
+
return rows
|
| 478 |
+
|
| 479 |
+
def load_trace(self, session_id: str) -> Trace:
|
| 480 |
+
"""Load one full trace as a normalized Trace."""
|
| 481 |
+
path = self._index().get(session_id)
|
| 482 |
+
if not path:
|
| 483 |
+
raise KeyError(f"claude session not found: {session_id}")
|
| 484 |
+
return self._parse_file(path, session_id)
|
| 485 |
+
|
| 486 |
+
# -------------------------------------------------------------- helpers
|
| 487 |
+
|
| 488 |
+
def stats(self) -> dict[str, Any]:
|
| 489 |
+
"""Counts over the store(s), plus the skip census.
|
| 490 |
+
|
| 491 |
+
The parse loop runs against a SCRATCH counter (2026-10-24): stats()
|
| 492 |
+
re-parses every session to total messages/tool calls, and those
|
| 493 |
+
parses bump the row-level skip counters again — so a census taken
|
| 494 |
+
after stats() reported every reframed brief and dropped notice row
|
| 495 |
+
twice. stats() must observe the funnel, not re-count it.
|
| 496 |
+
"""
|
| 497 |
+
idx = self._index()
|
| 498 |
+
n_msgs = 0
|
| 499 |
+
n_calls = 0
|
| 500 |
+
real_skips = self.skips
|
| 501 |
+
census = new_skips()
|
| 502 |
+
self.skips = census
|
| 503 |
+
try:
|
| 504 |
+
for _, path in idx.items():
|
| 505 |
+
try:
|
| 506 |
+
t = self._parse_file(path)
|
| 507 |
+
except OSError:
|
| 508 |
+
continue
|
| 509 |
+
n_msgs += len(t.messages)
|
| 510 |
+
n_calls += sum(len(m.tool_calls) for m in t.messages)
|
| 511 |
+
finally:
|
| 512 |
+
self.skips = real_skips
|
| 513 |
+
return {
|
| 514 |
+
"projects": sum(
|
| 515 |
+
len(
|
| 516 |
+
[
|
| 517 |
+
e
|
| 518 |
+
for e in os.listdir(d)
|
| 519 |
+
if os.path.isdir(os.path.join(d, e))
|
| 520 |
+
]
|
| 521 |
+
)
|
| 522 |
+
for d in self.projects_dirs
|
| 523 |
+
if os.path.isdir(d)
|
| 524 |
+
),
|
| 525 |
+
"sessions": len(idx),
|
| 526 |
+
"messages": n_msgs,
|
| 527 |
+
"tool_calls": n_calls,
|
| 528 |
+
# the scratch counter, not the restored one: reading self.skips
|
| 529 |
+
# here returned the caller's counter and threw the census away
|
| 530 |
+
"skips": dict(census),
|
| 531 |
+
}
|
traceforge/readers/codex.py
ADDED
|
@@ -0,0 +1,637 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Codex trace reader — rollout JSONLs under ~/.codex/sessions (and exports).
|
| 2 |
+
|
| 3 |
+
Verified schema (Codex Desktop 0.145.x, probed 2026-10-07 against the
|
| 4 |
+
`exported-seed-datas` archives + the live store). Layout:
|
| 5 |
+
<root>/YYYY/MM/DD/rollout-<iso>-<thread-uuid>.jsonl
|
| 6 |
+
one JSON object per line, top-level `type`:
|
| 7 |
+
session_meta payload:{session_id, id (=thread id, unique per file),
|
| 8 |
+
cwd, timestamp, originator, cli_version, source{...}}
|
| 9 |
+
turn_context payload:{turn_id, cwd, model, effort, ...} (engine noise)
|
| 10 |
+
response_item the actual conversation (payload.type below)
|
| 11 |
+
event_msg / world_state engine telemetry — skipped
|
| 12 |
+
|
| 13 |
+
response_item payload types:
|
| 14 |
+
message role:user|assistant|developer, content:
|
| 15 |
+
[{type:"input_text"|"output_text", text}]
|
| 16 |
+
reasoning summary:[{type:"summary_text", text}] (when
|
| 17 |
+
summaries are enabled; encrypted_content
|
| 18 |
+
otherwise — skipped)
|
| 19 |
+
custom_tool_call id, call_id, name, input (STRING of JS code
|
| 20 |
+
for the `exec` tool — kept verbatim)
|
| 21 |
+
custom_tool_call_output call_id, output:[{type:"input_text", text}]
|
| 22 |
+
function_call / function_call_output same via call_id
|
| 23 |
+
agent_message / tool_search_call / ... multi-agent chatter — skipped
|
| 24 |
+
|
| 25 |
+
Probed skip rules (harness artifacts, never real user content):
|
| 26 |
+
user rows starting with <recommended_plugins>, <subagent_notification>,
|
| 27 |
+
<turn_aborted>, <environment_context>, <permissions, <user_instructions
|
| 28 |
+
user rows starting with '# AGENTS.md' or 'The following is the Codex'
|
| 29 |
+
(harness preamble), and ALL developer-role rows (engine instructions)
|
| 30 |
+
Tool results attach via call_id -> the preceding call row. Session id =
|
| 31 |
+
the session_meta payload.id (thread id in the filename), NOT session_id
|
| 32 |
+
(forked rollouts share the parent's session_id; files would collide).
|
| 33 |
+
|
| 34 |
+
Self-reference guard: rollouts whose session_meta cwd sits inside the
|
| 35 |
+
improver workdir are dropped from listing (same doctrine as hermes).
|
| 36 |
+
"""
|
| 37 |
+
from __future__ import annotations
|
| 38 |
+
|
| 39 |
+
import glob
|
| 40 |
+
import json
|
| 41 |
+
import os
|
| 42 |
+
import re
|
| 43 |
+
import sys
|
| 44 |
+
import threading
|
| 45 |
+
import time
|
| 46 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 47 |
+
from datetime import datetime
|
| 48 |
+
from typing import Any, Optional
|
| 49 |
+
|
| 50 |
+
from traceforge.readers._common import (
|
| 51 |
+
bump, in_workdir, new_skips, oversize_row, reframe_task_rows,
|
| 52 |
+
unwrap_dispatch_brief,
|
| 53 |
+
)
|
| 54 |
+
from traceforge.trace import Message, ToolCall, Trace
|
| 55 |
+
|
| 56 |
+
_USER_ARTIFACT_PREFIXES = (
|
| 57 |
+
"<recommended_plugins>",
|
| 58 |
+
"<subagent_notification>",
|
| 59 |
+
"<turn_aborted>",
|
| 60 |
+
"<environment_context>",
|
| 61 |
+
"<permissions",
|
| 62 |
+
"<user_instructions",
|
| 63 |
+
"<plugins_available",
|
| 64 |
+
)
|
| 65 |
+
_USER_ARTIFACT_TEXT_PREFIXES = (
|
| 66 |
+
"# AGENTS.md",
|
| 67 |
+
"The following is the Codex",
|
| 68 |
+
)
|
| 69 |
+
_ROLLOUT_RE = re.compile(r"rollout-.*\.jsonl(?:\.zst)?$")
|
| 70 |
+
_ROLLOUT_SUFFIXES = (".jsonl.zst", ".jsonl")
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _strip_rollout_suffix(name: str) -> str:
|
| 74 |
+
"""rollout-<iso>-<uuid>.jsonl[.zst] -> rollout-<iso>-<uuid>."""
|
| 75 |
+
for suf in _ROLLOUT_SUFFIXES:
|
| 76 |
+
if name.endswith(suf):
|
| 77 |
+
return name[: -len(suf)]
|
| 78 |
+
return name
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _open_rollout(path: str):
|
| 82 |
+
"""Open a rollout as text, transparently decompressing ``.jsonl.zst``.
|
| 83 |
+
|
| 84 |
+
Codex compresses older rollouts in place — 107 of 1387 files on this host
|
| 85 |
+
(measured 2026-10-22), all of them older than the live ones. The
|
| 86 |
+
decompressed bytes are the same JSONL, so only the opener changes.
|
| 87 |
+
|
| 88 |
+
Python 3.14 ships ``compression.zstd``; on 3.10-3.13 this needs the
|
| 89 |
+
``zstandard`` package. Both are optional, so a missing backend raises a
|
| 90 |
+
clear error rather than silently skipping the file.
|
| 91 |
+
"""
|
| 92 |
+
if not path.endswith(".zst"):
|
| 93 |
+
return open(path, "r", encoding="utf-8", errors="replace")
|
| 94 |
+
try:
|
| 95 |
+
from compression import zstd as _zstd # Python 3.14+
|
| 96 |
+
except ImportError:
|
| 97 |
+
_zstd = None
|
| 98 |
+
if _zstd is not None:
|
| 99 |
+
return _zstd.open(path, "rt", encoding="utf-8", errors="replace")
|
| 100 |
+
try:
|
| 101 |
+
import zstandard # type: ignore
|
| 102 |
+
except ImportError as exc: # pragma: no cover - depends on interpreter
|
| 103 |
+
raise RuntimeError(
|
| 104 |
+
f"cannot read compressed rollout {path!r}: install the "
|
| 105 |
+
"'zstandard' package or run on Python 3.14+"
|
| 106 |
+
) from exc
|
| 107 |
+
return zstandard.open(path, "rt", encoding="utf-8", errors="replace")
|
| 108 |
+
_ISO_RE = re.compile(r"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})")
|
| 109 |
+
# Completion-report openings: work summaries, not task restatements.
|
| 110 |
+
# Anchored at string start (case-insensitive); "Implemented …", "Wrote …".
|
| 111 |
+
_REPORT_OPENING_RE = re.compile(
|
| 112 |
+
r"^\s*(?:i\s+)?(?:ich\s+)?(?:have\s+|hab\s+|habe\s+)?"
|
| 113 |
+
r"(?:implemented|created|fixed|added|built|completed|wrote|done)\b",
|
| 114 |
+
re.I,
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _iso_to_epoch(ts: Optional[str]) -> Optional[float]:
|
| 119 |
+
if not ts:
|
| 120 |
+
return None
|
| 121 |
+
try:
|
| 122 |
+
return datetime.fromisoformat(str(ts).replace("Z", "+00:00")).timestamp()
|
| 123 |
+
except ValueError:
|
| 124 |
+
return None
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _unwrap_dispatch_brief(text: str) -> str:
|
| 128 |
+
"""Return the real task from an Orca coordinator dispatch brief.
|
| 129 |
+
|
| 130 |
+
A codex subagent rollout opens with the coordinator's dispatch text: 6-7KB
|
| 131 |
+
of orchestration boilerplate (session/task ids, `orca orchestration` CLI
|
| 132 |
+
docs, worker_done rules) followed by a `=== TASK ===` block holding the
|
| 133 |
+
actual instruction. Promoted as-is, the trainee learns to emit harness
|
| 134 |
+
plumbing as the user turn (measured 2026-10-22: 81 raw traces, 6 already
|
| 135 |
+
promoted). The TASK block is the user's own words, so keep exactly that;
|
| 136 |
+
a brief with no TASK block yields "" and the row is dropped.
|
| 137 |
+
|
| 138 |
+
Thin alias over the shared helper (2026-10-24): the claude reader needs
|
| 139 |
+
the same unwrap, and two copies of the pattern drift. Kept as a module
|
| 140 |
+
name because tests and callers import it from here.
|
| 141 |
+
"""
|
| 142 |
+
return unwrap_dispatch_brief(text)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def recover_taskless_brief(messages: list, role_hint: str = "") -> bool:
|
| 146 |
+
"""Synthesize a task row for a taskless child thread (in place).
|
| 147 |
+
|
| 148 |
+
Subagent handoff recovery (owner directive 2026-10-07: taskless child
|
| 149 |
+
threads are valuable work traces, don't drop them): when the rollout
|
| 150 |
+
carries no real user row, codex children open with the agent restating
|
| 151 |
+
its dispatched brief in task terms. That restatement becomes a
|
| 152 |
+
synthesized first user row so the trace is a trainable
|
| 153 |
+
(task → work → result) pair; the improve pipeline's reframe path and
|
| 154 |
+
judges still verify task↔work fit. Completion-REPORT-shaped openings
|
| 155 |
+
("Implemented …", "Wrote …") are work summaries, not tasks — they stay
|
| 156 |
+
taskless.
|
| 157 |
+
|
| 158 |
+
Shared with the raw-pool repair pass (2026-10-24): dropping the
|
| 159 |
+
harness's orchestration-notification rows can leave a trace with no
|
| 160 |
+
user row at all, and a cached trace must land in the same state a
|
| 161 |
+
fresh pull would produce.
|
| 162 |
+
|
| 163 |
+
Returns True when a row was inserted.
|
| 164 |
+
"""
|
| 165 |
+
if any(m.role == "user" for m in messages):
|
| 166 |
+
return False
|
| 167 |
+
first_asst = next(
|
| 168 |
+
(m for m in messages if m.role == "assistant" and (m.content or "")),
|
| 169 |
+
None,
|
| 170 |
+
)
|
| 171 |
+
if (
|
| 172 |
+
first_asst is None
|
| 173 |
+
or len(first_asst.content or "") < 80
|
| 174 |
+
or _REPORT_OPENING_RE.search(first_asst.content or "")
|
| 175 |
+
):
|
| 176 |
+
return False
|
| 177 |
+
messages.insert(
|
| 178 |
+
0,
|
| 179 |
+
Message(
|
| 180 |
+
role="user",
|
| 181 |
+
content=role_hint + (first_asst.content or "").strip(),
|
| 182 |
+
timestamp=first_asst.timestamp,
|
| 183 |
+
),
|
| 184 |
+
)
|
| 185 |
+
return True
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _content_text(blocks: Any) -> str:
|
| 189 |
+
"""Flatten content-block lists (message bodies, tool outputs)."""
|
| 190 |
+
if isinstance(blocks, str):
|
| 191 |
+
return blocks
|
| 192 |
+
if not isinstance(blocks, list):
|
| 193 |
+
return ""
|
| 194 |
+
parts: list[str] = []
|
| 195 |
+
for b in blocks:
|
| 196 |
+
if isinstance(b, dict):
|
| 197 |
+
t = b.get("text")
|
| 198 |
+
if t:
|
| 199 |
+
parts.append(str(t))
|
| 200 |
+
elif isinstance(b, str):
|
| 201 |
+
parts.append(b)
|
| 202 |
+
return "\n\n".join(p for p in parts if p)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
class CodexTraceReader:
|
| 206 |
+
"""Reads real traces from Codex rollout session stores (read-only)."""
|
| 207 |
+
|
| 208 |
+
def __init__(self, roots: Optional[list[str]] = None):
|
| 209 |
+
if roots:
|
| 210 |
+
self.roots = [os.path.expanduser(r) for r in roots]
|
| 211 |
+
else:
|
| 212 |
+
self.roots = [os.path.expanduser("~/.codex/sessions")]
|
| 213 |
+
# files/rows dropped without a candidate row: reported by stats()
|
| 214 |
+
self.skips = new_skips()
|
| 215 |
+
|
| 216 |
+
# ---------------------------------------------------------------- index
|
| 217 |
+
|
| 218 |
+
def _agent_role(self, path: str) -> Optional[str]:
|
| 219 |
+
"""Subagent role (agent_path/agent_nickname) from session_meta."""
|
| 220 |
+
try:
|
| 221 |
+
with _open_rollout(path) as f:
|
| 222 |
+
for line in f:
|
| 223 |
+
try:
|
| 224 |
+
d = json.loads(line)
|
| 225 |
+
except json.JSONDecodeError:
|
| 226 |
+
continue
|
| 227 |
+
if d.get("type") == "session_meta":
|
| 228 |
+
pay = d.get("payload") or {}
|
| 229 |
+
role = pay.get("agent_path") or pay.get("agent_nickname")
|
| 230 |
+
if role:
|
| 231 |
+
return str(role).strip("/")
|
| 232 |
+
return None
|
| 233 |
+
except OSError:
|
| 234 |
+
pass
|
| 235 |
+
return None
|
| 236 |
+
|
| 237 |
+
def _rollout_files(self) -> list[str]:
|
| 238 |
+
files: list[str] = []
|
| 239 |
+
for root in self.roots:
|
| 240 |
+
if not os.path.isdir(root):
|
| 241 |
+
continue
|
| 242 |
+
for p in glob.glob(os.path.join(root, "**", "*.jsonl"), recursive=True):
|
| 243 |
+
if _ROLLOUT_RE.search(os.path.basename(p)):
|
| 244 |
+
files.append(p)
|
| 245 |
+
for p in glob.glob(os.path.join(root, "**", "*.jsonl.zst"), recursive=True):
|
| 246 |
+
if _ROLLOUT_RE.search(os.path.basename(p)):
|
| 247 |
+
files.append(p)
|
| 248 |
+
files.sort()
|
| 249 |
+
return files
|
| 250 |
+
|
| 251 |
+
def _index(self) -> dict[str, str]:
|
| 252 |
+
"""thread id (session_meta payload.id) -> rollout path."""
|
| 253 |
+
idx: dict[str, str] = {}
|
| 254 |
+
for path in self._rollout_files():
|
| 255 |
+
sid = self._thread_id(path)
|
| 256 |
+
if sid:
|
| 257 |
+
idx[sid] = path
|
| 258 |
+
else:
|
| 259 |
+
bump(self.skips, "no_thread_id")
|
| 260 |
+
return idx
|
| 261 |
+
|
| 262 |
+
def _thread_id(self, path: str) -> Optional[str]:
|
| 263 |
+
"""session_meta payload.id — the thread id from the filename.
|
| 264 |
+
|
| 265 |
+
The last uuid in the filename IS the thread id; parsing it avoids
|
| 266 |
+
scanning the file for sessions whose meta row was truncated away.
|
| 267 |
+
Fallback: scan for session_meta when the name is non-standard.
|
| 268 |
+
"""
|
| 269 |
+
base = _strip_rollout_suffix(os.path.basename(path))
|
| 270 |
+
mm = re.search(r"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$", base, re.I)
|
| 271 |
+
if mm:
|
| 272 |
+
return mm.group(1)
|
| 273 |
+
for line in _open_rollout(path):
|
| 274 |
+
try:
|
| 275 |
+
d = json.loads(line)
|
| 276 |
+
except json.JSONDecodeError:
|
| 277 |
+
continue
|
| 278 |
+
if d.get("type") == "session_meta":
|
| 279 |
+
pid = (d.get("payload") or {}).get("id")
|
| 280 |
+
if pid:
|
| 281 |
+
return str(pid)
|
| 282 |
+
return None
|
| 283 |
+
|
| 284 |
+
# ---------------------------------------------------------------- parse
|
| 285 |
+
|
| 286 |
+
def _parse_file(self, path: str, session_id: Optional[str] = None) -> Trace:
|
| 287 |
+
sid = session_id or self._thread_id(path) or _strip_rollout_suffix(os.path.basename(path))
|
| 288 |
+
messages: list[Message] = []
|
| 289 |
+
call_names: dict[str, str] = {} # call_id -> tool name
|
| 290 |
+
model: Optional[str] = None
|
| 291 |
+
cwd: Optional[str] = None
|
| 292 |
+
started: Optional[float] = None
|
| 293 |
+
last_ts: Optional[float] = None
|
| 294 |
+
title: Optional[str] = None # first real user task, set below
|
| 295 |
+
pending_reasoning: Optional[str] = None # reasoning buffered for the next assistant row
|
| 296 |
+
|
| 297 |
+
with _open_rollout(path) as f:
|
| 298 |
+
for line in f:
|
| 299 |
+
try:
|
| 300 |
+
d = json.loads(line)
|
| 301 |
+
except json.JSONDecodeError:
|
| 302 |
+
bump(self.skips, "unparseable_line")
|
| 303 |
+
continue
|
| 304 |
+
if not isinstance(d, dict):
|
| 305 |
+
continue
|
| 306 |
+
ts = _iso_to_epoch(d.get("timestamp"))
|
| 307 |
+
if started is None and ts is not None:
|
| 308 |
+
started = ts
|
| 309 |
+
if ts is not None:
|
| 310 |
+
last_ts = ts
|
| 311 |
+
|
| 312 |
+
t = d.get("type")
|
| 313 |
+
if t == "session_meta":
|
| 314 |
+
pay = d.get("payload") or {}
|
| 315 |
+
cwd = cwd or pay.get("cwd")
|
| 316 |
+
elif t == "turn_context":
|
| 317 |
+
pay = d.get("payload") or {}
|
| 318 |
+
if model is None and pay.get("model"):
|
| 319 |
+
model = str(pay["model"])
|
| 320 |
+
cwd = cwd or pay.get("cwd")
|
| 321 |
+
elif t != "response_item":
|
| 322 |
+
continue
|
| 323 |
+
|
| 324 |
+
pay = d.get("payload") or {}
|
| 325 |
+
pt = pay.get("type")
|
| 326 |
+
|
| 327 |
+
if pt == "message":
|
| 328 |
+
role = pay.get("role")
|
| 329 |
+
text = _content_text(pay.get("content"))
|
| 330 |
+
if not text:
|
| 331 |
+
continue
|
| 332 |
+
if role == "user":
|
| 333 |
+
if text.startswith(_USER_ARTIFACT_PREFIXES) or text.startswith(
|
| 334 |
+
_USER_ARTIFACT_TEXT_PREFIXES
|
| 335 |
+
):
|
| 336 |
+
continue
|
| 337 |
+
messages.append(
|
| 338 |
+
Message(role="user", content=text, timestamp=ts)
|
| 339 |
+
)
|
| 340 |
+
elif role == "assistant":
|
| 341 |
+
messages.append(
|
| 342 |
+
Message(role="assistant", content=text, timestamp=ts)
|
| 343 |
+
)
|
| 344 |
+
# developer-role rows: engine instructions, skipped
|
| 345 |
+
|
| 346 |
+
elif pt == "reasoning":
|
| 347 |
+
# codex emits reasoning BEFORE its assistant row
|
| 348 |
+
# (reasoning -> message / custom_tool_call). Buffer and
|
| 349 |
+
# attach to the next assistant message. Encrypted-only
|
| 350 |
+
# reasoning rows produce no text and are skipped.
|
| 351 |
+
s = pay.get("summary") or []
|
| 352 |
+
text = _content_text(s)
|
| 353 |
+
if text:
|
| 354 |
+
pending_reasoning = (
|
| 355 |
+
f"{pending_reasoning}\n\n{text}"
|
| 356 |
+
if pending_reasoning
|
| 357 |
+
else text
|
| 358 |
+
)
|
| 359 |
+
|
| 360 |
+
elif pt in ("custom_tool_call", "function_call"):
|
| 361 |
+
name = str(pay.get("name") or "")
|
| 362 |
+
call_id = str(pay.get("call_id") or pay.get("id") or "")
|
| 363 |
+
if call_id:
|
| 364 |
+
call_names[call_id] = name
|
| 365 |
+
inp = pay.get("input")
|
| 366 |
+
if isinstance(inp, str):
|
| 367 |
+
# `exec` custom tool calls carry raw JS code, not a
|
| 368 |
+
# JSON object — normalize to {"code": ...} so the
|
| 369 |
+
# structural gate (JSON tool args) stays satisfiable
|
| 370 |
+
# while the code remains byte-verbatim.
|
| 371 |
+
args = json.dumps({"code": inp}, ensure_ascii=False)
|
| 372 |
+
else:
|
| 373 |
+
args = json.dumps(inp or {}, ensure_ascii=False)
|
| 374 |
+
messages.append(
|
| 375 |
+
Message(
|
| 376 |
+
role="assistant",
|
| 377 |
+
content=None,
|
| 378 |
+
reasoning=pending_reasoning or None,
|
| 379 |
+
tool_calls=[
|
| 380 |
+
ToolCall(id=call_id, name=name, arguments=args)
|
| 381 |
+
],
|
| 382 |
+
timestamp=ts,
|
| 383 |
+
)
|
| 384 |
+
)
|
| 385 |
+
pending_reasoning = None
|
| 386 |
+
|
| 387 |
+
elif pt in ("custom_tool_call_output", "function_call_output"):
|
| 388 |
+
call_id = str(pay.get("call_id") or "")
|
| 389 |
+
out = pay.get("output")
|
| 390 |
+
body = _content_text(out) if isinstance(out, list) else (
|
| 391 |
+
str(out) if out is not None else ""
|
| 392 |
+
)
|
| 393 |
+
messages.append(
|
| 394 |
+
Message(
|
| 395 |
+
role="tool",
|
| 396 |
+
content=body or None,
|
| 397 |
+
tool_call_id=call_id,
|
| 398 |
+
tool_name=call_names.get(call_id),
|
| 399 |
+
timestamp=ts,
|
| 400 |
+
)
|
| 401 |
+
)
|
| 402 |
+
|
| 403 |
+
# Harness plumbing in user rows (2026-10-24): the coordinator's
|
| 404 |
+
# dispatch brief (keep its `=== TASK ===` block, drop the 6-7KB of
|
| 405 |
+
# Orca boilerplate) and the harness's "You have N orchestration
|
| 406 |
+
# messages" notifications. Both are plumbing where the user's own
|
| 407 |
+
# words belong, and the validate gate rejects the trace for either.
|
| 408 |
+
# Runs BEFORE the title pick so the title is the real task.
|
| 409 |
+
messages = reframe_task_rows(messages, self.skips)
|
| 410 |
+
|
| 411 |
+
# title: first real user task (codex has no title rows)
|
| 412 |
+
for m in messages:
|
| 413 |
+
if m.role == "user" and m.content:
|
| 414 |
+
title = " ".join(m.content.split())[:120]
|
| 415 |
+
break
|
| 416 |
+
|
| 417 |
+
# Subagent handoff recovery (owner directive 2026-10-07: taskless
|
| 418 |
+
# child threads are valuable work traces, don't drop them): when
|
| 419 |
+
# the rollout carries no real user row, codex children open with
|
| 420 |
+
# the agent restating its dispatched brief in task terms. That
|
| 421 |
+
# restatement becomes a synthesized first user row so the trace
|
| 422 |
+
# is a trainable (task → work → result) pair; the improve
|
| 423 |
+
# pipeline's reframe path and judges still verify task↔work fit.
|
| 424 |
+
# Completion-REPORT-shaped openings ("Implemented …", "Wrote …")
|
| 425 |
+
# are work summaries, not tasks — they stay taskless.
|
| 426 |
+
if not any(m.role == "user" for m in messages):
|
| 427 |
+
role = self._agent_role(path) # extra file read: taskless only
|
| 428 |
+
recover_taskless_brief(
|
| 429 |
+
messages, f"[subagent brief — role: {role}]\n" if role else ""
|
| 430 |
+
)
|
| 431 |
+
|
| 432 |
+
return Trace(
|
| 433 |
+
session_id=sid,
|
| 434 |
+
model=model,
|
| 435 |
+
title=title,
|
| 436 |
+
started_at=started or last_ts,
|
| 437 |
+
cwd=cwd,
|
| 438 |
+
messages=messages,
|
| 439 |
+
source="codex",
|
| 440 |
+
)
|
| 441 |
+
|
| 442 |
+
# ------------------------------------------------------------- sessions
|
| 443 |
+
|
| 444 |
+
def _cache_path(self) -> str:
|
| 445 |
+
"""Per-roots summary cache file (hash of the roots list)."""
|
| 446 |
+
import hashlib
|
| 447 |
+
|
| 448 |
+
key = hashlib.sha1(
|
| 449 |
+
"\x00".join(self.roots).encode("utf-8", "replace")
|
| 450 |
+
).hexdigest()[:12]
|
| 451 |
+
base = next(
|
| 452 |
+
(r for r in self.roots if os.path.isdir(r)), None
|
| 453 |
+
) or os.getcwd()
|
| 454 |
+
d = os.path.join(os.path.dirname(os.path.abspath(base)), ".cache")
|
| 455 |
+
try:
|
| 456 |
+
os.makedirs(d, exist_ok=True)
|
| 457 |
+
except OSError:
|
| 458 |
+
return os.path.join(
|
| 459 |
+
os.environ.get("TMPDIR", os.getcwd()), f".tf-codex-{key}.json"
|
| 460 |
+
)
|
| 461 |
+
return os.path.join(d, f"codex-summaries-{key}.json")
|
| 462 |
+
|
| 463 |
+
def _load_cache(self) -> dict:
|
| 464 |
+
try:
|
| 465 |
+
with open(self._cache_path(), "r", encoding="utf-8") as f:
|
| 466 |
+
d = json.load(f)
|
| 467 |
+
if isinstance(d, dict) and d.get("v") == 1:
|
| 468 |
+
return d.get("entries", {})
|
| 469 |
+
except (OSError, ValueError):
|
| 470 |
+
pass
|
| 471 |
+
return {}
|
| 472 |
+
|
| 473 |
+
def _save_cache(self, entries: dict) -> None:
|
| 474 |
+
try:
|
| 475 |
+
tmp = self._cache_path() + ".tmp"
|
| 476 |
+
with open(tmp, "w", encoding="utf-8") as f:
|
| 477 |
+
json.dump({"v": 1, "entries": entries}, f,
|
| 478 |
+
separators=(",", ":"))
|
| 479 |
+
os.replace(tmp, self._cache_path())
|
| 480 |
+
except OSError:
|
| 481 |
+
pass # cache is best-effort, never fatal
|
| 482 |
+
|
| 483 |
+
def list_sessions(
|
| 484 |
+
self,
|
| 485 |
+
*,
|
| 486 |
+
min_messages: int = 10,
|
| 487 |
+
max_messages: int = 120,
|
| 488 |
+
min_tool_calls: int = 2,
|
| 489 |
+
exclude_cwd: Optional[str] = None,
|
| 490 |
+
limit: Optional[int] = None,
|
| 491 |
+
status_cb=None,
|
| 492 |
+
use_cache: bool = True,
|
| 493 |
+
) -> list[dict[str, Any]]:
|
| 494 |
+
"""Candidate traces for improvement, newest first (summaries only).
|
| 495 |
+
|
| 496 |
+
Per-file summaries are cached (path -> {sig: size+mtime, row})
|
| 497 |
+
under the store's parent .cache dir; only new/changed files
|
| 498 |
+
parse. Parsing is thread-parallel — ~3.8k rollouts ≈ 10 GB
|
| 499 |
+
parse in ~20s wall on the first run, near-instant after.
|
| 500 |
+
``status_cb(msg)`` gets a discovery heartbeat while it runs.
|
| 501 |
+
|
| 502 |
+
``use_cache=False`` forces a full parse (2026-10-24). The row-level
|
| 503 |
+
skip counters are bumped during parsing, so a warm cache made the
|
| 504 |
+
census report near-zero: measured, 251 rollouts carry a dispatch
|
| 505 |
+
brief and the cached run counted 0 reframes. ``stats()`` passes
|
| 506 |
+
False so its census is authoritative.
|
| 507 |
+
|
| 508 |
+
``max_messages`` is a SOFT cap (2026-10-23): a rollout above it is
|
| 509 |
+
still listed, flagged ``oversize``. The hard cap it replaced
|
| 510 |
+
discarded 1,246 real codex rollouts (median 210 messages, 98 tool
|
| 511 |
+
calls) before they could reach ``split_long``.
|
| 512 |
+
"""
|
| 513 |
+
idx = self._index()
|
| 514 |
+
cached = self._load_cache() if use_cache else {}
|
| 515 |
+
entries: dict[str, Any] = {}
|
| 516 |
+
stale: list[tuple[str, str, str]] = []
|
| 517 |
+
for sid, path in idx.items():
|
| 518 |
+
try:
|
| 519 |
+
sig = f"{os.path.getsize(path):x}-{int(os.path.getmtime(path)):x}"
|
| 520 |
+
except OSError:
|
| 521 |
+
# stat failed: the file vanished or is locked. Counted —
|
| 522 |
+
# this used to drop the rollout with no trace of it.
|
| 523 |
+
bump(self.skips, "unreadable_file")
|
| 524 |
+
continue
|
| 525 |
+
c = cached.get(path)
|
| 526 |
+
if c and c.get("sig") == sig:
|
| 527 |
+
entries[path] = c
|
| 528 |
+
else:
|
| 529 |
+
stale.append((sid, path, sig))
|
| 530 |
+
rows: list[dict[str, Any]] = [
|
| 531 |
+
dict(e["row"]) for e in entries.values()
|
| 532 |
+
]
|
| 533 |
+
if stale:
|
| 534 |
+
t0 = time.monotonic()
|
| 535 |
+
done = 0
|
| 536 |
+
lock = threading.Lock()
|
| 537 |
+
|
| 538 |
+
def _parse_one(item):
|
| 539 |
+
sid, path, sig = item
|
| 540 |
+
try:
|
| 541 |
+
trace = self._parse_file(path, sid)
|
| 542 |
+
except OSError:
|
| 543 |
+
bump(self.skips, "unreadable_file")
|
| 544 |
+
return None
|
| 545 |
+
return path, sig, trace
|
| 546 |
+
|
| 547 |
+
workers = min(8, max(1, len(stale)))
|
| 548 |
+
with ThreadPoolExecutor(max_workers=workers) as ex:
|
| 549 |
+
futs = [ex.submit(_parse_one, it) for it in stale]
|
| 550 |
+
for f in as_completed(futs):
|
| 551 |
+
got = f.result()
|
| 552 |
+
with lock:
|
| 553 |
+
done += 1
|
| 554 |
+
if status_cb and (
|
| 555 |
+
done % 250 == 0 or done == len(stale)
|
| 556 |
+
):
|
| 557 |
+
status_cb(
|
| 558 |
+
f"discovery: {done}/{len(stale)} rollouts "
|
| 559 |
+
f"({time.monotonic() - t0:.0f}s)"
|
| 560 |
+
)
|
| 561 |
+
if got is None:
|
| 562 |
+
continue
|
| 563 |
+
path, sig, trace = got
|
| 564 |
+
roles: dict[str, int] = {}
|
| 565 |
+
for m in trace.messages:
|
| 566 |
+
roles[m.role] = roles.get(m.role, 0) + 1
|
| 567 |
+
row = {
|
| 568 |
+
"session_id": trace.session_id,
|
| 569 |
+
"model": trace.model,
|
| 570 |
+
"title": (trace.title or "")[:120].strip(),
|
| 571 |
+
"started_at": trace.started_at,
|
| 572 |
+
"ended_at": trace.messages[-1].timestamp
|
| 573 |
+
if trace.messages
|
| 574 |
+
else None,
|
| 575 |
+
"message_count": len(trace.messages),
|
| 576 |
+
"tool_call_count": sum(
|
| 577 |
+
len(m.tool_calls) for m in trace.messages
|
| 578 |
+
),
|
| 579 |
+
"cwd": trace.cwd,
|
| 580 |
+
"source": "codex",
|
| 581 |
+
"roles": roles,
|
| 582 |
+
}
|
| 583 |
+
rows.append(row)
|
| 584 |
+
entries[path] = {"sig": sig, "row": row}
|
| 585 |
+
self._save_cache(entries)
|
| 586 |
+
out: list[dict[str, Any]] = []
|
| 587 |
+
for r in rows:
|
| 588 |
+
if exclude_cwd and in_workdir(r["cwd"], exclude_cwd):
|
| 589 |
+
bump(self.skips, "cwd_self_reference")
|
| 590 |
+
continue
|
| 591 |
+
if r["message_count"] < min_messages:
|
| 592 |
+
bump(self.skips, "below_min_messages")
|
| 593 |
+
continue
|
| 594 |
+
if r["tool_call_count"] < min_tool_calls:
|
| 595 |
+
bump(self.skips, "below_min_tool_calls")
|
| 596 |
+
continue
|
| 597 |
+
out.append(oversize_row(r, max_messages))
|
| 598 |
+
out.sort(key=lambda r: r["started_at"] or 0.0, reverse=True)
|
| 599 |
+
if limit is not None:
|
| 600 |
+
out = out[:limit]
|
| 601 |
+
return out
|
| 602 |
+
|
| 603 |
+
def load_trace(self, session_id: str) -> Trace:
|
| 604 |
+
"""Load one full trace as a normalized Trace."""
|
| 605 |
+
path = self._index().get(session_id)
|
| 606 |
+
if not path:
|
| 607 |
+
raise KeyError(f"codex session not found: {session_id}")
|
| 608 |
+
return self._parse_file(path, session_id)
|
| 609 |
+
|
| 610 |
+
# -------------------------------------------------------------- helpers
|
| 611 |
+
|
| 612 |
+
def stats(self) -> dict[str, Any]:
|
| 613 |
+
"""Counts over the store(s), plus the skip census.
|
| 614 |
+
|
| 615 |
+
Runs against a SCRATCH counter (2026-10-24): stats() re-walks the
|
| 616 |
+
index and re-lists sessions, which bumps the file-level counters
|
| 617 |
+
again — a census taken after stats() reported every skip twice.
|
| 618 |
+
"""
|
| 619 |
+
real_skips = self.skips
|
| 620 |
+
census = new_skips()
|
| 621 |
+
self.skips = census
|
| 622 |
+
try:
|
| 623 |
+
rows = self.list_sessions(
|
| 624 |
+
min_messages=0, max_messages=10**9, min_tool_calls=0,
|
| 625 |
+
use_cache=False,
|
| 626 |
+
)
|
| 627 |
+
finally:
|
| 628 |
+
self.skips = real_skips
|
| 629 |
+
return {
|
| 630 |
+
"roots": len(self.roots),
|
| 631 |
+
"sessions": len(rows),
|
| 632 |
+
"messages": sum(r["message_count"] for r in rows),
|
| 633 |
+
"tool_calls": sum(r["tool_call_count"] for r in rows),
|
| 634 |
+
# the scratch counter, not the restored one: reading self.skips
|
| 635 |
+
# here returned the caller's counter and threw the census away
|
| 636 |
+
"skips": dict(census),
|
| 637 |
+
}
|
traceforge/readers/hermes.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hermes Agent trace reader — reads sessions+messages from state.db.
|
| 2 |
+
|
| 3 |
+
Verified schema (Hermes state.db, 2026-09-26):
|
| 4 |
+
sessions(id, model, title, display_name, started_at, ended_at,
|
| 5 |
+
message_count, tool_call_count, input_tokens, output_tokens,
|
| 6 |
+
cwd, source, ...)
|
| 7 |
+
messages(id, session_id, role, content, tool_call_id, tool_calls,
|
| 8 |
+
tool_name, timestamp, reasoning, reasoning_content, active,
|
| 9 |
+
compacted, display_order, ...)
|
| 10 |
+
|
| 11 |
+
display_order is NULL on ~47% of rows -> order by (timestamp, id).
|
| 12 |
+
tool_calls is a JSON array: [{"id","call_id","type":"function",
|
| 13 |
+
"function":{"name","arguments"}}]. Tool results are role='tool' rows
|
| 14 |
+
with tool_call_id + tool_name, paired against the issuing assistant turn.
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
import sqlite3
|
| 20 |
+
from typing import Any, Optional
|
| 21 |
+
|
| 22 |
+
from traceforge.readers._common import bump, in_workdir, new_skips, oversize_row
|
| 23 |
+
from traceforge.trace import Message, ToolCall, Trace, json_loads_lenient
|
| 24 |
+
|
| 25 |
+
# Sources that carry a real user-facing agent session. 'oneshot' is
|
| 26 |
+
# traceforge's OWN improver runtime (6,625 sessions in the seed archive,
|
| 27 |
+
# 1,578 of them long enough to look like candidates) and 'tool' is a
|
| 28 |
+
# 2-message probe — both stay out. 'subagent' / 'kanban' / 'local' are
|
| 29 |
+
# real work sessions: the subagent ones are delegate_task children whose
|
| 30 |
+
# transcripts are the densest multi-agent signal in the store (MASS,
|
| 31 |
+
# arXiv 2610.12176 — the claude reader already indexes its sidechains
|
| 32 |
+
# for the same reason), and excluding them here was an inconsistency,
|
| 33 |
+
# not a policy. Measured 2026-10-23: 43 such sessions pass every other
|
| 34 |
+
# filter and were being dropped by the whitelist alone.
|
| 35 |
+
_CANDIDATE_SOURCES = ("desktop", "cli", "tui", "subagent", "local", "kanban")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class HermesTraceReader:
|
| 39 |
+
"""Reads real traces from one or more Hermes Agent state.dbs (read-only).
|
| 40 |
+
|
| 41 |
+
Multi-db support (2026-10-13): a list of db paths is searched
|
| 42 |
+
together — the live store plus seed archives like
|
| 43 |
+
harness-traces/hermes/state.db. Session ids are uuids, unique per
|
| 44 |
+
store with zero expected cross-store overlap (verified against the
|
| 45 |
+
seed archive); a hit in the FIRST db wins on collision.
|
| 46 |
+
"""
|
| 47 |
+
|
| 48 |
+
def __init__(self, state_db: str | list[str]):
|
| 49 |
+
if isinstance(state_db, (list, tuple)):
|
| 50 |
+
self.state_dbs = [os.path.abspath(d) for d in state_db]
|
| 51 |
+
else:
|
| 52 |
+
self.state_dbs = [os.path.abspath(state_db)]
|
| 53 |
+
# rows dropped without a candidate row (self-reference guard,
|
| 54 |
+
# inactive turns): reported by stats() instead of vanishing.
|
| 55 |
+
self.skips = new_skips()
|
| 56 |
+
# Last filter settings list_sessions ran with — the SQL-level drop
|
| 57 |
+
# census in stats() attributes drops in the same order the WHERE
|
| 58 |
+
# clause applies, so it needs to know what the clause was.
|
| 59 |
+
self._last_min_messages = 10
|
| 60 |
+
self._last_min_tool_calls = 2
|
| 61 |
+
self._last_require_ended = True
|
| 62 |
+
self._last_max_input_tokens: Optional[int] = None
|
| 63 |
+
self._last_exclude_cwd: Optional[str] = None
|
| 64 |
+
|
| 65 |
+
@property
|
| 66 |
+
def state_db(self) -> str:
|
| 67 |
+
"""First db (backwards compat for callers/tests)."""
|
| 68 |
+
return self.state_dbs[0]
|
| 69 |
+
|
| 70 |
+
def _connect(self, db: str) -> sqlite3.Connection:
|
| 71 |
+
if not os.path.exists(db):
|
| 72 |
+
raise FileNotFoundError(f"Hermes state.db not found at {db}")
|
| 73 |
+
return sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
| 74 |
+
|
| 75 |
+
def _existing_dbs(self) -> list[str]:
|
| 76 |
+
"""All configured dbs that exist on disk (seed archives may be
|
| 77 |
+
absent; the live store always is)."""
|
| 78 |
+
return [d for d in self.state_dbs if os.path.exists(d)]
|
| 79 |
+
|
| 80 |
+
def _iter_dbs(self) -> tuple[str, sqlite3.Connection]:
|
| 81 |
+
"""(db, conn) for every existing store; caller closes each conn."""
|
| 82 |
+
for db in self._existing_dbs():
|
| 83 |
+
yield db, self._connect(db)
|
| 84 |
+
|
| 85 |
+
# ------------------------------------------------------------ sessions
|
| 86 |
+
|
| 87 |
+
def list_sessions(
|
| 88 |
+
self,
|
| 89 |
+
*,
|
| 90 |
+
min_messages: int = 10,
|
| 91 |
+
max_messages: int = 120,
|
| 92 |
+
min_tool_calls: int = 2,
|
| 93 |
+
max_input_tokens: Optional[int] = None,
|
| 94 |
+
source: Optional[str] = None,
|
| 95 |
+
require_ended: bool = True,
|
| 96 |
+
exclude_cwd: Optional[str] = None,
|
| 97 |
+
limit: Optional[int] = None,
|
| 98 |
+
) -> list[dict[str, Any]]:
|
| 99 |
+
"""Candidate traces for improvement, newest first (summaries only).
|
| 100 |
+
|
| 101 |
+
``exclude_cwd`` roots out traceforge's own improver children even
|
| 102 |
+
when mis-tagged: every improver stage runs with cwd inside the
|
| 103 |
+
improver workdir, so a cwd there means self-referential data no
|
| 104 |
+
matter what ``source`` says.
|
| 105 |
+
|
| 106 |
+
``max_messages`` is a SOFT cap (2026-10-23): a session above it is
|
| 107 |
+
still listed, flagged ``oversize``. The hard cap it replaced
|
| 108 |
+
discarded 563 real hermes sessions (median 187 messages, 99 tool
|
| 109 |
+
calls) before they could reach ``split_long`` — the stage that
|
| 110 |
+
actually bounds what the pipeline processes, by rendered size
|
| 111 |
+
rather than by message count. ``min_messages`` / ``min_tool_calls``
|
| 112 |
+
stay hard: they filter degenerate stubs, not long work.
|
| 113 |
+
"""
|
| 114 |
+
q = (
|
| 115 |
+
"SELECT s.id, s.model, s.title, s.display_name, s.started_at,"
|
| 116 |
+
" s.ended_at, s.message_count, s.tool_call_count,"
|
| 117 |
+
" s.input_tokens, s.output_tokens, s.cwd, s.source"
|
| 118 |
+
" FROM sessions s"
|
| 119 |
+
" WHERE s.message_count >= ? AND s.tool_call_count >= ?"
|
| 120 |
+
" AND s.source IN (" + ",".join("?" * len(_CANDIDATE_SOURCES)) + ")"
|
| 121 |
+
)
|
| 122 |
+
# remembered for stats()'s SQL-level drop census
|
| 123 |
+
self._last_min_messages = min_messages
|
| 124 |
+
self._last_min_tool_calls = min_tool_calls
|
| 125 |
+
self._last_require_ended = require_ended
|
| 126 |
+
self._last_max_input_tokens = max_input_tokens
|
| 127 |
+
self._last_exclude_cwd = exclude_cwd
|
| 128 |
+
params: list[Any] = [min_messages, min_tool_calls, *_CANDIDATE_SOURCES]
|
| 129 |
+
if exclude_cwd:
|
| 130 |
+
# Self-reference guard: improver children run with cwd inside
|
| 131 |
+
# the improver workdir; a mis-tagged source (e.g. inherited
|
| 132 |
+
# 'desktop') must never let them become candidates.
|
| 133 |
+
q += " AND (s.cwd IS NULL OR s.cwd NOT LIKE ?)"
|
| 134 |
+
params.append(exclude_cwd.rstrip("/\\") + "/%")
|
| 135 |
+
if require_ended:
|
| 136 |
+
q += " AND s.ended_at IS NOT NULL"
|
| 137 |
+
if source is not None:
|
| 138 |
+
q += " AND s.source = ?"
|
| 139 |
+
params.append(source)
|
| 140 |
+
if max_input_tokens is not None:
|
| 141 |
+
q += " AND s.input_tokens <= ?"
|
| 142 |
+
params.append(max_input_tokens)
|
| 143 |
+
q += " ORDER BY s.started_at DESC"
|
| 144 |
+
# (LIMIT applied per-db below, after the merge)
|
| 145 |
+
rows: list[dict[str, Any]] = []
|
| 146 |
+
seen: set[str] = set()
|
| 147 |
+
for _db, conn in self._iter_dbs():
|
| 148 |
+
try:
|
| 149 |
+
got = conn.execute(q, params).fetchall()
|
| 150 |
+
finally:
|
| 151 |
+
conn.close()
|
| 152 |
+
for r in got:
|
| 153 |
+
if r[0] in seen:
|
| 154 |
+
continue
|
| 155 |
+
seen.add(r[0])
|
| 156 |
+
if exclude_cwd and in_workdir(r[10], exclude_cwd):
|
| 157 |
+
bump(self.skips, "cwd_self_reference")
|
| 158 |
+
continue
|
| 159 |
+
rows.append(
|
| 160 |
+
oversize_row(
|
| 161 |
+
{
|
| 162 |
+
"session_id": r[0],
|
| 163 |
+
"model": r[1],
|
| 164 |
+
"title": (r[2] or r[3] or "").strip(),
|
| 165 |
+
"started_at": r[4],
|
| 166 |
+
"ended_at": r[5],
|
| 167 |
+
"message_count": r[6],
|
| 168 |
+
"tool_call_count": r[7],
|
| 169 |
+
"input_tokens": r[8],
|
| 170 |
+
"output_tokens": r[9],
|
| 171 |
+
"cwd": r[10],
|
| 172 |
+
"source": r[11],
|
| 173 |
+
},
|
| 174 |
+
max_messages,
|
| 175 |
+
)
|
| 176 |
+
)
|
| 177 |
+
rows.sort(key=lambda r: r["started_at"] or 0.0, reverse=True)
|
| 178 |
+
if limit is not None:
|
| 179 |
+
rows = rows[:limit]
|
| 180 |
+
return rows
|
| 181 |
+
|
| 182 |
+
# ------------------------------------------------------------- messages
|
| 183 |
+
|
| 184 |
+
MSG_QUERY = (
|
| 185 |
+
"SELECT id, role, content, tool_call_id, tool_calls, tool_name,"
|
| 186 |
+
" timestamp, reasoning, reasoning_content, active, compacted"
|
| 187 |
+
" FROM messages WHERE session_id = ?"
|
| 188 |
+
" ORDER BY timestamp ASC, id ASC"
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
def load_trace(self, session_id: str) -> Trace:
|
| 192 |
+
"""Load one full trace as a normalized Trace (searches all dbs)."""
|
| 193 |
+
for _db, conn in self._iter_dbs():
|
| 194 |
+
try:
|
| 195 |
+
sess = conn.execute(
|
| 196 |
+
"SELECT id, model, title, display_name, started_at, cwd,"
|
| 197 |
+
" source FROM sessions WHERE id = ?",
|
| 198 |
+
(session_id,),
|
| 199 |
+
).fetchone()
|
| 200 |
+
if not sess:
|
| 201 |
+
continue
|
| 202 |
+
rows = conn.execute(self.MSG_QUERY, (session_id,)).fetchall()
|
| 203 |
+
return Trace(
|
| 204 |
+
session_id=sess[0],
|
| 205 |
+
model=sess[1],
|
| 206 |
+
title=(sess[2] or sess[3] or "").strip() or None,
|
| 207 |
+
started_at=sess[4],
|
| 208 |
+
cwd=sess[5],
|
| 209 |
+
messages=list(self._parse_rows(rows)),
|
| 210 |
+
source="hermes" if not sess[6] else str(sess[6]),
|
| 211 |
+
)
|
| 212 |
+
finally:
|
| 213 |
+
conn.close()
|
| 214 |
+
raise KeyError(f"session not found: {session_id}")
|
| 215 |
+
|
| 216 |
+
def _parse_rows(self, rows: list[sqlite3.Row]) -> list[Message]:
|
| 217 |
+
out: list[Message] = []
|
| 218 |
+
for (mid, role, content, tc_id, tc_json, tool_name, ts,
|
| 219 |
+
reasoning, reasoning_content, active, compacted) in rows:
|
| 220 |
+
if not active:
|
| 221 |
+
bump(self.skips, "inactive_turn")
|
| 222 |
+
continue # rewound/deleted turns
|
| 223 |
+
m = Message(role=role, content=content, timestamp=ts)
|
| 224 |
+
if role == "tool":
|
| 225 |
+
m.tool_call_id = tc_id
|
| 226 |
+
m.tool_name = tool_name
|
| 227 |
+
elif role == "assistant" and tc_json:
|
| 228 |
+
calls = json_loads_lenient(tc_json)
|
| 229 |
+
if isinstance(calls, list):
|
| 230 |
+
for c in calls:
|
| 231 |
+
fn = c.get("function") or {}
|
| 232 |
+
m.tool_calls.append(
|
| 233 |
+
ToolCall(
|
| 234 |
+
id=str(c.get("call_id") or c.get("id") or ""),
|
| 235 |
+
name=str(fn.get("name") or ""),
|
| 236 |
+
arguments=fn.get("arguments") or "{}",
|
| 237 |
+
)
|
| 238 |
+
)
|
| 239 |
+
reasoning = reasoning or reasoning_content
|
| 240 |
+
if reasoning:
|
| 241 |
+
m.reasoning = reasoning
|
| 242 |
+
out.append(m)
|
| 243 |
+
return out
|
| 244 |
+
|
| 245 |
+
# -------------------------------------------------------------- helpers
|
| 246 |
+
|
| 247 |
+
def _sql_drop_census(self) -> dict[str, int]:
|
| 248 |
+
"""Attribute every dropped session to the filter that took it.
|
| 249 |
+
|
| 250 |
+
``list_sessions`` applies min_messages / min_tool_calls / the source
|
| 251 |
+
whitelist / require_ended / max_input_tokens / the cwd guard inside
|
| 252 |
+
ONE SQL query, so none of those drops can bump a counter — measured
|
| 253 |
+
2026-10-24: 6,741 of 9,110 sessions vanished with ``skips == {}``.
|
| 254 |
+
A skip census that reports nothing for the store that drops the most
|
| 255 |
+
is worse than no census, so the attribution runs here, in the same
|
| 256 |
+
order the WHERE clause applies.
|
| 257 |
+
|
| 258 |
+
Read-only and cheap (one row scan per db, ~9k rows).
|
| 259 |
+
"""
|
| 260 |
+
census: dict[str, int] = {}
|
| 261 |
+
seen: set[str] = set()
|
| 262 |
+
|
| 263 |
+
def bump_census(reason: str) -> None:
|
| 264 |
+
census[reason] = census.get(reason, 0) + 1
|
| 265 |
+
|
| 266 |
+
for _db, conn in self._iter_dbs():
|
| 267 |
+
try:
|
| 268 |
+
rows = conn.execute(
|
| 269 |
+
"SELECT id, message_count, tool_call_count, source,"
|
| 270 |
+
" ended_at, cwd, input_tokens FROM sessions"
|
| 271 |
+
).fetchall()
|
| 272 |
+
finally:
|
| 273 |
+
conn.close()
|
| 274 |
+
for (sid, mc, tc, src, ended, cwd, itok) in rows:
|
| 275 |
+
if sid in seen:
|
| 276 |
+
bump_census("cross_db_duplicate")
|
| 277 |
+
continue
|
| 278 |
+
seen.add(sid)
|
| 279 |
+
if src not in _CANDIDATE_SOURCES:
|
| 280 |
+
bump_census(f"source_not_candidate:{src}")
|
| 281 |
+
elif (mc or 0) < self._last_min_messages:
|
| 282 |
+
bump_census("below_min_messages")
|
| 283 |
+
elif (tc or 0) < self._last_min_tool_calls:
|
| 284 |
+
bump_census("below_min_tool_calls")
|
| 285 |
+
elif self._last_require_ended and ended is None:
|
| 286 |
+
bump_census("not_ended")
|
| 287 |
+
elif (self._last_max_input_tokens is not None
|
| 288 |
+
and (itok or 0) > self._last_max_input_tokens):
|
| 289 |
+
bump_census("over_max_input_tokens")
|
| 290 |
+
elif self._last_exclude_cwd and cwd and in_workdir(
|
| 291 |
+
cwd, self._last_exclude_cwd
|
| 292 |
+
):
|
| 293 |
+
bump_census("cwd_self_reference")
|
| 294 |
+
return census
|
| 295 |
+
|
| 296 |
+
def stats(self) -> dict[str, Any]:
|
| 297 |
+
"""Quick counts over the store(s) for sanity checks.
|
| 298 |
+
|
| 299 |
+
``skips`` merges the row-level counters with the SQL-level drop
|
| 300 |
+
census, so the store's whole funnel is visible in one place.
|
| 301 |
+
"""
|
| 302 |
+
out: dict[str, Any] = {"dbs": len(self._existing_dbs())}
|
| 303 |
+
for key in ("sessions", "messages", "tool_rows"):
|
| 304 |
+
out[key] = 0
|
| 305 |
+
for _db, conn in self._iter_dbs():
|
| 306 |
+
try:
|
| 307 |
+
one = lambda sql: conn.execute(sql).fetchone()[0]
|
| 308 |
+
out["sessions"] += one("SELECT COUNT(*) FROM sessions")
|
| 309 |
+
out["messages"] += one("SELECT COUNT(*) FROM messages")
|
| 310 |
+
out["tool_rows"] += one(
|
| 311 |
+
"SELECT COUNT(*) FROM messages WHERE role='tool'"
|
| 312 |
+
)
|
| 313 |
+
finally:
|
| 314 |
+
conn.close()
|
| 315 |
+
skips = dict(self.skips)
|
| 316 |
+
for reason, n in self._sql_drop_census().items():
|
| 317 |
+
skips[reason] = skips.get(reason, 0) + n
|
| 318 |
+
out["skips"] = skips
|
| 319 |
+
return out
|
traceforge/readers/nemotron.py
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Nemotron SFT dataset reader — NVIDIA's published agentic SFT corpora.
|
| 2 |
+
|
| 3 |
+
These are not live agent stores. Each row is an already-finished
|
| 4 |
+
``(messages, tools)`` pair from a released SFT dataset, so the reader's
|
| 5 |
+
job is narrower than the harness readers': normalize the row into a
|
| 6 |
+
:class:`~traceforge.trace.Trace` and serve it by session id.
|
| 7 |
+
|
| 8 |
+
Why they belong in the pool at all: the live stores are ~95% coding and
|
| 9 |
+
English, and the corpus's weak buckets (research, data-analysis, ops,
|
| 10 |
+
office-docs) cannot be filled by re-reading the same harness. The
|
| 11 |
+
Nemotron agentic subsets carry tool-call trajectories from other
|
| 12 |
+
harnesses and other domains (SEC filings, customer support, weather),
|
| 13 |
+
which is exactly the coverage the classifier reports as missing.
|
| 14 |
+
|
| 15 |
+
Three normalizations the raw rows need:
|
| 16 |
+
|
| 17 |
+
1. ``messages`` is a JSON *string* in some subsets (SWE-v3.5) and a list
|
| 18 |
+
in others — both are accepted.
|
| 19 |
+
2. SWE-v3.5 tool rows carry only ``content``: no ``tool_call_id`` and no
|
| 20 |
+
``name``. They are paired positionally against the issuing assistant
|
| 21 |
+
turn's calls (verified 1:1 in order on the released shards).
|
| 22 |
+
3. ``tools`` is a separate column with no counterpart in the Trace
|
| 23 |
+
model, and the export has no field for it either. Dropping it would
|
| 24 |
+
leave tool calls pointing at tools nothing defines, so the schemas are
|
| 25 |
+
folded into the system row as a compact block (name, short
|
| 26 |
+
description, parameter names) — capped, because SWE-v3.5 ships 33 KB
|
| 27 |
+
of JSON schema per row and the OpenCode system prompt already
|
| 28 |
+
describes the tools in prose.
|
| 29 |
+
|
| 30 |
+
Provenance is carried twice: ``source`` (``nemotron-swe`` …) for the
|
| 31 |
+
benchmark's ``by_source`` grouping, and the session-id namespace
|
| 32 |
+
(``nemotron-swe-<uuid>``) because the improve stage overwrites ``source``
|
| 33 |
+
with ``traceforge-splice`` while the id survives verbatim.
|
| 34 |
+
"""
|
| 35 |
+
from __future__ import annotations
|
| 36 |
+
|
| 37 |
+
import hashlib
|
| 38 |
+
import json
|
| 39 |
+
import os
|
| 40 |
+
from typing import Any, Optional
|
| 41 |
+
|
| 42 |
+
from traceforge.readers._common import bump, new_skips, oversize_row
|
| 43 |
+
from traceforge.trace import VALID_ROLES, Message, ToolCall, Trace
|
| 44 |
+
|
| 45 |
+
# Subset -> provenance tag. The tag is the reader's contract with the
|
| 46 |
+
# benchmark: `by_source` groups on it, so it must stay stable.
|
| 47 |
+
SUBSET_SOURCES = {
|
| 48 |
+
"swe": "nemotron-swe",
|
| 49 |
+
"agentic-tc": "nemotron-agentic-tc",
|
| 50 |
+
"agentic-ka": "nemotron-agentic-ka",
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
# Cap on the folded tool-schema block. SWE-v3.5 ships 33 KB of JSON schema
|
| 54 |
+
# per row; the OpenCode system prompt already names and describes every
|
| 55 |
+
# tool, so the block only has to make the calls resolvable.
|
| 56 |
+
TOOLS_BLOCK_MAX_CHARS = 4000
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# --------------------------------------------------------------- row parsing
|
| 60 |
+
|
| 61 |
+
def as_list(value: Any) -> Optional[list]:
|
| 62 |
+
"""A JSON list, whether the column stores a list or a JSON string."""
|
| 63 |
+
if isinstance(value, list):
|
| 64 |
+
return value
|
| 65 |
+
if isinstance(value, str):
|
| 66 |
+
try:
|
| 67 |
+
got = json.loads(value)
|
| 68 |
+
except json.JSONDecodeError:
|
| 69 |
+
return None
|
| 70 |
+
return got if isinstance(got, list) else None
|
| 71 |
+
return None
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def content_text(value: Any) -> str:
|
| 75 |
+
"""Message content as text.
|
| 76 |
+
|
| 77 |
+
OpenAI-style content may be a string or a list of parts
|
| 78 |
+
(``{"type": "text", "text": …}``); the datasets use strings, but a
|
| 79 |
+
part list must not silently become ``str(list)``.
|
| 80 |
+
"""
|
| 81 |
+
if value is None:
|
| 82 |
+
return ""
|
| 83 |
+
if isinstance(value, str):
|
| 84 |
+
return value
|
| 85 |
+
if isinstance(value, list):
|
| 86 |
+
out = []
|
| 87 |
+
for part in value:
|
| 88 |
+
if isinstance(part, str):
|
| 89 |
+
out.append(part)
|
| 90 |
+
elif isinstance(part, dict):
|
| 91 |
+
out.append(str(part.get("text") or part.get("content") or ""))
|
| 92 |
+
return "\n".join(p for p in out if p)
|
| 93 |
+
return str(value)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _arguments_text(value: Any) -> str:
|
| 97 |
+
"""Tool-call arguments as the JSON string the Trace model stores."""
|
| 98 |
+
if value is None:
|
| 99 |
+
return "{}"
|
| 100 |
+
if isinstance(value, str):
|
| 101 |
+
return value
|
| 102 |
+
try:
|
| 103 |
+
return json.dumps(value, ensure_ascii=False)
|
| 104 |
+
except (TypeError, ValueError):
|
| 105 |
+
return "{}"
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def tools_block(tools: Optional[list], max_chars: int = TOOLS_BLOCK_MAX_CHARS) -> str:
|
| 109 |
+
"""Compact ``## Available tools`` block for the system row.
|
| 110 |
+
|
| 111 |
+
Name + first line of the description + parameter names. Full JSON
|
| 112 |
+
schemas are deliberately not reproduced: they are 33 KB on SWE-v3.5
|
| 113 |
+
and the arguments in the trace are what the trainee has to match.
|
| 114 |
+
"""
|
| 115 |
+
if not tools:
|
| 116 |
+
return ""
|
| 117 |
+
lines = ["## Available tools", ""]
|
| 118 |
+
for t in tools:
|
| 119 |
+
if not isinstance(t, dict):
|
| 120 |
+
continue
|
| 121 |
+
fn = t.get("function") if isinstance(t.get("function"), dict) else t
|
| 122 |
+
if not isinstance(fn, dict):
|
| 123 |
+
continue
|
| 124 |
+
name = str(fn.get("name") or "").strip()
|
| 125 |
+
if not name:
|
| 126 |
+
continue
|
| 127 |
+
desc = str(fn.get("description") or "").strip().split("\n")[0][:160]
|
| 128 |
+
params = fn.get("parameters") or {}
|
| 129 |
+
props = params.get("properties") if isinstance(params, dict) else None
|
| 130 |
+
arg_names = ", ".join(props.keys()) if isinstance(props, dict) else ""
|
| 131 |
+
required = params.get("required") if isinstance(params, dict) else None
|
| 132 |
+
req = ", ".join(str(r) for r in required) if isinstance(required, list) else ""
|
| 133 |
+
line = f"- `{name}`"
|
| 134 |
+
if desc:
|
| 135 |
+
line += f": {desc}"
|
| 136 |
+
if arg_names:
|
| 137 |
+
line += f" (args: {arg_names}"
|
| 138 |
+
line += f"; required: {req})" if req else ")"
|
| 139 |
+
lines.append(line)
|
| 140 |
+
if sum(len(x) for x in lines) > max_chars:
|
| 141 |
+
lines.append("- … (further tool schemas omitted)")
|
| 142 |
+
break
|
| 143 |
+
return "\n".join(lines)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _normalize_calls(raw_calls: list, skips, counter: list[int]) -> list[ToolCall]:
|
| 147 |
+
"""Assistant tool calls -> ToolCall list, synthesizing missing ids.
|
| 148 |
+
|
| 149 |
+
``counter`` is a one-element list shared across the whole row: SWE-v3.5
|
| 150 |
+
ships no call ids at all, and a per-message ``call_0`` would collide
|
| 151 |
+
across turns (the export pairs results by id).
|
| 152 |
+
"""
|
| 153 |
+
out: list[ToolCall] = []
|
| 154 |
+
for tc in raw_calls:
|
| 155 |
+
if not isinstance(tc, dict):
|
| 156 |
+
continue
|
| 157 |
+
fn = tc.get("function") if isinstance(tc.get("function"), dict) else tc
|
| 158 |
+
if not isinstance(fn, dict):
|
| 159 |
+
continue
|
| 160 |
+
name = str(fn.get("name") or "").strip()
|
| 161 |
+
if not name:
|
| 162 |
+
bump(skips, "tool_call_without_name")
|
| 163 |
+
continue
|
| 164 |
+
call_id = str(tc.get("id") or tc.get("call_id") or "").strip()
|
| 165 |
+
if not call_id:
|
| 166 |
+
call_id = f"call_{counter[0]}"
|
| 167 |
+
counter[0] += 1
|
| 168 |
+
out.append(
|
| 169 |
+
ToolCall(
|
| 170 |
+
id=call_id,
|
| 171 |
+
name=name,
|
| 172 |
+
arguments=_arguments_text(fn.get("arguments")),
|
| 173 |
+
)
|
| 174 |
+
)
|
| 175 |
+
return out
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def _title_from(text: str) -> Optional[str]:
|
| 179 |
+
"""First substantive line of the task, for the listing.
|
| 180 |
+
|
| 181 |
+
SWE-v3.5 opens with an ``<uploaded_files>`` envelope whose next line is
|
| 182 |
+
a bare ``/workspace/repo`` path, so neither the literal first line nor
|
| 183 |
+
the first non-tag line is a title. Skip markup, bare paths and short
|
| 184 |
+
fragments; fall back to the first non-empty line.
|
| 185 |
+
"""
|
| 186 |
+
fallback: Optional[str] = None
|
| 187 |
+
for line in (text or "").splitlines():
|
| 188 |
+
s = line.strip().strip('"').strip()
|
| 189 |
+
if not s:
|
| 190 |
+
continue
|
| 191 |
+
if fallback is None:
|
| 192 |
+
fallback = s[:80]
|
| 193 |
+
if s.startswith("<") or s.startswith("```"):
|
| 194 |
+
continue
|
| 195 |
+
if " " not in s and ("/" in s or "\\" in s):
|
| 196 |
+
continue # a bare path, not prose
|
| 197 |
+
if len(s) < 20 or len(s.split()) < 3:
|
| 198 |
+
continue
|
| 199 |
+
return s[:80]
|
| 200 |
+
return fallback
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def normalize_row(
|
| 204 |
+
row: dict,
|
| 205 |
+
subset: str,
|
| 206 |
+
*,
|
| 207 |
+
skips=None,
|
| 208 |
+
embed_tools: bool = True,
|
| 209 |
+
) -> Optional[Trace]:
|
| 210 |
+
"""One dataset row -> Trace, or None when the row cannot be a trace.
|
| 211 |
+
|
| 212 |
+
Returns None (and bumps a skip counter) for a row with no messages, no
|
| 213 |
+
user turn, or no assistant turn — those are not improvable traces and
|
| 214 |
+
must not reach the raw pool.
|
| 215 |
+
"""
|
| 216 |
+
source = SUBSET_SOURCES.get(subset, f"nemotron-{subset}")
|
| 217 |
+
raw_msgs = as_list(row.get("messages"))
|
| 218 |
+
if not raw_msgs:
|
| 219 |
+
bump(skips, "no_messages")
|
| 220 |
+
return None
|
| 221 |
+
|
| 222 |
+
messages: list[Message] = []
|
| 223 |
+
pending: list[ToolCall] = [] # calls awaiting their result row
|
| 224 |
+
counter = [0] # synthesized call-id counter, shared across the row
|
| 225 |
+
for raw in raw_msgs:
|
| 226 |
+
if not isinstance(raw, dict):
|
| 227 |
+
bump(skips, "non_dict_message")
|
| 228 |
+
continue
|
| 229 |
+
role = str(raw.get("role") or "").strip()
|
| 230 |
+
if role not in VALID_ROLES:
|
| 231 |
+
bump(skips, f"unknown_role:{role or '?'}")
|
| 232 |
+
continue
|
| 233 |
+
text = content_text(raw.get("content"))
|
| 234 |
+
if role == "tool":
|
| 235 |
+
if not pending:
|
| 236 |
+
# Orphan result: no issuing call in this row. Keeping it
|
| 237 |
+
# would fail the pairing gate on every attempt.
|
| 238 |
+
bump(skips, "orphan_tool_result")
|
| 239 |
+
continue
|
| 240 |
+
call = pending.pop(0)
|
| 241 |
+
messages.append(
|
| 242 |
+
Message(
|
| 243 |
+
role="tool",
|
| 244 |
+
content=text,
|
| 245 |
+
tool_call_id=str(raw.get("tool_call_id") or call.id),
|
| 246 |
+
tool_name=str(raw.get("name") or call.name),
|
| 247 |
+
)
|
| 248 |
+
)
|
| 249 |
+
continue
|
| 250 |
+
if role == "assistant":
|
| 251 |
+
calls = _normalize_calls(raw.get("tool_calls") or [], skips, counter)
|
| 252 |
+
reasoning = raw.get("reasoning_content") or raw.get("reasoning")
|
| 253 |
+
if not text and not calls:
|
| 254 |
+
bump(skips, "empty_assistant_row")
|
| 255 |
+
continue
|
| 256 |
+
pending.extend(calls)
|
| 257 |
+
messages.append(
|
| 258 |
+
Message(
|
| 259 |
+
role="assistant",
|
| 260 |
+
content=text or None,
|
| 261 |
+
reasoning=str(reasoning) if reasoning else None,
|
| 262 |
+
tool_calls=calls,
|
| 263 |
+
)
|
| 264 |
+
)
|
| 265 |
+
continue
|
| 266 |
+
if role == "system" and not text.strip():
|
| 267 |
+
bump(skips, "empty_system_row")
|
| 268 |
+
continue
|
| 269 |
+
messages.append(Message(role=role, content=text))
|
| 270 |
+
|
| 271 |
+
if pending:
|
| 272 |
+
# Calls with no result: the row was cut mid-run. Drop the calls so
|
| 273 |
+
# the trace does not carry an unanswered invocation.
|
| 274 |
+
bump(skips, "unanswered_tool_call", len(pending))
|
| 275 |
+
dead = {c.id for c in pending}
|
| 276 |
+
for m in messages:
|
| 277 |
+
if m.role == "assistant" and m.tool_calls:
|
| 278 |
+
m.tool_calls = [c for c in m.tool_calls if c.id not in dead]
|
| 279 |
+
|
| 280 |
+
roles = {m.role for m in messages}
|
| 281 |
+
if "user" not in roles:
|
| 282 |
+
bump(skips, "no_user_message")
|
| 283 |
+
return None
|
| 284 |
+
if "assistant" not in roles:
|
| 285 |
+
bump(skips, "no_assistant_message")
|
| 286 |
+
return None
|
| 287 |
+
|
| 288 |
+
if embed_tools:
|
| 289 |
+
block = tools_block(as_list(row.get("tools")))
|
| 290 |
+
if block:
|
| 291 |
+
if messages and messages[0].role == "system":
|
| 292 |
+
messages[0].content = (messages[0].content or "").rstrip() + "\n\n" + block
|
| 293 |
+
else:
|
| 294 |
+
messages.insert(0, Message(role="system", content=block))
|
| 295 |
+
|
| 296 |
+
uuid = str(row.get("uuid") or "").strip()
|
| 297 |
+
if not uuid:
|
| 298 |
+
uuid = hashlib.sha1(
|
| 299 |
+
json.dumps(row, sort_keys=True, ensure_ascii=False).encode("utf-8")
|
| 300 |
+
).hexdigest()[:16]
|
| 301 |
+
first_user = next((m.content for m in messages if m.role == "user"), "") or ""
|
| 302 |
+
return Trace(
|
| 303 |
+
session_id=f"{source}-{uuid}",
|
| 304 |
+
title=_title_from(first_user),
|
| 305 |
+
messages=messages,
|
| 306 |
+
source=source,
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
# ------------------------------------------------------------------- reader
|
| 311 |
+
|
| 312 |
+
class NemotronTraceReader:
|
| 313 |
+
"""Reads normalized Nemotron SFT traces from one or more JSONL roots.
|
| 314 |
+
|
| 315 |
+
Each root holds one ``<subset>.jsonl`` per dataset subset, one Trace
|
| 316 |
+
dict per line (written by ``scripts/fetch_nemotron.py``). A byte-offset
|
| 317 |
+
index per file is cached next to the data, so ``load_trace`` seeks
|
| 318 |
+
instead of re-parsing a multi-hundred-MB file.
|
| 319 |
+
"""
|
| 320 |
+
|
| 321 |
+
def __init__(self, roots: Optional[list[str]] = None):
|
| 322 |
+
if isinstance(roots, str):
|
| 323 |
+
roots = [roots]
|
| 324 |
+
self.roots = [os.path.abspath(os.path.expanduser(r)) for r in (roots or [])]
|
| 325 |
+
self.skips = new_skips()
|
| 326 |
+
|
| 327 |
+
# ------------------------------------------------------------- indexing
|
| 328 |
+
|
| 329 |
+
def _files(self) -> list[str]:
|
| 330 |
+
out: list[str] = []
|
| 331 |
+
for root in self.roots:
|
| 332 |
+
if not os.path.isdir(root):
|
| 333 |
+
continue
|
| 334 |
+
for name in sorted(os.listdir(root)):
|
| 335 |
+
if name.endswith(".jsonl"):
|
| 336 |
+
out.append(os.path.join(root, name))
|
| 337 |
+
return out
|
| 338 |
+
|
| 339 |
+
def _index_path(self, path: str) -> str:
|
| 340 |
+
root = os.path.dirname(path)
|
| 341 |
+
return os.path.join(root, ".index", os.path.basename(path) + ".idx.json")
|
| 342 |
+
|
| 343 |
+
def _build_index(self, path: str) -> dict[str, Any]:
|
| 344 |
+
"""Byte offsets + summary per row, cached on (size, mtime)."""
|
| 345 |
+
try:
|
| 346 |
+
st = os.stat(path)
|
| 347 |
+
except OSError:
|
| 348 |
+
bump(self.skips, "unreadable_file")
|
| 349 |
+
return {"sig": "", "rows": []}
|
| 350 |
+
sig = f"{st.st_size:x}-{int(st.st_mtime):x}"
|
| 351 |
+
cache_path = self._index_path(path)
|
| 352 |
+
try:
|
| 353 |
+
with open(cache_path, "r", encoding="utf-8") as f:
|
| 354 |
+
cached = json.load(f)
|
| 355 |
+
if cached.get("sig") == sig:
|
| 356 |
+
return cached
|
| 357 |
+
except (OSError, json.JSONDecodeError):
|
| 358 |
+
pass
|
| 359 |
+
|
| 360 |
+
rows: list[dict[str, Any]] = []
|
| 361 |
+
with open(path, "rb") as f:
|
| 362 |
+
offset = 0
|
| 363 |
+
for raw in f:
|
| 364 |
+
length = len(raw)
|
| 365 |
+
if raw.strip():
|
| 366 |
+
try:
|
| 367 |
+
d = json.loads(raw.decode("utf-8"))
|
| 368 |
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
| 369 |
+
bump(self.skips, "unparseable_row")
|
| 370 |
+
offset += length
|
| 371 |
+
continue
|
| 372 |
+
msgs = d.get("messages") or []
|
| 373 |
+
rows.append(
|
| 374 |
+
{
|
| 375 |
+
"sid": d.get("session_id"),
|
| 376 |
+
"offset": offset,
|
| 377 |
+
"length": length,
|
| 378 |
+
"n_messages": len(msgs),
|
| 379 |
+
"n_tool_calls": sum(
|
| 380 |
+
len(m.get("tool_calls") or []) for m in msgs
|
| 381 |
+
),
|
| 382 |
+
"chars": sum(len(m.get("content") or "") for m in msgs),
|
| 383 |
+
"title": d.get("title"),
|
| 384 |
+
"source": d.get("source"),
|
| 385 |
+
"started_at": d.get("started_at"),
|
| 386 |
+
}
|
| 387 |
+
)
|
| 388 |
+
offset += length
|
| 389 |
+
index = {"sig": sig, "rows": rows}
|
| 390 |
+
try:
|
| 391 |
+
os.makedirs(os.path.dirname(cache_path), exist_ok=True)
|
| 392 |
+
tmp = cache_path + ".tmp"
|
| 393 |
+
with open(tmp, "w", encoding="utf-8") as f:
|
| 394 |
+
json.dump(index, f)
|
| 395 |
+
os.replace(tmp, cache_path)
|
| 396 |
+
except OSError:
|
| 397 |
+
pass # a read-only seed dir still works, just slower
|
| 398 |
+
return index
|
| 399 |
+
|
| 400 |
+
def _index(self) -> dict[str, dict[str, Any]]:
|
| 401 |
+
"""session id -> index entry (with its file path)."""
|
| 402 |
+
out: dict[str, dict[str, Any]] = {}
|
| 403 |
+
for path in self._files():
|
| 404 |
+
for row in self._build_index(path).get("rows", []):
|
| 405 |
+
sid = row.get("sid")
|
| 406 |
+
if not sid:
|
| 407 |
+
bump(self.skips, "row_without_session_id")
|
| 408 |
+
continue
|
| 409 |
+
entry = dict(row)
|
| 410 |
+
entry["path"] = path
|
| 411 |
+
out[sid] = entry
|
| 412 |
+
return out
|
| 413 |
+
|
| 414 |
+
# ------------------------------------------------------------ sessions
|
| 415 |
+
|
| 416 |
+
def list_sessions(
|
| 417 |
+
self,
|
| 418 |
+
*,
|
| 419 |
+
min_messages: int = 10,
|
| 420 |
+
max_messages: int = 120,
|
| 421 |
+
min_tool_calls: int = 2,
|
| 422 |
+
limit: Optional[int] = None,
|
| 423 |
+
status_cb=None,
|
| 424 |
+
**_ignored,
|
| 425 |
+
) -> list[dict[str, Any]]:
|
| 426 |
+
"""Candidate traces, newest first (summaries only).
|
| 427 |
+
|
| 428 |
+
``max_messages`` is the same SOFT cap the other readers use: an
|
| 429 |
+
over-cap row is listed with ``oversize`` and bounded later by
|
| 430 |
+
``split_long``. ``min_messages`` / ``min_tool_calls`` stay hard.
|
| 431 |
+
"""
|
| 432 |
+
if status_cb:
|
| 433 |
+
status_cb(f"indexing {len(self._files())} nemotron file(s)")
|
| 434 |
+
rows: list[dict[str, Any]] = []
|
| 435 |
+
for sid, e in self._index().items():
|
| 436 |
+
if e["n_messages"] < min_messages:
|
| 437 |
+
bump(self.skips, "below_min_messages")
|
| 438 |
+
continue
|
| 439 |
+
if e["n_tool_calls"] < min_tool_calls:
|
| 440 |
+
bump(self.skips, "below_min_tool_calls")
|
| 441 |
+
continue
|
| 442 |
+
rows.append(
|
| 443 |
+
oversize_row(
|
| 444 |
+
{
|
| 445 |
+
"session_id": sid,
|
| 446 |
+
"model": None,
|
| 447 |
+
"title": e.get("title") or "",
|
| 448 |
+
"started_at": e.get("started_at"),
|
| 449 |
+
"ended_at": None,
|
| 450 |
+
"message_count": e["n_messages"],
|
| 451 |
+
"tool_call_count": e["n_tool_calls"],
|
| 452 |
+
"input_tokens": None,
|
| 453 |
+
"output_tokens": None,
|
| 454 |
+
"cwd": None,
|
| 455 |
+
"source": e.get("source") or "nemotron",
|
| 456 |
+
},
|
| 457 |
+
max_messages,
|
| 458 |
+
)
|
| 459 |
+
)
|
| 460 |
+
rows.sort(key=lambda r: (r["started_at"] or 0.0, r["session_id"]), reverse=True)
|
| 461 |
+
if limit is not None:
|
| 462 |
+
rows = rows[:limit]
|
| 463 |
+
return rows
|
| 464 |
+
|
| 465 |
+
# ------------------------------------------------------------- loading
|
| 466 |
+
|
| 467 |
+
def load_trace(self, session_id: str) -> Trace:
|
| 468 |
+
entry = self._index().get(session_id)
|
| 469 |
+
if entry is None:
|
| 470 |
+
raise KeyError(f"session not found: {session_id}")
|
| 471 |
+
with open(entry["path"], "rb") as f:
|
| 472 |
+
f.seek(entry["offset"])
|
| 473 |
+
raw = f.read(entry["length"])
|
| 474 |
+
return Trace.from_dict(json.loads(raw.decode("utf-8")))
|
| 475 |
+
|
| 476 |
+
# --------------------------------------------------------------- stats
|
| 477 |
+
|
| 478 |
+
def stats(self) -> dict[str, Any]:
|
| 479 |
+
files = self._files()
|
| 480 |
+
idx = self._index()
|
| 481 |
+
return {
|
| 482 |
+
"files": len(files),
|
| 483 |
+
"traces": len(idx),
|
| 484 |
+
"messages": sum(e["n_messages"] for e in idx.values()),
|
| 485 |
+
"tool_calls": sum(e["n_tool_calls"] for e in idx.values()),
|
| 486 |
+
"by_source": {
|
| 487 |
+
src: sum(1 for e in idx.values() if e.get("source") == src)
|
| 488 |
+
for src in sorted({e.get("source") or "?" for e in idx.values()})
|
| 489 |
+
},
|
| 490 |
+
"skips": dict(self.skips),
|
| 491 |
+
}
|
traceforge/space/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Portable traceforge: content-addressed seeds, a queue, and a Gradio UI.
|
| 2 |
+
|
| 3 |
+
The package is import-safe without Gradio — `app.py` imports it lazily, so
|
| 4 |
+
the CLI and the tests never pay for the UI dependency.
|
| 5 |
+
|
| 6 |
+
from traceforge.space import TraceStore, SpacePipeline, seed_hash
|
| 7 |
+
"""
|
| 8 |
+
from traceforge.space.hashing import (canonical_messages, fingerprint,
|
| 9 |
+
output_hash, seed_hash, short_hash,
|
| 10 |
+
tool_names)
|
| 11 |
+
from traceforge.space.ingest import parse_records, read_upload, trace_from_record
|
| 12 |
+
from traceforge.space.pipeline import (SpacePipeline, pipeline_ready,
|
| 13 |
+
space_config)
|
| 14 |
+
from traceforge.space.store import (DONE, FAILED, QUEUED, REJECTED, RUNNING,
|
| 15 |
+
TraceStore)
|
| 16 |
+
|
| 17 |
+
__all__ = [
|
| 18 |
+
"TraceStore", "SpacePipeline", "space_config", "pipeline_ready",
|
| 19 |
+
"seed_hash", "output_hash", "short_hash", "fingerprint",
|
| 20 |
+
"canonical_messages", "tool_names", "trace_from_record",
|
| 21 |
+
"parse_records", "read_upload",
|
| 22 |
+
"QUEUED", "RUNNING", "DONE", "FAILED", "REJECTED",
|
| 23 |
+
]
|
traceforge/space/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (1.17 kB). View file
|
|
|
traceforge/space/__pycache__/hashing.cpython-314.pyc
ADDED
|
Binary file (12.9 kB). View file
|
|
|
traceforge/space/__pycache__/ingest.cpython-314.pyc
ADDED
|
Binary file (12.1 kB). View file
|
|
|
traceforge/space/__pycache__/pipeline.cpython-314.pyc
ADDED
|
Binary file (15.2 kB). View file
|
|
|
traceforge/space/__pycache__/stats.cpython-314.pyc
ADDED
|
Binary file (9.1 kB). View file
|
|
|
traceforge/space/__pycache__/store.cpython-314.pyc
ADDED
|
Binary file (23.8 kB). View file
|
|
|
traceforge/space/app.py
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gradio app: submit a trace, sponsor a dataset, read the dashboard.
|
| 2 |
+
|
| 3 |
+
The UI is deliberately thin — every decision lives in `SpacePipeline` and
|
| 4 |
+
`TraceStore`, so the same code runs locally (`python app.py`) and on a
|
| 5 |
+
Hugging Face Space (`app.py` at the repo root).
|
| 6 |
+
|
| 7 |
+
Tabs
|
| 8 |
+
Submit one trace (JSON / JSONL / pasted) -> hash -> queue -> output
|
| 9 |
+
Sponsor a JSONL of traces -> hashed, deduped, queued, attributed
|
| 10 |
+
Dashboard public stats: corpus, time sensitivity, tasks, sponsors, queue
|
| 11 |
+
Browse the stored outputs, by hash
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import html
|
| 16 |
+
import json
|
| 17 |
+
import os
|
| 18 |
+
from typing import Any, Optional
|
| 19 |
+
|
| 20 |
+
from traceforge.space.hashing import short_hash
|
| 21 |
+
from traceforge.space.ingest import parse_records, read_upload
|
| 22 |
+
from traceforge.space.jobs import get_worker
|
| 23 |
+
from traceforge.space.pipeline import SpacePipeline, pipeline_ready, space_config
|
| 24 |
+
from traceforge.space.stats import bar_rows, dashboard, pct
|
| 25 |
+
from traceforge.space.store import DONE, FAILED, QUEUED, RUNNING, TraceStore
|
| 26 |
+
|
| 27 |
+
REFRESH_S = 8.0
|
| 28 |
+
|
| 29 |
+
CSS = """
|
| 30 |
+
.tf-wrap { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
| 31 |
+
.tf-kpis { display:grid; grid-template-columns:repeat(auto-fit,minmax(130px,1fr));
|
| 32 |
+
gap:8px; margin:6px 0 14px; }
|
| 33 |
+
.tf-kpi { background:#161b22; border:1px solid #2a3140; border-radius:8px;
|
| 34 |
+
padding:9px 11px; }
|
| 35 |
+
.tf-kpi .v { font-size:21px; font-weight:600; color:#e6edf3; line-height:1.2; }
|
| 36 |
+
.tf-kpi .k { font-size:11px; color:#8b949e; text-transform:uppercase;
|
| 37 |
+
letter-spacing:.04em; }
|
| 38 |
+
.tf-kpi .s { font-size:11px; color:#6e7681; }
|
| 39 |
+
.tf-ok { color:#3fb950; } .tf-warn { color:#d29922; } .tf-bad { color:#f85149; }
|
| 40 |
+
.tf-bar-row { display:grid; grid-template-columns:190px 1fr 64px; gap:8px;
|
| 41 |
+
align-items:center; font-size:12px; color:#9aa4b2; margin:3px 0; }
|
| 42 |
+
.tf-bar-outer { background:#1b2029; border-radius:6px; height:14px;
|
| 43 |
+
overflow:hidden; border:1px solid #2a3140; }
|
| 44 |
+
.tf-bar-inner { height:100%; background:linear-gradient(90deg,#2f81f7,#3fb950); }
|
| 45 |
+
.tf-head { font-size:13px; color:#8b949e; margin:2px 0 8px; }
|
| 46 |
+
.tf-pill { padding:2px 8px; border-radius:999px; font-size:11px;
|
| 47 |
+
border:1px solid #2a3140; }
|
| 48 |
+
"""
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# --------------------------------------------------------------------- html
|
| 52 |
+
|
| 53 |
+
def _esc(s: Any) -> str:
|
| 54 |
+
return html.escape(str(s if s is not None else ""))
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _kpi(value: Any, key: str, sub: str = "", cls: str = "") -> str:
|
| 58 |
+
sub_html = f'<div class="s">{_esc(sub)}</div>' if sub else ""
|
| 59 |
+
return (f'<div class="tf-kpi"><div class="v {cls}">{_esc(value)}</div>'
|
| 60 |
+
f'<div class="k">{_esc(key)}</div>{sub_html}</div>')
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _bars(counts: dict, total: Optional[int] = None) -> str:
|
| 64 |
+
rows = bar_rows(counts, total=total)
|
| 65 |
+
if not rows:
|
| 66 |
+
return '<div class="tf-head">no data yet</div>'
|
| 67 |
+
out = ['<div class="tf-wrap">']
|
| 68 |
+
for label, n, p in rows:
|
| 69 |
+
out.append(
|
| 70 |
+
'<div class="tf-bar-row">'
|
| 71 |
+
f'<span>{_esc(label)}</span>'
|
| 72 |
+
f'<span class="tf-bar-outer"><span class="tf-bar-inner" '
|
| 73 |
+
f'style="width:{max(0.0, min(100.0, p)):.1f}%"></span></span>'
|
| 74 |
+
f'<span>{n} · {p:.0f}%</span></div>'
|
| 75 |
+
)
|
| 76 |
+
out.append("</div>")
|
| 77 |
+
return "".join(out)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _status_pill(status: str) -> str:
|
| 81 |
+
cls = {DONE: "tf-ok", FAILED: "tf-bad", RUNNING: "tf-warn",
|
| 82 |
+
QUEUED: "tf-warn"}.get(status, "")
|
| 83 |
+
return f'<span class="tf-pill {cls}">{_esc(status)}</span>'
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _kpi_html(space: dict, ref: dict, worker: dict, ready: tuple[bool, str]) -> str:
|
| 87 |
+
ok, reason = ready
|
| 88 |
+
cards = [
|
| 89 |
+
_kpi(f"{space['seeds']:,}", "seeds", "distinct content hashes"),
|
| 90 |
+
_kpi(f"{space['done']:,}", "outputs",
|
| 91 |
+
f"{pct(space['done'], space['seeds']):.0f}% of seeds"),
|
| 92 |
+
_kpi(f"{space['queued']:,}", "queued",
|
| 93 |
+
f"{space['running']} running"),
|
| 94 |
+
_kpi(f"{space['failed']:,}", "failed", "rejected or errored"),
|
| 95 |
+
_kpi(f"{len(space['sponsors'])}", "sponsors",
|
| 96 |
+
f"{sum(s['seeds'] for s in space['sponsors'])} seeds"),
|
| 97 |
+
_kpi(f"{space['duration_s']['median']:.0f}s", "median runtime",
|
| 98 |
+
f"p90 {space['duration_s']['p90']:.0f}s"),
|
| 99 |
+
]
|
| 100 |
+
if ref.get("available"):
|
| 101 |
+
cards.append(_kpi(f"{ref.get('traces', 0):,}", "reference corpus",
|
| 102 |
+
"published ideal traces"))
|
| 103 |
+
head = (
|
| 104 |
+
'<div class="tf-wrap"><div class="tf-head">'
|
| 105 |
+
f'<span class="tf-pill {"tf-ok" if ok else "tf-bad"}">'
|
| 106 |
+
f'{"pipeline ready" if ok else "pipeline not configured"}</span> '
|
| 107 |
+
f'{_esc(reason)} · worker {"alive" if worker.get("alive") else "stopped"}'
|
| 108 |
+
f' · queue {worker.get("queued", 0)}'
|
| 109 |
+
"</div></div>"
|
| 110 |
+
)
|
| 111 |
+
return head + '<div class="tf-wrap"><div class="tf-kpis">' + "".join(cards) + "</div></div>"
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _ts_html(space: dict, ref: dict) -> str:
|
| 115 |
+
"""Time sensitivity: the metric that says whether an answer decays."""
|
| 116 |
+
parts = ['<div class="tf-wrap"><div class="tf-head">'
|
| 117 |
+
"time sensitivity — a temporal fact answered from weights decays; "
|
| 118 |
+
"the same fact fetched with a tool does not</div>"]
|
| 119 |
+
space_ts = space.get("time_sensitivity") or {}
|
| 120 |
+
if space_ts:
|
| 121 |
+
parts.append("<b>this Space</b>")
|
| 122 |
+
parts.append(_bars(space_ts))
|
| 123 |
+
census = (ref or {}).get("time_sensitivity") or {}
|
| 124 |
+
if census.get("available"):
|
| 125 |
+
parts.append(f"<b>reference corpus</b> ({census.get('total', 0):,} traces)")
|
| 126 |
+
parts.append(_bars(census.get("classes") or {}))
|
| 127 |
+
parts.append(
|
| 128 |
+
f'<div class="tf-head">needs rewrite (tool available but never '
|
| 129 |
+
f'called): {census.get("needs_rewrite", 0):,}</div>')
|
| 130 |
+
if not space_ts and not census.get("available"):
|
| 131 |
+
parts.append('<div class="tf-head">no data yet</div>')
|
| 132 |
+
parts.append("</div>")
|
| 133 |
+
return "".join(parts)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# ------------------------------------------------------------------- tables
|
| 137 |
+
|
| 138 |
+
def _rows(records: list[dict], cols: list[str]) -> list[list]:
|
| 139 |
+
out = []
|
| 140 |
+
for r in records:
|
| 141 |
+
row = []
|
| 142 |
+
for c in cols:
|
| 143 |
+
v = r.get(c)
|
| 144 |
+
if c == "hash":
|
| 145 |
+
v = short_hash(v or "")
|
| 146 |
+
elif c == "time_sensitivity":
|
| 147 |
+
v = (v or {}).get("class", "")
|
| 148 |
+
elif isinstance(v, float):
|
| 149 |
+
v = round(v, 1)
|
| 150 |
+
row.append(v if v is not None else "")
|
| 151 |
+
out.append(row)
|
| 152 |
+
return out
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
# ---------------------------------------------------------------------- app
|
| 156 |
+
|
| 157 |
+
def build_app(store: Optional[TraceStore] = None,
|
| 158 |
+
pipeline: Optional[SpacePipeline] = None):
|
| 159 |
+
import gradio as gr
|
| 160 |
+
|
| 161 |
+
cfg = pipeline.cfg if pipeline else space_config()
|
| 162 |
+
store = store or (pipeline.store if pipeline else TraceStore(
|
| 163 |
+
os.path.join(cfg.data_dir)))
|
| 164 |
+
pipeline = pipeline or SpacePipeline(store, cfg)
|
| 165 |
+
worker = get_worker(pipeline)
|
| 166 |
+
ready = pipeline_ready(cfg)
|
| 167 |
+
dataset_dir = os.path.join(os.path.dirname(cfg.data_dir), "dataset")
|
| 168 |
+
cache_path = os.path.join(store.root, "reference_census.json")
|
| 169 |
+
|
| 170 |
+
# ------------------------------------------------------------- submit
|
| 171 |
+
|
| 172 |
+
def on_submit(file_path, pasted, sponsor, sync):
|
| 173 |
+
if not ready:
|
| 174 |
+
return ("**pipeline not configured** — this Space has no LLM endpoint, "
|
| 175 |
+
"so nothing was queued. Set `TRACEFORGE_BASE_URL` and "
|
| 176 |
+
"`TRACEFORGE_LLM_KEY` in the Space settings and restart.", None, None)
|
| 177 |
+
if file_path:
|
| 178 |
+
records, errors = read_upload(file_path)
|
| 179 |
+
else:
|
| 180 |
+
records, errors = parse_records(pasted or "")
|
| 181 |
+
if errors:
|
| 182 |
+
return (f"**parse errors**\n\n" + "\n".join(f"- {e}" for e in errors[:10]),
|
| 183 |
+
None, None)
|
| 184 |
+
if not records:
|
| 185 |
+
return "**nothing to submit** — upload a file or paste JSON", None, None
|
| 186 |
+
if len(records) > 1:
|
| 187 |
+
return (f"**{len(records)} records** — the Submit tab takes one trace. "
|
| 188 |
+
"Use **Sponsor a dataset** for a batch.", None, None)
|
| 189 |
+
try:
|
| 190 |
+
res = pipeline.submit(records[0], sponsor=sponsor)
|
| 191 |
+
except Exception as e:
|
| 192 |
+
return f"**submit failed**: {e}", None, None
|
| 193 |
+
h = res["hash"]
|
| 194 |
+
if res.get("duplicate") and res.get("status") == DONE:
|
| 195 |
+
path = store.output_path(h)
|
| 196 |
+
return (f"**already processed** — this seed hash already has an output.\n\n"
|
| 197 |
+
f"`{h}`\n\nNo second output was created.", h,
|
| 198 |
+
path if os.path.exists(path) else None)
|
| 199 |
+
if sync:
|
| 200 |
+
res = pipeline.process(h)
|
| 201 |
+
rec = pipeline.result(h)
|
| 202 |
+
md = _result_md(rec)
|
| 203 |
+
path = store.output_path(h) if rec.get("status") == DONE else None
|
| 204 |
+
return md, h, path if path and os.path.exists(path) else None
|
| 205 |
+
|
| 206 |
+
def on_check(h):
|
| 207 |
+
if not h:
|
| 208 |
+
return "no hash yet — submit a trace first", None
|
| 209 |
+
rec = pipeline.result(h)
|
| 210 |
+
path = store.output_path(h) if rec.get("status") == DONE else None
|
| 211 |
+
return _result_md(rec), path if path and os.path.exists(path) else None
|
| 212 |
+
|
| 213 |
+
def _result_md(rec: dict) -> str:
|
| 214 |
+
h = rec.get("hash") or ""
|
| 215 |
+
st = rec.get("status") or "unknown"
|
| 216 |
+
lines = [f"### {_status_pill(st)} `{h}`", ""]
|
| 217 |
+
if rec.get("duplicate"):
|
| 218 |
+
lines.append("_duplicate submission — the existing record was returned_")
|
| 219 |
+
lines.append("")
|
| 220 |
+
if rec.get("task"):
|
| 221 |
+
lines.append(f"- task: **{rec['task']}** · language: **{rec.get('language', '?')}**")
|
| 222 |
+
if rec.get("n_messages"):
|
| 223 |
+
lines.append(f"- seed: {rec['n_messages']} messages · "
|
| 224 |
+
f"{rec.get('n_tool_calls', 0)} tool calls")
|
| 225 |
+
if rec.get("n_messages_out"):
|
| 226 |
+
lines.append(f"- output: {rec['n_messages_out']} messages")
|
| 227 |
+
if rec.get("route"):
|
| 228 |
+
lines.append(f"- route: `{rec['route']}` · attempts: {rec.get('attempts', 0)}")
|
| 229 |
+
if rec.get("duration_s"):
|
| 230 |
+
lines.append(f"- runtime: {rec['duration_s']:.0f}s")
|
| 231 |
+
ts = rec.get("time_sensitivity") or {}
|
| 232 |
+
if ts.get("class"):
|
| 233 |
+
lines.append(f"- time sensitivity: **{ts['class']}** — {ts.get('label', '')}")
|
| 234 |
+
if ts.get("temporal_facts"):
|
| 235 |
+
lines.append(f" - temporal facts: {', '.join(ts['temporal_facts'])}")
|
| 236 |
+
if ts.get("retrieval_available"):
|
| 237 |
+
lines.append(f" - retrieval available: {', '.join(ts['retrieval_available'])}")
|
| 238 |
+
if ts.get("needs_rewrite"):
|
| 239 |
+
lines.append(" - the raw trace answered from weights although a "
|
| 240 |
+
"retrieval tool was available")
|
| 241 |
+
ts2 = rec.get("time_sensitivity_improved") or {}
|
| 242 |
+
if ts2.get("class") and ts2.get("class") != ts.get("class"):
|
| 243 |
+
lines.append(f"- after rewrite: **{ts2['class']}**")
|
| 244 |
+
if rec.get("error"):
|
| 245 |
+
lines.append(f"- error: `{rec['error'][:600]}`")
|
| 246 |
+
if st == QUEUED:
|
| 247 |
+
lines.append("")
|
| 248 |
+
lines.append("_queued — the worker picks it up in submission order. "
|
| 249 |
+
"Press **Check status** to refresh._")
|
| 250 |
+
return "\n".join(lines)
|
| 251 |
+
|
| 252 |
+
# ------------------------------------------------------------ sponsor
|
| 253 |
+
|
| 254 |
+
def on_sponsor(file_path, pasted, sponsor):
|
| 255 |
+
if not ready:
|
| 256 |
+
return ("**pipeline not configured** — this Space has no LLM endpoint, "
|
| 257 |
+
"so nothing was queued. Set `TRACEFORGE_BASE_URL` and "
|
| 258 |
+
"`TRACEFORGE_LLM_KEY` in the Space settings and restart.", [], "")
|
| 259 |
+
if file_path:
|
| 260 |
+
records, errors = read_upload(file_path)
|
| 261 |
+
else:
|
| 262 |
+
records, errors = parse_records(pasted or "")
|
| 263 |
+
if errors:
|
| 264 |
+
return (f"**parse errors**\n\n" + "\n".join(f"- {e}" for e in errors[:10]),
|
| 265 |
+
[], "")
|
| 266 |
+
if not records:
|
| 267 |
+
return "**nothing to sponsor** — upload a JSONL of traces", [], ""
|
| 268 |
+
results = pipeline.submit_many(records, sponsor=sponsor)
|
| 269 |
+
new = sum(1 for r in results if not r.get("duplicate") and r.get("hash"))
|
| 270 |
+
dup = sum(1 for r in results if r.get("duplicate"))
|
| 271 |
+
err = sum(1 for r in results if not r.get("hash"))
|
| 272 |
+
rows = []
|
| 273 |
+
for r in results:
|
| 274 |
+
rows.append([
|
| 275 |
+
short_hash(r.get("hash") or ""),
|
| 276 |
+
r.get("status") or "error",
|
| 277 |
+
"duplicate" if r.get("duplicate") else ("new" if r.get("hash") else "error"),
|
| 278 |
+
r.get("n_messages", ""),
|
| 279 |
+
r.get("task", "")[:60] if r.get("task") else "",
|
| 280 |
+
(r.get("time_sensitivity") or {}).get("class", ""),
|
| 281 |
+
(r.get("error") or "")[:120],
|
| 282 |
+
])
|
| 283 |
+
md = (f"**{len(records)} records** — {new} new, {dup} already known, "
|
| 284 |
+
f"{err} rejected. Sponsor: **{_esc(sponsor or 'anonymous')}**.\n\n"
|
| 285 |
+
"New seeds are queued; each one produces at most one output.")
|
| 286 |
+
return md, rows, sponsor or "anonymous"
|
| 287 |
+
|
| 288 |
+
# ---------------------------------------------------------- dashboard
|
| 289 |
+
|
| 290 |
+
def on_dashboard():
|
| 291 |
+
d = dashboard(store, dataset_dir=dataset_dir, cache_path=cache_path)
|
| 292 |
+
space, ref = d["space"], d["reference"]
|
| 293 |
+
w = worker.status()
|
| 294 |
+
kpis = _kpi_html(space, ref, w, ready)
|
| 295 |
+
ts = _ts_html(space, ref)
|
| 296 |
+
tasks = bar_rows(space.get("tasks") or {})
|
| 297 |
+
langs = bar_rows(space.get("languages") or {})
|
| 298 |
+
sponsors = [[s["sponsor"], s["seeds"], s["done"], s["failed"]]
|
| 299 |
+
for s in space.get("sponsors") or []]
|
| 300 |
+
recent = _rows(d["recent"], ["hash", "status", "sponsor", "task",
|
| 301 |
+
"time_sensitivity", "duration_s"])
|
| 302 |
+
queue = _rows(d["queue"], ["hash", "sponsor", "task", "n_messages"])
|
| 303 |
+
ref_rows = []
|
| 304 |
+
if ref.get("available"):
|
| 305 |
+
card = ref.get("card") or {}
|
| 306 |
+
for k, v in (card.get("tasks") or {}).items():
|
| 307 |
+
ref_rows.append([k, v, pct(v, card.get("traces", 0))])
|
| 308 |
+
return kpis, ts, tasks, langs, sponsors, recent, queue, ref_rows
|
| 309 |
+
|
| 310 |
+
# ------------------------------------------------------------- browse
|
| 311 |
+
|
| 312 |
+
def browse_choices():
|
| 313 |
+
done = [r for r in store.all() if r.get("status") == DONE]
|
| 314 |
+
done.sort(key=lambda r: r.get("updated_at") or 0, reverse=True)
|
| 315 |
+
return gr.update(choices=[r["hash"] for r in done][:500])
|
| 316 |
+
|
| 317 |
+
def on_browse(h):
|
| 318 |
+
if not h:
|
| 319 |
+
return None, None
|
| 320 |
+
out = store.load_output(h)
|
| 321 |
+
if out is None:
|
| 322 |
+
return None, None
|
| 323 |
+
return out.to_dict(), store.output_path(h)
|
| 324 |
+
|
| 325 |
+
# ---------------------------------------------------------------- UI
|
| 326 |
+
|
| 327 |
+
with gr.Blocks(title="traceforge — ideal-world agent traces") as app:
|
| 328 |
+
gr.Markdown(
|
| 329 |
+
"# traceforge\n"
|
| 330 |
+
"Turn a real agent trace into its **ideal-world reference**: the same "
|
| 331 |
+
"work, with the dead ends removed, gated for structure and grounding.\n\n"
|
| 332 |
+
"Every seed is identified by a **content hash** — submitting the same "
|
| 333 |
+
"trace twice returns the existing output instead of creating a second one."
|
| 334 |
+
)
|
| 335 |
+
|
| 336 |
+
with gr.Tab("Submit a trace"):
|
| 337 |
+
with gr.Row():
|
| 338 |
+
with gr.Column(scale=3):
|
| 339 |
+
file_in = gr.File(label="trace file (.json / .jsonl)",
|
| 340 |
+
file_types=[".json", ".jsonl"],
|
| 341 |
+
type="filepath")
|
| 342 |
+
paste_in = gr.Textbox(
|
| 343 |
+
label="…or paste a trace (JSON, JSONL, or a message list)",
|
| 344 |
+
lines=10, placeholder='{"messages": [{"role": "user", ...}]}')
|
| 345 |
+
with gr.Column(scale=2):
|
| 346 |
+
sponsor_in = gr.Textbox(label="your name / handle (optional)",
|
| 347 |
+
max_lines=1)
|
| 348 |
+
sync_in = gr.Checkbox(
|
| 349 |
+
label="process synchronously (debug — blocks until done)",
|
| 350 |
+
value=os.environ.get("TRACEFORGE_SPACE_SYNC") == "1")
|
| 351 |
+
btn_submit = gr.Button("Submit", variant="primary")
|
| 352 |
+
btn_check = gr.Button("Check status")
|
| 353 |
+
last_hash = gr.State("")
|
| 354 |
+
status_md = gr.Markdown("_no submission yet_")
|
| 355 |
+
out_file = gr.File(label="improved trace (download)", interactive=False)
|
| 356 |
+
|
| 357 |
+
btn_submit.click(on_submit,
|
| 358 |
+
inputs=[file_in, paste_in, sponsor_in, sync_in],
|
| 359 |
+
outputs=[status_md, last_hash, out_file])
|
| 360 |
+
btn_check.click(on_check, inputs=[last_hash],
|
| 361 |
+
outputs=[status_md, out_file])
|
| 362 |
+
|
| 363 |
+
with gr.Tab("Sponsor a dataset"):
|
| 364 |
+
gr.Markdown(
|
| 365 |
+
"Upload a JSONL of traces. Each row is hashed; **new** seeds are "
|
| 366 |
+
"queued for the pipeline and attributed to you, **known** seeds are "
|
| 367 |
+
"skipped. The processed rows join the public output dataset."
|
| 368 |
+
)
|
| 369 |
+
with gr.Row():
|
| 370 |
+
with gr.Column(scale=3):
|
| 371 |
+
sp_file = gr.File(label="dataset (.jsonl)", file_types=[".jsonl"],
|
| 372 |
+
type="filepath")
|
| 373 |
+
sp_paste = gr.Textbox(label="…or paste JSONL", lines=8)
|
| 374 |
+
with gr.Column(scale=2):
|
| 375 |
+
sp_name = gr.Textbox(label="sponsor name", max_lines=1)
|
| 376 |
+
sp_btn = gr.Button("Sponsor", variant="primary")
|
| 377 |
+
sp_md = gr.Markdown("_no dataset sponsored yet_")
|
| 378 |
+
sp_table = gr.Dataframe(
|
| 379 |
+
headers=["hash", "status", "new?", "messages", "task",
|
| 380 |
+
"time sensitivity", "error"],
|
| 381 |
+
interactive=False, wrap=True, max_height=420,
|
| 382 |
+
)
|
| 383 |
+
sp_credit = gr.State("")
|
| 384 |
+
sp_btn.click(on_sponsor, inputs=[sp_file, sp_paste, sp_name],
|
| 385 |
+
outputs=[sp_md, sp_table, sp_credit])
|
| 386 |
+
|
| 387 |
+
with gr.Tab("Dashboard"):
|
| 388 |
+
gr.Markdown("Public statistics. Refreshes automatically.")
|
| 389 |
+
dash_kpis = gr.HTML()
|
| 390 |
+
dash_ts = gr.HTML()
|
| 391 |
+
with gr.Row():
|
| 392 |
+
with gr.Column():
|
| 393 |
+
gr.Markdown("### task mix (this Space)")
|
| 394 |
+
dash_tasks = gr.Dataframe(
|
| 395 |
+
headers=["task", "seeds", "pct"], interactive=False,
|
| 396 |
+
wrap=True, max_height=300)
|
| 397 |
+
with gr.Column():
|
| 398 |
+
gr.Markdown("### languages")
|
| 399 |
+
dash_langs = gr.Dataframe(
|
| 400 |
+
headers=["language", "seeds", "pct"], interactive=False,
|
| 401 |
+
wrap=True, max_height=300)
|
| 402 |
+
with gr.Row():
|
| 403 |
+
with gr.Column():
|
| 404 |
+
gr.Markdown("### sponsors")
|
| 405 |
+
dash_sponsors = gr.Dataframe(
|
| 406 |
+
headers=["sponsor", "seeds", "done", "failed"],
|
| 407 |
+
interactive=False, wrap=True, max_height=300)
|
| 408 |
+
with gr.Column():
|
| 409 |
+
gr.Markdown("### reference corpus — task mix")
|
| 410 |
+
dash_ref = gr.Dataframe(
|
| 411 |
+
headers=["task", "traces", "pct"], interactive=False,
|
| 412 |
+
wrap=True, max_height=300)
|
| 413 |
+
gr.Markdown("### recent outputs")
|
| 414 |
+
dash_recent = gr.Dataframe(
|
| 415 |
+
headers=["hash", "status", "sponsor", "task",
|
| 416 |
+
"time sensitivity", "runtime s"],
|
| 417 |
+
interactive=False, wrap=True, max_height=320)
|
| 418 |
+
gr.Markdown("### queue")
|
| 419 |
+
dash_queue = gr.Dataframe(
|
| 420 |
+
headers=["hash", "sponsor", "task", "messages"],
|
| 421 |
+
interactive=False, wrap=True, max_height=240)
|
| 422 |
+
|
| 423 |
+
dash_outputs = [dash_kpis, dash_ts, dash_tasks, dash_langs,
|
| 424 |
+
dash_sponsors, dash_recent, dash_queue, dash_ref]
|
| 425 |
+
timer = gr.Timer(REFRESH_S)
|
| 426 |
+
timer.tick(on_dashboard, outputs=dash_outputs)
|
| 427 |
+
app.load(on_dashboard, outputs=dash_outputs)
|
| 428 |
+
|
| 429 |
+
with gr.Tab("Browse outputs"):
|
| 430 |
+
with gr.Row():
|
| 431 |
+
pick = gr.Dropdown(label="processed seed (by hash)", choices=[],
|
| 432 |
+
scale=4)
|
| 433 |
+
btn_reload = gr.Button("Reload list", scale=1)
|
| 434 |
+
preview = gr.JSON(label="improved trace", max_height=520)
|
| 435 |
+
dl = gr.File(label="download", interactive=False)
|
| 436 |
+
btn_reload.click(browse_choices, outputs=[pick])
|
| 437 |
+
pick.change(on_browse, inputs=[pick], outputs=[preview, dl])
|
| 438 |
+
app.load(browse_choices, outputs=[pick])
|
| 439 |
+
|
| 440 |
+
return app
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
def main(argv: Optional[list[str]] = None) -> int:
|
| 444 |
+
import argparse
|
| 445 |
+
|
| 446 |
+
import gradio as gr
|
| 447 |
+
|
| 448 |
+
ap = argparse.ArgumentParser(description="traceforge Gradio app")
|
| 449 |
+
ap.add_argument("--host", default=os.environ.get("GRADIO_SERVER_NAME",
|
| 450 |
+
"127.0.0.1"))
|
| 451 |
+
ap.add_argument("--port", type=int,
|
| 452 |
+
default=int(os.environ.get("GRADIO_SERVER_PORT", "7860")))
|
| 453 |
+
ap.add_argument("--share", action="store_true")
|
| 454 |
+
args = ap.parse_args(argv)
|
| 455 |
+
|
| 456 |
+
app = build_app()
|
| 457 |
+
app.queue(default_concurrency_limit=4)
|
| 458 |
+
app.launch(server_name=args.host, server_port=args.port,
|
| 459 |
+
share=args.share, inbrowser=False, show_error=True,
|
| 460 |
+
css=CSS, theme=gr.themes.Base())
|
| 461 |
+
return 0
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
if __name__ == "__main__":
|
| 465 |
+
raise SystemExit(main())
|
traceforge/space/hashing.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Content-addressed identity for a seed trace.
|
| 2 |
+
|
| 3 |
+
The Space must never turn one seed trace into two outputs. The key is a
|
| 4 |
+
hash over the *conversation content*, not over the harness metadata:
|
| 5 |
+
|
| 6 |
+
* session id, model name, cwd, timestamps and tool-call ids are excluded —
|
| 7 |
+
the same conversation exported twice by two harnesses is one seed;
|
| 8 |
+
* message text is normalised (line endings, trailing spaces, blank runs)
|
| 9 |
+
so a re-indent or a CRLF conversion does not mint a new seed;
|
| 10 |
+
* tool-call arguments are canonicalised as JSON (sorted keys, no spaces)
|
| 11 |
+
so `{"a": 1, "b": 2}` and `{"b":2,"a":1}` are the same call;
|
| 12 |
+
* the AVAILABLE tool names are included. A trace where the agent had
|
| 13 |
+
`web_search` and one where it did not are different seeds: the first is
|
| 14 |
+
time-anchored, the second is time-sensitive, and they must not collapse
|
| 15 |
+
into one output.
|
| 16 |
+
|
| 17 |
+
from traceforge.space.hashing import seed_hash, fingerprint
|
| 18 |
+
seed_hash(trace) # 64-char sha256
|
| 19 |
+
fingerprint(trace) # hash + the shape the dashboard reports
|
| 20 |
+
"""
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import hashlib
|
| 24 |
+
import json
|
| 25 |
+
import re
|
| 26 |
+
from typing import Any, Optional
|
| 27 |
+
|
| 28 |
+
# Roles the canonical form keeps. Anything else is harness noise.
|
| 29 |
+
_KEEP_ROLES = ("system", "user", "assistant", "tool")
|
| 30 |
+
|
| 31 |
+
_BLANK_RUN_RE = re.compile(r"\n{3,}")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# ------------------------------------------------------------------ helpers
|
| 35 |
+
|
| 36 |
+
def _messages(obj: Any) -> list[dict]:
|
| 37 |
+
"""Accept a Trace, an exported record, or a bare message list."""
|
| 38 |
+
if isinstance(obj, list):
|
| 39 |
+
return [m for m in obj if isinstance(m, dict)]
|
| 40 |
+
if hasattr(obj, "messages"):
|
| 41 |
+
return [m.to_dict() if hasattr(m, "to_dict") else m for m in obj.messages]
|
| 42 |
+
if isinstance(obj, dict):
|
| 43 |
+
msgs = obj.get("messages") or []
|
| 44 |
+
return [m for m in msgs if isinstance(m, dict)]
|
| 45 |
+
return []
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _norm_text(text: Any) -> str:
|
| 49 |
+
"""Whitespace-normalise message text without changing its words."""
|
| 50 |
+
if text is None:
|
| 51 |
+
return ""
|
| 52 |
+
if not isinstance(text, str):
|
| 53 |
+
# OpenAI content parts: [{"type": "text", "text": "..."}]
|
| 54 |
+
if isinstance(text, list):
|
| 55 |
+
parts = []
|
| 56 |
+
for p in text:
|
| 57 |
+
if isinstance(p, dict):
|
| 58 |
+
parts.append(str(p.get("text") or p.get("content") or ""))
|
| 59 |
+
else:
|
| 60 |
+
parts.append(str(p))
|
| 61 |
+
text = "\n".join(parts)
|
| 62 |
+
else:
|
| 63 |
+
text = str(text)
|
| 64 |
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
| 65 |
+
text = "\n".join(line.rstrip() for line in text.split("\n"))
|
| 66 |
+
text = _BLANK_RUN_RE.sub("\n\n", text)
|
| 67 |
+
return text.strip()
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _canon_arguments(raw: Any) -> str:
|
| 71 |
+
"""Canonical JSON for a tool call's arguments.
|
| 72 |
+
|
| 73 |
+
Accepts the OpenAI string form, a mapping, or a malformed blob. A blob
|
| 74 |
+
that does not parse is normalised as text — it is still part of the
|
| 75 |
+
seed's identity, just not comparable field-by-field.
|
| 76 |
+
"""
|
| 77 |
+
if isinstance(raw, (dict, list)):
|
| 78 |
+
return json.dumps(raw, sort_keys=True, separators=(",", ":"),
|
| 79 |
+
ensure_ascii=False)
|
| 80 |
+
s = (raw or "").strip()
|
| 81 |
+
if not s:
|
| 82 |
+
return "{}"
|
| 83 |
+
try:
|
| 84 |
+
return json.dumps(json.loads(s), sort_keys=True,
|
| 85 |
+
separators=(",", ":"), ensure_ascii=False)
|
| 86 |
+
except (ValueError, TypeError):
|
| 87 |
+
return _norm_text(s)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _tool_call(tc: Any) -> Optional[dict]:
|
| 91 |
+
"""Normalise one tool call from either schema.
|
| 92 |
+
|
| 93 |
+
Flat (traceforge): {id, name, arguments}
|
| 94 |
+
OpenAI: {id, type: "function", function: {name, arguments}}
|
| 95 |
+
"""
|
| 96 |
+
if not isinstance(tc, dict):
|
| 97 |
+
return None
|
| 98 |
+
fn_raw = tc.get("function")
|
| 99 |
+
fn: dict = fn_raw if isinstance(fn_raw, dict) else {}
|
| 100 |
+
name = (fn.get("name") or tc.get("name") or "").strip()
|
| 101 |
+
if not name:
|
| 102 |
+
return None
|
| 103 |
+
args = fn.get("arguments") if fn else tc.get("arguments")
|
| 104 |
+
return {"name": name, "arguments": _canon_arguments(args)}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def tool_names(obj: Any) -> list[str]:
|
| 108 |
+
"""Names of the tools the harness made available, sorted.
|
| 109 |
+
|
| 110 |
+
Only a record with a `tools` array can answer this; a bare Trace cannot
|
| 111 |
+
(the model was never told which tools existed). An empty list is the
|
| 112 |
+
honest answer, not a claim that no tools were available.
|
| 113 |
+
"""
|
| 114 |
+
if not isinstance(obj, dict):
|
| 115 |
+
return []
|
| 116 |
+
names: set[str] = set()
|
| 117 |
+
for t in obj.get("tools") or []:
|
| 118 |
+
if not isinstance(t, dict):
|
| 119 |
+
continue
|
| 120 |
+
fn = t.get("function") if isinstance(t.get("function"), dict) else {}
|
| 121 |
+
name = (fn.get("name") or t.get("name") or "").strip()
|
| 122 |
+
if name:
|
| 123 |
+
names.add(name)
|
| 124 |
+
return sorted(names)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
# --------------------------------------------------------------- canonical
|
| 128 |
+
|
| 129 |
+
def canonical_messages(obj: Any) -> list[dict]:
|
| 130 |
+
"""The conversation as a stable, metadata-free structure."""
|
| 131 |
+
out: list[dict] = []
|
| 132 |
+
for m in _messages(obj):
|
| 133 |
+
role = str(m.get("role") or "").strip().lower()
|
| 134 |
+
if role not in _KEEP_ROLES:
|
| 135 |
+
continue
|
| 136 |
+
rec: dict[str, Any] = {"role": role}
|
| 137 |
+
content = _norm_text(m.get("content"))
|
| 138 |
+
if content:
|
| 139 |
+
rec["content"] = content
|
| 140 |
+
calls = [c for c in (_tool_call(tc) for tc in (m.get("tool_calls") or []))
|
| 141 |
+
if c]
|
| 142 |
+
if calls:
|
| 143 |
+
rec["tool_calls"] = calls
|
| 144 |
+
if role == "tool":
|
| 145 |
+
name = (m.get("tool_name") or m.get("name") or "").strip()
|
| 146 |
+
if name:
|
| 147 |
+
rec["tool_name"] = name
|
| 148 |
+
# A row with no content and no calls carries nothing.
|
| 149 |
+
if len(rec) == 1:
|
| 150 |
+
continue
|
| 151 |
+
out.append(rec)
|
| 152 |
+
return out
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def canonical_payload(obj: Any) -> dict:
|
| 156 |
+
return {
|
| 157 |
+
"messages": canonical_messages(obj),
|
| 158 |
+
"tools": tool_names(obj),
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def seed_hash(obj: Any) -> str:
|
| 163 |
+
"""Stable sha256 of a seed trace's content. Same content -> same hash."""
|
| 164 |
+
payload = json.dumps(canonical_payload(obj), sort_keys=True,
|
| 165 |
+
separators=(",", ":"), ensure_ascii=False)
|
| 166 |
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def output_hash(obj: Any) -> str:
|
| 170 |
+
"""Hash of an improved trace, for integrity checks on the stored output."""
|
| 171 |
+
return seed_hash(obj)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def short_hash(h: str, n: int = 12) -> str:
|
| 175 |
+
return (h or "")[:n]
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
# ------------------------------------------------------------- fingerprint
|
| 179 |
+
|
| 180 |
+
def fingerprint(obj: Any) -> dict:
|
| 181 |
+
"""Hash plus the shape the dashboard and the index row report."""
|
| 182 |
+
msgs = _messages(obj)
|
| 183 |
+
n_calls = 0
|
| 184 |
+
n_users = 0
|
| 185 |
+
chars = 0
|
| 186 |
+
for m in msgs:
|
| 187 |
+
chars += len(_norm_text(m.get("content")))
|
| 188 |
+
if m.get("role") == "user":
|
| 189 |
+
n_users += 1
|
| 190 |
+
for tc in (m.get("tool_calls") or []):
|
| 191 |
+
n_calls += 1
|
| 192 |
+
fn = tc.get("function") if isinstance(tc.get("function"), dict) else {}
|
| 193 |
+
chars += len(str(fn.get("arguments") or tc.get("arguments") or ""))
|
| 194 |
+
task = ""
|
| 195 |
+
for m in msgs:
|
| 196 |
+
if m.get("role") == "user":
|
| 197 |
+
task = _norm_text(m.get("content"))
|
| 198 |
+
break
|
| 199 |
+
lang = "en"
|
| 200 |
+
try:
|
| 201 |
+
from traceforge.classify import detect_language
|
| 202 |
+
lang = detect_language(task)
|
| 203 |
+
except Exception:
|
| 204 |
+
pass
|
| 205 |
+
return {
|
| 206 |
+
"hash": seed_hash(obj),
|
| 207 |
+
"n_messages": len(msgs),
|
| 208 |
+
"n_tool_calls": n_calls,
|
| 209 |
+
"n_user_turns": n_users,
|
| 210 |
+
"chars": chars,
|
| 211 |
+
"task": task[:400],
|
| 212 |
+
"language": lang,
|
| 213 |
+
"tools": tool_names(obj),
|
| 214 |
+
}
|
traceforge/space/ingest.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Turn whatever a user uploads into a `Trace`.
|
| 2 |
+
|
| 3 |
+
Three shapes arrive in practice:
|
| 4 |
+
|
| 5 |
+
1. traceforge native — `{"session_id", "messages": [...], "source", ...}`
|
| 6 |
+
2. OpenAI chat JSONL — `{"messages": [...], "tools": [...]}`
|
| 7 |
+
3. a bare message list — `[{...}, {...}]`
|
| 8 |
+
|
| 9 |
+
The normaliser accepts all three and both tool-call schemas (flat
|
| 10 |
+
`{id,name,arguments}` and OpenAI `{type:"function",function:{...}}`), so a
|
| 11 |
+
user does not have to convert their export before submitting it.
|
| 12 |
+
|
| 13 |
+
`parse_records` is the batch entry point: it reads a JSON file, a JSONL file
|
| 14 |
+
or a pasted blob and yields `(record, trace)` pairs. A malformed row is
|
| 15 |
+
reported, never silently dropped.
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import json
|
| 20 |
+
import os
|
| 21 |
+
from typing import Any, Iterable, Iterator, Optional
|
| 22 |
+
|
| 23 |
+
from traceforge.trace import Message, ToolCall, Trace
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# ------------------------------------------------------------------ helpers
|
| 27 |
+
|
| 28 |
+
def _content_text(content: Any) -> Optional[str]:
|
| 29 |
+
if content is None:
|
| 30 |
+
return None
|
| 31 |
+
if isinstance(content, str):
|
| 32 |
+
return content
|
| 33 |
+
if isinstance(content, list):
|
| 34 |
+
parts: list[str] = []
|
| 35 |
+
for p in content:
|
| 36 |
+
if isinstance(p, dict):
|
| 37 |
+
if p.get("type") in (None, "text", "input_text", "output_text"):
|
| 38 |
+
parts.append(str(p.get("text") or ""))
|
| 39 |
+
elif p.get("type") == "image_url":
|
| 40 |
+
parts.append("[image]")
|
| 41 |
+
else:
|
| 42 |
+
parts.append(str(p))
|
| 43 |
+
return "\n".join(x for x in parts if x)
|
| 44 |
+
return str(content)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _tool_call(tc: Any, idx: int) -> Optional[ToolCall]:
|
| 48 |
+
if not isinstance(tc, dict):
|
| 49 |
+
return None
|
| 50 |
+
fn_raw = tc.get("function")
|
| 51 |
+
fn: dict = fn_raw if isinstance(fn_raw, dict) else {}
|
| 52 |
+
name = (fn.get("name") or tc.get("name") or "").strip()
|
| 53 |
+
if not name:
|
| 54 |
+
return None
|
| 55 |
+
args = fn.get("arguments") if fn else tc.get("arguments")
|
| 56 |
+
if isinstance(args, (dict, list)):
|
| 57 |
+
args = json.dumps(args, ensure_ascii=False)
|
| 58 |
+
return ToolCall(
|
| 59 |
+
id=str(tc.get("id") or f"call_{idx}"),
|
| 60 |
+
name=name,
|
| 61 |
+
arguments=args if isinstance(args, str) and args.strip() else "{}",
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def message_from_dict(d: dict, idx: int = 0) -> Optional[Message]:
|
| 66 |
+
if not isinstance(d, dict):
|
| 67 |
+
return None
|
| 68 |
+
role = str(d.get("role") or "").strip().lower()
|
| 69 |
+
if role not in ("system", "user", "assistant", "tool"):
|
| 70 |
+
return None
|
| 71 |
+
calls = [c for c in (_tool_call(tc, i)
|
| 72 |
+
for i, tc in enumerate(d.get("tool_calls") or [])) if c]
|
| 73 |
+
return Message(
|
| 74 |
+
role=role,
|
| 75 |
+
content=_content_text(d.get("content")),
|
| 76 |
+
reasoning=d.get("reasoning") if isinstance(d.get("reasoning"), str) else None,
|
| 77 |
+
tool_calls=calls,
|
| 78 |
+
tool_call_id=(str(d["tool_call_id"]) if d.get("tool_call_id") is not None
|
| 79 |
+
else None),
|
| 80 |
+
tool_name=(str(d.get("tool_name") or d.get("name") or "") or None),
|
| 81 |
+
timestamp=(float(d["timestamp"]) if isinstance(d.get("timestamp"), (int, float))
|
| 82 |
+
else None),
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def trace_from_record(rec: Any, *, fallback_id: str = "upload",
|
| 87 |
+
source: str = "space-upload") -> Trace:
|
| 88 |
+
"""Build a Trace from a native record, an OpenAI row, or a message list."""
|
| 89 |
+
if isinstance(rec, list):
|
| 90 |
+
meta: dict = {}
|
| 91 |
+
msgs_raw = rec
|
| 92 |
+
elif isinstance(rec, dict):
|
| 93 |
+
meta = rec
|
| 94 |
+
msgs_raw = rec.get("messages") or []
|
| 95 |
+
else:
|
| 96 |
+
raise ValueError(f"unsupported record type: {type(rec).__name__}")
|
| 97 |
+
|
| 98 |
+
msgs = [m for m in (message_from_dict(d, i)
|
| 99 |
+
for i, d in enumerate(msgs_raw)) if m]
|
| 100 |
+
if not msgs:
|
| 101 |
+
raise ValueError("record has no usable messages")
|
| 102 |
+
|
| 103 |
+
sid = str(meta.get("session_id") or meta.get("id") or fallback_id)
|
| 104 |
+
return Trace(
|
| 105 |
+
session_id=sid,
|
| 106 |
+
model=meta.get("model"),
|
| 107 |
+
title=meta.get("title"),
|
| 108 |
+
started_at=(meta.get("started_at")
|
| 109 |
+
if isinstance(meta.get("started_at"), (int, float)) else None),
|
| 110 |
+
cwd=meta.get("cwd"),
|
| 111 |
+
messages=msgs,
|
| 112 |
+
source=str(meta.get("source") or source),
|
| 113 |
+
improved=bool(meta.get("improved", False)),
|
| 114 |
+
parent_session_id=meta.get("parent_session_id"),
|
| 115 |
+
task=meta.get("task"),
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
# ------------------------------------------------------------------- input
|
| 120 |
+
|
| 121 |
+
def _iter_jsonl(text: str) -> Iterator[Any]:
|
| 122 |
+
for line in text.splitlines():
|
| 123 |
+
line = line.strip()
|
| 124 |
+
if not line:
|
| 125 |
+
continue
|
| 126 |
+
yield json.loads(line)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def parse_records(text: str) -> tuple[list[Any], list[str]]:
|
| 130 |
+
"""Parse a pasted/uploaded blob into records.
|
| 131 |
+
|
| 132 |
+
Accepts a JSON array, a single JSON object, or JSONL. Returns
|
| 133 |
+
`(records, errors)`; a row that fails to parse is reported by line.
|
| 134 |
+
"""
|
| 135 |
+
text = (text or "").strip()
|
| 136 |
+
if not text:
|
| 137 |
+
return [], ["empty input"]
|
| 138 |
+
# A single JSON document (object or array) is the common case.
|
| 139 |
+
try:
|
| 140 |
+
doc = json.loads(text)
|
| 141 |
+
if isinstance(doc, list):
|
| 142 |
+
# A bare message list is ONE trace, not N records.
|
| 143 |
+
if doc and all(isinstance(x, dict) and "role" in x for x in doc):
|
| 144 |
+
return [doc], []
|
| 145 |
+
return doc, []
|
| 146 |
+
return [doc], []
|
| 147 |
+
except json.JSONDecodeError:
|
| 148 |
+
pass
|
| 149 |
+
records: list[Any] = []
|
| 150 |
+
errors: list[str] = []
|
| 151 |
+
for i, line in enumerate(text.splitlines(), 1):
|
| 152 |
+
line = line.strip()
|
| 153 |
+
if not line:
|
| 154 |
+
continue
|
| 155 |
+
try:
|
| 156 |
+
records.append(json.loads(line))
|
| 157 |
+
except json.JSONDecodeError as e:
|
| 158 |
+
errors.append(f"line {i}: {e.msg}")
|
| 159 |
+
return records, errors
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def read_upload(path: str) -> tuple[list[Any], list[str]]:
|
| 163 |
+
"""Read an uploaded file (JSON or JSONL) into records."""
|
| 164 |
+
if not path or not os.path.exists(path):
|
| 165 |
+
return [], [f"file not found: {path}"]
|
| 166 |
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
| 167 |
+
return parse_records(f.read())
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def iter_traces(records: Iterable[Any], *, source: str = "space-upload"
|
| 171 |
+
) -> Iterator[tuple[Any, Trace]]:
|
| 172 |
+
"""Yield `(record, trace)`; a bad record raises with its index."""
|
| 173 |
+
for i, rec in enumerate(records):
|
| 174 |
+
yield rec, trace_from_record(rec, fallback_id=f"upload-{i}",
|
| 175 |
+
source=source)
|
traceforge/space/jobs.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Background worker: drain the store's queue without blocking the UI.
|
| 2 |
+
|
| 3 |
+
A Space request must return in seconds, but one trace takes minutes. The
|
| 4 |
+
worker is a single daemon thread (configurable) that claims the oldest
|
| 5 |
+
queued hash, runs the pipeline, and records the outcome. It is safe to
|
| 6 |
+
start twice — the second call is a no-op — and it survives a Gradio
|
| 7 |
+
reload because the queue lives in the store, not in memory.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import os
|
| 12 |
+
import threading
|
| 13 |
+
import time
|
| 14 |
+
from typing import Optional, Protocol
|
| 15 |
+
|
| 16 |
+
from traceforge.space.pipeline import SpacePipeline
|
| 17 |
+
from traceforge.space.store import TraceStore
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class _Pipeline(Protocol):
|
| 21 |
+
"""What the worker needs: a store and a process(hash) -> dict."""
|
| 22 |
+
store: TraceStore
|
| 23 |
+
|
| 24 |
+
def process(self, h: str) -> dict: ...
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class Worker:
|
| 28 |
+
def __init__(self, pipeline: _Pipeline, *, poll_s: float = 2.0,
|
| 29 |
+
name: str = "traceforge-space-worker"):
|
| 30 |
+
self.pipeline = pipeline
|
| 31 |
+
self.store: TraceStore = pipeline.store
|
| 32 |
+
self.poll_s = poll_s
|
| 33 |
+
self.name = name
|
| 34 |
+
self._stop = threading.Event()
|
| 35 |
+
self._thread: Optional[threading.Thread] = None
|
| 36 |
+
self._current: Optional[str] = None
|
| 37 |
+
self._last: Optional[dict] = None
|
| 38 |
+
self._started_at: Optional[float] = None
|
| 39 |
+
self._lock = threading.Lock()
|
| 40 |
+
|
| 41 |
+
# ------------------------------------------------------------- control
|
| 42 |
+
|
| 43 |
+
def start(self) -> "Worker":
|
| 44 |
+
with self._lock:
|
| 45 |
+
if self._thread and self._thread.is_alive():
|
| 46 |
+
return self
|
| 47 |
+
self._stop.clear()
|
| 48 |
+
self._thread = threading.Thread(target=self._run, name=self.name,
|
| 49 |
+
daemon=True)
|
| 50 |
+
self._started_at = time.time()
|
| 51 |
+
self._thread.start()
|
| 52 |
+
return self
|
| 53 |
+
|
| 54 |
+
def stop(self, timeout: float = 5.0) -> None:
|
| 55 |
+
self._stop.set()
|
| 56 |
+
t = self._thread
|
| 57 |
+
if t and t.is_alive():
|
| 58 |
+
t.join(timeout=timeout)
|
| 59 |
+
|
| 60 |
+
@property
|
| 61 |
+
def alive(self) -> bool:
|
| 62 |
+
return bool(self._thread and self._thread.is_alive())
|
| 63 |
+
|
| 64 |
+
def status(self) -> dict:
|
| 65 |
+
return {
|
| 66 |
+
"alive": self.alive,
|
| 67 |
+
"current": self._current,
|
| 68 |
+
"last": self._last,
|
| 69 |
+
"uptime_s": (round(time.time() - self._started_at, 1)
|
| 70 |
+
if self._started_at else 0.0),
|
| 71 |
+
"queued": self.store.queue_depth(),
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
# ---------------------------------------------------------------- loop
|
| 75 |
+
|
| 76 |
+
def _run(self) -> None:
|
| 77 |
+
while not self._stop.is_set():
|
| 78 |
+
job = None
|
| 79 |
+
try:
|
| 80 |
+
job = self.store.claim_next()
|
| 81 |
+
except Exception:
|
| 82 |
+
job = None
|
| 83 |
+
if not job:
|
| 84 |
+
self._stop.wait(self.poll_s)
|
| 85 |
+
continue
|
| 86 |
+
h = job["hash"]
|
| 87 |
+
self._current = h
|
| 88 |
+
try:
|
| 89 |
+
self._last = self.pipeline.process(h)
|
| 90 |
+
except Exception as e: # process() is meant not to raise; belt+braces
|
| 91 |
+
self._last = self.store.update(
|
| 92 |
+
h, status="failed", error=f"worker crashed: {e}")
|
| 93 |
+
finally:
|
| 94 |
+
self._current = None
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
# --------------------------------------------------------------- singleton
|
| 98 |
+
|
| 99 |
+
_WORKER: Optional[Worker] = None
|
| 100 |
+
_WORKER_LOCK = threading.Lock()
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def get_worker(pipeline: SpacePipeline, *, autostart: bool = True) -> Worker:
|
| 104 |
+
"""One worker per process, started on first use."""
|
| 105 |
+
global _WORKER
|
| 106 |
+
with _WORKER_LOCK:
|
| 107 |
+
if _WORKER is None:
|
| 108 |
+
_WORKER = Worker(pipeline)
|
| 109 |
+
if autostart and not _WORKER.alive:
|
| 110 |
+
_WORKER.start()
|
| 111 |
+
return _WORKER
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def worker_count() -> int:
|
| 115 |
+
try:
|
| 116 |
+
return max(1, int(os.environ.get("TRACEFORGE_SPACE_WORKERS", "1")))
|
| 117 |
+
except ValueError:
|
| 118 |
+
return 1
|
traceforge/space/pipeline.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The pipeline behind the web UI.
|
| 2 |
+
|
| 3 |
+
`SpacePipeline` is the only thing the Gradio app talks to. It wraps the
|
| 4 |
+
windowed splice engine (`improve_trace_splice`) so a trace that arrives as
|
| 5 |
+
JSON — not as a session id in a local store — can be processed:
|
| 6 |
+
|
| 7 |
+
submit(record) hash -> dedupe -> enqueue (idempotent)
|
| 8 |
+
process(hash) run triage + rewrite + gates, store the output
|
| 9 |
+
|
| 10 |
+
Configuration is environment-only, because a Space has no `~/.hermes`:
|
| 11 |
+
|
| 12 |
+
TRACEFORGE_BASE_URL OpenAI-compatible endpoint (…/v1)
|
| 13 |
+
TRACEFORGE_LLM_KEY bearer token
|
| 14 |
+
TRACEFORGE_MODEL writer/judge model id
|
| 15 |
+
TRACEFORGE_TRIAGE_MODEL cheap labeler (default: TRACEFORGE_MODEL)
|
| 16 |
+
TRACEFORGE_JUDGE_MODEL judge override (default: TRACEFORGE_MODEL)
|
| 17 |
+
TRACEFORGE_DATA_DIR store root (default: <repo>/data/space)
|
| 18 |
+
|
| 19 |
+
`split_long` is deliberately OFF: splitting one submitted seed into N
|
| 20 |
+
segments would produce N outputs for one hash, which is exactly what the
|
| 21 |
+
one-seed-one-output rule forbids. The splice engine already windows a long
|
| 22 |
+
trace internally.
|
| 23 |
+
"""
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import os
|
| 27 |
+
import shutil
|
| 28 |
+
import time
|
| 29 |
+
from typing import Any, Optional
|
| 30 |
+
|
| 31 |
+
from traceforge.config import Config, repo_root_from_pkg
|
| 32 |
+
from traceforge.space.hashing import fingerprint, output_hash, seed_hash
|
| 33 |
+
from traceforge.space.ingest import trace_from_record
|
| 34 |
+
from traceforge.space.store import (DONE, FAILED, QUEUED, REJECTED, RUNNING,
|
| 35 |
+
TraceStore)
|
| 36 |
+
from traceforge.trace import Trace
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ------------------------------------------------------------------- config
|
| 40 |
+
|
| 41 |
+
def _env_int(name: str, default: int) -> int:
|
| 42 |
+
try:
|
| 43 |
+
return int(os.environ.get(name, "") or default)
|
| 44 |
+
except ValueError:
|
| 45 |
+
return default
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def space_config(data_dir: Optional[str] = None, **overrides: Any) -> Config:
|
| 49 |
+
"""A Config that needs no Hermes profile — env vars only."""
|
| 50 |
+
root = repo_root_from_pkg()
|
| 51 |
+
cfg = Config()
|
| 52 |
+
cfg.data_dir = (data_dir or os.environ.get("TRACEFORGE_DATA_DIR")
|
| 53 |
+
or os.path.join(root, "data", "space"))
|
| 54 |
+
cfg.guidelines_path = os.path.join(root, "guidelines",
|
| 55 |
+
"IDEAL_TRACE_GUIDELINES.md")
|
| 56 |
+
cfg.provider = os.environ.get("TRACEFORGE_PROVIDER") or None
|
| 57 |
+
cfg.model = os.environ.get("TRACEFORGE_MODEL") or "qwen-fable-selfhosted"
|
| 58 |
+
cfg.triage_model = (os.environ.get("TRACEFORGE_TRIAGE_MODEL")
|
| 59 |
+
or cfg.model)
|
| 60 |
+
cfg.judge_model = os.environ.get("TRACEFORGE_JUDGE_MODEL") or ""
|
| 61 |
+
cfg.triage_base_url = (os.environ.get("TRACEFORGE_BASE_URL") or "").rstrip("/")
|
| 62 |
+
cfg.triage = True
|
| 63 |
+
cfg.splice = True
|
| 64 |
+
# The Space processes one submitted trace as one unit.
|
| 65 |
+
cfg.split_long = False
|
| 66 |
+
cfg.raw_cache = False
|
| 67 |
+
cfg.verify = False
|
| 68 |
+
cfg.review = False
|
| 69 |
+
cfg.splice_workers = _env_int("TRACEFORGE_SPLICE_WORKERS", 4)
|
| 70 |
+
cfg.triage_workers = _env_int("TRACEFORGE_TRIAGE_WORKERS", 4)
|
| 71 |
+
cfg.rewrite_timeout_s = _env_int("TRACEFORGE_REWRITE_TIMEOUT_S", 300)
|
| 72 |
+
cfg.judge_timeout_s = _env_int("TRACEFORGE_JUDGE_TIMEOUT_S", 300)
|
| 73 |
+
for k, v in overrides.items():
|
| 74 |
+
setattr(cfg, k, v)
|
| 75 |
+
return cfg.resolve(root)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def pipeline_ready(cfg: Config) -> tuple[bool, str]:
|
| 79 |
+
"""(ready, reason). A Space without an endpoint must say so, not hang."""
|
| 80 |
+
try:
|
| 81 |
+
from traceforge.triage import _resolve_endpoint
|
| 82 |
+
base, _key, model = _resolve_endpoint(cfg)
|
| 83 |
+
except Exception as e: # RuntimeError from the resolver, or a bad config
|
| 84 |
+
return False, f"no LLM endpoint: {e}"
|
| 85 |
+
if not base:
|
| 86 |
+
return False, "no LLM endpoint configured (TRACEFORGE_BASE_URL)"
|
| 87 |
+
if not model:
|
| 88 |
+
return False, "no model configured (TRACEFORGE_MODEL)"
|
| 89 |
+
return True, f"{model} @ {base}"
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# ----------------------------------------------------------------- pipeline
|
| 93 |
+
|
| 94 |
+
class SpacePipeline:
|
| 95 |
+
"""Submit / process / report, with the store as the only state."""
|
| 96 |
+
|
| 97 |
+
def __init__(self, store: TraceStore, cfg: Optional[Config] = None,
|
| 98 |
+
*, max_attempts: int = 2):
|
| 99 |
+
self.store = store
|
| 100 |
+
self.cfg = cfg or space_config()
|
| 101 |
+
self.max_attempts = max_attempts
|
| 102 |
+
|
| 103 |
+
# ------------------------------------------------------------- submit
|
| 104 |
+
|
| 105 |
+
def submit(self, record: Any, *, sponsor: str = "",
|
| 106 |
+
source: str = "space-upload") -> dict:
|
| 107 |
+
return self.store.submit(record, sponsor=sponsor, source=source)
|
| 108 |
+
|
| 109 |
+
def submit_many(self, records: list[Any], *, sponsor: str = "",
|
| 110 |
+
source: str = "space-sponsor") -> list[dict]:
|
| 111 |
+
out = []
|
| 112 |
+
for rec in records:
|
| 113 |
+
try:
|
| 114 |
+
out.append(self.submit(rec, sponsor=sponsor, source=source))
|
| 115 |
+
except Exception as e:
|
| 116 |
+
out.append({"hash": "", "status": "error",
|
| 117 |
+
"error": str(e), "duplicate": False})
|
| 118 |
+
return out
|
| 119 |
+
|
| 120 |
+
# ------------------------------------------------------------ process
|
| 121 |
+
|
| 122 |
+
def process(self, h: str) -> dict:
|
| 123 |
+
"""Run the pipeline for one queued hash. Never raises."""
|
| 124 |
+
rec = self.store.get(h)
|
| 125 |
+
if not rec:
|
| 126 |
+
return {"hash": h, "status": FAILED, "error": "unknown hash"}
|
| 127 |
+
if rec.get("status") == DONE:
|
| 128 |
+
return {**rec, "skipped": True}
|
| 129 |
+
|
| 130 |
+
sid = f"space-{h[:16]}"
|
| 131 |
+
started = time.time()
|
| 132 |
+
self.store.update(h, status=RUNNING, started_at=started,
|
| 133 |
+
attempts=int(rec.get("attempts") or 0) + 1)
|
| 134 |
+
try:
|
| 135 |
+
record = self.store.load_seed(h)
|
| 136 |
+
trace = trace_from_record(record, fallback_id=sid,
|
| 137 |
+
source="space-upload")
|
| 138 |
+
trace.session_id = sid
|
| 139 |
+
trace.source = "space-upload"
|
| 140 |
+
trace.improved = False
|
| 141 |
+
trace.parent_session_id = None
|
| 142 |
+
except Exception as e:
|
| 143 |
+
return self._fail(h, [f"unparseable seed: {e}"], started)
|
| 144 |
+
|
| 145 |
+
# A fresh attempt must not regate a stale artifact from a previous
|
| 146 |
+
# run (the splice engine's stage-0 fast path would promote it as-is).
|
| 147 |
+
workdir = os.path.join(self.cfg.improver_workdir, sid)
|
| 148 |
+
shutil.rmtree(workdir, ignore_errors=True)
|
| 149 |
+
|
| 150 |
+
try:
|
| 151 |
+
from traceforge.splice import improve_trace_splice
|
| 152 |
+
res = improve_trace_splice(
|
| 153 |
+
self.cfg, trace, max_attempts=self.max_attempts,
|
| 154 |
+
)
|
| 155 |
+
except Exception as e:
|
| 156 |
+
return self._fail(h, [f"pipeline crashed: {e}"], started)
|
| 157 |
+
|
| 158 |
+
if not res.get("ok") or res.get("improved") is None:
|
| 159 |
+
return self._fail(h, list(res.get("errors") or ["no output"]),
|
| 160 |
+
started, route=res.get("route"),
|
| 161 |
+
attempts=res.get("attempts"))
|
| 162 |
+
|
| 163 |
+
improved: Trace = res["improved"]
|
| 164 |
+
improved.session_id = sid
|
| 165 |
+
improved.parent_session_id = sid
|
| 166 |
+
improved.improved = True
|
| 167 |
+
improved.source = "traceforge-space"
|
| 168 |
+
|
| 169 |
+
# The splice engine gates before returning, but a structural check
|
| 170 |
+
# here is cheap and makes the Space's own contract explicit.
|
| 171 |
+
try:
|
| 172 |
+
from traceforge.validate import validate_structural
|
| 173 |
+
structural = validate_structural(improved)
|
| 174 |
+
except Exception as e:
|
| 175 |
+
structural = [f"validator crashed: {e}"]
|
| 176 |
+
if structural:
|
| 177 |
+
return self._fail(h, structural, started, route=res.get("route"),
|
| 178 |
+
attempts=res.get("attempts"))
|
| 179 |
+
|
| 180 |
+
# Task bucket: heuristic only — the Space must not spend an LLM call
|
| 181 |
+
# on a label the dashboard can derive for free.
|
| 182 |
+
try:
|
| 183 |
+
from traceforge.classify import classify_heuristic
|
| 184 |
+
improved.task = classify_heuristic(improved).get("task")
|
| 185 |
+
except Exception:
|
| 186 |
+
pass
|
| 187 |
+
|
| 188 |
+
# Time sensitivity: measured on the RAW record (it may carry the
|
| 189 |
+
# `tools` array, which is what decides anchored vs sensitive) and on
|
| 190 |
+
# the ideal rewrite (did it actually call the retrieval tool?).
|
| 191 |
+
ts_raw = self._time_sensitivity(record)
|
| 192 |
+
ts_out = self._time_sensitivity(improved)
|
| 193 |
+
|
| 194 |
+
self.store.put_output(h, improved)
|
| 195 |
+
duration = time.time() - started
|
| 196 |
+
row = self.store.update(
|
| 197 |
+
h, status=DONE, finished_at=time.time(), duration_s=round(duration, 1),
|
| 198 |
+
output_hash=output_hash(improved),
|
| 199 |
+
route=res.get("route") or "",
|
| 200 |
+
attempts=res.get("attempts") or 0,
|
| 201 |
+
n_messages_out=len(improved.messages),
|
| 202 |
+
task=improved.task or rec.get("task") or "",
|
| 203 |
+
time_sensitivity=ts_raw,
|
| 204 |
+
time_sensitivity_improved=ts_out,
|
| 205 |
+
error="",
|
| 206 |
+
)
|
| 207 |
+
return row
|
| 208 |
+
|
| 209 |
+
def _fail(self, h: str, errors: list[str], started: float, *,
|
| 210 |
+
route: Optional[str] = None, attempts: Any = None) -> dict:
|
| 211 |
+
msg = "; ".join(str(e) for e in errors)[:1200]
|
| 212 |
+
return self.store.update(
|
| 213 |
+
h, status=FAILED, finished_at=time.time(),
|
| 214 |
+
duration_s=round(time.time() - started, 1),
|
| 215 |
+
route=route or "", attempts=attempts or 0, error=msg,
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
@staticmethod
|
| 219 |
+
def _time_sensitivity(obj: Any) -> dict:
|
| 220 |
+
try:
|
| 221 |
+
from traceforge.timesensitivity import detect
|
| 222 |
+
d = detect(obj)
|
| 223 |
+
return {k: d[k] for k in ("class", "label", "temporal_facts",
|
| 224 |
+
"retrieval_available", "retrieval_called",
|
| 225 |
+
"needs_rewrite", "score")}
|
| 226 |
+
except Exception as e:
|
| 227 |
+
return {"class": "", "label": f"unavailable: {e}"}
|
| 228 |
+
|
| 229 |
+
# -------------------------------------------------------------- report
|
| 230 |
+
|
| 231 |
+
def result(self, h: str) -> dict:
|
| 232 |
+
"""Everything the UI needs about one hash."""
|
| 233 |
+
rec = self.store.get(h) or {"hash": h, "status": "unknown"}
|
| 234 |
+
out = self.store.load_output(h)
|
| 235 |
+
rec = dict(rec)
|
| 236 |
+
rec["has_output"] = out is not None
|
| 237 |
+
if out is not None:
|
| 238 |
+
rec["output_messages"] = len(out.messages)
|
| 239 |
+
return rec
|