rote-cli 0.12.1__tar.gz → 0.14.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 (76) hide show
  1. {rote_cli-0.12.1 → rote_cli-0.14.0}/PKG-INFO +43 -23
  2. {rote_cli-0.12.1 → rote_cli-0.14.0}/README.md +36 -16
  3. {rote_cli-0.12.1 → rote_cli-0.14.0}/pyproject.toml +10 -6
  4. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/__init__.py +1 -1
  5. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/adapters/__init__.py +10 -2
  6. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/adapters/_common.py +76 -1
  7. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/adapters/_py_common.py +80 -59
  8. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/adapters/_ts_common.py +413 -3
  9. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/adapters/cloudflare.py +240 -52
  10. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/adapters/dbos.py +5 -1
  11. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/adapters/dbos_ts.py +46 -0
  12. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/adapters/inngest.py +72 -7
  13. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/adapters/python.py +48 -6
  14. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/adapters/temporal.py +87 -47
  15. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/cli.py +219 -23
  16. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/compiler/__init__.py +139 -2
  17. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/compiler/drivers/__init__.py +9 -0
  18. rote_cli-0.14.0/src/rote/compiler/drivers/_heartbeat.py +52 -0
  19. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/compiler/drivers/anthropic_api.py +161 -22
  20. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/compiler/drivers/claude.py +129 -56
  21. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/compiler/drivers/openai_api.py +218 -19
  22. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/compiler/events.py +2 -0
  23. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/deploy_rote_cloud.py +10 -6
  24. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/eval/baseline.py +5 -6
  25. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/eval/pricing.py +70 -2
  26. rote_cli-0.14.0/src/rote/mcp/live_tools.py +303 -0
  27. {rote_cli-0.12.1 → rote_cli-0.14.0}/.gitignore +0 -0
  28. {rote_cli-0.12.1 → rote_cli-0.14.0}/LICENSE +0 -0
  29. {rote_cli-0.12.1 → rote_cli-0.14.0}/skills/rote-compile/SKILL.md +0 -0
  30. {rote_cli-0.12.1 → rote_cli-0.14.0}/skills/rote-compile/references/crystallization-heuristics.md +0 -0
  31. {rote_cli-0.12.1 → rote_cli-0.14.0}/skills/rote-compile/references/eval-estimates.md +0 -0
  32. {rote_cli-0.12.1 → rote_cli-0.14.0}/skills/rote-compile/references/implementation.md +0 -0
  33. {rote_cli-0.12.1 → rote_cli-0.14.0}/skills/rote-compile/references/ir-schema.md +0 -0
  34. {rote_cli-0.12.1 → rote_cli-0.14.0}/skills/rote-compile/references/llm-judge-extraction.md +0 -0
  35. {rote_cli-0.12.1 → rote_cli-0.14.0}/skills/rote-compile/references/node-kinds.md +0 -0
  36. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/__main__.py +0 -0
  37. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/_dbos.py +0 -0
  38. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/app_registry.py +0 -0
  39. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/cloud_auth.py +0 -0
  40. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/cloud_compile.py +0 -0
  41. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/compiler/drivers/_fs_tools.py +0 -0
  42. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/compiler/drivers/codex.py +0 -0
  43. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/compiler/update.py +0 -0
  44. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/config.py +0 -0
  45. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/contracts.py +0 -0
  46. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/deploy.py +0 -0
  47. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/eval/__init__.py +0 -0
  48. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/eval/empirical.py +0 -0
  49. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/eval/estimate.py +0 -0
  50. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/eval/priors.py +0 -0
  51. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/eval/scorecard.py +0 -0
  52. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/eval/sidecar.py +0 -0
  53. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/eval/tokens.py +0 -0
  54. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/inference/__init__.py +0 -0
  55. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/inference/_runtime_helper.py +0 -0
  56. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/init_wizard.py +0 -0
  57. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/ir.py +0 -0
  58. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/mcp/__init__.py +0 -0
  59. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/mcp/_runtime_helper.py +0 -0
  60. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/mcp/auth.py +0 -0
  61. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/mcp/registry.py +0 -0
  62. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/mcp/release.py +0 -0
  63. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/mcp/tokens.py +0 -0
  64. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/probe.py +0 -0
  65. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/py.typed +0 -0
  66. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/runners/__init__.py +0 -0
  67. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/runners/_node.py +0 -0
  68. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/runners/cloudflare.py +0 -0
  69. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/runners/dbos_ts.py +0 -0
  70. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/runners/inngest.py +0 -0
  71. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/runners/temporal.py +0 -0
  72. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/serve/__init__.py +0 -0
  73. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/serve/backends.py +0 -0
  74. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/serve/registry.py +0 -0
  75. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/serve/server.py +0 -0
  76. {rote_cli-0.12.1 → rote_cli-0.14.0}/src/rote/skill_source.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: rote-cli
