rote-cli 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- rote/adapters/__init__.py +70 -0
- rote/adapters/_common.py +149 -0
- rote/adapters/cloudflare.py +978 -0
- rote/adapters/dbos.py +1235 -0
- rote/adapters/temporal.py +568 -0
- rote/cli.py +520 -0
- rote/graduator/__init__.py +259 -0
- rote/graduator/drivers/__init__.py +226 -0
- rote/graduator/drivers/anthropic_api.py +423 -0
- rote/graduator/drivers/claude.py +312 -0
- rote/graduator/drivers/codex.py +83 -0
- rote/ir.py +495 -0
- rote/serve/__init__.py +14 -0
- rote/serve/backends.py +162 -0
- rote/serve/registry.py +201 -0
- rote/serve/server.py +249 -0
- rote/skills/rote-graduate/SKILL.md +230 -0
- rote/skills/rote-graduate/references/crystallization-heuristics.md +260 -0
- rote/skills/rote-graduate/references/ir-schema.md +420 -0
- rote/skills/rote-graduate/references/llm-judge-extraction.md +341 -0
- rote/skills/rote-graduate/references/node-kinds.md +223 -0
- rote_cli-0.1.0.dist-info/METADATA +546 -0
- rote_cli-0.1.0.dist-info/RECORD +27 -0
- rote_cli-0.1.0.dist-info/WHEEL +4 -0
- rote_cli-0.1.0.dist-info/entry_points.txt +2 -0
- rote_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
rote/adapters/dbos.py
ADDED
|
@@ -0,0 +1,1235 @@
|
|
|
1
|
+
"""DBOS adapter — emits a durable Python app from a Pipeline IR.
|
|
2
|
+
|
|
3
|
+
Layer 3 of rote: takes a validated :class:`rote.ir.Pipeline` and emits a
|
|
4
|
+
runnable DBOS Transact application. DBOS is the "no workflow runner"
|
|
5
|
+
target — durable execution as a Python library checkpointing to Postgres
|
|
6
|
+
(or SQLite for local dev). There is no orchestrator process to deploy;
|
|
7
|
+
``python main.py`` *is* the runtime.
|
|
8
|
+
|
|
9
|
+
Output layout::
|
|
10
|
+
|
|
11
|
+
out/
|
|
12
|
+
main.py # @DBOS.workflow + one @DBOS.step per node
|
|
13
|
+
dbos-config.yaml # CLI/Cloud tooling config (`dbos start`)
|
|
14
|
+
README.md # how to run, signal HITL gates, deploy
|
|
15
|
+
extracted/<module>.py # stubs for pure_function / external_call
|
|
16
|
+
extracted/<agent_loop>.py # stubs for agent_loop nodes
|
|
17
|
+
signatures/<llm_judge>.py # typed Pydantic + vendor-SDK signatures
|
|
18
|
+
|
|
19
|
+
Key design choices vs. the other adapters:
|
|
20
|
+
|
|
21
|
+
* **Single process, single file orchestration.** DBOS has no
|
|
22
|
+
workflow/activity split (Temporal) and no platform entrypoint class
|
|
23
|
+
(Cloudflare). Steps and the workflow live together in ``main.py``;
|
|
24
|
+
durability comes from the library checkpointing every step result to
|
|
25
|
+
the system database.
|
|
26
|
+
|
|
27
|
+
* **Parallel waves via ``Queue``.** DBOS's documented concurrency
|
|
28
|
+
primitive is enqueueing work onto a :class:`dbos.Queue`, which returns
|
|
29
|
+
a ``WorkflowHandle`` per enqueued function. Multi-node waves enqueue
|
|
30
|
+
every node then join with ``get_result()``; single-node waves call the
|
|
31
|
+
step directly. This keeps the emitted code synchronous and the
|
|
32
|
+
step-checkpoint ordering deterministic (asyncio.gather over steps has
|
|
33
|
+
subtler replay-ordering semantics).
|
|
34
|
+
|
|
35
|
+
* **HITL gates via durable messages.** ``DBOS.recv(topic=...)`` parks
|
|
36
|
+
the workflow in the system database until ``DBOS.send(workflow_id,
|
|
37
|
+
payload, topic=...)`` delivers the message — the officially documented
|
|
38
|
+
human-in-the-loop pattern. The IR ``signal`` name maps to the topic.
|
|
39
|
+
|
|
40
|
+
* **Data-flow threading matches the Temporal adapter.** The workflow
|
|
41
|
+
takes the pipeline input dict as ``pipeline_input``, binds every
|
|
42
|
+
node's result (including HITL gate resume payloads) as
|
|
43
|
+
``<id>_result``, and builds each step's payload from the node's
|
|
44
|
+
``inputs:`` bindings via the shared reference grammar
|
|
45
|
+
(:func:`rote.ir.parse_input_ref`). Forward references are rejected at
|
|
46
|
+
emit time by :func:`rote.adapters._common.check_input_refs_available`.
|
|
47
|
+
|
|
48
|
+
* **Typed LLM signatures as generated Pydantic modules.** When the IR
|
|
49
|
+
carries ``signature_spec`` (preferred), the adapter converts the JSON
|
|
50
|
+
Schemas to Pydantic model source and emits a direct vendor-SDK call
|
|
51
|
+
with structured (tool-use / JSON-schema) output — the Python analog of
|
|
52
|
+
the Cloudflare adapter's Zod emission. The legacy ``signature`` path
|
|
53
|
+
form falls back to importing the user's module, matching Temporal.
|
|
54
|
+
|
|
55
|
+
The emitted code never imports MCP runtime — same architectural invariant
|
|
56
|
+
as the other adapters, enforced by AST tests.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
from __future__ import annotations
|
|
60
|
+
|
|
61
|
+
import json
|
|
62
|
+
import keyword
|
|
63
|
+
import re
|
|
64
|
+
import textwrap
|
|
65
|
+
from dataclasses import dataclass
|
|
66
|
+
from pathlib import Path
|
|
67
|
+
from typing import Any
|
|
68
|
+
|
|
69
|
+
from rote.adapters._common import (
|
|
70
|
+
_execution_waves,
|
|
71
|
+
_pipeline_hash,
|
|
72
|
+
_to_pascal_case,
|
|
73
|
+
check_input_refs_available,
|
|
74
|
+
)
|
|
75
|
+
from rote.adapters.temporal import (
|
|
76
|
+
_impl_path_parts,
|
|
77
|
+
_payload_literal,
|
|
78
|
+
_signature_path_parts,
|
|
79
|
+
)
|
|
80
|
+
from rote.ir import LLMSignature, Node, NodeKind, Pipeline
|
|
81
|
+
|
|
82
|
+
# ───────── Adapter configuration ─────────
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class DbosAdapterConfig:
|
|
87
|
+
"""Per-emission knobs for the DBOS adapter.
|
|
88
|
+
|
|
89
|
+
Defaults work out-of-the-box for the BDR example: SQLite system
|
|
90
|
+
database for local dev (overridable via ``DBOS_SYSTEM_DATABASE_URL``
|
|
91
|
+
for Postgres in production), admin server off.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
anthropic_default_model: str = "claude-sonnet-4-6"
|
|
95
|
+
openai_default_model: str = "gpt-4.1"
|
|
96
|
+
# Module path used for *legacy* `signature: path.py:Class` judges,
|
|
97
|
+
# which import a user-maintained Python module instead of a
|
|
98
|
+
# generated one. Mirrors TemporalAdapterConfig.signatures_module.
|
|
99
|
+
legacy_signatures_module: str = "signatures"
|
|
100
|
+
# Steps enqueued for parallel waves land on this queue. None means
|
|
101
|
+
# derive "<pipeline-name>-queue".
|
|
102
|
+
queue_name: str | None = None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ───────── Duration handling ─────────
|
|
106
|
+
|
|
107
|
+
_DURATION_RE = re.compile(r"^(\d+(?:\.\d+)?)(ms|s|m|h|d)$")
|
|
108
|
+
|
|
109
|
+
_UNIT_TO_SECONDS = {
|
|
110
|
+
"ms": 0.001,
|
|
111
|
+
"s": 1.0,
|
|
112
|
+
"m": 60.0,
|
|
113
|
+
"h": 3600.0,
|
|
114
|
+
"d": 86400.0,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _duration_to_seconds(s: str) -> float:
|
|
119
|
+
"""Convert an IR duration string ('5m', '30s', '7d') to seconds.
|
|
120
|
+
|
|
121
|
+
DBOS APIs take plain ``timeout_seconds`` floats, so unlike the
|
|
122
|
+
Temporal adapter (which emits a runtime parser) we convert at
|
|
123
|
+
emission time and emit a literal.
|
|
124
|
+
"""
|
|
125
|
+
m = _DURATION_RE.fullmatch(s.strip())
|
|
126
|
+
if not m:
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"DBOS adapter: cannot parse IR duration {s!r}. Expected forms like '30s', '5m', '7d'."
|
|
129
|
+
)
|
|
130
|
+
return float(m.group(1)) * _UNIT_TO_SECONDS[m.group(2)]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _seconds_literal(seconds: float) -> str:
|
|
134
|
+
"""Render a seconds value as a compact Python literal."""
|
|
135
|
+
if seconds == int(seconds):
|
|
136
|
+
return str(int(seconds))
|
|
137
|
+
return repr(seconds)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ───────── Retry mapping ─────────
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _step_decorator(node: Node) -> str:
|
|
144
|
+
"""Render the ``@DBOS.step(...)`` decorator line for a node.
|
|
145
|
+
|
|
146
|
+
Maps the IR's ``RetryPolicy`` onto DBOS step retry parameters:
|
|
147
|
+
|
|
148
|
+
| IR field | DBOS parameter |
|
|
149
|
+
|---------------|-----------------------------------------------|
|
|
150
|
+
| ``max`` | ``max_attempts`` (= max + 1; DBOS counts the |
|
|
151
|
+
| | initial attempt, like Temporal) |
|
|
152
|
+
| ``backoff`` | ``backoff_rate`` (exponential → 2.0; |
|
|
153
|
+
| | constant → 1.0; linear → 1.0, the closest |
|
|
154
|
+
| | available approximation — DBOS delay is |
|
|
155
|
+
| | ``interval_seconds * backoff_rate**attempt``) |
|
|
156
|
+
| ``retry_on`` | not mapped (DBOS retries any exception; its |
|
|
157
|
+
| | ``should_retry`` predicate takes a callable, |
|
|
158
|
+
| | not categories — surfaced as a comment) |
|
|
159
|
+
"""
|
|
160
|
+
args = [f'name="{node.id}"']
|
|
161
|
+
if node.retry and node.retry.max > 0:
|
|
162
|
+
args.append("retries_allowed=True")
|
|
163
|
+
args.append(f"max_attempts={node.retry.max + 1}")
|
|
164
|
+
backoff_rate = 2.0 if node.retry.backoff == "exponential" else 1.0
|
|
165
|
+
args.append(f"backoff_rate={backoff_rate}")
|
|
166
|
+
return f"@DBOS.step({', '.join(args)})"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _retry_on_comment(node: Node) -> str:
|
|
170
|
+
"""Emit a comment documenting retry_on categories DBOS can't express."""
|
|
171
|
+
if node.retry and node.retry.retry_on:
|
|
172
|
+
cats = ", ".join(node.retry.retry_on)
|
|
173
|
+
return (
|
|
174
|
+
f" # retry_on categories from the IR: {cats}. DBOS retries any\n"
|
|
175
|
+
f" # exception; narrow with @DBOS.step(should_retry=...) if needed.\n"
|
|
176
|
+
)
|
|
177
|
+
return ""
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _timeout_comment(node: Node) -> str:
|
|
181
|
+
"""DBOS steps have no per-step timeout primitive; document the IR value."""
|
|
182
|
+
if node.timeout:
|
|
183
|
+
return (
|
|
184
|
+
f" # IR timeout {node.timeout!r}: DBOS has no per-step timeout\n"
|
|
185
|
+
f" # primitive; enforce inside the implementation if required.\n"
|
|
186
|
+
)
|
|
187
|
+
return ""
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ───────── JSON Schema → Pydantic source ─────────
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _pascal_ident(s: str) -> str:
|
|
194
|
+
"""Sanitize an arbitrary schema title into a PascalCase identifier.
|
|
195
|
+
|
|
196
|
+
Preserves interior capitalization ('EmploymentEntry' stays
|
|
197
|
+
'EmploymentEntry'; ``str.capitalize`` would mangle it to
|
|
198
|
+
'Employmententry').
|
|
199
|
+
"""
|
|
200
|
+
cleaned = re.sub(r"[^0-9a-zA-Z_]", "_", s)
|
|
201
|
+
parts = [p for p in cleaned.split("_") if p]
|
|
202
|
+
if not parts:
|
|
203
|
+
return "Model"
|
|
204
|
+
ident = "".join(p[0].upper() + p[1:] for p in parts)
|
|
205
|
+
if ident[0].isdigit():
|
|
206
|
+
ident = f"Model{ident}"
|
|
207
|
+
return ident
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _py_literal(value: Any) -> str:
|
|
211
|
+
"""Render a JSON-compatible value as a Python literal expression.
|
|
212
|
+
|
|
213
|
+
Strings use double quotes (matching the repo's ruff-format style);
|
|
214
|
+
containers recurse so nested strings get the same treatment.
|
|
215
|
+
"""
|
|
216
|
+
if isinstance(value, str):
|
|
217
|
+
return json.dumps(value)
|
|
218
|
+
if isinstance(value, bool) or value is None:
|
|
219
|
+
return repr(value)
|
|
220
|
+
if isinstance(value, list):
|
|
221
|
+
return "[" + ", ".join(_py_literal(v) for v in value) + "]"
|
|
222
|
+
if isinstance(value, dict):
|
|
223
|
+
items = ", ".join(f"{_py_literal(k)}: {_py_literal(v)}" for k, v in value.items())
|
|
224
|
+
return "{" + items + "}"
|
|
225
|
+
return repr(value)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _literal_alias(name: str, values: list[Any]) -> str:
|
|
229
|
+
"""Render ``Name = Literal[...]``, wrapping when the one-liner is long."""
|
|
230
|
+
rendered = [_py_literal(v) for v in values]
|
|
231
|
+
one_line = f"{name} = Literal[{', '.join(rendered)}]"
|
|
232
|
+
if len(one_line) <= 96:
|
|
233
|
+
return one_line
|
|
234
|
+
body = "\n".join(f" {r}," for r in rendered)
|
|
235
|
+
return f"{name} = Literal[\n{body}\n]"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class _SchemaToPydantic:
|
|
239
|
+
"""Convert JSON Schemas (with ``$defs``/``$ref``) to Pydantic source.
|
|
240
|
+
|
|
241
|
+
The Python analog of the Cloudflare adapter's ``json_schema_to_zod``.
|
|
242
|
+
Named ``$defs`` become named classes / ``Literal`` aliases; inline
|
|
243
|
+
object schemas get synthesized names. Blocks are accumulated in
|
|
244
|
+
dependency order (a class is emitted only after everything it
|
|
245
|
+
references), so the generated module imports cleanly top-to-bottom.
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
def __init__(self) -> None:
|
|
249
|
+
self._defs: dict[str, dict[str, Any]] = {}
|
|
250
|
+
self._blocks: list[str] = []
|
|
251
|
+
self._emitted: dict[str, str] = {} # $def name -> python name
|
|
252
|
+
self._in_progress: set[str] = set()
|
|
253
|
+
self._used_names: set[str] = set()
|
|
254
|
+
self.uses_literal = False
|
|
255
|
+
|
|
256
|
+
# ── public API ──
|
|
257
|
+
|
|
258
|
+
def add_defs(self, defs: dict[str, Any]) -> None:
|
|
259
|
+
for name, schema in defs.items():
|
|
260
|
+
if name in self._defs and self._defs[name] != schema:
|
|
261
|
+
raise ValueError(
|
|
262
|
+
f"DBOS adapter: $defs entry {name!r} appears in both input "
|
|
263
|
+
f"and output schemas with different definitions"
|
|
264
|
+
)
|
|
265
|
+
self._defs[name] = schema
|
|
266
|
+
|
|
267
|
+
def emit_root(self, class_name: str, schema: dict[str, Any]) -> None:
|
|
268
|
+
if schema.get("type") != "object":
|
|
269
|
+
raise ValueError(
|
|
270
|
+
f"DBOS adapter: root signature schema for {class_name} must be "
|
|
271
|
+
f"an object schema, got type={schema.get('type')!r}"
|
|
272
|
+
)
|
|
273
|
+
self._emit_object_class(class_name, schema)
|
|
274
|
+
|
|
275
|
+
@property
|
|
276
|
+
def blocks(self) -> list[str]:
|
|
277
|
+
return list(self._blocks)
|
|
278
|
+
|
|
279
|
+
# ── internals ──
|
|
280
|
+
|
|
281
|
+
def _unique_name(self, base: str) -> str:
|
|
282
|
+
name = base
|
|
283
|
+
n = 2
|
|
284
|
+
while name in self._used_names:
|
|
285
|
+
name = f"{base}{n}"
|
|
286
|
+
n += 1
|
|
287
|
+
self._used_names.add(name)
|
|
288
|
+
return name
|
|
289
|
+
|
|
290
|
+
def _ensure_def(self, ref: str) -> str:
|
|
291
|
+
if not ref.startswith("#/$defs/"):
|
|
292
|
+
raise ValueError(f"DBOS adapter: unsupported $ref form: {ref!r}")
|
|
293
|
+
def_name = ref[len("#/$defs/") :]
|
|
294
|
+
if def_name in self._emitted:
|
|
295
|
+
return self._emitted[def_name]
|
|
296
|
+
if def_name in self._in_progress:
|
|
297
|
+
raise ValueError(f"DBOS adapter: cyclic $ref through {def_name!r} is not supported")
|
|
298
|
+
if def_name not in self._defs:
|
|
299
|
+
raise ValueError(f"DBOS adapter: unknown $ref target: {def_name!r}")
|
|
300
|
+
|
|
301
|
+
schema = self._defs[def_name]
|
|
302
|
+
self._in_progress.add(def_name)
|
|
303
|
+
try:
|
|
304
|
+
py_name = self._unique_name(_pascal_ident(schema.get("title", def_name)))
|
|
305
|
+
self._emitted[def_name] = py_name
|
|
306
|
+
if "enum" in schema:
|
|
307
|
+
self.uses_literal = True
|
|
308
|
+
self._blocks.append(_literal_alias(py_name, schema["enum"]))
|
|
309
|
+
elif schema.get("type") == "object":
|
|
310
|
+
self._emit_object_class(py_name, schema)
|
|
311
|
+
else:
|
|
312
|
+
self._blocks.append(f"{py_name} = {self._type_expr(schema, py_name)}")
|
|
313
|
+
finally:
|
|
314
|
+
self._in_progress.discard(def_name)
|
|
315
|
+
return py_name
|
|
316
|
+
|
|
317
|
+
def _type_expr(self, schema: dict[str, Any], hint: str) -> str:
|
|
318
|
+
if "$ref" in schema:
|
|
319
|
+
return self._ensure_def(schema["$ref"])
|
|
320
|
+
|
|
321
|
+
for union_key in ("anyOf", "oneOf"):
|
|
322
|
+
if union_key in schema:
|
|
323
|
+
variants = schema[union_key]
|
|
324
|
+
non_null = [
|
|
325
|
+
v for v in variants if not (isinstance(v, dict) and v.get("type") == "null")
|
|
326
|
+
]
|
|
327
|
+
has_null = len(non_null) < len(variants)
|
|
328
|
+
parts = [self._type_expr(v, f"{hint}Variant{i}") for i, v in enumerate(non_null)]
|
|
329
|
+
expr = " | ".join(parts) if parts else "None"
|
|
330
|
+
if has_null and parts:
|
|
331
|
+
expr += " | None"
|
|
332
|
+
return expr
|
|
333
|
+
|
|
334
|
+
if "enum" in schema:
|
|
335
|
+
self.uses_literal = True
|
|
336
|
+
values = ", ".join(_py_literal(v) for v in schema["enum"])
|
|
337
|
+
return f"Literal[{values}]"
|
|
338
|
+
|
|
339
|
+
schema_type = schema.get("type")
|
|
340
|
+
if schema_type == "object":
|
|
341
|
+
if not schema.get("properties"):
|
|
342
|
+
return "dict[str, Any]"
|
|
343
|
+
name = self._unique_name(_pascal_ident(schema.get("title", hint)))
|
|
344
|
+
self._emit_object_class(name, schema)
|
|
345
|
+
return name
|
|
346
|
+
if schema_type == "array":
|
|
347
|
+
items = schema.get("items")
|
|
348
|
+
if not isinstance(items, dict):
|
|
349
|
+
return "list[Any]"
|
|
350
|
+
return f"list[{self._type_expr(items, f'{hint}Item')}]"
|
|
351
|
+
if schema_type == "string":
|
|
352
|
+
return "str"
|
|
353
|
+
if schema_type == "integer":
|
|
354
|
+
return "int"
|
|
355
|
+
if schema_type == "number":
|
|
356
|
+
return "float"
|
|
357
|
+
if schema_type == "boolean":
|
|
358
|
+
return "bool"
|
|
359
|
+
if schema_type == "null":
|
|
360
|
+
return "None"
|
|
361
|
+
return "Any"
|
|
362
|
+
|
|
363
|
+
def _emit_object_class(self, class_name: str, schema: dict[str, Any]) -> None:
|
|
364
|
+
required = set(schema.get("required", []))
|
|
365
|
+
properties: dict[str, Any] = schema.get("properties", {})
|
|
366
|
+
|
|
367
|
+
field_lines: list[str] = []
|
|
368
|
+
for field_name, field_schema in properties.items():
|
|
369
|
+
if not field_name.isidentifier() or keyword.iskeyword(field_name):
|
|
370
|
+
raise ValueError(
|
|
371
|
+
f"DBOS adapter: schema property {field_name!r} in "
|
|
372
|
+
f"{class_name} is not a valid Python identifier"
|
|
373
|
+
)
|
|
374
|
+
expr = self._type_expr(field_schema, f"{class_name}{_pascal_ident(field_name)}")
|
|
375
|
+
if field_name in required and "default" not in field_schema:
|
|
376
|
+
field_lines.append(f" {field_name}: {expr}")
|
|
377
|
+
elif "default" in field_schema:
|
|
378
|
+
field_lines.append(
|
|
379
|
+
f" {field_name}: {expr} = {_py_literal(field_schema['default'])}"
|
|
380
|
+
)
|
|
381
|
+
else:
|
|
382
|
+
if not expr.endswith("| None") and expr != "None":
|
|
383
|
+
expr += " | None"
|
|
384
|
+
field_lines.append(f" {field_name}: {expr} = None")
|
|
385
|
+
|
|
386
|
+
lines = [f"class {class_name}(BaseModel):"]
|
|
387
|
+
description = schema.get("description")
|
|
388
|
+
if description:
|
|
389
|
+
first = str(description).strip().splitlines()[0]
|
|
390
|
+
lines.append(f' """{first}"""')
|
|
391
|
+
lines.append("")
|
|
392
|
+
if schema.get("additionalProperties") is False:
|
|
393
|
+
lines.append(' model_config = ConfigDict(extra="forbid")')
|
|
394
|
+
lines.append("")
|
|
395
|
+
if field_lines:
|
|
396
|
+
lines.extend(field_lines)
|
|
397
|
+
elif len(lines) == 1:
|
|
398
|
+
lines.append(" pass")
|
|
399
|
+
self._blocks.append("\n".join(lines))
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# ───────── Long-string chunking (ruff line-length safety) ─────────
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _str_chunks(text: str, indent: str) -> list[str]:
|
|
406
|
+
"""Split a string into escaped literal chunks that fit the line budget.
|
|
407
|
+
|
|
408
|
+
Emitted snapshots are committed to the repo and linted with ruff
|
|
409
|
+
(line-length 100), so single-line ``json.dumps`` literals for prompts
|
|
410
|
+
and schemas are not an option. The budget is measured against the
|
|
411
|
+
*escaped* form at the given indent depth so no emitted line exceeds
|
|
412
|
+
the limit.
|
|
413
|
+
"""
|
|
414
|
+
width = max(40, 98 - len(indent) - 8)
|
|
415
|
+
chunks: list[str] = []
|
|
416
|
+
current = ""
|
|
417
|
+
for ch in text:
|
|
418
|
+
current += ch
|
|
419
|
+
if len(json.dumps(current)) >= width:
|
|
420
|
+
chunks.append(current)
|
|
421
|
+
current = ""
|
|
422
|
+
if current or not chunks:
|
|
423
|
+
chunks.append(current)
|
|
424
|
+
return chunks
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _chunked_str_literal(text: str, indent: str = " ") -> str:
|
|
428
|
+
"""Render a long string as a parenthesized adjacent-literal expression."""
|
|
429
|
+
chunks = _str_chunks(text, indent)
|
|
430
|
+
if len(chunks) == 1:
|
|
431
|
+
return json.dumps(chunks[0])
|
|
432
|
+
body = "\n".join(f"{indent}{json.dumps(c)}" for c in chunks)
|
|
433
|
+
outer = indent[:-4] if len(indent) >= 4 else ""
|
|
434
|
+
return f"(\n{body}\n{outer})"
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _chunked_call_arg(text: str, indent: str) -> str:
|
|
438
|
+
"""Render a long string as adjacent literals for direct use as a call arg.
|
|
439
|
+
|
|
440
|
+
Unlike :func:`_chunked_str_literal` this emits no wrapping parentheses
|
|
441
|
+
(the call's own parentheses group the literals), which keeps ruff's
|
|
442
|
+
UP034 (extraneous parentheses) quiet.
|
|
443
|
+
"""
|
|
444
|
+
chunks = _str_chunks(text, indent)
|
|
445
|
+
return "\n".join(f"{indent}{json.dumps(c)}" for c in chunks)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
# ───────── signatures/<id>.py emission ─────────
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def emit_signature_module(node: Node, cfg: DbosAdapterConfig) -> str:
|
|
452
|
+
"""Emit signatures/<node_id>.py for an llm_judge node with a spec.
|
|
453
|
+
|
|
454
|
+
The emitted module:
|
|
455
|
+
|
|
456
|
+
1. Declares Pydantic models for input + output, generated from the
|
|
457
|
+
IR's ``signature_spec`` JSON Schemas.
|
|
458
|
+
2. Exports a ``<Pascal>`` judge class whose synchronous ``forward``
|
|
459
|
+
interpolates the prompt, calls the vendor SDK with structured
|
|
460
|
+
output (Anthropic tool-use / OpenAI json_schema), validates the
|
|
461
|
+
response with Pydantic, and returns the typed output.
|
|
462
|
+
|
|
463
|
+
The prompt template is interpolated with a minimal ``{{ key }}``
|
|
464
|
+
substitution at runtime — same contract as the Cloudflare adapter's
|
|
465
|
+
TS emission, so the two runtimes share prompt semantics.
|
|
466
|
+
"""
|
|
467
|
+
if node.signature_spec is None:
|
|
468
|
+
raise ValueError(
|
|
469
|
+
f"DBOS adapter: emit_signature_module requires signature_spec on node {node.id!r}"
|
|
470
|
+
)
|
|
471
|
+
spec = node.signature_spec
|
|
472
|
+
pascal = _to_pascal_case(node.id)
|
|
473
|
+
|
|
474
|
+
converter = _SchemaToPydantic()
|
|
475
|
+
converter.add_defs(spec.input_schema.get("$defs", {}))
|
|
476
|
+
converter.add_defs(spec.output_schema.get("$defs", {}))
|
|
477
|
+
converter.emit_root(f"{pascal}Input", spec.input_schema)
|
|
478
|
+
converter.emit_root(f"{pascal}Output", spec.output_schema)
|
|
479
|
+
|
|
480
|
+
model = spec.model or _default_model_for(spec, cfg)
|
|
481
|
+
desc_first = node.description.strip().splitlines()[0] if node.description else node.id
|
|
482
|
+
|
|
483
|
+
typing_names = "Any, Literal" if converter.uses_literal else "Any"
|
|
484
|
+
schema_json = json.dumps(spec.output_schema, separators=(",", ":"))
|
|
485
|
+
|
|
486
|
+
header = textwrap.dedent(
|
|
487
|
+
f'''\
|
|
488
|
+
"""Typed LLM signature: {node.id}
|
|
489
|
+
|
|
490
|
+
{desc_first}
|
|
491
|
+
|
|
492
|
+
Auto-generated by rote.adapters.dbos from the IR's ``signature_spec``.
|
|
493
|
+
The non-determinism lives inside this module; the workflow step that
|
|
494
|
+
calls it stays a checkpointed, retryable unit.
|
|
495
|
+
|
|
496
|
+
DO NOT EDIT BY HAND. Re-run ``rote emit --runtime dbos`` to regenerate.
|
|
497
|
+
"""
|
|
498
|
+
|
|
499
|
+
from __future__ import annotations
|
|
500
|
+
|
|
501
|
+
import json
|
|
502
|
+
import re
|
|
503
|
+
from typing import {typing_names}
|
|
504
|
+
|
|
505
|
+
from pydantic import BaseModel, ConfigDict
|
|
506
|
+
'''
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
models_block = "\n\n\n".join(converter.blocks)
|
|
510
|
+
|
|
511
|
+
prompt_block = f"PROMPT = {_chunked_str_literal(spec.prompt)}"
|
|
512
|
+
description_block = (
|
|
513
|
+
f"TOOL_DESCRIPTION = {_chunked_str_literal(desc_first)}\n\n"
|
|
514
|
+
if spec.client == "anthropic"
|
|
515
|
+
else ""
|
|
516
|
+
)
|
|
517
|
+
schema_block = (
|
|
518
|
+
"OUTPUT_JSON_SCHEMA: dict[str, Any] = json.loads(\n"
|
|
519
|
+
f"{_chunked_call_arg(schema_json, indent=' ')}\n"
|
|
520
|
+
")"
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
interpolate_block = textwrap.dedent(
|
|
524
|
+
'''\
|
|
525
|
+
def _interpolate(template: str, variables: dict[str, Any]) -> str:
|
|
526
|
+
"""Resolve ``{{ dotted.path }}`` placeholders against the input dict."""
|
|
527
|
+
|
|
528
|
+
def _resolve(match: re.Match[str]) -> str:
|
|
529
|
+
value: Any = variables
|
|
530
|
+
for part in match.group(1).split("."):
|
|
531
|
+
value = value.get(part) if isinstance(value, dict) else None
|
|
532
|
+
if value is None:
|
|
533
|
+
return ""
|
|
534
|
+
return value if isinstance(value, str) else json.dumps(value, default=str)
|
|
535
|
+
|
|
536
|
+
return re.sub(r"\\{\\{\\s*([\\w.]+)\\s*\\}\\}", _resolve, template)
|
|
537
|
+
'''
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
if spec.client == "anthropic":
|
|
541
|
+
call_block = _emit_forward_anthropic(node, pascal, model, spec)
|
|
542
|
+
elif spec.client == "openai":
|
|
543
|
+
call_block = _emit_forward_openai(node, pascal, model, spec)
|
|
544
|
+
else: # pragma: no cover — LLMSignature validates the client field
|
|
545
|
+
raise ValueError(f"Unsupported LLM client: {spec.client!r}")
|
|
546
|
+
|
|
547
|
+
return (
|
|
548
|
+
header
|
|
549
|
+
+ "\n\n"
|
|
550
|
+
+ models_block
|
|
551
|
+
+ "\n\n\n"
|
|
552
|
+
+ prompt_block
|
|
553
|
+
+ "\n\n"
|
|
554
|
+
+ description_block
|
|
555
|
+
+ schema_block
|
|
556
|
+
+ "\n\n\n"
|
|
557
|
+
+ interpolate_block
|
|
558
|
+
+ "\n\n"
|
|
559
|
+
+ call_block
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _default_model_for(spec: LLMSignature, cfg: DbosAdapterConfig) -> str:
|
|
564
|
+
if spec.client == "openai":
|
|
565
|
+
return cfg.openai_default_model
|
|
566
|
+
return cfg.anthropic_default_model
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _temperature_line(spec: LLMSignature) -> str:
|
|
570
|
+
if spec.temperature is None:
|
|
571
|
+
return ""
|
|
572
|
+
return f" temperature={spec.temperature},\n"
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _emit_forward_anthropic(node: Node, pascal: str, model: str, spec: LLMSignature) -> str:
|
|
576
|
+
temp = _temperature_line(spec)
|
|
577
|
+
return (
|
|
578
|
+
f"class {pascal}:\n"
|
|
579
|
+
f' """Typed LLM judge for {node.id} (Anthropic structured output)."""\n'
|
|
580
|
+
f"\n"
|
|
581
|
+
f" def forward(self, inputs: {pascal}Input) -> {pascal}Output:\n"
|
|
582
|
+
f" # Lazy import: the SDK is only needed at call time, and the\n"
|
|
583
|
+
f" # module stays importable in environments without it.\n"
|
|
584
|
+
f" import anthropic\n"
|
|
585
|
+
f"\n"
|
|
586
|
+
f" client = anthropic.Anthropic()\n"
|
|
587
|
+
f" response = client.messages.create(\n"
|
|
588
|
+
f" model={json.dumps(model)},\n"
|
|
589
|
+
f" max_tokens=4096,\n"
|
|
590
|
+
f"{temp}"
|
|
591
|
+
f" tools=[\n"
|
|
592
|
+
f" {{\n"
|
|
593
|
+
f' "name": {json.dumps(node.id)},\n'
|
|
594
|
+
f' "description": TOOL_DESCRIPTION,\n'
|
|
595
|
+
f' "input_schema": OUTPUT_JSON_SCHEMA,\n'
|
|
596
|
+
f" }}\n"
|
|
597
|
+
f" ],\n"
|
|
598
|
+
f' tool_choice={{"type": "tool", "name": {json.dumps(node.id)}}},\n'
|
|
599
|
+
f" messages=[\n"
|
|
600
|
+
f" {{\n"
|
|
601
|
+
f' "role": "user",\n'
|
|
602
|
+
f' "content": _interpolate(PROMPT, inputs.model_dump()),\n'
|
|
603
|
+
f" }}\n"
|
|
604
|
+
f" ],\n"
|
|
605
|
+
f" )\n"
|
|
606
|
+
f" for block in response.content:\n"
|
|
607
|
+
f' if block.type == "tool_use":\n'
|
|
608
|
+
f" return {pascal}Output.model_validate(block.input)\n"
|
|
609
|
+
f" raise RuntimeError(\n"
|
|
610
|
+
f' "LLM did not return a tool_use block for {node.id}"\n'
|
|
611
|
+
f" )\n"
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _emit_forward_openai(node: Node, pascal: str, model: str, spec: LLMSignature) -> str:
|
|
616
|
+
temp = _temperature_line(spec)
|
|
617
|
+
return (
|
|
618
|
+
f"class {pascal}:\n"
|
|
619
|
+
f' """Typed LLM judge for {node.id} (OpenAI structured output)."""\n'
|
|
620
|
+
f"\n"
|
|
621
|
+
f" def forward(self, inputs: {pascal}Input) -> {pascal}Output:\n"
|
|
622
|
+
f" # Lazy import: the SDK is only needed at call time, and the\n"
|
|
623
|
+
f" # module stays importable in environments without it.\n"
|
|
624
|
+
f" import openai\n"
|
|
625
|
+
f"\n"
|
|
626
|
+
f" client = openai.OpenAI()\n"
|
|
627
|
+
f" response = client.chat.completions.create(\n"
|
|
628
|
+
f" model={json.dumps(model)},\n"
|
|
629
|
+
f"{temp}"
|
|
630
|
+
f" response_format={{\n"
|
|
631
|
+
f' "type": "json_schema",\n'
|
|
632
|
+
f' "json_schema": {{\n'
|
|
633
|
+
f' "name": {json.dumps(node.id)},\n'
|
|
634
|
+
f' "schema": OUTPUT_JSON_SCHEMA,\n'
|
|
635
|
+
f' "strict": True,\n'
|
|
636
|
+
f" }},\n"
|
|
637
|
+
f" }},\n"
|
|
638
|
+
f" messages=[\n"
|
|
639
|
+
f" {{\n"
|
|
640
|
+
f' "role": "user",\n'
|
|
641
|
+
f' "content": _interpolate(PROMPT, inputs.model_dump()),\n'
|
|
642
|
+
f" }}\n"
|
|
643
|
+
f" ],\n"
|
|
644
|
+
f" )\n"
|
|
645
|
+
f" content = response.choices[0].message.content\n"
|
|
646
|
+
f" if not content:\n"
|
|
647
|
+
f' raise RuntimeError("OpenAI returned no content for {node.id}")\n'
|
|
648
|
+
f" return {pascal}Output.model_validate(json.loads(content))\n"
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
# ───────── extracted/<module>.py emission ─────────
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _extracted_layout(pipeline: Pipeline) -> dict[str, list[Node]]:
|
|
656
|
+
"""Group impl-bearing and agent_loop nodes into extracted modules.
|
|
657
|
+
|
|
658
|
+
``pure_function`` / ``external_call`` nodes carry an
|
|
659
|
+
``extracted/<module>.py:<func>`` impl path; several nodes may share
|
|
660
|
+
a module (BDR's hubspot.py has three functions). ``agent_loop``
|
|
661
|
+
nodes have no impl path — each gets its own ``<node_id>.py`` module
|
|
662
|
+
with a same-named stub function.
|
|
663
|
+
"""
|
|
664
|
+
modules: dict[str, list[Node]] = {}
|
|
665
|
+
for node in pipeline.nodes:
|
|
666
|
+
if node.kind in (NodeKind.PURE_FUNCTION, NodeKind.EXTERNAL_CALL):
|
|
667
|
+
assert node.impl is not None
|
|
668
|
+
module_name, _ = _impl_path_parts(node.impl)
|
|
669
|
+
modules.setdefault(module_name, []).append(node)
|
|
670
|
+
elif node.kind is NodeKind.AGENT_LOOP:
|
|
671
|
+
modules.setdefault(node.id, []).append(node)
|
|
672
|
+
return modules
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _extracted_func_name(node: Node) -> str:
|
|
676
|
+
if node.kind is NodeKind.AGENT_LOOP:
|
|
677
|
+
return node.id
|
|
678
|
+
assert node.impl is not None
|
|
679
|
+
_, func_name = _impl_path_parts(node.impl)
|
|
680
|
+
return func_name
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def emit_extracted_module(module_name: str, nodes: list[Node]) -> str:
|
|
684
|
+
"""Emit extracted/<module_name>.py with one stub function per node.
|
|
685
|
+
|
|
686
|
+
Same scaffolding convention as the Temporal example package: the
|
|
687
|
+
functions raise ``NotImplementedError`` until the user fills them in
|
|
688
|
+
with direct vendor API calls. The graduation history (MCP origin) is
|
|
689
|
+
documented in docstrings only — never in executable code.
|
|
690
|
+
"""
|
|
691
|
+
parts: list[str] = [
|
|
692
|
+
textwrap.dedent(
|
|
693
|
+
f'''\
|
|
694
|
+
"""Extracted module: {module_name}
|
|
695
|
+
|
|
696
|
+
Auto-generated stubs by rote.adapters.dbos. Replace each body
|
|
697
|
+
with the real implementation (direct vendor API calls — the MCP
|
|
698
|
+
tool calls from the source skill were graduated away at emit
|
|
699
|
+
time). Keep the signatures: the DBOS steps in main.py call these
|
|
700
|
+
with the step payload as keyword arguments.
|
|
701
|
+
"""
|
|
702
|
+
|
|
703
|
+
from __future__ import annotations
|
|
704
|
+
|
|
705
|
+
from typing import Any
|
|
706
|
+
'''
|
|
707
|
+
)
|
|
708
|
+
]
|
|
709
|
+
|
|
710
|
+
seen: set[str] = set()
|
|
711
|
+
for node in nodes:
|
|
712
|
+
func_name = _extracted_func_name(node)
|
|
713
|
+
if func_name in seen:
|
|
714
|
+
continue
|
|
715
|
+
seen.add(func_name)
|
|
716
|
+
desc_first = node.description.strip().splitlines()[0] if node.description else node.id
|
|
717
|
+
|
|
718
|
+
doc_lines = [f" {desc_first}", ""]
|
|
719
|
+
if node.kind is NodeKind.AGENT_LOOP:
|
|
720
|
+
doc_lines.append(" STUB — agent loops require an LLM agent runtime. Implement")
|
|
721
|
+
doc_lines.append(" against the project's preferred agent harness (Anthropic")
|
|
722
|
+
doc_lines.append(" Agent SDK, OpenAI Agents SDK, LangGraph, etc.).")
|
|
723
|
+
if node.tools:
|
|
724
|
+
doc_lines.append("")
|
|
725
|
+
doc_lines.append(" Tools the agent should be allowed to call:")
|
|
726
|
+
doc_lines.extend(f" - {t}" for t in node.tools)
|
|
727
|
+
if node.loop_body:
|
|
728
|
+
doc_lines.append("")
|
|
729
|
+
doc_lines.append(" Loop body sub-nodes (call once per iteration):")
|
|
730
|
+
doc_lines.extend(f" - {sn}" for sn in node.loop_body)
|
|
731
|
+
else:
|
|
732
|
+
doc_lines.append(" STUB — replace with the deterministic API call.")
|
|
733
|
+
if node.mandatory:
|
|
734
|
+
doc_lines.append("")
|
|
735
|
+
doc_lines.append(" MANDATORY: this node was marked mandatory in the source")
|
|
736
|
+
doc_lines.append(" skill. The workflow always calls it; do not make it")
|
|
737
|
+
doc_lines.append(" conditional.")
|
|
738
|
+
if node.constants:
|
|
739
|
+
doc_lines.append("")
|
|
740
|
+
doc_lines.append(" Constants from the IR (lifted from the source skill):")
|
|
741
|
+
for k, v in node.constants.items():
|
|
742
|
+
doc_lines.extend(
|
|
743
|
+
textwrap.wrap(
|
|
744
|
+
f"{k} = {v!r}",
|
|
745
|
+
width=88,
|
|
746
|
+
initial_indent=" ",
|
|
747
|
+
subsequent_indent=" ",
|
|
748
|
+
)
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
doc = "\n".join(doc_lines)
|
|
752
|
+
parts.append(
|
|
753
|
+
f"\n\ndef {func_name}(**payload: Any) -> Any:\n"
|
|
754
|
+
f' """\n{doc}\n """\n'
|
|
755
|
+
f" raise NotImplementedError(\n"
|
|
756
|
+
f' "{module_name}.{func_name}: implement against the vendor API"\n'
|
|
757
|
+
f" )\n"
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
return "".join(parts)
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
# ───────── main.py emission ─────────
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _emit_step_pure_or_external(node: Node) -> str:
|
|
767
|
+
assert node.impl is not None
|
|
768
|
+
module_name, func_name = _impl_path_parts(node.impl)
|
|
769
|
+
mandatory_marker = ""
|
|
770
|
+
if node.mandatory:
|
|
771
|
+
mandatory_marker = (
|
|
772
|
+
" # MANDATORY: this node was marked mandatory in the source\n"
|
|
773
|
+
" # skill. The workflow always calls it; do not make it conditional.\n"
|
|
774
|
+
)
|
|
775
|
+
return (
|
|
776
|
+
f"{_step_decorator(node)}\n"
|
|
777
|
+
f"def {node.id}(payload: dict) -> dict:\n"
|
|
778
|
+
f' """{node.description.strip().splitlines()[0]}\n'
|
|
779
|
+
f"\n"
|
|
780
|
+
f" Graduated from MCP tool call → deterministic API call. See\n"
|
|
781
|
+
f" ``extracted.{module_name}`` for the implementation.\n"
|
|
782
|
+
f' """\n'
|
|
783
|
+
f"{mandatory_marker}"
|
|
784
|
+
f"{_retry_on_comment(node)}"
|
|
785
|
+
f"{_timeout_comment(node)}"
|
|
786
|
+
f" from extracted.{module_name} import {func_name}\n"
|
|
787
|
+
f"\n"
|
|
788
|
+
f" return _serialize({func_name}(**payload))\n"
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _emit_step_llm_judge(node: Node, cfg: DbosAdapterConfig) -> str:
|
|
793
|
+
pascal_parts: tuple[str, str]
|
|
794
|
+
if node.signature_spec is not None:
|
|
795
|
+
# Preferred: generated signature module (signatures/<node_id>.py).
|
|
796
|
+
module_name = node.id
|
|
797
|
+
class_name = _to_pascal_case(node.id)
|
|
798
|
+
import_line = f" from signatures.{module_name} import {class_name}, {class_name}Input\n"
|
|
799
|
+
call_lines = (
|
|
800
|
+
f" judge = {class_name}()\n"
|
|
801
|
+
f" return judge.forward({class_name}Input(**payload)).model_dump()\n"
|
|
802
|
+
)
|
|
803
|
+
else:
|
|
804
|
+
# Legacy Temporal-style path: user-maintained module with an
|
|
805
|
+
# async ``forward`` (the Temporal adapter's convention).
|
|
806
|
+
assert node.signature is not None
|
|
807
|
+
pascal_parts = _signature_path_parts(node.signature)
|
|
808
|
+
module_name, class_name = pascal_parts
|
|
809
|
+
import_line = (
|
|
810
|
+
f" import asyncio\n"
|
|
811
|
+
f"\n"
|
|
812
|
+
f" from {cfg.legacy_signatures_module}.{module_name} import (\n"
|
|
813
|
+
f" {class_name},\n"
|
|
814
|
+
f" {class_name}Input,\n"
|
|
815
|
+
f" )\n"
|
|
816
|
+
)
|
|
817
|
+
call_lines = (
|
|
818
|
+
f" judge = {class_name}()\n"
|
|
819
|
+
f" result = asyncio.run(judge.forward({class_name}Input(**payload)))\n"
|
|
820
|
+
f" return result.model_dump()\n"
|
|
821
|
+
)
|
|
822
|
+
return (
|
|
823
|
+
f"{_step_decorator(node)}\n"
|
|
824
|
+
f"def {node.id}(payload: dict) -> dict:\n"
|
|
825
|
+
f' """{node.description.strip().splitlines()[0]}\n'
|
|
826
|
+
f"\n"
|
|
827
|
+
f" LLM judge — typed input/output, bounded decision space. The\n"
|
|
828
|
+
f" non-determinism lives inside this step, not the workflow.\n"
|
|
829
|
+
f' """\n'
|
|
830
|
+
f"{_retry_on_comment(node)}"
|
|
831
|
+
f"{_timeout_comment(node)}"
|
|
832
|
+
f"{import_line}"
|
|
833
|
+
f"\n"
|
|
834
|
+
f"{call_lines}"
|
|
835
|
+
)
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def _emit_step_agent_loop(node: Node) -> str:
|
|
839
|
+
return (
|
|
840
|
+
f"{_step_decorator(node)}\n"
|
|
841
|
+
f"def {node.id}(payload: dict) -> dict:\n"
|
|
842
|
+
f' """{node.description.strip().splitlines()[0]}\n'
|
|
843
|
+
f"\n"
|
|
844
|
+
f" Agent loop — bounded, tool-restricted. The stub in\n"
|
|
845
|
+
f" ``extracted.{node.id}`` raises until implemented against an\n"
|
|
846
|
+
f" agent harness.\n"
|
|
847
|
+
f' """\n'
|
|
848
|
+
f"{_retry_on_comment(node)}"
|
|
849
|
+
f"{_timeout_comment(node)}"
|
|
850
|
+
f" from extracted.{node.id} import {node.id} as _impl\n"
|
|
851
|
+
f"\n"
|
|
852
|
+
f" return _serialize(_impl(**payload))\n"
|
|
853
|
+
)
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _emit_hitl_wait(node: Node, pipeline: Pipeline) -> str:
|
|
857
|
+
assert node.signal is not None
|
|
858
|
+
timeout_str = node.timeout or pipeline.config.hitl.default_timeout
|
|
859
|
+
timeout_seconds = _duration_to_seconds(timeout_str)
|
|
860
|
+
return (
|
|
861
|
+
f" # ─── HITL gate: {node.id} ───\n"
|
|
862
|
+
f" # Durable receive: the workflow parks in the system database until\n"
|
|
863
|
+
f" # DBOS.send(workflow_id, payload, topic={node.signal!r})\n"
|
|
864
|
+
f" # delivers the resume message. Survives process restarts.\n"
|
|
865
|
+
f" {node.id}_result = DBOS.recv(\n"
|
|
866
|
+
f' topic="{node.signal}",\n'
|
|
867
|
+
f" timeout_seconds={_seconds_literal(timeout_seconds)}, # {timeout_str}\n"
|
|
868
|
+
f" )\n"
|
|
869
|
+
f" if {node.id}_result is None:\n"
|
|
870
|
+
f" raise TimeoutError(\n"
|
|
871
|
+
f' "HITL gate {node.id!r} timed out after {timeout_str} waiting "\n'
|
|
872
|
+
f' "for signal {node.signal!r}"\n'
|
|
873
|
+
f" )\n"
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _emit_workflow_body(pipeline: Pipeline) -> str:
|
|
878
|
+
waves = _execution_waves(pipeline)
|
|
879
|
+
lines: list[str] = []
|
|
880
|
+
|
|
881
|
+
# Node ids whose results are bound by the time each wave starts —
|
|
882
|
+
# used to reject inputs that reference a later wave at emit time.
|
|
883
|
+
# HITL gates count: their DBOS.recv payload binds `<id>_result`, so
|
|
884
|
+
# gate resume payloads participate as that gate's result downstream.
|
|
885
|
+
available: set[str] = set()
|
|
886
|
+
|
|
887
|
+
for wave_idx, wave in enumerate(waves, start=1):
|
|
888
|
+
non_hitl = [n for n in wave if n.kind is not NodeKind.HITL_GATE]
|
|
889
|
+
hitl = [n for n in wave if n.kind is NodeKind.HITL_GATE]
|
|
890
|
+
|
|
891
|
+
for n in non_hitl:
|
|
892
|
+
check_input_refs_available(n, available)
|
|
893
|
+
|
|
894
|
+
lines.append("")
|
|
895
|
+
lines.append(f" # ─── Wave {wave_idx} ───")
|
|
896
|
+
|
|
897
|
+
if len(non_hitl) == 1:
|
|
898
|
+
node = non_hitl[0]
|
|
899
|
+
payload = _payload_literal(node, indent=" " * 8)
|
|
900
|
+
if payload == "{}":
|
|
901
|
+
lines.append(f" {node.id}_result = {node.id}({{}})")
|
|
902
|
+
else:
|
|
903
|
+
lines.append(f" {node.id}_result = {node.id}(")
|
|
904
|
+
lines.append(f" {payload}")
|
|
905
|
+
lines.append(" )")
|
|
906
|
+
elif len(non_hitl) > 1:
|
|
907
|
+
lines.append(" # Parallel fan-out: enqueue every node in the wave, then")
|
|
908
|
+
lines.append(" # join. Each enqueued step runs as its own one-step")
|
|
909
|
+
lines.append(" # workflow; get_result() blocks durably.")
|
|
910
|
+
for node in non_hitl:
|
|
911
|
+
payload = _payload_literal(node, indent=" " * 8)
|
|
912
|
+
if payload == "{}":
|
|
913
|
+
lines.append(f" {node.id}_handle = queue.enqueue({node.id}, {{}})")
|
|
914
|
+
else:
|
|
915
|
+
lines.append(f" {node.id}_handle = queue.enqueue(")
|
|
916
|
+
lines.append(f" {node.id},")
|
|
917
|
+
lines.append(f" {payload},")
|
|
918
|
+
lines.append(" )")
|
|
919
|
+
for node in non_hitl:
|
|
920
|
+
lines.append(f" {node.id}_result = {node.id}_handle.get_result()")
|
|
921
|
+
|
|
922
|
+
for gate in hitl:
|
|
923
|
+
lines.append(_emit_hitl_wait(gate, pipeline).rstrip("\n"))
|
|
924
|
+
|
|
925
|
+
available.update(n.id for n in wave)
|
|
926
|
+
|
|
927
|
+
lines.append("")
|
|
928
|
+
lines.append(" return {")
|
|
929
|
+
for exit_id in pipeline.exit_nodes:
|
|
930
|
+
lines.append(f' "{exit_id}": {exit_id}_result,')
|
|
931
|
+
lines.append(" }")
|
|
932
|
+
return "\n".join(lines)
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def emit_main(pipeline: Pipeline, cfg: DbosAdapterConfig | None = None) -> str:
|
|
936
|
+
"""Render the main.py source for a pipeline."""
|
|
937
|
+
cfg = cfg or DbosAdapterConfig()
|
|
938
|
+
|
|
939
|
+
pascal = _to_pascal_case(pipeline.name)
|
|
940
|
+
pipeline_h = _pipeline_hash(pipeline)
|
|
941
|
+
workflow_name = f"{pascal}_{pipeline_h}"
|
|
942
|
+
queue_name = cfg.queue_name or f"{pipeline.name}-queue"
|
|
943
|
+
sqlite_file = f"{pipeline.name}.dbos.sqlite"
|
|
944
|
+
desc_first = (
|
|
945
|
+
pipeline.description.strip().splitlines()[0] if pipeline.description else pipeline.name
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
header = textwrap.dedent(
|
|
949
|
+
f'''\
|
|
950
|
+
"""Auto-generated by rote.adapters.dbos.
|
|
951
|
+
|
|
952
|
+
Pipeline: {pipeline.name} v{pipeline.version}
|
|
953
|
+
Source skill: {pipeline.source_skill or "unknown"}
|
|
954
|
+
Pipeline hash: {pipeline_h}
|
|
955
|
+
|
|
956
|
+
DO NOT EDIT BY HAND. Re-run ``rote emit --runtime dbos`` to regenerate.
|
|
957
|
+
|
|
958
|
+
Workflow versioning: the registered workflow name includes a hash of
|
|
959
|
+
the pipeline so regenerated pipelines become a new workflow type.
|
|
960
|
+
In-flight workflows recover onto the code they started with (DBOS
|
|
961
|
+
recovers by workflow name + application_version); new starts use the
|
|
962
|
+
new code.
|
|
963
|
+
|
|
964
|
+
Architecture note: every step in this file wraps a deterministic
|
|
965
|
+
function from ``extracted/`` or a typed LLM signature from
|
|
966
|
+
``signatures/``. None of them call MCP tools at runtime — the MCP
|
|
967
|
+
tool calls from the source skill were graduated into direct API
|
|
968
|
+
calls during the rote emission step.
|
|
969
|
+
"""
|
|
970
|
+
|
|
971
|
+
from __future__ import annotations
|
|
972
|
+
|
|
973
|
+
import json
|
|
974
|
+
import os
|
|
975
|
+
import sys
|
|
976
|
+
from pathlib import Path
|
|
977
|
+
from typing import Any
|
|
978
|
+
|
|
979
|
+
from dbos import DBOS, DBOSConfig, Queue
|
|
980
|
+
|
|
981
|
+
# ───────── DBOS configuration ─────────
|
|
982
|
+
#
|
|
983
|
+
# Local dev runs on SQLite (zero infrastructure). Production should
|
|
984
|
+
# point DBOS_SYSTEM_DATABASE_URL at Postgres — SQLite is
|
|
985
|
+
# single-process only.
|
|
986
|
+
_APP_DIR = Path(__file__).resolve().parent
|
|
987
|
+
|
|
988
|
+
config: DBOSConfig = {{
|
|
989
|
+
"name": "{pipeline.name}",
|
|
990
|
+
"system_database_url": os.environ.get(
|
|
991
|
+
"DBOS_SYSTEM_DATABASE_URL",
|
|
992
|
+
f"sqlite:///{{_APP_DIR / '{sqlite_file}'}}",
|
|
993
|
+
),
|
|
994
|
+
# The admin server (port 3001) is opt-in; enable it when you
|
|
995
|
+
# want the DBOS console / management API.
|
|
996
|
+
"run_admin_server": False,
|
|
997
|
+
}}
|
|
998
|
+
DBOS(config=config)
|
|
999
|
+
|
|
1000
|
+
# Parallel waves fan out onto this queue; each enqueued step runs as
|
|
1001
|
+
# a one-step workflow with its own durable handle.
|
|
1002
|
+
queue = Queue("{queue_name}")
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def _serialize(obj: Any) -> Any:
|
|
1006
|
+
"""Convert pydantic models / tuples to plain JSON-safe values.
|
|
1007
|
+
|
|
1008
|
+
Steps return JSON-serializable payloads so workflow state stays
|
|
1009
|
+
portable across the system database.
|
|
1010
|
+
"""
|
|
1011
|
+
if hasattr(obj, "model_dump"):
|
|
1012
|
+
return obj.model_dump()
|
|
1013
|
+
if isinstance(obj, (list, tuple)):
|
|
1014
|
+
return [_serialize(x) for x in obj]
|
|
1015
|
+
if isinstance(obj, dict):
|
|
1016
|
+
return {{k: _serialize(v) for k, v in obj.items()}}
|
|
1017
|
+
return obj
|
|
1018
|
+
'''
|
|
1019
|
+
)
|
|
1020
|
+
|
|
1021
|
+
step_parts: list[str] = []
|
|
1022
|
+
for node in pipeline.nodes:
|
|
1023
|
+
if node.kind is NodeKind.HITL_GATE:
|
|
1024
|
+
continue
|
|
1025
|
+
step_parts.append("\n\n")
|
|
1026
|
+
if node.kind in (NodeKind.PURE_FUNCTION, NodeKind.EXTERNAL_CALL):
|
|
1027
|
+
step_parts.append(_emit_step_pure_or_external(node))
|
|
1028
|
+
elif node.kind is NodeKind.LLM_JUDGE:
|
|
1029
|
+
step_parts.append(_emit_step_llm_judge(node, cfg))
|
|
1030
|
+
elif node.kind is NodeKind.AGENT_LOOP:
|
|
1031
|
+
step_parts.append(_emit_step_agent_loop(node))
|
|
1032
|
+
|
|
1033
|
+
workflow_block = (
|
|
1034
|
+
f"\n\n@DBOS.workflow(name={json.dumps(workflow_name)})\n"
|
|
1035
|
+
f"def run_pipeline(pipeline_input: dict) -> dict:\n"
|
|
1036
|
+
f' """{desc_first}"""\n'
|
|
1037
|
+
f"{_emit_workflow_body(pipeline)}\n"
|
|
1038
|
+
)
|
|
1039
|
+
|
|
1040
|
+
main_block = textwrap.dedent(
|
|
1041
|
+
"""\
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
if __name__ == "__main__":
|
|
1045
|
+
# Launch DBOS (connects to the system database, starts queue
|
|
1046
|
+
# workers and recovery), start one pipeline run with the input
|
|
1047
|
+
# from argv[1] (JSON), and block until it completes. HITL gates
|
|
1048
|
+
# are resumed from another process — see README.md.
|
|
1049
|
+
DBOS.launch()
|
|
1050
|
+
try:
|
|
1051
|
+
pipeline_input = json.loads(sys.argv[1]) if len(sys.argv) > 1 else {}
|
|
1052
|
+
handle = DBOS.start_workflow(run_pipeline, pipeline_input)
|
|
1053
|
+
print(f"workflow started: {handle.workflow_id}", file=sys.stderr)
|
|
1054
|
+
print(json.dumps(handle.get_result(), indent=2, default=str))
|
|
1055
|
+
finally:
|
|
1056
|
+
DBOS.destroy()
|
|
1057
|
+
"""
|
|
1058
|
+
)
|
|
1059
|
+
|
|
1060
|
+
return header + "".join(step_parts) + workflow_block + main_block
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
# ───────── dbos-config.yaml + README emission ─────────
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def emit_dbos_config(pipeline: Pipeline) -> str:
|
|
1067
|
+
"""Emit dbos-config.yaml so ``dbos start`` (and DBOS Cloud) work.
|
|
1068
|
+
|
|
1069
|
+
Runtime configuration is programmatic (the ``DBOSConfig`` dict in
|
|
1070
|
+
main.py); this file only feeds the CLI/Cloud tooling.
|
|
1071
|
+
"""
|
|
1072
|
+
return (
|
|
1073
|
+
"# Auto-generated by rote.adapters.dbos. Used by the `dbos` CLI and\n"
|
|
1074
|
+
"# DBOS Cloud; runtime config lives in main.py's DBOSConfig.\n"
|
|
1075
|
+
f"name: {pipeline.name}\n"
|
|
1076
|
+
"language: python\n"
|
|
1077
|
+
"runtimeConfig:\n"
|
|
1078
|
+
" start:\n"
|
|
1079
|
+
" - python3 main.py\n"
|
|
1080
|
+
)
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def emit_readme(pipeline: Pipeline, cfg: DbosAdapterConfig) -> str:
|
|
1084
|
+
gates = [n for n in pipeline.nodes if n.kind is NodeKind.HITL_GATE]
|
|
1085
|
+
gate_lines = "\n".join(
|
|
1086
|
+
f"| `{g.id}` | `{g.signal}` | {g.timeout or pipeline.config.hitl.default_timeout} |"
|
|
1087
|
+
for g in gates
|
|
1088
|
+
)
|
|
1089
|
+
first_signal = gates[0].signal if gates else "example_signal"
|
|
1090
|
+
return textwrap.dedent(
|
|
1091
|
+
f"""\
|
|
1092
|
+
# {pipeline.name} — DBOS runtime
|
|
1093
|
+
|
|
1094
|
+
Auto-generated by `rote emit --runtime dbos`. Do not edit generated
|
|
1095
|
+
files by hand; re-run the emitter to regenerate.
|
|
1096
|
+
|
|
1097
|
+
DBOS is durable execution as a library: there is no orchestrator to
|
|
1098
|
+
deploy. `main.py` checkpoints every step to a system database and
|
|
1099
|
+
resumes from the last completed step after a crash.
|
|
1100
|
+
|
|
1101
|
+
## Layout
|
|
1102
|
+
|
|
1103
|
+
- `main.py` — the `@DBOS.workflow` DAG plus one `@DBOS.step` per node
|
|
1104
|
+
- `extracted/` — stubs for deterministic nodes; fill these in with
|
|
1105
|
+
direct vendor API calls (`NotImplementedError` until you do)
|
|
1106
|
+
- `signatures/` — typed LLM judges generated from the pipeline IR
|
|
1107
|
+
(Pydantic models + direct vendor SDK calls)
|
|
1108
|
+
- `dbos-config.yaml` — CLI/Cloud tooling config for `dbos start`
|
|
1109
|
+
|
|
1110
|
+
## Run locally
|
|
1111
|
+
|
|
1112
|
+
```sh
|
|
1113
|
+
pip install dbos
|
|
1114
|
+
python main.py '{{"your": "input"}}'
|
|
1115
|
+
```
|
|
1116
|
+
|
|
1117
|
+
By default the system database is a SQLite file next to `main.py` —
|
|
1118
|
+
zero infrastructure, ideal for development. For production, point
|
|
1119
|
+
DBOS at Postgres (SQLite is single-process only):
|
|
1120
|
+
|
|
1121
|
+
```sh
|
|
1122
|
+
export DBOS_SYSTEM_DATABASE_URL="postgresql://user:pass@host:5432/db"
|
|
1123
|
+
python main.py '{{...}}' # or: dbos start
|
|
1124
|
+
```
|
|
1125
|
+
|
|
1126
|
+
LLM judge steps call the vendor SDK directly and read the standard
|
|
1127
|
+
`ANTHROPIC_API_KEY` / `OPENAI_API_KEY` environment variables.
|
|
1128
|
+
|
|
1129
|
+
## HITL gates
|
|
1130
|
+
|
|
1131
|
+
The workflow parks durably at each gate until a message arrives on
|
|
1132
|
+
the gate's topic:
|
|
1133
|
+
|
|
1134
|
+
| Gate | Signal (topic) | Timeout |
|
|
1135
|
+
| --- | --- | --- |
|
|
1136
|
+
{gate_lines}
|
|
1137
|
+
|
|
1138
|
+
Resume a parked workflow from any process that can reach the system
|
|
1139
|
+
database:
|
|
1140
|
+
|
|
1141
|
+
```python
|
|
1142
|
+
from dbos import DBOSClient
|
|
1143
|
+
|
|
1144
|
+
client = DBOSClient(system_database_url="...")
|
|
1145
|
+
client.send(workflow_id, {{"approved": True}}, topic="{first_signal}")
|
|
1146
|
+
```
|
|
1147
|
+
|
|
1148
|
+
(In-process, `DBOS.send(workflow_id, payload, topic=...)` does the
|
|
1149
|
+
same.) A gate that times out raises `TimeoutError` and fails the
|
|
1150
|
+
run — silence is not approval.
|
|
1151
|
+
"""
|
|
1152
|
+
)
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
# ───────── Adapter facade ─────────
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
class DbosAdapter:
|
|
1159
|
+
"""Facade that emits a DBOS application from a Pipeline IR.
|
|
1160
|
+
|
|
1161
|
+
Output layout::
|
|
1162
|
+
|
|
1163
|
+
out/
|
|
1164
|
+
main.py
|
|
1165
|
+
dbos-config.yaml
|
|
1166
|
+
README.md
|
|
1167
|
+
extracted/__init__.py
|
|
1168
|
+
extracted/<module>.py
|
|
1169
|
+
signatures/__init__.py
|
|
1170
|
+
signatures/<llm_judge_id>.py
|
|
1171
|
+
|
|
1172
|
+
The directory runs with ``python main.py`` once the user fills in the
|
|
1173
|
+
extracted/ stubs (SQLite system DB by default; Postgres via
|
|
1174
|
+
``DBOS_SYSTEM_DATABASE_URL``).
|
|
1175
|
+
"""
|
|
1176
|
+
|
|
1177
|
+
def __init__(self, config: DbosAdapterConfig | None = None) -> None:
|
|
1178
|
+
self.config = config or DbosAdapterConfig()
|
|
1179
|
+
|
|
1180
|
+
def emit_main(self, pipeline: Pipeline) -> str:
|
|
1181
|
+
return emit_main(pipeline, self.config)
|
|
1182
|
+
|
|
1183
|
+
def emit(self, pipeline: Pipeline, output_dir: str | Path) -> dict[str, Path]:
|
|
1184
|
+
out = Path(output_dir)
|
|
1185
|
+
extracted_dir = out / "extracted"
|
|
1186
|
+
signatures_dir = out / "signatures"
|
|
1187
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
1188
|
+
|
|
1189
|
+
written: dict[str, Path] = {}
|
|
1190
|
+
|
|
1191
|
+
main_path = out / "main.py"
|
|
1192
|
+
main_path.write_text(self.emit_main(pipeline), encoding="utf-8")
|
|
1193
|
+
written["main"] = main_path
|
|
1194
|
+
|
|
1195
|
+
extracted_modules = _extracted_layout(pipeline)
|
|
1196
|
+
if extracted_modules:
|
|
1197
|
+
extracted_dir.mkdir(exist_ok=True)
|
|
1198
|
+
init = extracted_dir / "__init__.py"
|
|
1199
|
+
init.write_text(
|
|
1200
|
+
f'"""Extracted deterministic modules for {pipeline.name}."""\n',
|
|
1201
|
+
encoding="utf-8",
|
|
1202
|
+
)
|
|
1203
|
+
written["extracted/__init__"] = init
|
|
1204
|
+
for module_name, nodes in sorted(extracted_modules.items()):
|
|
1205
|
+
p = extracted_dir / f"{module_name}.py"
|
|
1206
|
+
p.write_text(emit_extracted_module(module_name, nodes), encoding="utf-8")
|
|
1207
|
+
written[f"extracted/{module_name}"] = p
|
|
1208
|
+
|
|
1209
|
+
spec_judges = [
|
|
1210
|
+
n
|
|
1211
|
+
for n in pipeline.nodes
|
|
1212
|
+
if n.kind is NodeKind.LLM_JUDGE and n.signature_spec is not None
|
|
1213
|
+
]
|
|
1214
|
+
if spec_judges:
|
|
1215
|
+
signatures_dir.mkdir(exist_ok=True)
|
|
1216
|
+
init = signatures_dir / "__init__.py"
|
|
1217
|
+
init.write_text(
|
|
1218
|
+
f'"""Generated LLM signatures for {pipeline.name}."""\n',
|
|
1219
|
+
encoding="utf-8",
|
|
1220
|
+
)
|
|
1221
|
+
written["signatures/__init__"] = init
|
|
1222
|
+
for node in spec_judges:
|
|
1223
|
+
p = signatures_dir / f"{node.id}.py"
|
|
1224
|
+
p.write_text(emit_signature_module(node, self.config), encoding="utf-8")
|
|
1225
|
+
written[f"signatures/{node.id}"] = p
|
|
1226
|
+
|
|
1227
|
+
config_path = out / "dbos-config.yaml"
|
|
1228
|
+
config_path.write_text(emit_dbos_config(pipeline), encoding="utf-8")
|
|
1229
|
+
written["dbos-config"] = config_path
|
|
1230
|
+
|
|
1231
|
+
readme_path = out / "README.md"
|
|
1232
|
+
readme_path.write_text(emit_readme(pipeline, self.config), encoding="utf-8")
|
|
1233
|
+
written["README"] = readme_path
|
|
1234
|
+
|
|
1235
|
+
return written
|