rote-cli 0.12.1__tar.gz → 0.13.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.12.1 → rote_cli-0.13.0}/PKG-INFO +4 -7
- {rote_cli-0.12.1 → rote_cli-0.13.0}/README.md +3 -6
- {rote_cli-0.12.1 → rote_cli-0.13.0}/pyproject.toml +5 -1
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/__init__.py +1 -1
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/adapters/_common.py +76 -1
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/adapters/_py_common.py +56 -42
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/adapters/_ts_common.py +62 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/adapters/cloudflare.py +99 -6
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/adapters/dbos.py +5 -1
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/adapters/dbos_ts.py +46 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/adapters/inngest.py +72 -7
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/adapters/python.py +48 -6
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/adapters/temporal.py +87 -47
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/cli.py +6 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/.gitignore +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/LICENSE +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/skills/rote-compile/SKILL.md +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/skills/rote-compile/references/crystallization-heuristics.md +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/skills/rote-compile/references/eval-estimates.md +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/skills/rote-compile/references/implementation.md +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/skills/rote-compile/references/ir-schema.md +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/skills/rote-compile/references/llm-judge-extraction.md +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/skills/rote-compile/references/node-kinds.md +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/__main__.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/_dbos.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/adapters/__init__.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/app_registry.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/cloud_auth.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/cloud_compile.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/compiler/__init__.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/compiler/drivers/__init__.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/compiler/drivers/_fs_tools.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/compiler/drivers/anthropic_api.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/compiler/drivers/claude.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/compiler/drivers/codex.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/compiler/drivers/openai_api.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/compiler/events.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/compiler/update.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/config.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/contracts.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/deploy.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/deploy_rote_cloud.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/eval/__init__.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/eval/baseline.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/eval/empirical.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/eval/estimate.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/eval/pricing.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/eval/priors.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/eval/scorecard.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/eval/sidecar.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/eval/tokens.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/inference/__init__.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/inference/_runtime_helper.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/init_wizard.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/ir.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/mcp/__init__.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/mcp/_runtime_helper.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/mcp/auth.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/mcp/registry.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/mcp/release.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/mcp/tokens.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/probe.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/py.typed +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/runners/__init__.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/runners/_node.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/runners/cloudflare.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/runners/dbos_ts.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/runners/inngest.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/runners/temporal.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/serve/__init__.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/serve/backends.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/serve/registry.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/serve/server.py +0 -0
- {rote_cli-0.12.1 → rote_cli-0.13.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.
|
|
3
|
+
Version: 0.13.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
|
|
@@ -434,9 +434,8 @@ plain-Python subprocess); those need a Node toolchain / Docker, so they
|
|
|
434
434
|
run locally with `pytest tests/ -m slow`, not in CI.
|
|
435
435
|
|
|
436
436
|
Known gaps: the extracted modules are `NotImplementedError` stubs
|
|
437
|
-
you fill in with real API-client code, a Restate adapter is planned
|
|
438
|
-
|
|
439
|
-
invocation (per-element dispatch is a planned enhancement). Published on
|
|
437
|
+
you fill in with real API-client code, and a Restate adapter is planned.
|
|
438
|
+
Published on
|
|
440
439
|
PyPI as [`rote-cli`](https://pypi.org/project/rote-cli/) via tag-driven
|
|
441
440
|
Trusted Publishing ([docs/releasing.md](docs/releasing.md)).
|
|
442
441
|
|
|
@@ -517,9 +516,7 @@ In rough priority order:
|
|
|
517
516
|
Cloudflare; a separate node makes the short-circuit uniform.
|
|
518
517
|
3. **More example skills**: BDR is one shape; research-heavy,
|
|
519
518
|
retrieval-heavy, and code-review skills stress the IR differently.
|
|
520
|
-
4.
|
|
521
|
-
arrives in one invocation.
|
|
522
|
-
5. **The compiler compiling itself**: `rote-compile` is a SKILL.md;
|
|
519
|
+
4. **The compiler compiling itself**: `rote-compile` is a SKILL.md;
|
|
523
520
|
pointing `rote compile` at it should crystallize its rubric-grade
|
|
524
521
|
pieces and leave only the genuinely fuzzy judgments in the loop.
|
|
525
522
|
|
|
@@ -378,9 +378,8 @@ plain-Python subprocess); those need a Node toolchain / Docker, so they
|
|
|
378
378
|
run locally with `pytest tests/ -m slow`, not in CI.
|
|
379
379
|
|
|
380
380
|
Known gaps: the extracted modules are `NotImplementedError` stubs
|
|
381
|
-
you fill in with real API-client code, a Restate adapter is planned
|
|
382
|
-
|
|
383
|
-
invocation (per-element dispatch is a planned enhancement). Published on
|
|
381
|
+
you fill in with real API-client code, and a Restate adapter is planned.
|
|
382
|
+
Published on
|
|
384
383
|
PyPI as [`rote-cli`](https://pypi.org/project/rote-cli/) via tag-driven
|
|
385
384
|
Trusted Publishing ([docs/releasing.md](docs/releasing.md)).
|
|
386
385
|
|
|
@@ -461,9 +460,7 @@ In rough priority order:
|
|
|
461
460
|
Cloudflare; a separate node makes the short-circuit uniform.
|
|
462
461
|
3. **More example skills**: BDR is one shape; research-heavy,
|
|
463
462
|
retrieval-heavy, and code-review skills stress the IR differently.
|
|
464
|
-
4.
|
|
465
|
-
arrives in one invocation.
|
|
466
|
-
5. **The compiler compiling itself**: `rote-compile` is a SKILL.md;
|
|
463
|
+
4. **The compiler compiling itself**: `rote-compile` is a SKILL.md;
|
|
467
464
|
pointing `rote compile` at it should crystallize its rubric-grade
|
|
468
465
|
pieces and leave only the genuinely fuzzy judgments in the loop.
|
|
469
466
|
|
|
@@ -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
|
-
|
|
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
|
]
|
|
@@ -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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
116
|
-
raise
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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.
|
|
139
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.
|
|
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
|
-
|
|
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 ─────────
|
|
@@ -40,6 +40,7 @@ from rote.adapters._common import (
|
|
|
40
40
|
DEFAULT_AGENT_MAX_ITERATIONS,
|
|
41
41
|
_to_camel_case,
|
|
42
42
|
_to_pascal_case,
|
|
43
|
+
fan_out_element_param,
|
|
43
44
|
safe_block_comment_line,
|
|
44
45
|
)
|
|
45
46
|
from rote.ir import LLMSignature, Node, NodeKind, Pipeline, parse_input_ref
|
|
@@ -593,6 +594,67 @@ def payload_ts_literal(node: Node, indent: str) -> str:
|
|
|
593
594
|
return "\n".join(lines)
|
|
594
595
|
|
|
595
596
|
|
|
597
|
+
FAN_OUT_LIST_HELPER_TS = """\
|
|
598
|
+
/** The list a fan_out node dispatches over.
|
|
599
|
+
*
|
|
600
|
+
* A bare `.map()` on a missing key fails with "Cannot read properties
|
|
601
|
+
* of undefined (reading 'map')", which names neither the node nor the
|
|
602
|
+
* reference — in generated code that is close to undebuggable, and the
|
|
603
|
+
* cause is almost always an upstream node that didn't return the key
|
|
604
|
+
* the IR says it does. One call turns that into an actionable message.
|
|
605
|
+
* Found by running it: the first live fan_out run hit exactly this. */
|
|
606
|
+
function fanOutList(value: unknown, nodeId: string, ref: string): unknown[] {
|
|
607
|
+
if (!Array.isArray(value)) {
|
|
608
|
+
const got = value === null ? "null" : typeof value;
|
|
609
|
+
throw new Error(
|
|
610
|
+
`fan_out node '${nodeId}' expected an array from '${ref}', got ${got}. ` +
|
|
611
|
+
`The upstream node must return that key as a list.`,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
return value;
|
|
615
|
+
}
|
|
616
|
+
"""
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def fan_out_ts_binding(node: Node, pipeline: Pipeline, indent: str = "") -> tuple[str, str]:
|
|
620
|
+
"""``(payload_literal, list_expr)`` for a ``fan_out`` node in TypeScript.
|
|
621
|
+
|
|
622
|
+
The payload is a one-line object binding the element param to the
|
|
623
|
+
loop variable ``_item`` and every other input to its shared
|
|
624
|
+
expression — the TS rendering of
|
|
625
|
+
:func:`rote.adapters._common.fan_out_element_param`, which decides
|
|
626
|
+
*which* input is the list.
|
|
627
|
+
|
|
628
|
+
The list expression goes through ``fanOutList``
|
|
629
|
+
(:data:`FAN_OUT_LIST_HELPER_TS`), which both narrows the type — node
|
|
630
|
+
results are ``Record<string, unknown>`` or ``never``, so neither
|
|
631
|
+
``.map`` nor ``for…of`` compiles against them — and reports a
|
|
632
|
+
missing upstream key by node id instead of as a bare TypeError.
|
|
633
|
+
|
|
634
|
+
``indent`` is the column the ``fanOutList(`` token sits at. The call
|
|
635
|
+
wraps against it instead of running to ~180 characters on one line —
|
|
636
|
+
emitted code is meant to be read and reviewed.
|
|
637
|
+
"""
|
|
638
|
+
element_param = fan_out_element_param(node, pipeline)
|
|
639
|
+
entries = []
|
|
640
|
+
for param, input_ref in node.inputs.items() if node.inputs else []:
|
|
641
|
+
key = param if _TS_IDENT_RE.fullmatch(param) else json.dumps(param)
|
|
642
|
+
value = "_item" if param == element_param else ref_to_ts_expr(input_ref)
|
|
643
|
+
entries.append(f"{key}: {value}")
|
|
644
|
+
payload = "{ " + ", ".join(entries) + " }"
|
|
645
|
+
assert node.inputs is not None
|
|
646
|
+
ref = node.inputs[element_param]
|
|
647
|
+
inner = indent + " "
|
|
648
|
+
list_expr = (
|
|
649
|
+
"fanOutList(\n"
|
|
650
|
+
f"{inner}{ref_to_ts_expr(ref)},\n"
|
|
651
|
+
f"{inner}{json.dumps(node.id)},\n"
|
|
652
|
+
f"{inner}{json.dumps(ref)},\n"
|
|
653
|
+
f"{indent})"
|
|
654
|
+
)
|
|
655
|
+
return payload, list_expr
|
|
656
|
+
|
|
657
|
+
|
|
596
658
|
# ───────── Pipeline queries ─────────
|
|
597
659
|
|
|
598
660
|
|
|
@@ -42,6 +42,7 @@ from rote.adapters._common import (
|
|
|
42
42
|
_pipeline_hash,
|
|
43
43
|
_to_camel_case,
|
|
44
44
|
check_input_refs_available,
|
|
45
|
+
fan_out_nodes,
|
|
45
46
|
pipeline_identity,
|
|
46
47
|
safe_block_comment_line,
|
|
47
48
|
workflow_class_name,
|
|
@@ -52,6 +53,7 @@ from rote.adapters._common import (
|
|
|
52
53
|
from rote.adapters._ts_common import (
|
|
53
54
|
AI_UTILS_NPM_VERSION,
|
|
54
55
|
ANTHROPIC_SDK_NPM_VERSION,
|
|
56
|
+
FAN_OUT_LIST_HELPER_TS,
|
|
55
57
|
MCP_SDK_NPM_VERSION,
|
|
56
58
|
ROTE_INFERENCE_HELPER_TS,
|
|
57
59
|
ROTE_MCP_WORKERS_HELPER_TS,
|
|
@@ -60,6 +62,7 @@ from rote.adapters._ts_common import (
|
|
|
60
62
|
emit_agent_loop_module,
|
|
61
63
|
emit_ts_signature_module,
|
|
62
64
|
emit_workers_mcp_call_module,
|
|
65
|
+
fan_out_ts_binding,
|
|
63
66
|
llm_clients,
|
|
64
67
|
mcp_backed_nodes,
|
|
65
68
|
module_imports,
|
|
@@ -181,7 +184,12 @@ def auth_event_type(server: str) -> str:
|
|
|
181
184
|
|
|
182
185
|
|
|
183
186
|
def _emit_step_call_mcp_parkable(
|
|
184
|
-
node: Node,
|
|
187
|
+
node: Node,
|
|
188
|
+
cfg: CloudflareAdapterConfig,
|
|
189
|
+
*,
|
|
190
|
+
pipeline: Pipeline,
|
|
191
|
+
payload: str | None = None,
|
|
192
|
+
name_suffix: str = "",
|
|
185
193
|
) -> str:
|
|
186
194
|
"""MCP-backed dispatch: auth failures park the instance durably.
|
|
187
195
|
|
|
@@ -194,10 +202,15 @@ def _emit_step_call_mcp_parkable(
|
|
|
194
202
|
behavior; and events sent before the wait starts are buffered
|
|
195
203
|
per-instance, so `rote mcp release` can blast every non-terminal
|
|
196
204
|
instance without a race.
|
|
205
|
+
|
|
206
|
+
``payload`` and ``name_suffix`` are supplied by the fan_out path:
|
|
207
|
+
one element per call, and a step-name fragment (``[${_index}]``)
|
|
208
|
+
that keeps each element's step name unique within the instance.
|
|
197
209
|
"""
|
|
198
210
|
fn_name = _to_camel_case(node.id)
|
|
199
211
|
config = _step_config_literal(node, cfg)
|
|
200
|
-
payload
|
|
212
|
+
if payload is None:
|
|
213
|
+
payload = payload_ts_literal(node, indent=" " * 24)
|
|
201
214
|
nid = node.id
|
|
202
215
|
if node.mcp is not None:
|
|
203
216
|
# One binding, one server: the release event is known at emit time.
|
|
@@ -220,6 +233,10 @@ def _emit_step_call_mcp_parkable(
|
|
|
220
233
|
" // so `rote mcp release <server>` releases the right instances.\n"
|
|
221
234
|
)
|
|
222
235
|
label = "agent loop" if node.mcp is None else "MCP-backed"
|
|
236
|
+
# A fan_out element's step names carry its index; every step name
|
|
237
|
+
# must be unique within an instance or the elements collapse onto
|
|
238
|
+
# one cached step.
|
|
239
|
+
first_name = f"`{nid}{name_suffix}`" if name_suffix else json.dumps(nid)
|
|
223
240
|
return (
|
|
224
241
|
f" // ─── {nid} ({label}): park on dead credentials ───\n"
|
|
225
242
|
f"{release_hint}"
|
|
@@ -228,8 +245,8 @@ def _emit_step_call_mcp_parkable(
|
|
|
228
245
|
f" try {{\n"
|
|
229
246
|
f" {nid}_result = (await step.do(\n"
|
|
230
247
|
f" {nid}_attempt === 0\n"
|
|
231
|
-
f" ? {
|
|
232
|
-
f" : `{nid} (auth retry ${{{nid}_attempt}})`,\n"
|
|
248
|
+
f" ? {first_name}\n"
|
|
249
|
+
f" : `{nid}{name_suffix} (auth retry ${{{nid}_attempt}})`,\n"
|
|
233
250
|
f" {config},\n"
|
|
234
251
|
f" async () => {{\n"
|
|
235
252
|
f" try {{\n"
|
|
@@ -252,7 +269,7 @@ def _emit_step_call_mcp_parkable(
|
|
|
252
269
|
f" // consecutive failure. Fresh step names per attempt.\n"
|
|
253
270
|
f" if ({nid}_attempt % 2 === 1) {{\n"
|
|
254
271
|
f" await step.waitForEvent<any>(\n"
|
|
255
|
-
f" `{nid} auth wait ${{{nid}_attempt}}`,\n"
|
|
272
|
+
f" `{nid}{name_suffix} auth wait ${{{nid}_attempt}}`,\n"
|
|
256
273
|
f' {{ type: {event_expr}, timeout: "30 days" }},\n'
|
|
257
274
|
f" );\n"
|
|
258
275
|
f" }}\n"
|
|
@@ -341,6 +358,67 @@ def _emit_parallel_step_calls(nodes: list[Node], cfg: CloudflareAdapterConfig) -
|
|
|
341
358
|
return "\n".join(lines) + "\n"
|
|
342
359
|
|
|
343
360
|
|
|
361
|
+
def _emit_fan_out_parallel(node: Node, cfg: CloudflareAdapterConfig, *, pipeline: Pipeline) -> str:
|
|
362
|
+
"""Emit a plain ``fan_out`` node as one ``step.do`` per element.
|
|
363
|
+
|
|
364
|
+
Same ``Promise.all`` reasoning as a parallel wave: it is
|
|
365
|
+
Cloudflare's documented concurrency primitive, each element persists
|
|
366
|
+
under its own step name, and a rejection means that element already
|
|
367
|
+
exhausted its own retries.
|
|
368
|
+
|
|
369
|
+
Step names are index-suffixed. Cloudflare caches a step's result by
|
|
370
|
+
name, so reusing one name for every element would return the first
|
|
371
|
+
element's result for all of them — the failure would look like a
|
|
372
|
+
correct run with suspiciously uniform output.
|
|
373
|
+
"""
|
|
374
|
+
payload, list_expr = fan_out_ts_binding(node, pipeline, indent=" " * 12)
|
|
375
|
+
pass_env = node.kind in (NodeKind.LLM_JUDGE, NodeKind.AGENT_LOOP)
|
|
376
|
+
args = f"{payload}, this.env" if pass_env else payload
|
|
377
|
+
return "\n".join(
|
|
378
|
+
[
|
|
379
|
+
f" // fan_out: {node.id} runs once per element of the bound list,",
|
|
380
|
+
" // each as its own durable step (names are index-suffixed —",
|
|
381
|
+
" // Cloudflare caches step results by name).",
|
|
382
|
+
f" const {node.id}_result = await Promise.all(",
|
|
383
|
+
f" {list_expr}.map((_item, _index) =>",
|
|
384
|
+
" step.do(",
|
|
385
|
+
f" `{node.id}[${{_index}}]`,",
|
|
386
|
+
f" {_step_config_literal(node, cfg)},",
|
|
387
|
+
f" async () => {_to_camel_case(node.id)}({args}),",
|
|
388
|
+
" ),",
|
|
389
|
+
" ),",
|
|
390
|
+
" );",
|
|
391
|
+
]
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _emit_fan_out_parkable(node: Node, cfg: CloudflareAdapterConfig, *, pipeline: Pipeline) -> str:
|
|
396
|
+
"""Emit a parkable ``fan_out`` node: one element per iteration.
|
|
397
|
+
|
|
398
|
+
Sequential, for the same reason a parkable step leaves its parallel
|
|
399
|
+
wave — it can suspend on ``waitForEvent``, whose behavior inside a
|
|
400
|
+
promise combinator is undocumented and whose timeout *throws*, which
|
|
401
|
+
would reject every other element.
|
|
402
|
+
"""
|
|
403
|
+
payload, list_expr = fan_out_ts_binding(node, pipeline, indent=" " * 8)
|
|
404
|
+
inner = _emit_step_call_mcp_parkable(
|
|
405
|
+
node, cfg, pipeline=pipeline, payload=payload, name_suffix="[${_index}]"
|
|
406
|
+
)
|
|
407
|
+
return "\n".join(
|
|
408
|
+
[
|
|
409
|
+
f" // fan_out: {node.id} runs once per element of the bound list.",
|
|
410
|
+
" // Parkable steps stay sequential — a waitForEvent inside a",
|
|
411
|
+
" // promise combinator would reject every sibling on timeout.",
|
|
412
|
+
f" const {node.id}_results: Record<string, unknown>[] = [];",
|
|
413
|
+
f" for (const [_index, _item] of {list_expr}.entries()) {{",
|
|
414
|
+
textwrap.indent(inner.rstrip("\n"), " "),
|
|
415
|
+
f" {node.id}_results.push({node.id}_result);",
|
|
416
|
+
" }",
|
|
417
|
+
f" const {node.id}_result = {node.id}_results;",
|
|
418
|
+
]
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
|
|
344
422
|
def _emit_hitl_gate(node: Node, cfg: CloudflareAdapterConfig) -> str:
|
|
345
423
|
assert node.signal is not None
|
|
346
424
|
_validate_signal_name(node.signal, node.id)
|
|
@@ -508,7 +586,14 @@ def emit_workflow(pipeline: Pipeline, cfg: CloudflareAdapterConfig | None = None
|
|
|
508
586
|
parkable = [n for n in wave if _is_parkable(n)]
|
|
509
587
|
plain = [n for n in wave if n.kind is not NodeKind.HITL_GATE and not _is_parkable(n)]
|
|
510
588
|
|
|
511
|
-
|
|
589
|
+
# fan_out nodes dispatch once per element of their bound list —
|
|
590
|
+
# they never share the single/parallel payload shapes below, but
|
|
591
|
+
# they keep the parkable/plain split (a parkable fan_out runs its
|
|
592
|
+
# elements sequentially, for the same waitForEvent reason).
|
|
593
|
+
fanned_parkable, parkable = fan_out_nodes(parkable)
|
|
594
|
+
fanned_plain, plain = fan_out_nodes(plain)
|
|
595
|
+
|
|
596
|
+
for node in plain + parkable + fanned_plain + fanned_parkable:
|
|
512
597
|
check_input_refs_available(node, available)
|
|
513
598
|
|
|
514
599
|
if len(plain) > 1:
|
|
@@ -527,6 +612,12 @@ def emit_workflow(pipeline: Pipeline, cfg: CloudflareAdapterConfig | None = None
|
|
|
527
612
|
_emit_step_call_mcp_parkable(node, cfg, pipeline=pipeline).rstrip("\n")
|
|
528
613
|
)
|
|
529
614
|
|
|
615
|
+
for node in fanned_plain:
|
|
616
|
+
body_lines.append(_emit_fan_out_parallel(node, cfg, pipeline=pipeline))
|
|
617
|
+
|
|
618
|
+
for node in fanned_parkable:
|
|
619
|
+
body_lines.append(_emit_fan_out_parkable(node, cfg, pipeline=pipeline))
|
|
620
|
+
|
|
530
621
|
for node in gates:
|
|
531
622
|
body_lines.append(_emit_hitl_gate(node, cfg).rstrip("\n"))
|
|
532
623
|
|
|
@@ -552,6 +643,8 @@ def emit_workflow(pipeline: Pipeline, cfg: CloudflareAdapterConfig | None = None
|
|
|
552
643
|
)
|
|
553
644
|
|
|
554
645
|
helper_block = f"{_STEP_NEEDS_AUTH_HELPER}\n" if parks_on_auth else ""
|
|
646
|
+
if any(n.fan_out for n in pipeline.nodes):
|
|
647
|
+
helper_block += f"{FAN_OUT_LIST_HELPER_TS}\n"
|
|
555
648
|
|
|
556
649
|
return header + imports + "\n" + env_block + helper_block + class_block
|
|
557
650
|
|
|
@@ -88,6 +88,7 @@ from rote.adapters._py_common import (
|
|
|
88
88
|
_signature_path_parts,
|
|
89
89
|
agent_loop_call,
|
|
90
90
|
fan_out_binding,
|
|
91
|
+
fan_out_list_helper,
|
|
91
92
|
resolve_extracted_source,
|
|
92
93
|
serialize_helper,
|
|
93
94
|
write_signature_package,
|
|
@@ -521,7 +522,7 @@ def _emit_workflow_body(pipeline: Pipeline, cfg: DbosAdapterConfig) -> str:
|
|
|
521
522
|
lines.append(f" {node.id}_result = {node.id}_handle.get_result()")
|
|
522
523
|
|
|
523
524
|
for node in fan_out_nodes:
|
|
524
|
-
element_param, list_expr, scalars = fan_out_binding(node, pipeline)
|
|
525
|
+
element_param, list_expr, scalars = fan_out_binding(node, pipeline, indent=" " * 8)
|
|
525
526
|
payload_items = "".join(
|
|
526
527
|
f', "{param}": {expr}' for param, expr in sorted(scalars.items())
|
|
527
528
|
)
|
|
@@ -660,6 +661,9 @@ def emit_main(pipeline: Pipeline, cfg: DbosAdapterConfig | None = None) -> str:
|
|
|
660
661
|
" portable across the system database."
|
|
661
662
|
)
|
|
662
663
|
|
|
664
|
+
if any(n.fan_out for n in pipeline.nodes):
|
|
665
|
+
header += "\n\n" + fan_out_list_helper()
|
|
666
|
+
|
|
663
667
|
if mcp_backed:
|
|
664
668
|
header += "\n\n" + textwrap.dedent(
|
|
665
669
|
'''\
|