3
- Version: 0.12.1
3
+ Version: 0.14.0
4
4
  Summary: Compile AI agent skills into cheap, fast, deterministic pipelines that run without an LLM in the loop
5
5
  Project-URL: Homepage, https://roteskills.com
6
6
  Project-URL: Repository, https://github.com/trevhud/rote
@@ -29,11 +29,11 @@ Requires-Dist: pyyaml>=6.0
29
29
  Provides-Extra: api
30
30
  Requires-Dist: anthropic>=0.89; extra == 'api'
31
31
  Provides-Extra: dbos
32
- Requires-Dist: dbos>=2.26; extra == 'dbos'
32
+ Requires-Dist: dbos<3,>=2.26; extra == 'dbos'
33
33
  Provides-Extra: dev
34
34
  Requires-Dist: anthropic>=0.89; extra == 'dev'
35
- Requires-Dist: dbos>=2.26; extra == 'dev'
36
- Requires-Dist: fastmcp>=3.4.2; extra == 'dev'
35
+ Requires-Dist: dbos<3,>=2.26; extra == 'dev'
36
+ Requires-Dist: fastmcp<4,>=3.4.2; extra == 'dev'
37
37
  Requires-Dist: httpx>=0.28; extra == 'dev'
38
38
  Requires-Dist: mypy>=1.10; extra == 'dev'
39
39
  Requires-Dist: openai>=2.45; extra == 'dev'
@@ -43,12 +43,12 @@ Requires-Dist: ruff>=0.6; extra == 'dev'
43
43
  Requires-Dist: temporalio>=1.7; extra == 'dev'
44
44
  Requires-Dist: types-pyyaml; extra == 'dev'
45
45
  Provides-Extra: mcp
46
- Requires-Dist: fastmcp>=3.4.2; extra == 'mcp'
46
+ Requires-Dist: fastmcp<4,>=3.4.2; extra == 'mcp'
47
47
  Requires-Dist: httpx>=0.28; extra == 'mcp'
48
48
  Provides-Extra: openai-api
49
49
  Requires-Dist: openai>=2.45; extra == 'openai-api'
50
50
  Provides-Extra: serve
51
- Requires-Dist: fastmcp>=3.4.2; extra == 'serve'
51
+ Requires-Dist: fastmcp<4,>=3.4.2; extra == 'serve'
52
52
  Requires-Dist: httpx>=0.28; extra == 'serve'
53
53
  Provides-Extra: temporal
54
54
  Requires-Dist: temporalio>=1.7; extra == 'temporal'
@@ -257,6 +257,24 @@ override with `--agent`. The output directory splits into `compiled/`
257
257
  (the agent's `pipeline.yaml`, `extracted/`, `signatures/`, eval seeds,
258
258
  and a `compile-report.md`) and `runtime/<runtime>/` (the adapter's
259
259
  emitted code + a README on how to run, signal gates, and deploy).
260
+ `compiled/compile-metrics.json` preserves final token counts, session ID,
261
+ and the driver's cost estimate before emission starts, so an adapter
262
+ failure does not lose the paid run's metrics. Missing final output stays
263
+ null with `usage_complete: false`; unchanged updates retain prior metrics.
264
+
265
+ Local `api` and `openai-api` compilation automatically discovers your
266
+ registered, authenticated MCP servers and exposes their read-only tools.
267
+ Pass `--no-mcp` to skip that discovery and compile without live MCP calls:
268
+
269
+ ```sh
270
+ rote compile examples/bdr-outreach/skill --local --agent api --no-mcp --out /tmp/bdr-compiled
271
+ ```
272
+
273
+ This leaves the emitted pipeline's MCP bindings intact. The flag also works
274
+ with `rote analyze` and with an API driver selected through config or
275
+ auto-detection. It rejects cloud compilation, subprocess drivers, and
276
+ `--baseline`, whose raw skill run uses its own MCP tools. API drivers bill
277
+ through your API credentials.
260
278
 
