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.
Files changed (43) hide show
  1. {rote_cli-0.7.0 → rote_cli-0.8.0}/PKG-INFO +1 -1
  2. {rote_cli-0.7.0 → rote_cli-0.8.0}/skills/rote-graduate/references/eval-estimates.md +41 -20
  3. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/__init__.py +1 -1
  4. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/cli.py +24 -0
  5. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/eval/empirical.py +156 -2
  6. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/eval/estimate.py +20 -6
  7. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/eval/priors.py +13 -0
  8. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/eval/scorecard.py +3 -2
  9. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/eval/sidecar.py +30 -3
  10. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/graduator/__init__.py +38 -0
  11. {rote_cli-0.7.0 → rote_cli-0.8.0}/.gitignore +0 -0
  12. {rote_cli-0.7.0 → rote_cli-0.8.0}/LICENSE +0 -0
  13. {rote_cli-0.7.0 → rote_cli-0.8.0}/README.md +0 -0
  14. {rote_cli-0.7.0 → rote_cli-0.8.0}/pyproject.toml +0 -0
  15. {rote_cli-0.7.0 → rote_cli-0.8.0}/skills/rote-graduate/SKILL.md +0 -0
  16. {rote_cli-0.7.0 → rote_cli-0.8.0}/skills/rote-graduate/references/crystallization-heuristics.md +0 -0
  17. {rote_cli-0.7.0 → rote_cli-0.8.0}/skills/rote-graduate/references/ir-schema.md +0 -0
  18. {rote_cli-0.7.0 → rote_cli-0.8.0}/skills/rote-graduate/references/llm-judge-extraction.md +0 -0
  19. {rote_cli-0.7.0 → rote_cli-0.8.0}/skills/rote-graduate/references/node-kinds.md +0 -0
  20. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/adapters/__init__.py +0 -0
  21. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/adapters/_common.py +0 -0
  22. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/adapters/_py_common.py +0 -0
  23. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/adapters/_ts_common.py +0 -0
  24. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/adapters/cloudflare.py +0 -0
  25. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/adapters/dbos.py +0 -0
  26. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/adapters/dbos_ts.py +0 -0
  27. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/adapters/inngest.py +0 -0
  28. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/adapters/python.py +0 -0
  29. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/adapters/temporal.py +0 -0
  30. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/eval/__init__.py +0 -0
  31. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/eval/pricing.py +0 -0
  32. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/eval/tokens.py +0 -0
  33. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/graduator/drivers/__init__.py +0 -0
  34. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/graduator/drivers/anthropic_api.py +0 -0
  35. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/graduator/drivers/claude.py +0 -0
  36. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/graduator/drivers/codex.py +0 -0
  37. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/graduator/update.py +0 -0
  38. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/ir.py +0 -0
  39. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/serve/__init__.py +0 -0
  40. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/serve/backends.py +0 -0
  41. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/serve/registry.py +0 -0
  42. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/serve/server.py +0 -0
  43. {rote_cli-0.7.0 → rote_cli-0.8.0}/src/rote/skill_source.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rote-cli
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Graduate fuzzy AI skills into deterministic, reliable workflows
5
5
  Project-URL: Homepage, https://github.com/trevhud/rote
6
6
  Project-URL: Repository, https://github.com/trevhud/rote
@@ -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
- totals: # optional omit if the sum is right
58
- low: 28
59
- high: 45
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
- | `totals` | no | Whole-run `{low, high}`; omit to mean "sum the steps" |
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 (BDR reference points)
85
-
86
- From real runs of BDR-scale skills (7 phases, ~20 distinct steps,
87
- heavy tool use):
88
-
89
- - Whole-run turn counts landed in the **30–57 turn** range.
90
- - Wall clock was **~13 seconds per turn** including tool execution.
91
- - A single fixed API call step: 1–2 turns. A batch-and-review step:
92
- 2–4. A bounded search loop: 6–15. A genuinely open research step:
93
- 4–10.
94
-
95
- If your step estimates sum to something wildly outside the anchor range
96
- for a comparably sized skill, re-examine before writing — either the
97
- skill really is bigger/smaller (fine, say so in `notes`) or a step
98
- estimate is off.
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
 
@@ -1,3 +1,3 @@
1
1
  """rote — graduate fuzzy AI skills into deterministic workflows."""
2
2
 
3
- __version__ = "0.7.0"
3
+ __version__ = "0.8.0"
@@ -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=meta.get("num_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 C₀ + (i−1)·Δ tokens
373
- of context, of which only ~Δ is first-sight (billed at cache-write
374
- price); the rest is a cache read. Totals over N turns:
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
- return (n - 1) * context_tokens + priors.transcript_growth_per_turn * (
413
- (n - 2) * (n - 1) / 2
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)·Δ context; fresh tokens bill at cache-write "
257
- "price, re-reads at cache-read price."
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.estimated_turns.low for s in self.steps)
88
- high = sum(s.estimated_turns.high for s in self.steps)
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