rote-cli 0.7.0__tar.gz → 0.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {rote_cli-0.7.0 → rote_cli-0.8.0}/PKG-INFO +1 -1
- {rote_cli-0.7.0 → rote_cli-0.8.0}/skills/rote-graduate/references/eval-estimates.md +41 -20
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/__init__.py +1 -1
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/cli.py +24 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/eval/empirical.py +156 -2
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/eval/estimate.py +20 -6
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/eval/priors.py +13 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/eval/scorecard.py +3 -2
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/eval/sidecar.py +30 -3
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/graduator/__init__.py +38 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/.gitignore +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/LICENSE +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/README.md +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/pyproject.toml +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/skills/rote-graduate/SKILL.md +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/skills/rote-graduate/references/crystallization-heuristics.md +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/skills/rote-graduate/references/ir-schema.md +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/skills/rote-graduate/references/llm-judge-extraction.md +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/skills/rote-graduate/references/node-kinds.md +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/adapters/__init__.py +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/adapters/_common.py +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/adapters/_py_common.py +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/adapters/_ts_common.py +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/adapters/cloudflare.py +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/adapters/dbos.py +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/adapters/dbos_ts.py +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/adapters/inngest.py +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/adapters/python.py +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/adapters/temporal.py +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/eval/__init__.py +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/eval/pricing.py +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/eval/tokens.py +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/graduator/drivers/__init__.py +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/graduator/drivers/anthropic_api.py +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/graduator/drivers/claude.py +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/graduator/drivers/codex.py +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/graduator/update.py +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/ir.py +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/serve/__init__.py +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/serve/backends.py +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/serve/registry.py +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/serve/server.py +0 -0
- {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/skill_source.py +0 -0
|
@@ -25,6 +25,13 @@ Ground rules:
|
|
|
25
25
|
(agents batch parallel calls). If it says "repeat until you have 25
|
|
26
26
|
qualified contacts", estimate the realistic iteration count from any
|
|
27
27
|
hints (quota sizes, batch sizes, typical yields), not the worst case.
|
|
28
|
+
- **Steps that repeat per item MUST declare `iterations`.** A "process
|
|
29
|
+
each row" step costs its per-row turns *times the row count* — this
|
|
30
|
+
is almost always the dominant term, and flattening it understates the
|
|
31
|
+
whole run by 5–20×. Put the per-iteration turns in `estimated_turns`
|
|
32
|
+
and the realistic item-count range in `iterations` (page sizes,
|
|
33
|
+
pagination labels, batch quotas, and filter windows in the skill text
|
|
34
|
+
are your evidence). The loader multiplies them; never pre-multiply.
|
|
28
35
|
- **Include the overhead the prose hides.** Reading reference files,
|
|
29
36
|
re-checking rubric sections, correcting malformed tool calls — a
|
|
30
37
|
realistic agent spends turns on these. A step whose happy path is 2
|
|
@@ -54,9 +61,14 @@ steps:
|
|
|
54
61
|
phase: "1"
|
|
55
62
|
estimated_turns: {low: 1, high: 2}
|
|
56
63
|
estimated_tool_calls: {low: 4, high: 4}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
64
|
+
- description: "Push each eligible invoice and capture its sent date"
|
|
65
|
+
node_id: process_invoices_loop
|
|
66
|
+
phase: "Processing"
|
|
67
|
+
estimated_turns: {low: 3, high: 6} # PER ROW —
|
|
68
|
+
iterations: {low: 20, high: 90} # — × realistic row count
|
|
69
|
+
totals: # optional — omit if the sum is right;
|
|
70
|
+
low: 88 # must be WHOLE-RUN (post-iteration)
|
|
71
|
+
high: 602 # when any step declares iterations
|
|
60
72
|
notes: >-
|
|
61
73
|
Lead generation dominates: the loop re-queries ZoomInfo with adjusted
|
|
62
74
|
filters until the quota is met; typical campaigns converge in 3-5
|
|
@@ -72,30 +84,39 @@ Field reference:
|
|
|
72
84
|
| `steps[].description` | yes | The step, in the skill's own vocabulary |
|
|
73
85
|
| `steps[].node_id` | no | IR node id, only when the mapping is clean 1:1 |
|
|
74
86
|
| `steps[].phase` | no | Source skill phase, same convention as the IR |
|
|
75
|
-
| `steps[].estimated_turns` | yes | `{low, high}` agent turns |
|
|
87
|
+
| `steps[].estimated_turns` | yes | `{low, high}` agent turns (per iteration when `iterations` is set) |
|
|
76
88
|
| `steps[].estimated_tool_calls` | no | `{low, high}` tool calls, when distinct from turns |
|
|
77
|
-
| `
|
|
89
|
+
| `steps[].iterations` | when the step repeats | `{low, high}` repeats per run (rows, pages, items); whole-run cost = turns × iterations |
|
|
90
|
+
| `totals` | no | Whole-run `{low, high}`; omit to mean "sum the steps (× iterations)" |
|
|
78
91
|
| `notes` | no | The one paragraph a reader needs to trust the numbers |
|
|
79
92
|
|
|
80
93
|
Validated by `rote.eval.sidecar.EvalEstimates` — if you can run Python,
|
|
81
94
|
`load_eval_estimates("eval.yaml")` is the dry check, same pattern as
|
|
82
95
|
`load_pipeline`.
|
|
83
96
|
|
|
84
|
-
## Calibration anchors (
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
-
|
|
90
|
-
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
## Calibration anchors (measured production runs)
|
|
98
|
+
|
|
99
|
+
Whole-run turn counts are regime-dependent — anchor to the skill's
|
|
100
|
+
*shape*, not to a universal range:
|
|
101
|
+
|
|
102
|
+
- **Sequential tool-heavy skills** (BDR-scale: 7 phases, ~20 distinct
|
|
103
|
+
steps, no per-item loop): **30–57 turns** measured. ~13 seconds per
|
|
104
|
+
turn including tool execution.
|
|
105
|
+
- **Per-item loop skills** (browser automation over a table, per-record
|
|
106
|
+
processing): measured production runs landed at **184 and 730 turns**
|
|
107
|
+
— the loop multiplier dominates everything else in the file. If a
|
|
108
|
+
skill says "for each row…", expect hundreds of turns, not tens, and
|
|
109
|
+
express it via `iterations`, never by inflating per-step turns.
|
|
110
|
+
- Per-step anchors: a single fixed API call step: 1–2 turns. A
|
|
111
|
+
batch-and-review step: 2–4. A bounded search loop: 6–15. A genuinely
|
|
112
|
+
open research step: 4–10. One iteration of a per-row browser cycle
|
|
113
|
+
(locate, act, confirm, refresh): 2–6.
|
|
114
|
+
|
|
115
|
+
If your estimate lands wildly outside the anchor for the skill's shape,
|
|
116
|
+
re-examine before writing — either the skill really is bigger/smaller
|
|
117
|
+
(fine, say so in `notes`) or a step estimate is off. A per-item skill
|
|
118
|
+
whose whole-run total sums to under ~100 turns almost certainly has a
|
|
119
|
+
missing `iterations` block.
|
|
99
120
|
|
|
100
121
|
## What NOT to do
|
|
101
122
|
|
|
@@ -784,7 +784,9 @@ def _run_empirical(
|
|
|
784
784
|
from rote.eval.empirical import (
|
|
785
785
|
EmpiricalResult,
|
|
786
786
|
append_corpus,
|
|
787
|
+
mcp_servers_for_pipeline,
|
|
787
788
|
measured_to_dict,
|
|
789
|
+
min_expected_turns_for,
|
|
788
790
|
render_measured_markdown,
|
|
789
791
|
run_pipeline_trial,
|
|
790
792
|
run_skill_trial,
|
|
@@ -846,6 +848,25 @@ def _run_empirical(
|
|
|
846
848
|
|
|
847
849
|
skill_runs = []
|
|
848
850
|
if skill_dir is not None:
|
|
851
|
+
# Wire the pipeline's MCP bindings into the skill trial so the
|
|
852
|
+
# agent runs over the same live tools the pipeline binds to —
|
|
853
|
+
# the measurement is only representative when the skill can
|
|
854
|
+
# actually pull its data.
|
|
855
|
+
mcp_servers, missing_servers = mcp_servers_for_pipeline(pipeline)
|
|
856
|
+
if mcp_servers:
|
|
857
|
+
print(
|
|
858
|
+
"rote eval: wiring MCP servers into the skill trial: "
|
|
859
|
+
+ ", ".join(sorted(mcp_servers)),
|
|
860
|
+
file=sys.stderr,
|
|
861
|
+
)
|
|
862
|
+
if missing_servers:
|
|
863
|
+
print(
|
|
864
|
+
"rote eval: WARNING — pipeline binds MCP servers with no "
|
|
865
|
+
"resolvable endpoint (set ROTE_MCP_<SERVER>_URL): "
|
|
866
|
+
+ ", ".join(missing_servers)
|
|
867
|
+
+ " — skill trials will be flagged unrepresentative",
|
|
868
|
+
file=sys.stderr,
|
|
869
|
+
)
|
|
849
870
|
for i in range(args.trials):
|
|
850
871
|
print(
|
|
851
872
|
f"rote eval: skill trial {i + 1}/{args.trials} "
|
|
@@ -859,6 +880,9 @@ def _run_empirical(
|
|
|
859
880
|
model=skill_model,
|
|
860
881
|
max_turns=args.max_turns,
|
|
861
882
|
output_fields=sorted(output_fields) or None,
|
|
883
|
+
mcp_servers=mcp_servers or None,
|
|
884
|
+
missing_mcp_servers=missing_servers or None,
|
|
885
|
+
min_expected_turns=min_expected_turns_for(pipeline),
|
|
862
886
|
)
|
|
863
887
|
)
|
|
864
888
|
else:
|
|
@@ -43,15 +43,69 @@ from rote.graduator.drivers.claude import (
|
|
|
43
43
|
DEFAULT_ALLOWED_TOOLS,
|
|
44
44
|
build_subscription_env,
|
|
45
45
|
)
|
|
46
|
+
from rote.ir import NodeKind, Pipeline
|
|
46
47
|
|
|
47
48
|
RESULT_FILENAME = "result.json"
|
|
49
|
+
MCP_CONFIG_FILENAME = "mcp-config.json"
|
|
48
50
|
DEFAULT_CORPUS_PATH = Path.home() / ".local" / "share" / "rote" / "eval-corpus.jsonl"
|
|
49
51
|
|
|
52
|
+
# IR transport values → Claude Code mcp-config `type` values. The CLI
|
|
53
|
+
# spells Streamable HTTP as plain "http" (verified against the current
|
|
54
|
+
# docs at code.claude.com/docs/en/mcp — July 2026, CLI v2.1.205).
|
|
55
|
+
_TRANSPORT_TO_MCP_TYPE = {"streamable-http": "http", "sse": "sse"}
|
|
56
|
+
|
|
50
57
|
|
|
51
58
|
class EmpiricalError(RuntimeError):
|
|
52
59
|
"""A trial could not be started (misconfiguration, missing deps)."""
|
|
53
60
|
|
|
54
61
|
|
|
62
|
+
# ───────── MCP auto-wiring ─────────
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def mcp_servers_for_pipeline(pipeline: Pipeline) -> tuple[dict[str, dict[str, Any]], list[str]]:
|
|
66
|
+
"""Build the ``claude -p`` MCP server config from the pipeline's bindings.
|
|
67
|
+
|
|
68
|
+
The graduated pipeline's ``mcp:`` bindings name exactly the servers the
|
|
69
|
+
source skill uses, so wiring them into the skill trial makes the
|
|
70
|
+
"before" measurement representative: the agent pulls real data over the
|
|
71
|
+
same tools instead of erroring out or improvising. URL resolution
|
|
72
|
+
follows the adapters' rule — an explicit ``mcp.url`` wins, else the
|
|
73
|
+
``ROTE_MCP_<SERVER>_URL`` env var.
|
|
74
|
+
|
|
75
|
+
Returns ``(mcp_servers, missing)``: the ``mcpServers`` mapping for the
|
|
76
|
+
config file, and the servers the pipeline binds that could not be
|
|
77
|
+
resolved to an endpoint (a trial run without them is flagged
|
|
78
|
+
unrepresentative rather than silently measured).
|
|
79
|
+
"""
|
|
80
|
+
servers: dict[str, dict[str, Any]] = {}
|
|
81
|
+
missing: list[str] = []
|
|
82
|
+
for node in pipeline.nodes:
|
|
83
|
+
if node.mcp is None or node.mcp.server in servers or node.mcp.server in missing:
|
|
84
|
+
continue
|
|
85
|
+
binding = node.mcp
|
|
86
|
+
url = binding.url or os.environ.get(f"ROTE_MCP_{binding.server.upper()}_URL")
|
|
87
|
+
if not url:
|
|
88
|
+
missing.append(binding.server)
|
|
89
|
+
continue
|
|
90
|
+
servers[binding.server] = {
|
|
91
|
+
"type": _TRANSPORT_TO_MCP_TYPE[binding.transport],
|
|
92
|
+
"url": url,
|
|
93
|
+
}
|
|
94
|
+
return servers, missing
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def min_expected_turns_for(pipeline: Pipeline) -> int:
|
|
98
|
+
"""The floor below which a skill run cannot have done the work.
|
|
99
|
+
|
|
100
|
+
Each ``external_call`` node is a data pull the source skill also
|
|
101
|
+
performs; an agent run with fewer turns than data pulls necessarily
|
|
102
|
+
skipped sources (auth failure, refusal, early error) and would poison
|
|
103
|
+
the calibration corpus if fitted as a real measurement.
|
|
104
|
+
"""
|
|
105
|
+
pulls = sum(1 for n in pipeline.nodes if n.kind is NodeKind.EXTERNAL_CALL)
|
|
106
|
+
return max(3, pulls)
|
|
107
|
+
|
|
108
|
+
|
|
55
109
|
@dataclass(frozen=True)
|
|
56
110
|
class MeasuredRun:
|
|
57
111
|
"""One executed trial, either side."""
|
|
@@ -69,11 +123,22 @@ class MeasuredRun:
|
|
|
69
123
|
model: str | None = None
|
|
70
124
|
# Pipeline side (from the emitted $ROTE_USAGE_LOG hook):
|
|
71
125
|
judge_usage: tuple[dict[str, Any], ...] = field(default_factory=tuple)
|
|
126
|
+
flags: tuple[str, ...] = ()
|
|
127
|
+
"""Reliability flags: why this run should not calibrate priors.
|
|
128
|
+
``errored`` / ``hit_max_turns`` (truncated — its cost is a floor, not
|
|
129
|
+
a measurement) / ``suspiciously_few_turns`` (fewer turns than the
|
|
130
|
+
pipeline has data pulls — the agent never did the work) /
|
|
131
|
+
``missing_mcp_servers`` (the skill's tools weren't all available)."""
|
|
72
132
|
|
|
73
133
|
@property
|
|
74
134
|
def succeeded(self) -> bool:
|
|
75
135
|
return self.error is None
|
|
76
136
|
|
|
137
|
+
@property
|
|
138
|
+
def representative(self) -> bool:
|
|
139
|
+
"""Safe to fit priors from: succeeded and nothing flagged."""
|
|
140
|
+
return self.succeeded and not self.flags
|
|
141
|
+
|
|
77
142
|
|
|
78
143
|
# ───────── Skill (before) trials ─────────
|
|
79
144
|
|
|
@@ -116,12 +181,24 @@ def run_skill_trial(
|
|
|
116
181
|
executable: str = "claude",
|
|
117
182
|
timeout_seconds: float = 1800.0,
|
|
118
183
|
output_fields: list[str] | None = None,
|
|
184
|
+
mcp_servers: dict[str, dict[str, Any]] | None = None,
|
|
185
|
+
missing_mcp_servers: list[str] | None = None,
|
|
186
|
+
min_expected_turns: int | None = None,
|
|
119
187
|
) -> MeasuredRun:
|
|
120
188
|
"""Run the raw skill once under ``claude -p`` and measure it.
|
|
121
189
|
|
|
122
190
|
Billing follows the ClaudeDriver rules: API-key env vars are
|
|
123
191
|
scrubbed so the run uses the subscription OAuth session, and the
|
|
124
192
|
reported ``total_cost_usd`` is Claude Code's list-price notional.
|
|
193
|
+
|
|
194
|
+
``mcp_servers`` (from :func:`mcp_servers_for_pipeline`) is written to
|
|
195
|
+
a config file and passed via ``--mcp-config --strict-mcp-config`` —
|
|
196
|
+
strict, so the trial exercises exactly the pipeline-declared servers,
|
|
197
|
+
never whatever happens to be configured on the host. Each wired
|
|
198
|
+
server's tools are allowlisted wholesale (``mcp__<server>__*``): the
|
|
199
|
+
skill may legitimately use more of a server's tools than the pipeline
|
|
200
|
+
binds. ``missing_mcp_servers`` and ``min_expected_turns`` feed the
|
|
201
|
+
reliability flags on the returned run.
|
|
125
202
|
"""
|
|
126
203
|
skill_path = Path(skill_dir).resolve()
|
|
127
204
|
if not (skill_path / "SKILL.md").is_file():
|
|
@@ -134,6 +211,10 @@ def run_skill_trial(
|
|
|
134
211
|
prompt = _skill_prompt(skill_path, input_payload, output_fields)
|
|
135
212
|
with tempfile.TemporaryDirectory(prefix="rote-eval-skill-") as workdir_str:
|
|
136
213
|
workdir = Path(workdir_str)
|
|
214
|
+
if mcp_servers:
|
|
215
|
+
allowed_tools = ",".join(
|
|
216
|
+
[allowed_tools, *(f"mcp__{name}__*" for name in sorted(mcp_servers))]
|
|
217
|
+
)
|
|
137
218
|
args = [
|
|
138
219
|
executable,
|
|
139
220
|
"-p",
|
|
@@ -149,6 +230,12 @@ def run_skill_trial(
|
|
|
149
230
|
"--max-turns",
|
|
150
231
|
str(max_turns),
|
|
151
232
|
]
|
|
233
|
+
if mcp_servers:
|
|
234
|
+
mcp_config_path = workdir / MCP_CONFIG_FILENAME
|
|
235
|
+
mcp_config_path.write_text(
|
|
236
|
+
json.dumps({"mcpServers": mcp_servers}, indent=2), encoding="utf-8"
|
|
237
|
+
)
|
|
238
|
+
args += ["--mcp-config", str(mcp_config_path), "--strict-mcp-config"]
|
|
152
239
|
started = time.monotonic()
|
|
153
240
|
try:
|
|
154
241
|
proc = subprocess.run(
|
|
@@ -165,6 +252,13 @@ def run_skill_trial(
|
|
|
165
252
|
output=None,
|
|
166
253
|
error=f"timed out after {timeout_seconds:g}s",
|
|
167
254
|
model=model,
|
|
255
|
+
flags=_reliability_flags(
|
|
256
|
+
error="timeout",
|
|
257
|
+
subtype=None,
|
|
258
|
+
turns=None,
|
|
259
|
+
min_expected_turns=min_expected_turns,
|
|
260
|
+
missing_mcp_servers=missing_mcp_servers,
|
|
261
|
+
),
|
|
168
262
|
)
|
|
169
263
|
wall = time.monotonic() - started
|
|
170
264
|
|
|
@@ -191,20 +285,60 @@ def run_skill_trial(
|
|
|
191
285
|
error = f"claude exited {proc.returncode}: {(proc.stderr or proc.stdout)[:500]}"
|
|
192
286
|
elif output is None:
|
|
193
287
|
error = parse_error
|
|
288
|
+
turns = meta.get("num_turns")
|
|
194
289
|
return MeasuredRun(
|
|
195
290
|
wall_seconds=wall,
|
|
196
291
|
output=output,
|
|
197
292
|
error=error,
|
|
198
|
-
turns=
|
|
293
|
+
turns=turns,
|
|
199
294
|
cost_usd=meta.get("total_cost_usd"),
|
|
200
295
|
input_tokens=usage.get("input_tokens"),
|
|
201
296
|
cache_read_tokens=usage.get("cache_read_input_tokens"),
|
|
202
297
|
cache_creation_tokens=usage.get("cache_creation_input_tokens"),
|
|
203
298
|
output_tokens=usage.get("output_tokens"),
|
|
204
299
|
model=model,
|
|
300
|
+
flags=_reliability_flags(
|
|
301
|
+
error=error,
|
|
302
|
+
subtype=meta.get("subtype"),
|
|
303
|
+
turns=turns,
|
|
304
|
+
min_expected_turns=min_expected_turns,
|
|
305
|
+
missing_mcp_servers=missing_mcp_servers,
|
|
306
|
+
),
|
|
205
307
|
)
|
|
206
308
|
|
|
207
309
|
|
|
310
|
+
def _reliability_flags(
|
|
311
|
+
*,
|
|
312
|
+
error: str | None,
|
|
313
|
+
subtype: str | None,
|
|
314
|
+
turns: int | None,
|
|
315
|
+
min_expected_turns: int | None,
|
|
316
|
+
missing_mcp_servers: list[str] | None,
|
|
317
|
+
) -> tuple[str, ...]:
|
|
318
|
+
"""Why a run must not calibrate priors (empty = representative).
|
|
319
|
+
|
|
320
|
+
Flags are additive and deliberately structural — no LLM judges the
|
|
321
|
+
run. A truncated run's cost is a floor, not a measurement; a run
|
|
322
|
+
with fewer turns than the pipeline has data pulls never did the
|
|
323
|
+
work; a run whose skill lacked its tools measured a different task.
|
|
324
|
+
"""
|
|
325
|
+
flags: list[str] = []
|
|
326
|
+
if error is not None:
|
|
327
|
+
flags.append("errored")
|
|
328
|
+
if subtype == "error_max_turns":
|
|
329
|
+
flags.append("hit_max_turns")
|
|
330
|
+
if (
|
|
331
|
+
min_expected_turns is not None
|
|
332
|
+
and turns is not None
|
|
333
|
+
and turns < min_expected_turns
|
|
334
|
+
and subtype != "error_max_turns"
|
|
335
|
+
):
|
|
336
|
+
flags.append("suspiciously_few_turns")
|
|
337
|
+
if missing_mcp_servers:
|
|
338
|
+
flags.append("missing_mcp_servers")
|
|
339
|
+
return tuple(flags)
|
|
340
|
+
|
|
341
|
+
|
|
208
342
|
# ───────── Pipeline (after) trials ─────────
|
|
209
343
|
|
|
210
344
|
|
|
@@ -452,8 +586,15 @@ def suggested_priors(
|
|
|
452
586
|
skill_runs: tuple[MeasuredRun, ...], priors: Priors | None = None
|
|
453
587
|
) -> dict[str, float]:
|
|
454
588
|
"""Prior values re-fitted from measured agent runs (reported, never
|
|
455
|
-
silently applied — the static model's constants stay explicit).
|
|
589
|
+
silently applied — the static model's constants stay explicit).
|
|
590
|
+
|
|
591
|
+
Only representative runs participate: a flagged run (errored,
|
|
592
|
+
truncated at max-turns, too few turns to have done the work, or
|
|
593
|
+
missing its MCP servers) measured something other than the skill
|
|
594
|
+
executing normally, and fitting it would poison the calibration.
|
|
595
|
+
"""
|
|
456
596
|
priors = priors or Priors()
|
|
597
|
+
skill_runs = tuple(r for r in skill_runs if r.representative)
|
|
457
598
|
fitted: dict[str, float] = {}
|
|
458
599
|
timed = [r for r in skill_runs if r.turns and r.wall_seconds]
|
|
459
600
|
if timed:
|
|
@@ -500,6 +641,7 @@ def append_corpus(result: EmpiricalResult, *, generated_at: str, path: Path | No
|
|
|
500
641
|
"cache_creation_tokens": r.cache_creation_tokens,
|
|
501
642
|
"output_tokens": r.output_tokens,
|
|
502
643
|
"succeeded": r.succeeded,
|
|
644
|
+
"flags": list(r.flags),
|
|
503
645
|
}
|
|
504
646
|
for r in result.skill_runs
|
|
505
647
|
],
|
|
@@ -577,6 +719,17 @@ def render_measured_markdown(result: EmpiricalResult, catalog: PricingCatalog) -
|
|
|
577
719
|
)
|
|
578
720
|
lines.append("")
|
|
579
721
|
|
|
722
|
+
flagged = [r for r in result.skill_runs if r.flags]
|
|
723
|
+
if flagged:
|
|
724
|
+
lines.append(
|
|
725
|
+
"Unrepresentative agent runs (excluded from prior fits and "
|
|
726
|
+
"not safe to read as the skill's real cost):"
|
|
727
|
+
)
|
|
728
|
+
for i, r in enumerate(result.skill_runs, 1):
|
|
729
|
+
if r.flags:
|
|
730
|
+
lines.append(f"- trial {i}: {', '.join(r.flags)}")
|
|
731
|
+
lines.append("")
|
|
732
|
+
|
|
580
733
|
fitted = suggested_priors(result.skill_runs)
|
|
581
734
|
if fitted:
|
|
582
735
|
lines.append("Suggested prior re-fits from these runs (not auto-applied):")
|
|
@@ -610,6 +763,7 @@ def measured_to_dict(result: EmpiricalResult, catalog: PricingCatalog) -> dict[s
|
|
|
610
763
|
"cost_usd": r.cost_usd,
|
|
611
764
|
"output_tokens": r.output_tokens,
|
|
612
765
|
"error": r.error,
|
|
766
|
+
"flags": list(r.flags),
|
|
613
767
|
}
|
|
614
768
|
for r in result.skill_runs
|
|
615
769
|
],
|
|
@@ -369,9 +369,13 @@ def estimate_skill(
|
|
|
369
369
|
) -> AgentRunEstimate:
|
|
370
370
|
"""Predict running the skill as raw agent instructions.
|
|
371
371
|
|
|
372
|
-
Cache-aware transcript model: turn *i* re-reads
|
|
373
|
-
of context, of which only ~Δ is
|
|
374
|
-
price); the rest is a cache read.
|
|
372
|
+
Cache-aware transcript model: turn *i* re-reads
|
|
373
|
+
min(C₀ + (i−1)·Δ, cap) tokens of context, of which only ~Δ is
|
|
374
|
+
first-sight (billed at cache-write price); the rest is a cache read.
|
|
375
|
+
The cap (``priors.transcript_cap_tokens``) models harness compaction:
|
|
376
|
+
real transcripts saturate near the context window instead of growing
|
|
377
|
+
without bound, so on long runs the cached-read total transitions from
|
|
378
|
+
quadratic to linear. Uncapped totals over N turns:
|
|
375
379
|
|
|
376
380
|
* fresh ≈ C₀ + (N−1)·Δ
|
|
377
381
|
* cached ≈ (N−1)·C₀ + Δ·(N−2)(N−1)/2
|
|
@@ -404,14 +408,24 @@ def estimate_skill(
|
|
|
404
408
|
turn_method = f"structural heuristic ({step_count} steps in SKILL.md)"
|
|
405
409
|
|
|
406
410
|
def _fresh(n: float) -> float:
|
|
411
|
+
# Fresh (first-sight) content is unaffected by the cap: each
|
|
412
|
+
# turn's new tool results are written to cache once even when
|
|
413
|
+
# compaction evicts older content to make room.
|
|
407
414
|
return context_tokens + max(0.0, n - 1) * priors.transcript_growth_per_turn
|
|
408
415
|
|
|
409
416
|
def _cached(n: float) -> float:
|
|
417
|
+
# Turn i (i >= 2) re-reads min(C₀ + (i−2)·Δ, cap): quadratic
|
|
418
|
+
# growth until the transcript saturates at the compaction cap,
|
|
419
|
+
# linear after. m counts the pre-saturation re-reads.
|
|
410
420
|
if n <= 1:
|
|
411
421
|
return 0.0
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
422
|
+
delta = priors.transcript_growth_per_turn
|
|
423
|
+
cap = priors.transcript_cap_tokens
|
|
424
|
+
reads = n - 1
|
|
425
|
+
if context_tokens >= cap or delta <= 0:
|
|
426
|
+
return reads * min(float(context_tokens), cap)
|
|
427
|
+
m = min(reads, (cap - context_tokens) / delta + 1)
|
|
428
|
+
return m * context_tokens + delta * (m - 1) * m / 2 + (reads - m) * cap
|
|
415
429
|
|
|
416
430
|
output_tokens = turns.scale(priors.output_tokens_per_turn)
|
|
417
431
|
|
|
@@ -46,6 +46,19 @@ class Priors:
|
|
|
46
46
|
turn *i* re-reads roughly C₀ + (i−1)·Δ tokens of context.
|
|
47
47
|
"""
|
|
48
48
|
|
|
49
|
+
transcript_cap_tokens: float = 165_000.0
|
|
50
|
+
"""Ceiling on the transcript the agent re-reads per turn. Real agent
|
|
51
|
+
harnesses compact or truncate the conversation as it approaches the
|
|
52
|
+
model's context window, so per-turn input saturates instead of
|
|
53
|
+
growing without bound. Without the cap, the quadratic model
|
|
54
|
+
overshoots long runs severely (a measured 730-turn browser-automation
|
|
55
|
+
run read 78M cache tokens; uncapped Δ·N²/2 alone predicts 240M+).
|
|
56
|
+
|
|
57
|
+
Measured 2026-07-09 on two production browser-automation runs
|
|
58
|
+
(184 and 730 turns, Sonnet 4.6, 200k window): per-turn cache-read
|
|
59
|
+
plateaued at ~165k in both.
|
|
60
|
+
"""
|
|
61
|
+
|
|
49
62
|
system_overhead_tokens: int = 16_000
|
|
50
63
|
"""Context the agent carries beyond the skill itself (system prompt,
|
|
51
64
|
tool definitions, environment preamble) — part of C₀.
|
|
@@ -253,8 +253,9 @@ class Scorecard:
|
|
|
253
253
|
lines.append(f"- Token counting: {self.skill.token_count_method}.")
|
|
254
254
|
lines.append(
|
|
255
255
|
"- Agent cost model: cache-aware transcript growth — turn *i* "
|
|
256
|
-
"re-reads C₀ + (i−1)
|
|
257
|
-
"
|
|
256
|
+
"re-reads min(C₀ + (i−1)·Δ, cap) context, where the cap models "
|
|
257
|
+
"harness compaction near the context window; fresh tokens bill "
|
|
258
|
+
"at cache-write price, re-reads at cache-read price."
|
|
258
259
|
)
|
|
259
260
|
lines.append("- HITL gate waits are human time and excluded from wall-clock on both sides.")
|
|
260
261
|
lines.append(
|
|
@@ -53,6 +53,27 @@ class StepEstimate(BaseModel):
|
|
|
53
53
|
phase: str | None = None
|
|
54
54
|
estimated_turns: TurnRange
|
|
55
55
|
estimated_tool_calls: TurnRange | None = None
|
|
56
|
+
iterations: TurnRange | None = Field(
|
|
57
|
+
default=None,
|
|
58
|
+
description=(
|
|
59
|
+
"How many times this step repeats per run (per row, per page, "
|
|
60
|
+
"per item). When present, estimated_turns/estimated_tool_calls "
|
|
61
|
+
"are PER-ITERATION and the whole-run contribution is their "
|
|
62
|
+
"product. Absent means the step runs once. This is where loop "
|
|
63
|
+
"cost lives: a 3-turn step over 200 rows is 600 turns, and a "
|
|
64
|
+
"sidecar that cannot say so underestimates loop-dominated "
|
|
65
|
+
"skills by an order of magnitude."
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def turn_contribution(self) -> TurnRange:
|
|
70
|
+
"""This step's whole-run turns: per-iteration turns × iterations."""
|
|
71
|
+
if self.iterations is None:
|
|
72
|
+
return self.estimated_turns
|
|
73
|
+
return TurnRange(
|
|
74
|
+
low=self.estimated_turns.low * self.iterations.low,
|
|
75
|
+
high=self.estimated_turns.high * self.iterations.high,
|
|
76
|
+
)
|
|
56
77
|
|
|
57
78
|
@model_validator(mode="before")
|
|
58
79
|
@classmethod
|
|
@@ -81,11 +102,17 @@ class EvalEstimates(BaseModel):
|
|
|
81
102
|
notes: str | None = None
|
|
82
103
|
|
|
83
104
|
def turn_range(self) -> TurnRange:
|
|
84
|
-
"""The whole-run turn estimate: explicit totals, else the step sum.
|
|
105
|
+
"""The whole-run turn estimate: explicit totals, else the step sum.
|
|
106
|
+
|
|
107
|
+
The step sum multiplies per-iteration estimates by their
|
|
108
|
+
``iterations`` range, so a per-row loop step contributes its
|
|
109
|
+
whole-run cost. An explicit ``totals`` must therefore also be a
|
|
110
|
+
whole-run (post-multiplication) figure.
|
|
111
|
+
"""
|
|
85
112
|
if self.totals is not None:
|
|
86
113
|
return self.totals
|
|
87
|
-
low = sum(s.
|
|
88
|
-
high = sum(s.
|
|
114
|
+
low = sum(s.turn_contribution().low for s in self.steps)
|
|
115
|
+
high = sum(s.turn_contribution().high for s in self.steps)
|
|
89
116
|
return TurnRange(low=low, high=high)
|
|
90
117
|
|
|
91
118
|
|
|
@@ -29,6 +29,7 @@ from dataclasses import dataclass, field
|
|
|
29
29
|
from pathlib import Path
|
|
30
30
|
from typing import Any
|
|
31
31
|
|
|
32
|
+
from rote.eval.sidecar import EVAL_SIDECAR_FILENAME, load_eval_estimates
|
|
32
33
|
from rote.graduator.drivers import (
|
|
33
34
|
DriverError,
|
|
34
35
|
GraduatorDriver,
|
|
@@ -299,6 +300,9 @@ class Graduator:
|
|
|
299
300
|
pipeline = self._repoint_source_skill(
|
|
300
301
|
output_dir / "pipeline.yaml", pipeline, skill_dir, output_dir
|
|
301
302
|
)
|
|
303
|
+
self._repoint_sidecar_source_skill(
|
|
304
|
+
output_dir / EVAL_SIDECAR_FILENAME, skill_dir, output_dir
|
|
305
|
+
)
|
|
302
306
|
|
|
303
307
|
# Re-point the result's path to the moved location for the
|
|
304
308
|
# caller's benefit.
|
|
@@ -395,6 +399,40 @@ class Graduator:
|
|
|
395
399
|
pipeline_yaml.write_text(original, encoding="utf-8")
|
|
396
400
|
return pipeline
|
|
397
401
|
|
|
402
|
+
@staticmethod
|
|
403
|
+
def _repoint_sidecar_source_skill(
|
|
404
|
+
sidecar_path: Path, skill_dir: Path, output_dir: Path
|
|
405
|
+
) -> None:
|
|
406
|
+
"""Rewrite the eval sidecar's ``source_skill`` the same way.
|
|
407
|
+
|
|
408
|
+
The sidecar suffers the identical dead-pointer failure as
|
|
409
|
+
pipeline.yaml (the agent records the path relative to its temp
|
|
410
|
+
work dir), just with a quieter blast radius: its field is
|
|
411
|
+
documentary today, but a stale path checked into an example is a
|
|
412
|
+
bug report waiting to happen. Same surgical substitution, same
|
|
413
|
+
restore-on-breakage guarantee, validated with the sidecar's own
|
|
414
|
+
loader.
|
|
415
|
+
"""
|
|
416
|
+
if not sidecar_path.is_file():
|
|
417
|
+
return
|
|
418
|
+
try:
|
|
419
|
+
source_ref = os.path.relpath(skill_dir, output_dir)
|
|
420
|
+
except ValueError:
|
|
421
|
+
source_ref = str(skill_dir)
|
|
422
|
+
|
|
423
|
+
original = sidecar_path.read_text(encoding="utf-8")
|
|
424
|
+
replacement = f"source_skill: {source_ref}"
|
|
425
|
+
text, n = re.subn(r"(?m)^source_skill:[^\n]*$", replacement, original, count=1)
|
|
426
|
+
if n == 0:
|
|
427
|
+
text, n = re.subn(r"(?m)^(version:[^\n]*)$", rf"\1\n{replacement}", original, count=1)
|
|
428
|
+
if n == 0:
|
|
429
|
+
return
|
|
430
|
+
sidecar_path.write_text(text, encoding="utf-8")
|
|
431
|
+
try:
|
|
432
|
+
load_eval_estimates(sidecar_path)
|
|
433
|
+
except Exception:
|
|
434
|
+
sidecar_path.write_text(original, encoding="utf-8")
|
|
435
|
+
|
|
398
436
|
@staticmethod
|
|
399
437
|
def _merge_work_dir_to_output(work_dir: Path, output_dir: Path) -> None:
|
|
400
438
|
"""File-level merge of ``work_dir`` into ``output_dir``.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rote_cli-0.7.0 → rote_cli-0.8.0}/skills/rote-graduate/references/crystallization-heuristics.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|