261
279
  ### Other commands
262
280
 
@@ -434,9 +452,8 @@ plain-Python subprocess); those need a Node toolchain / Docker, so they
434
452
  run locally with `pytest tests/ -m slow`, not in CI.
435
453
 
436
454
  Known gaps: the extracted modules are `NotImplementedError` stubs
437
- you fill in with real API-client code, a Restate adapter is planned, and
438
- `fan_out` nodes currently receive the whole upstream list in one
439
- invocation (per-element dispatch is a planned enhancement). Published on
455
+ you fill in with real API-client code, and a Restate adapter is planned.
456
+ Published on
440
457
  PyPI as [`rote-cli`](https://pypi.org/project/rote-cli/) via tag-driven
441
458
  Trusted Publishing ([docs/releasing.md](docs/releasing.md)).
442
459
 
@@ -502,6 +519,9 @@ rote/
502
519
  archetype: a bounded browser-automation loop stays one agent node
503
520
  while the date math, filtering, and reporting around it compile to
504
521
  code, plus the measured runs that forced the loop-aware cost model
522
+ - [`examples/rote-compile/`](examples/rote-compile/): the compiler compiling
523
+ its own core skill, with preserved artifacts and an offline audit of
524
+ the generated workflow's runtime and data-flow failures
505
525
 
506
526
  ---
507
527
 
@@ -509,19 +529,19 @@ rote/
509
529
 
510
530
  In rough priority order:
511
531
 
512
- 1. **Re-compile BDR end-to-end with `signature_spec`**: the bundled IR
513
- was hand-extended with structured schemas; the rubric now teaches the
514
- field, but no real run has produced one yet.
515
- 2. **Pre-filter as a `pure_function` node**: today hard thresholds are
516
- lifted into a judge's `forward()`, which works for Temporal but not
517
- Cloudflare; a separate node makes the short-circuit uniform.
518
- 3. **More example skills**: BDR is one shape; research-heavy,
519
- retrieval-heavy, and code-review skills stress the IR differently.
520
- 4. **`fan_out` per-element dispatch**: currently the whole upstream list
521
- arrives in one invocation.
522
- 5. **The compiler compiling itself**: `rote-compile` is a SKILL.md;
523
- pointing `rote compile` at it should crystallize its rubric-grade
524
- pieces and leave only the genuinely fuzzy judgments in the loop.
532
+ 1. **Pre-filter as a `pure_function` node**: today hard thresholds are
533
+ lifted into a judge's `forward()`, which works for the Python
534
+ runtimes but not the TypeScript ones; a separate node makes the
535
+ short-circuit uniform across all six.
536
+ 2. **Make self-compilation executable**: the
537
+ [preserved core-skill run](examples/rote-compile/) produced valid IR,
538
+ and runtime emission now succeeds. Its offline audit still has
539
+ data-flow and agent-result contract failures. Close those gaps before
540
+ treating it as a working replacement compiler.
541
+ 3. **More example skills**: four archetypes are covered today (BDR
542
+ outreach, 100%-roteness, data-heavy, agent-loop). Research-heavy,
543
+ retrieval-heavy, and code-review skills each stress the IR in ways
544
+ none of those four do.
525
545
 
526
546
  ---
527
547
 
@@ -201,6 +201,24 @@ override with `--agent`. The output directory splits into `compiled/`
201
201
  (the agent's `pipeline.yaml`, `extracted/`, `signatures/`, eval seeds,
202
202
  and a `compile-report.md`) and `runtime/<runtime>/` (the adapter's
203
203
  emitted code + a README on how to run, signal gates, and deploy).
204
+ `compiled/compile-metrics.json` preserves final token counts, session ID,
205
+ and the driver's cost estimate before emission starts, so an adapter
206
+ failure does not lose the paid run's metrics. Missing final output stays
207
+ null with `usage_complete: false`; unchanged updates retain prior metrics.
208
+
209
+ Local `api` and `openai-api` compilation automatically discovers your
210
+ registered, authenticated MCP servers and exposes their read-only tools.
211
+ Pass `--no-mcp` to skip that discovery and compile without live MCP calls:
212
+
213
+ ```sh
214
+ rote compile examples/bdr-outreach/skill --local --agent api --no-mcp --out /tmp/bdr-compiled
215
+ ```
216
+
217
+ This leaves the emitted pipeline's MCP bindings intact. The flag also works
218
+ with `rote analyze` and with an API driver selected through config or
219
+ auto-detection. It rejects cloud compilation, subprocess drivers, and
220
+ `--baseline`, whose raw skill run uses its own MCP tools. API drivers bill
221
+ through your API credentials.
204
222
 
205
223
  ### Other commands
206
224
 
@@ -378,9 +396,8 @@ plain-Python subprocess); those need a Node toolchain / Docker, so they
378
396
  run locally with `pytest tests/ -m slow`, not in CI.
379
397
 
380
398
  Known gaps: the extracted modules are `NotImplementedError` stubs
381
- you fill in with real API-client code, a Restate adapter is planned, and
382
- `fan_out` nodes currently receive the whole upstream list in one
383
- invocation (per-element dispatch is a planned enhancement). Published on
399
+ you fill in with real API-client code, and a Restate adapter is planned.
400
+ Published on
384
401
  PyPI as [`rote-cli`](https://pypi.org/project/rote-cli/) via tag-driven
385
402
  Trusted Publishing ([docs/releasing.md](docs/releasing.md)).
386
403
 
@@ -446,6 +463,9 @@ rote/
446
463
  archetype: a bounded browser-automation loop stays one agent node
447
464
  while the date math, filtering, and reporting around it compile to
448
465
  code, plus the measured runs that forced the loop-aware cost model
466
+ - [`examples/rote-compile/`](examples/rote-compile/): the compiler compiling
467
+ its own core skill, with preserved artifacts and an offline audit of
468
+ the generated workflow's runtime and data-flow failures
449
469
 
450
470
  ---
451
471
 
@@ -453,19 +473,19 @@ rote/
453
473
 
454
474
  In rough priority order:
455
475
 
456
- 1. **Re-compile BDR end-to-end with `signature_spec`**: the bundled IR
457
- was hand-extended with structured schemas; the rubric now teaches the
458
- field, but no real run has produced one yet.
459
- 2. **Pre-filter as a `pure_function` node**: today hard thresholds are
460
- lifted into a judge's `forward()`, which works for Temporal but not
461
- Cloudflare; a separate node makes the short-circuit uniform.
462
- 3. **More example skills**: BDR is one shape; research-heavy,
463
- retrieval-heavy, and code-review skills stress the IR differently.
464
- 4. **`fan_out` per-element dispatch**: currently the whole upstream list
465
- arrives in one invocation.
466
- 5. **The compiler compiling itself**: `rote-compile` is a SKILL.md;
467
- pointing `rote compile` at it should crystallize its rubric-grade
468
- pieces and leave only the genuinely fuzzy judgments in the loop.
476
+ 1. **Pre-filter as a `pure_function` node**: today hard thresholds are
477
+ lifted into a judge's `forward()`, which works for the Python
478
+ runtimes but not the TypeScript ones; a separate node makes the
479
+ short-circuit uniform across all six.
480
+ 2. **Make self-compilation executable**: the
481
+ [preserved core-skill run](examples/rote-compile/) produced valid IR,
482
+ and runtime emission now succeeds. Its offline audit still has
483
+ data-flow and agent-result contract failures. Close those gaps before
484
+ treating it as a working replacement compiler.
485
+ 3. **More example skills**: four archetypes are covered today (BDR
486
+ outreach, 100%-roteness, data-heavy, agent-loop). Research-heavy,
487
+ retrieval-heavy, and code-review skills each stress the IR in ways
488
+ none of those four do.
469
489
 
470
490
  ---
471
491
 
@@ -69,14 +69,14 @@ openai-api = [
69
69
  "openai>=2.45",
70
70
  ]
71
71
  dbos = [
72
- "dbos>=2.26",
72
+ "dbos>=2.26,<3",
73
73
  ]
74
74
  serve = [
75
- "fastmcp>=3.4.2",
75
+ "fastmcp>=3.4.2,<4",
76
76
  "httpx>=0.28",
77
77
  ]
78
78
  mcp = [
79
- "fastmcp>=3.4.2",
79
+ "fastmcp>=3.4.2,<4",
80
80
  "httpx>=0.28",
81
81
  ]
82
82
  dev = [
@@ -88,8 +88,8 @@ dev = [
88
88
  "temporalio>=1.7",
89
89
  "anthropic>=0.89",
90
90
  "openai>=2.45",
91
- "dbos>=2.26",
92
- "fastmcp>=3.4.2",
91
+ "dbos>=2.26,<3",
92
+ "fastmcp>=3.4.2,<4",
93
93
  "httpx>=0.28",
94
94
  ]
95
95
 
@@ -138,7 +138,11 @@ extend-exclude = ["examples"]
138
138
  select = ["E", "F", "I", "UP", "B", "SIM"]
139
139
 
140
140
  [tool.pytest.ini_options]
141
- addopts = "-m 'not slow'"
141
+ # -rs: always print the REASON for every skip. A conditional skip whose
142
+ # condition silently becomes permanent is invisible behind a bare
143
+ # "1 skipped" count — that is how `test_per_tool_override_changes_payload`
144
+ # went from a real test to dead weight without anyone noticing.
145
+ addopts = "-m 'not slow' -rs"
142
146
  markers = [
143
147
  "slow: tests that need an external toolchain (Node/npm/tsc) or take >5s. Run with `pytest -m slow` to include.",
144
148
  ]
@@ -1,3 +1,3 @@
1
1
  """rote — compile fuzzy AI skills into deterministic workflows."""
2
2
 
3
- __version__ = "0.12.1"
3
+ __version__ = "0.14.0"
@@ -56,9 +56,17 @@ def _cloudflare_adapter_factory(**options: Any) -> Adapter:
56
56
  from rote.adapters.cloudflare import CloudflareAdapter, CloudflareAdapterConfig
57
57
 
58
58
  backend = _validated_backend(options)
59
- if backend is None:
59
+ mcp_client = options.get("mcp_client")
60
+ if mcp_client not in (None, "direct", "binding"):
61
+ raise ValueError(f"mcp_client must be 'direct' or 'binding', got {mcp_client!r}")
62
+ if backend is None and mcp_client is None:
60
63
  return CloudflareAdapter()
61
- return CloudflareAdapter(CloudflareAdapterConfig(external_backend=backend))
64
+ return CloudflareAdapter(
65
+ CloudflareAdapterConfig(
66
+ external_backend=backend or "mcp",
67
+ mcp_client=mcp_client or "direct",
68
+ )
69
+ )
62
70
 
63
71
 
64
72
  def _source_dir(options: dict[str, Any]) -> Path | None:
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
  import hashlib
15
15
  import json
16
16
  import re
17
+ from collections.abc import Sequence
17
18
  from pathlib import Path
18
19
 
19
20
  from rote.ir import Node, NodeKind, Pipeline, parse_input_ref
@@ -305,12 +306,20 @@ def ir_duration_to_human(s: str) -> str:
305
306
  Cloudflare's step config accepts directly. Strings that don't match
306
307
  the IR shorthand pattern are passed through unchanged (they're
307
308
  assumed to already be in an acceptable form).
309
+
310
+ A value of exactly 1 is singularized ('1 hour', not '1 hours').
311
+ Cloudflare's ``WorkflowDuration`` accepts either spelling, so this
312
+ is legibility rather than correctness — but emitted code is read by
313
+ humans, and '1 hours' in a reviewed artifact looks like a bug.
308
314
  """
309
315
  s = s.strip()
310
316
  m = _IR_DURATION_RE.fullmatch(s)
311
317
  if not m:
312
318
  return s
313
- return f"{m.group(1)} {_UNIT_TO_HUMAN[m.group(2)]}"
319
+ value, unit = m.group(1), _UNIT_TO_HUMAN[m.group(2)]
320
+ if float(value) == 1:
321
+ unit = unit.removesuffix("s")
322
+ return f"{value} {unit}"
314
323
 
315
324
 
316
325
  _UNIT_TO_SECONDS = {
@@ -422,6 +431,72 @@ def check_input_refs_available(node: Node, available: set[str]) -> None:
422
431
  )
423
432
 
424
433
 
434
+ def fan_out_element_param(node: Node, pipeline: Pipeline) -> str:
435
+ """Which ``inputs`` param carries the list a ``fan_out`` node fans over.
436
+
437
+ Every other input is shared verbatim by all invocations. Which param
438
+ is the list, in precedence order:
439
+
440
+ 1. the param bound to the source of an incoming ``fan_out: true``
441
+ edge (the IR's explicit marker);
442
+ 2. else the param bound to a node with any incoming edge — inputs
443
+ may also reference nodes *without* an edge (shared context, e.g.
444
+ BDR's ``intel``), which is what makes "the only node-bound
445
+ param" too naive;
446
+ 3. else the only node-bound param.
447
+
448
+ Ambiguity after all three is an emit-time error, never a guess:
449
+ dispatching over the wrong list would silently judge the wrong
450
+ things.
451
+
452
+ This lives in the language-neutral common module because the answer
453
+ is a property of the IR, not of the target language — every adapter
454
+ must fan over the *same* input or the same pipeline would mean
455
+ different things on different runtimes.
456
+ """
457
+ if not node.inputs:
458
+ raise ValueError(f"fan_out node {node.id!r} has no inputs: nothing to fan over")
459
+ node_bound = {
460
+ param: parsed.node_id
461
+ for param, ref in node.inputs.items()
462
+ if (parsed := parse_input_ref(ref)).node_id is not None
463
+ }
464
+ edge_sources = {e.from_ for e in pipeline.edges if e.to == node.id}
465
+ fan_edge_sources = {e.from_ for e in pipeline.edges if e.to == node.id and e.fan_out}
466
+
467
+ # The tiers are tried narrowest-first, but the ORDER is provably
468
+ # unobservable: fan_edge_sources ⊆ edge_sources by construction, so
469
+ # matching(fan) ⊆ matching(edge), and a tier is accepted only when it
470
+ # singles out exactly one param. If the broader tier singles one out,
471
+ # it is necessarily the same one. (Mutation testing flags swapping
472
+ # these as a surviving mutant — it is equivalent, not a test gap.)
473
+ # The tiers still earn their place: they express which signal the
474
+ # author gave, and tier 2 is what handles a marker pointing at a node
475
+ # no input references.
476
+ for sources in (fan_edge_sources, edge_sources):
477
+ matching = sorted(p for p, src in node_bound.items() if src in sources)
478
+ if len(matching) == 1:
479
+ return matching[0]
480
+ if len(node_bound) != 1:
481
+ raise ValueError(
482
+ f"fan_out node {node.id!r}: cannot identify the element param — "
483
+ f"node-bound inputs {sorted(node_bound)} and incoming edges "
484
+ f"{sorted(edge_sources)} don't single one out; mark the list edge "
485
+ f"with `fan_out: true`"
486
+ )
487
+ return next(iter(node_bound))
488
+
489
+
490
+ def fan_out_nodes(wave: Sequence[Node]) -> tuple[list[Node], list[Node]]:
491
+ """Split a wave into ``(fan_out_nodes, plain_nodes)``, order preserved.
492
+
493
+ A ``fan_out`` node dispatches once per element of its bound list, so
494
+ it never shares the single/parallel payload shapes an adapter emits
495
+ for plain nodes.
496
+ """
497
+ return [n for n in wave if n.fan_out], [n for n in wave if not n.fan_out]
498
+
499
+
425
500
  # ───────── MCP-capability refusal ─────────
426
501
 
427
502
 
@@ -30,6 +30,7 @@ from rote.adapters._common import (
30
30
  DEFAULT_AGENT_MAX_ITERATIONS,
31
31
  EmitWriter,
32
32
  _to_pascal_case,
33
+ fan_out_element_param,
33
34
  safe_docstring_line,
34
35
  )
35
36
  from rote.ir import LLMSignature, Node, NodeKind, Pipeline, parse_input_ref
@@ -93,52 +94,65 @@ def _payload_literal(node: Node, indent: str) -> str:
93
94
  return "\n".join(lines)
94
95
 
95
96
 
96
- def fan_out_binding(node: Node, pipeline: Pipeline) -> tuple[str, str, dict[str, str]]:
97
- """``(element_param, list_expr, scalar_exprs)`` for a ``fan_out`` node.
97
+ _FAN_OUT_LIST_HELPER_PY = '''\
98
+ def _fan_out_list(value: object, node_id: str, ref: str) -> list:
99
+ """The list a fan_out node dispatches over.
98
100
 
99
- The element parameter is the one bound to the upstream *list* the
100
- node fans over (ir-schema.md); every other input is shared verbatim
101
- by all invocations. Which param that is, in precedence order:
102
-
103
- 1. the param bound to the source of an incoming ``fan_out: true``
104
- edge (the IR's explicit marker);
105
- 2. else the param bound to a node with any incoming edge — inputs
106
- may also reference nodes *without* an edge (shared context, e.g.
107
- BDR's ``intel``), which is what makes "the only node-bound
108
- param" too naive;
109
- 3. else the only node-bound param.
110
-
111
- Ambiguity after all three is an emit-time error, never a guess:
112
- dispatching over the wrong list would silently judge the wrong
113
- things.
101
+ Iterating a missing key raises "'NoneType' object is not iterable",
102
+ which names neither the node nor the reference — in generated code
103
+ that is close to undebuggable, and the cause is almost always an
104
+ upstream node that didn't return the key the IR says it does. The
105
+ TypeScript runtimes emit the same guard (`fanOutList`), so a missing
106
+ key reports identically on every target.
114
107
  """
115
- if not node.inputs:
116
- raise ValueError(f"fan_out node {node.id!r} has no inputs: nothing to fan over")
117
- node_bound = {
118
- param: parsed.node_id
119
- for param, ref in node.inputs.items()
120
- if (parsed := parse_input_ref(ref)).node_id is not None
121
- }
122
- edge_sources = {e.from_ for e in pipeline.edges if e.to == node.id}
123
- fan_edge_sources = {e.from_ for e in pipeline.edges if e.to == node.id and e.fan_out}
124
-
125
- for sources in (fan_edge_sources, edge_sources):
126
- matching = sorted(p for p, src in node_bound.items() if src in sources)
127
- if len(matching) == 1:
128
- element_param = matching[0]
129
- break
130
- else:
131
- if len(node_bound) != 1:
132
- raise ValueError(
133
- f"fan_out node {node.id!r}: cannot identify the element param — "
134
- f"node-bound inputs {sorted(node_bound)} and incoming edges "
135
- f"{sorted(edge_sources)} don't single one out; mark the list edge "
136
- f"with `fan_out: true`"
137
- )
138
- element_param = next(iter(node_bound))
108
+ if not isinstance(value, list):
109
+ raise TypeError(
110
+ f"fan_out node {node_id!r} expected a list from {ref!r}, got "
111
+ f"{type(value).__name__}. The upstream node must return that "
112
+ f"key as a list."
113
+ )
114
+ return value
115
+ '''
116
+
117
+
118
+ def fan_out_list_helper() -> str:
119
+ """The ``_fan_out_list`` guard emitted into Python runtimes that fan out.
120
+
121
+ Behavioral emitted code, like ``_serialize``: if the runtimes' guards
122
+ drift, the same broken pipeline reports differently per target.
123
+ """
124
+ return _FAN_OUT_LIST_HELPER_PY
125
+
126
+
127
+ def fan_out_binding(
128
+ node: Node, pipeline: Pipeline, indent: str = ""
129
+ ) -> tuple[str, str, dict[str, str]]:
130
+ """``(element_param, list_expr, scalar_exprs)`` for a ``fan_out`` node.
131
+
132
+ The Python rendering of :func:`fan_out_element_param` — which input
133
+ is the list is a language-neutral property of the IR, so it is
134
+ resolved there and only the expressions are built here.
139
135
 
136
+ ``indent`` is the column the ``_fan_out_list(`` token sits at; the
137
+ guard call wraps against it rather than running long on one line.
138
+ """
139
+ element_param = fan_out_element_param(node, pipeline)
140
+ # Narrowing only: fan_out_element_param raises on a node with no
141
+ # inputs, so reaching here means there is something to fan over.
142
+ assert node.inputs is not None
140
143
  scalars = {p: _ref_to_python_expr(r) for p, r in node.inputs.items() if p != element_param}
141
- return element_param, _ref_to_python_expr(node.inputs[element_param]), scalars
144
+ ref = node.inputs[element_param]
145
+ inner = indent + " "
146
+ # json.dumps, not !r: emitted Python uses double quotes throughout
147
+ # (the repo's ruff-format style), same as _py_literal.
148
+ list_expr = (
149
+ "_fan_out_list(\n"
150
+ f"{inner}{_ref_to_python_expr(ref)},\n"
151
+ f"{inner}{json.dumps(node.id)},\n"
152
+ f"{inner}{json.dumps(ref)},\n"
153
+ f"{indent})"
154
+ )
155
+ return element_param, list_expr, scalars
142
156
 
143
157
 
144
158
  # ───────── JSON Schema → Pydantic source ─────────
@@ -218,38 +232,47 @@ class _SchemaToPydantic:
218
232
 
219
233
  The Python analog of the Cloudflare adapter's ``json_schema_to_zod``.
220
234
  Named ``$defs`` become named classes / ``Literal`` aliases; inline
221
- object schemas get synthesized names. Blocks are accumulated in
222
- dependency order (a class is emitted only after everything it
223
- references), so the generated module imports cleanly top-to-bottom.
235
+ object schemas get synthesized names. Each root schema resolves its
236
+ own definitions; generated names and dependency-ordered blocks are
237
+ shared across the module.
224
238
  """
225
239
 
226
- def __init__(self) -> None:
240
+ def __init__(self, *, reserved_names: tuple[str, ...] = ()) -> None:
227
241
  self._defs: dict[str, dict[str, Any]] = {}
228
242
  self._blocks: list[str] = []
229
243
  self._emitted: dict[str, str] = {} # $def name -> python name
230
244
  self._in_progress: set[str] = set()
231
- self._used_names: set[str] = set()
245
+ self._used_names: set[str] = {
246
+ *keyword.kwlist,
247
+ *reserved_names,
248
+ "Any",
249
+ "Literal",
250
+ "BaseModel",
251
+ "ConfigDict",
252
+ "Field",
253
+ "PROMPT",
254
+ "MODEL",
255
+ "TEMPERATURE",
256
+ }
232
257
  self.uses_literal = False
233
258
  self.uses_config_dict = False
234
259
  self.uses_field = False
235
260
 
236
261
  # ── public API ──
237
262
 
238
- def add_defs(self, defs: dict[str, Any]) -> None:
239
- for name, schema in defs.items():
240
- if name in self._defs and self._defs[name] != schema:
241
- raise ValueError(
242
- f"signature emission: $defs entry {name!r} appears in both input "
243
- f"and output schemas with different definitions"
244
- )
245
- self._defs[name] = schema
246
-
247
263
  def emit_root(self, class_name: str, schema: dict[str, Any]) -> None:
248
264
  if schema.get("type") != "object":
249
265
  raise ValueError(
250
266
  f"signature emission: root signature schema for {class_name} must be "
251
267
  f"an object schema, got type={schema.get('type')!r}"
252
268
  )
269
+ # A #/$defs reference belongs to its root document. Sharing this
270
+ # cache across input/output can bind even identical wrappers to the
271
+ # wrong nested definition. Only Python symbol allocation is global.
272
+ self._defs = schema.get("$defs", {})
273
+ self._emitted.clear()
274
+ self._in_progress.clear()
275
+ self._used_names.add(class_name)
253
276
  self._emit_object_class(class_name, schema)
254
277
 
255
278
  @property
@@ -484,9 +507,7 @@ def emit_signature_module(
484
507
  spec = node.signature_spec
485
508
  pascal = _to_pascal_case(node.id)
486
509
 
487
- converter = _SchemaToPydantic()
488
- converter.add_defs(spec.input_schema.get("$defs", {}))
489
- converter.add_defs(spec.output_schema.get("$defs", {}))
510
+ converter = _SchemaToPydantic(reserved_names=(pascal, f"{pascal}Input", f"{pascal}Output"))
490
511
  converter.emit_root(f"{pascal}Input", spec.input_schema)
491
512
  converter.emit_root(f"{pascal}Output", spec.output_schema)
492
513