rote-cli 0.3.0__tar.gz → 0.4.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 (29) hide show
  1. {rote_cli-0.3.0 → rote_cli-0.4.0}/PKG-INFO +40 -16
  2. {rote_cli-0.3.0 → rote_cli-0.4.0}/README.md +39 -15
  3. {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/__init__.py +1 -1
  4. {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/adapters/__init__.py +7 -0
  5. {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/adapters/_common.py +54 -0
  6. rote_cli-0.4.0/src/rote/adapters/_ts_common.py +338 -0
  7. {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/adapters/cloudflare.py +21 -302
  8. {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/adapters/dbos.py +11 -11
  9. rote_cli-0.4.0/src/rote/adapters/dbos_ts.py +906 -0
  10. {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/adapters/temporal.py +6 -5
  11. {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/cli.py +8 -4
  12. {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/ir.py +85 -0
  13. {rote_cli-0.3.0 → rote_cli-0.4.0}/.gitignore +0 -0
  14. {rote_cli-0.3.0 → rote_cli-0.4.0}/LICENSE +0 -0
  15. {rote_cli-0.3.0 → rote_cli-0.4.0}/pyproject.toml +0 -0
  16. {rote_cli-0.3.0 → rote_cli-0.4.0}/skills/rote-graduate/SKILL.md +0 -0
  17. {rote_cli-0.3.0 → rote_cli-0.4.0}/skills/rote-graduate/references/crystallization-heuristics.md +0 -0
  18. {rote_cli-0.3.0 → rote_cli-0.4.0}/skills/rote-graduate/references/ir-schema.md +0 -0
  19. {rote_cli-0.3.0 → rote_cli-0.4.0}/skills/rote-graduate/references/llm-judge-extraction.md +0 -0
  20. {rote_cli-0.3.0 → rote_cli-0.4.0}/skills/rote-graduate/references/node-kinds.md +0 -0
  21. {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/graduator/__init__.py +0 -0
  22. {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/graduator/drivers/__init__.py +0 -0
  23. {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/graduator/drivers/anthropic_api.py +0 -0
  24. {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/graduator/drivers/claude.py +0 -0
  25. {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/graduator/drivers/codex.py +0 -0
  26. {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/serve/__init__.py +0 -0
  27. {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/serve/backends.py +0 -0
  28. {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/serve/registry.py +0 -0
  29. {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/serve/server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rote-cli
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Graduate fuzzy AI skills into deterministic, reliable workflows
5
5
  Project-URL: Homepage, https://github.com/trevhud/rote
6
6
  Project-URL: Repository, https://github.com/trevhud/rote
@@ -59,8 +59,13 @@ applies a structured graduation rubric, and emits:
59
59
  - and runtime code for your durable execution engine of choice.
60
60
 
61
61
  ```sh
62
- # Two runtimes shipped today emit Python for Temporal, or TypeScript
63
- # for Cloudflare Workflows:
62
+ pip install rote-cli # or zero-install: uvx --from rote-cli rote ...
63
+
64
+ # Default target is DBOS — durable execution as a plain Python library,
65
+ # no orchestrator to run, SQLite for dev / Postgres for prod:
66
+ rote graduate ./examples/bdr-outreach/skill --out ./graduated/
67
+
68
+ # Or target Temporal (Python) or Cloudflare Workflows (TypeScript):
64
69
  rote graduate ./examples/bdr-outreach/skill --runtime temporal --out ./graduated/
65
70
  rote graduate ./examples/bdr-outreach/skill --runtime cloudflare --out ./graduated/
66
71
  ```
@@ -140,6 +145,10 @@ The graduator independently:
140
145
  After the agent finishes, a runtime adapter consumes the IR and emits
141
146
  the target runtime's native code shape:
142
147
 
148
+ - The **DBOS** adapter (the default) emits a single `main.py` — one
149
+ `@DBOS.workflow` DAG plus a `@DBOS.step` per node, checkpointing to
150
+ SQLite or Postgres. `python main.py` *is* the runtime; there is no
151
+ orchestrator to deploy.
143
152
  - The **Temporal** adapter emits `workflow.py` (the orchestration
144
153
  class with `@workflow.defn` and signal handlers for the HITL gates)
145
154
  and `activities.py` (one `@activity.defn` per node, lazy-importing
@@ -150,6 +159,14 @@ the target runtime's native code shape:
150
159
  `signatures/*.ts` (Zod schemas + Anthropic SDK calls) and the
151
160
  supporting `wrangler.jsonc` / `package.json` / `tsconfig.json`. The
152
161
  output is `wrangler deploy`-ready.
162
+ - The **DBOS (TypeScript)** adapter (`--runtime dbos-ts`) emits a
163
+ Node.js app for DBOS Transact: `src/main.ts` registers one durable
164
+ workflow (`DBOS.registerWorkflow`) running the DAG waves and one
165
+ `DBOS.registerStep` per node, with `DBOS.recv(...)` parking each
166
+ HITL gate durably in Postgres until `DBOS.send(...)` resumes it.
167
+ Zero-orchestrator like the Python DBOS target — `node dist/main.js`
168
+ *is* the runtime — but note the TS SDK is Postgres-only (no SQLite
169
+ mode; `npx dbos postgres start` covers local dev).
153
170
 
154
171
  None of the emitted code references an MCP runtime, in either
155
172
  language — the agent's crystallization step replaces tool calls with
@@ -297,11 +314,12 @@ The repository includes a real BDR outreach skill in
297
314
  `examples/bdr-outreach/skill/`. Graduate it:
298
315
 
299
316
  ```sh
300
- rote graduate examples/bdr-outreach/skill \
301
- --runtime temporal \
302
- --out /tmp/bdr-graduated
317
+ rote graduate examples/bdr-outreach/skill --out /tmp/bdr-graduated
303
318
  ```
304
319
 
320
+ This targets DBOS by default; pass `--runtime temporal` or
321
+ `--runtime cloudflare` for the other adapters.
322
+
305
323
  By default `rote` auto-detects an available agent driver in this
306
324
  order: `claude` (Claude Code CLI) → `codex` (Codex CLI) → `api`
307
325
  (Anthropic SDK with `ANTHROPIC_API_KEY`). Override with `--agent`:
@@ -320,10 +338,12 @@ The output directory is structured as:
320
338
  │ ├── signatures/*.py # typed LLM-judge signatures
321
339
  │ ├── evals/*.jsonl # seed eval examples
322
340
  │ └── graduation-report.md # human-readable summary
323
- └── runtime/temporal/ # produced by the adapter
324
- ├── workflow.py
325
- ├── activities.py
326
- └── __init__.py
341
+ └── runtime/dbos/ # produced by the adapter
342
+ ├── main.py # @DBOS.workflow + one @DBOS.step per node
343
+ ├── extracted/*.py # copied deterministic functions
344
+ ├── signatures/*.py # generated Pydantic + vendor-SDK judges
345
+ ├── dbos-config.yaml
346
+ └── README.md # how to run, signal HITL gates, deploy
327
347
  ```
328
348
 
329
349
  ### Render an IR without re-running the agent
@@ -332,6 +352,7 @@ If you already have a `pipeline.yaml` (hand-written or from a previous
332
352
  graduation), `rote emit` runs just the adapter step:
333
353
 
334
354
  ```sh
355
+ rote emit /path/to/pipeline.yaml --out /tmp/emitted/ # dbos (default)
335
356
  rote emit /path/to/pipeline.yaml --runtime temporal --out /tmp/emitted/
336
357
  ```
337
358
 
@@ -393,10 +414,12 @@ Opus's extra reasoning earns its cost.
393
414
  ## Status
394
415
 
395
416
  `rote` is **pre-1.0**. The end-to-end flow works on the BDR example
396
- and the test suite covers each layer (133 tests in the fast suite,
397
- plus 3 slow tests that compile the emitted Cloudflare TypeScript
398
- against the real `@cloudflare/workers-types` definitions and drive a
399
- real workflow instance through both HITL gates via `wrangler dev`).
417
+ and the test suite covers each layer (231 tests in the fast suite,
418
+ plus 5 slow tests that run the emitted code against real runtimes:
419
+ a DBOS runtime over SQLite, Temporal's time-skipping test server, the
420
+ emitted Cloudflare TypeScript compiled against real
421
+ `@cloudflare/workers-types` and driven through both HITL gates via
422
+ `wrangler dev`, and the MCP server over a real stdio transport).
400
423
  Run `pytest tests/` for the fast suite; `pytest tests/ -m slow` for
401
424
  the toolchain-dependent integration tests.
402
425
 
@@ -406,6 +429,7 @@ the toolchain-dependent integration tests.
406
429
  | Temporal adapter | working (validated with mocked-activities e2e test) |
407
430
  | Cloudflare Workflows adapter | working (validated with `tsc --noEmit` over the real emitted output) |
408
431
  | DBOS adapter | working (validated against a real DBOS runtime over SQLite in the e2e test) |
432
+ | DBOS TypeScript adapter (`dbos-ts`) | working (validated with `tsc --noEmit` over the real emitted output and a live run on the DBOS TS runtime against Docker Postgres) |
409
433
  | Graduator orchestrator | working |
410
434
  | `rote graduate` / `rote emit` CLI commands | working |
411
435
  | `claude` driver | working |
@@ -413,8 +437,8 @@ the toolchain-dependent integration tests.
413
437
  | `codex` driver | stub (`is_available` works; `run` not implemented) |
414
438
  | Inngest / Restate adapters | planned |
415
439
  | Real implementations of the extracted modules | the agent produces stubs that raise `NotImplementedError`; humans fill them in with real API client code |
416
- | Workflow data flow between activities | working — nodes declare `inputs:` bindings and all three adapters (Temporal, Cloudflare, DBOS) thread real payloads through the DAG, validated in the runtime e2e tests |
417
- | Distribution via PyPI | not yet published install from source. The release pipeline (tag-driven, Trusted Publishing) is in place; see [docs/releasing.md](docs/releasing.md) |
440
+ | Workflow data flow between activities | working — nodes declare `inputs:` bindings and all four adapters (Temporal, Cloudflare, DBOS, DBOS-TS) thread real payloads through the DAG, validated in the runtime e2e tests |
441
+ | Distribution via PyPI | published as [`rote-cli`](https://pypi.org/project/rote-cli/) (`pip install rote-cli`); tag-driven Trusted Publishing releases see [docs/releasing.md](docs/releasing.md) |
418
442
 
419
443
  The project explicitly **does not** depend on `claude-agent-sdk`.
420
444
  Anthropic's terms of service forbid third-party agents built on the
@@ -13,8 +13,13 @@ applies a structured graduation rubric, and emits:
13
13
  - and runtime code for your durable execution engine of choice.
14
14
 
15
15
  ```sh
16
- # Two runtimes shipped today emit Python for Temporal, or TypeScript
17
- # for Cloudflare Workflows:
16
+ pip install rote-cli # or zero-install: uvx --from rote-cli rote ...
17
+
18
+ # Default target is DBOS — durable execution as a plain Python library,
19
+ # no orchestrator to run, SQLite for dev / Postgres for prod:
20
+ rote graduate ./examples/bdr-outreach/skill --out ./graduated/
21
+
22
+ # Or target Temporal (Python) or Cloudflare Workflows (TypeScript):
18
23
  rote graduate ./examples/bdr-outreach/skill --runtime temporal --out ./graduated/
19
24
  rote graduate ./examples/bdr-outreach/skill --runtime cloudflare --out ./graduated/
20
25
  ```
@@ -94,6 +99,10 @@ The graduator independently:
94
99
  After the agent finishes, a runtime adapter consumes the IR and emits
95
100
  the target runtime's native code shape:
96
101
 
102
+ - The **DBOS** adapter (the default) emits a single `main.py` — one
103
+ `@DBOS.workflow` DAG plus a `@DBOS.step` per node, checkpointing to
104
+ SQLite or Postgres. `python main.py` *is* the runtime; there is no
105
+ orchestrator to deploy.
97
106
  - The **Temporal** adapter emits `workflow.py` (the orchestration
98
107
  class with `@workflow.defn` and signal handlers for the HITL gates)
99
108
  and `activities.py` (one `@activity.defn` per node, lazy-importing
@@ -104,6 +113,14 @@ the target runtime's native code shape:
104
113
  `signatures/*.ts` (Zod schemas + Anthropic SDK calls) and the
105
114
  supporting `wrangler.jsonc` / `package.json` / `tsconfig.json`. The
106
115
  output is `wrangler deploy`-ready.
116
+ - The **DBOS (TypeScript)** adapter (`--runtime dbos-ts`) emits a
117
+ Node.js app for DBOS Transact: `src/main.ts` registers one durable
118
+ workflow (`DBOS.registerWorkflow`) running the DAG waves and one
119
+ `DBOS.registerStep` per node, with `DBOS.recv(...)` parking each
120
+ HITL gate durably in Postgres until `DBOS.send(...)` resumes it.
121
+ Zero-orchestrator like the Python DBOS target — `node dist/main.js`
122
+ *is* the runtime — but note the TS SDK is Postgres-only (no SQLite
123
+ mode; `npx dbos postgres start` covers local dev).
107
124
 
108
125
  None of the emitted code references an MCP runtime, in either
109
126
  language — the agent's crystallization step replaces tool calls with
@@ -251,11 +268,12 @@ The repository includes a real BDR outreach skill in
251
268
  `examples/bdr-outreach/skill/`. Graduate it:
252
269
 
253
270
  ```sh
254
- rote graduate examples/bdr-outreach/skill \
255
- --runtime temporal \
256
- --out /tmp/bdr-graduated
271
+ rote graduate examples/bdr-outreach/skill --out /tmp/bdr-graduated
257
272
  ```
258
273
 
274
+ This targets DBOS by default; pass `--runtime temporal` or
275
+ `--runtime cloudflare` for the other adapters.
276
+
259
277
  By default `rote` auto-detects an available agent driver in this
260
278
  order: `claude` (Claude Code CLI) → `codex` (Codex CLI) → `api`
261
279
  (Anthropic SDK with `ANTHROPIC_API_KEY`). Override with `--agent`:
@@ -274,10 +292,12 @@ The output directory is structured as:
274
292
  │ ├── signatures/*.py # typed LLM-judge signatures
275
293
  │ ├── evals/*.jsonl # seed eval examples
276
294
  │ └── graduation-report.md # human-readable summary
277
- └── runtime/temporal/ # produced by the adapter
278
- ├── workflow.py
279
- ├── activities.py
280
- └── __init__.py
295
+ └── runtime/dbos/ # produced by the adapter
296
+ ├── main.py # @DBOS.workflow + one @DBOS.step per node
297
+ ├── extracted/*.py # copied deterministic functions
298
+ ├── signatures/*.py # generated Pydantic + vendor-SDK judges
299
+ ├── dbos-config.yaml
300
+ └── README.md # how to run, signal HITL gates, deploy
281
301
  ```
282
302
 
283
303
  ### Render an IR without re-running the agent
@@ -286,6 +306,7 @@ If you already have a `pipeline.yaml` (hand-written or from a previous
286
306
  graduation), `rote emit` runs just the adapter step:
287
307
 
288
308
  ```sh
309
+ rote emit /path/to/pipeline.yaml --out /tmp/emitted/ # dbos (default)
289
310
  rote emit /path/to/pipeline.yaml --runtime temporal --out /tmp/emitted/
290
311
  ```
291
312
 
@@ -347,10 +368,12 @@ Opus's extra reasoning earns its cost.
347
368
  ## Status
348
369
 
349
370
  `rote` is **pre-1.0**. The end-to-end flow works on the BDR example
350
- and the test suite covers each layer (133 tests in the fast suite,
351
- plus 3 slow tests that compile the emitted Cloudflare TypeScript
352
- against the real `@cloudflare/workers-types` definitions and drive a
353
- real workflow instance through both HITL gates via `wrangler dev`).
371
+ and the test suite covers each layer (231 tests in the fast suite,
372
+ plus 5 slow tests that run the emitted code against real runtimes:
373
+ a DBOS runtime over SQLite, Temporal's time-skipping test server, the
374
+ emitted Cloudflare TypeScript compiled against real
375
+ `@cloudflare/workers-types` and driven through both HITL gates via
376
+ `wrangler dev`, and the MCP server over a real stdio transport).
354
377
  Run `pytest tests/` for the fast suite; `pytest tests/ -m slow` for
355
378
  the toolchain-dependent integration tests.
356
379
 
@@ -360,6 +383,7 @@ the toolchain-dependent integration tests.
360
383
  | Temporal adapter | working (validated with mocked-activities e2e test) |
361
384
  | Cloudflare Workflows adapter | working (validated with `tsc --noEmit` over the real emitted output) |
362
385
  | DBOS adapter | working (validated against a real DBOS runtime over SQLite in the e2e test) |
386
+ | DBOS TypeScript adapter (`dbos-ts`) | working (validated with `tsc --noEmit` over the real emitted output and a live run on the DBOS TS runtime against Docker Postgres) |
363
387
  | Graduator orchestrator | working |
364
388
  | `rote graduate` / `rote emit` CLI commands | working |
365
389
  | `claude` driver | working |
@@ -367,8 +391,8 @@ the toolchain-dependent integration tests.
367
391
  | `codex` driver | stub (`is_available` works; `run` not implemented) |
368
392
  | Inngest / Restate adapters | planned |
369
393
  | Real implementations of the extracted modules | the agent produces stubs that raise `NotImplementedError`; humans fill them in with real API client code |
370
- | Workflow data flow between activities | working — nodes declare `inputs:` bindings and all three adapters (Temporal, Cloudflare, DBOS) thread real payloads through the DAG, validated in the runtime e2e tests |
371
- | Distribution via PyPI | not yet published install from source. The release pipeline (tag-driven, Trusted Publishing) is in place; see [docs/releasing.md](docs/releasing.md) |
394
+ | Workflow data flow between activities | working — nodes declare `inputs:` bindings and all four adapters (Temporal, Cloudflare, DBOS, DBOS-TS) thread real payloads through the DAG, validated in the runtime e2e tests |
395
+ | Distribution via PyPI | published as [`rote-cli`](https://pypi.org/project/rote-cli/) (`pip install rote-cli`); tag-driven Trusted Publishing releases see [docs/releasing.md](docs/releasing.md) |
372
396
 
373
397
  The project explicitly **does not** depend on `claude-agent-sdk`.
374
398
  Anthropic's terms of service forbid third-party agents built on the
@@ -1,3 +1,3 @@
1
1
  """rote — graduate fuzzy AI skills into deterministic workflows."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
@@ -48,12 +48,19 @@ def _dbos_adapter_factory() -> Adapter:
48
48
  return DbosAdapter()
49
49
 
50
50
 
51
+ def _dbos_ts_adapter_factory() -> Adapter:
52
+ from rote.adapters.dbos_ts import DbosTsAdapter
53
+
54
+ return DbosTsAdapter()
55
+
56
+
51
57
  #: Name → factory. Keep the values as zero-arg callables so adapters can
52
58
  #: lazy-import their heavy dependencies.
53
59
  ADAPTERS: dict[str, Callable[[], Adapter]] = {
54
60
  "temporal": _temporal_adapter_factory,
55
61
  "cloudflare": _cloudflare_adapter_factory,
56
62
  "dbos": _dbos_adapter_factory,
63
+ "dbos-ts": _dbos_ts_adapter_factory,
57
64
  }
58
65
 
59
66
 
@@ -13,9 +13,63 @@ from __future__ import annotations
13
13
 
14
14
  import hashlib
15
15
  import re
16
+ from pathlib import Path
16
17
 
17
18
  from rote.ir import Node, Pipeline, parse_input_ref
18
19
 
20
+ # ───────── Prose → safe code comment/docstring ─────────
21
+
22
+
23
+ def safe_docstring_line(text: str, fallback: str = "") -> str:
24
+ """First line of ``text``, escaped so it cannot break out of a docstring.
25
+
26
+ Node ``description`` is prose (it legitimately contains quotes and
27
+ backslashes), yet adapters splice its first line straight into a
28
+ ``\"\"\"…\"\"\"`` Python docstring / block comment. An unescaped
29
+ ``\"\"\"`` — or a trailing backslash that escapes the closing quote —
30
+ lets a crafted pipeline.yaml close the docstring early and inject
31
+ code. Escaping backslashes and double-quotes neutralizes both without
32
+ charset-restricting prose at the IR layer.
33
+ """
34
+ stripped = text.strip() if text else ""
35
+ first = stripped.splitlines()[0] if stripped else fallback
36
+ return first.replace("\\", "\\\\").replace('"', '\\"')
37
+
38
+
39
+ def safe_block_comment_line(text: str, fallback: str = "") -> str:
40
+ """First line of ``text``, safe to splice into a ``/* … */`` block comment.
41
+
42
+ The TypeScript adapter emits ``description`` first-lines into JSDoc
43
+ comments. An unescaped ``*/`` would close the comment early and let a
44
+ crafted pipeline.yaml inject code after it; neutralize the sequence by
45
+ inserting a space (``*/`` → ``* /``). Newlines are already dropped by
46
+ taking the first line.
47
+ """
48
+ stripped = text.strip() if text else ""
49
+ first = stripped.splitlines()[0] if stripped else fallback
50
+ return first.replace("*/", "* /")
51
+
52
+
53
+ # ───────── Output-path containment ─────────
54
+
55
+
56
+ def resolve_within(base: Path, *parts: str) -> Path:
57
+ """Join ``parts`` onto ``base`` and assert the result stays inside it.
58
+
59
+ Defense-in-depth for emitted-file paths. IR validation already pins
60
+ ``node.id`` to an identifier, but this guard means any future field
61
+ that reaches a filename cannot escape the output directory via an
62
+ absolute segment or ``..`` traversal — it fails loudly at emit time.
63
+ """
64
+ base_resolved = base.resolve()
65
+ target = base_resolved.joinpath(*parts).resolve()
66
+ if target != base_resolved and base_resolved not in target.parents:
67
+ raise ValueError(
68
+ f"Refusing to write outside output directory: {target} is not within {base_resolved}"
69
+ )
70
+ return target
71
+
72
+
19
73
  # ───────── Case conversion ─────────
20
74
 
21
75
 
@@ -0,0 +1,338 @@
1
+ """Helpers shared by every TypeScript-emitting runtime adapter.
2
+
3
+ Extracted from ``rote.adapters.cloudflare`` once the DBOS TypeScript
4
+ adapter became the second TS consumer — same rule as ``_common``: a
5
+ helper moves here only after two adapters prove it's genuinely shared.
6
+
7
+ What lives here:
8
+
9
+ * the JSON-Schema-to-Zod converter (``json_schema_to_zod`` and its
10
+ internals ``_resolve_refs`` / ``_convert_zod``),
11
+ * the typed LLM signature module emitters
12
+ (``emit_signature_anthropic`` / ``emit_signature_openai``) plus the
13
+ runtime prompt-interpolation helper they embed
14
+ (``_INTERPOLATE_HELPER``).
15
+
16
+ What deliberately does *not* live here: anything encoding a runtime's
17
+ execution semantics (Cloudflare ``step.do`` configs, DBOS step retry
18
+ options, …) — that stays in the adapter that owns it. Case conversion
19
+ (``_to_camel_case`` etc.) already lives in ``rote.adapters._common``
20
+ because the Python adapters use it too.
21
+
22
+ The emitted signature modules are runtime-agnostic TypeScript: they
23
+ import only Zod and the vendor SDK, and take API keys via an explicit
24
+ ``env`` parameter so they work identically inside a Workers isolate
25
+ (bindings) and a Node process (``process.env``).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ from typing import Any
32
+
33
+ from rote.adapters._common import safe_block_comment_line
34
+
35
+ # ───────── JSON Schema → Zod ─────────
36
+
37
+
38
+ def _resolve_refs(schema: Any, defs: dict[str, Any]) -> Any:
39
+ """Recursively inline ``$ref`` references using a pre-extracted ``$defs`` map.
40
+
41
+ Pydantic emits nested types as ``$ref: "#/$defs/Name"``. Zod schemas
42
+ are constructed inline so we resolve refs eagerly.
43
+ """
44
+ if isinstance(schema, dict):
45
+ if "$ref" in schema:
46
+ ref = schema["$ref"]
47
+ if not ref.startswith("#/$defs/"):
48
+ raise ValueError(f"Unsupported $ref form: {ref!r}")
49
+ name = ref[len("#/$defs/") :]
50
+ if name not in defs:
51
+ raise ValueError(f"Unknown $ref target: {name!r}")
52
+ return _resolve_refs(defs[name], defs)
53
+ return {k: _resolve_refs(v, defs) for k, v in schema.items() if k != "$defs"}
54
+ if isinstance(schema, list):
55
+ return [_resolve_refs(x, defs) for x in schema]
56
+ return schema
57
+
58
+
59
+ def _convert_zod(schema: Any, indent: int = 0) -> str:
60
+ """Convert a (resolved, ref-free) JSON Schema fragment to a Zod expression."""
61
+ if not isinstance(schema, dict):
62
+ raise ValueError(f"Expected schema dict, got {type(schema).__name__}: {schema!r}")
63
+
64
+ # Nullable / unions via anyOf or oneOf
65
+ for union_key in ("anyOf", "oneOf"):
66
+ if union_key in schema:
67
+ variants = schema[union_key]
68
+ non_null = [
69
+ v for v in variants if not (isinstance(v, dict) and v.get("type") == "null")
70
+ ]
71
+ has_null = len(non_null) < len(variants)
72
+ if len(non_null) == 1:
73
+ inner = _convert_zod(non_null[0], indent)
74
+ return f"{inner}.nullable()" if has_null else inner
75
+ parts = [_convert_zod(v, indent) for v in non_null]
76
+ union = "z.union([" + ", ".join(parts) + "])"
77
+ return f"{union}.nullable()" if has_null else union
78
+
79
+ if "enum" in schema:
80
+ values = schema["enum"]
81
+ if all(isinstance(v, str) for v in values):
82
+ return "z.enum([" + ", ".join(json.dumps(v) for v in values) + "])"
83
+ # Mixed-type enum
84
+ literals = [f"z.literal({json.dumps(v)})" for v in values]
85
+ return "z.union([" + ", ".join(literals) + "])"
86
+
87
+ schema_type = schema.get("type")
88
+
89
+ if schema_type == "object":
90
+ props = schema.get("properties", {})
91
+ required = set(schema.get("required", []))
92
+ if not props:
93
+ return "z.object({}).strict()"
94
+ pad = " " * (indent + 1)
95
+ outer = " " * indent
96
+ lines = []
97
+ for name, prop_schema in props.items():
98
+ inner = _convert_zod(prop_schema, indent + 1)
99
+ if name not in required:
100
+ inner = f"{inner}.optional()"
101
+ lines.append(f"{pad}{json.dumps(name)}: {inner},")
102
+ body = "\n".join(lines)
103
+ return f"z.object({{\n{body}\n{outer}}}).strict()"
104
+
105
+ if schema_type == "array":
106
+ items = schema.get("items", {})
107
+ return f"z.array({_convert_zod(items, indent)})"
108
+
109
+ if schema_type == "string":
110
+ return "z.string()"
111
+ if schema_type == "integer":
112
+ return "z.number().int()"
113
+ if schema_type == "number":
114
+ return "z.number()"
115
+ if schema_type == "boolean":
116
+ return "z.boolean()"
117
+ if schema_type == "null":
118
+ return "z.null()"
119
+
120
+ # Permissive fallback for under-specified schemas.
121
+ return "z.unknown()"
122
+
123
+
124
+ def json_schema_to_zod(schema: dict[str, Any], indent: int = 0) -> str:
125
+ """Public entry point: convert a (possibly ref-laden) JSON Schema to Zod source."""
126
+ defs = schema.get("$defs", {})
127
+ resolved = _resolve_refs(schema, defs)
128
+ return _convert_zod(resolved, indent)
129
+
130
+
131
+ # ───────── signatures/<id>.ts emission ─────────
132
+
133
+ _INTERPOLATE_HELPER = """\
134
+ function interpolate(template: string, vars: Record<string, unknown>): string {
135
+ return template.replace(/\\{\\{\\s*([\\w.]+)\\s*\\}\\}/g, (_match, key: string) => {
136
+ const value = key.split(".").reduce<unknown>(
137
+ (acc, part) =>
138
+ acc != null && typeof acc === "object"
139
+ ? (acc as Record<string, unknown>)[part]
140
+ : undefined,
141
+ vars,
142
+ );
143
+ if (value === undefined) {
144
+ // A hole in a judge prompt produces confident garbage that is
145
+ // far harder to debug than an error naming the missing field.
146
+ throw new Error(
147
+ `prompt template references {{ ${key} }} but the input has no ` +
148
+ `such field; available top-level keys: ${Object.keys(vars).sort().join(", ")}`,
149
+ );
150
+ }
151
+ if (value === null) return "";
152
+ return typeof value === "string" ? value : JSON.stringify(value);
153
+ });
154
+ }
155
+ """
156
+
157
+
158
+ def emit_signature_anthropic(
159
+ *,
160
+ node_id: str,
161
+ fn_name: str,
162
+ pascal: str,
163
+ description: str,
164
+ input_zod: str,
165
+ output_zod: str,
166
+ output_schema_json: str,
167
+ prompt_template: str,
168
+ model: str,
169
+ temperature: float | None,
170
+ generated_by: str,
171
+ ) -> str:
172
+ """Emit a signatures/<id>.ts module calling Anthropic with tool-use output.
173
+
174
+ ``generated_by`` names the emitting adapter module in the header
175
+ JSDoc (e.g. ``rote.adapters.cloudflare``) so regeneration
176
+ instructions point at the right runtime.
177
+ """
178
+ desc_first = safe_block_comment_line(description, fallback=node_id)
179
+ temp_line = f" temperature: {temperature},\n" if temperature is not None else ""
180
+ quoted_id = json.dumps(node_id)
181
+ quoted_desc = json.dumps(desc_first)
182
+ quoted_model = json.dumps(model)
183
+ quoted_prompt = json.dumps(prompt_template)
184
+ parts = [
185
+ "/**",
186
+ f" * Typed LLM signature: {node_id}",
187
+ " *",
188
+ f" * {desc_first}",
189
+ " *",
190
+ f" * Auto-generated by {generated_by} from the IR's",
191
+ " * `signature_spec`. The non-determinism lives inside this module;",
192
+ " * the workflow that calls it stays deterministic.",
193
+ " */",
194
+ "",
195
+ 'import Anthropic from "@anthropic-ai/sdk";',
196
+ 'import { z } from "zod";',
197
+ "",
198
+ f"export const {pascal}Input = {input_zod};",
199
+ f"export type {pascal}Input = z.infer<typeof {pascal}Input>;",
200
+ "",
201
+ f"export const {pascal}Output = {output_zod};",
202
+ f"export type {pascal}Output = z.infer<typeof {pascal}Output>;",
203
+ "",
204
+ f"const PROMPT = {quoted_prompt};",
205
+ "",
206
+ f"const OUTPUT_JSON_SCHEMA = {output_schema_json};",
207
+ "",
208
+ _INTERPOLATE_HELPER.rstrip("\n"),
209
+ "",
210
+ f"export async function {fn_name}(",
211
+ " rawInput: unknown,",
212
+ " env: { ANTHROPIC_API_KEY: string },",
213
+ f"): Promise<{pascal}Output> {{",
214
+ f" const input = {pascal}Input.parse(rawInput);",
215
+ " const client = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY });",
216
+ "",
217
+ " const response = await client.messages.create({",
218
+ f" model: {quoted_model},",
219
+ " max_tokens: 4096,",
220
+ ]
221
+ if temp_line:
222
+ parts.append(temp_line.rstrip("\n"))
223
+ schema_cast = 'as { type: "object"; [k: string]: unknown }'
224
+ msg_line = (
225
+ ' { role: "user", '
226
+ "content: interpolate(PROMPT, input as Record<string, unknown>) },"
227
+ )
228
+ parts.extend(
229
+ [
230
+ " tools: [",
231
+ " {",
232
+ f" name: {quoted_id},",
233
+ f" description: {quoted_desc},",
234
+ f" input_schema: OUTPUT_JSON_SCHEMA {schema_cast},",
235
+ " },",
236
+ " ],",
237
+ f' tool_choice: {{ type: "tool", name: {quoted_id} }},',
238
+ " messages: [",
239
+ msg_line,
240
+ " ],",
241
+ " });",
242
+ "",
243
+ ' const toolUse = response.content.find((b) => b.type === "tool_use");',
244
+ ' if (!toolUse || toolUse.type !== "tool_use") {',
245
+ f' throw new Error("LLM did not return a tool_use block for {node_id}");',
246
+ " }",
247
+ f" return {pascal}Output.parse(toolUse.input);",
248
+ "}",
249
+ "",
250
+ ]
251
+ )
252
+ return "\n".join(parts)
253
+
254
+
255
+ def emit_signature_openai(
256
+ *,
257
+ node_id: str,
258
+ fn_name: str,
259
+ pascal: str,
260
+ description: str,
261
+ input_zod: str,
262
+ output_zod: str,
263
+ output_schema_json: str,
264
+ prompt_template: str,
265
+ model: str,
266
+ temperature: float | None,
267
+ generated_by: str,
268
+ ) -> str:
269
+ """Emit a signatures/<id>.ts module using OpenAI structured outputs."""
270
+ desc_first = safe_block_comment_line(description, fallback=node_id)
271
+ temp_line = f" temperature: {temperature},\n" if temperature is not None else ""
272
+ quoted_id = json.dumps(node_id)
273
+ quoted_model = json.dumps(model)
274
+ quoted_prompt = json.dumps(prompt_template)
275
+ parts = [
276
+ "/**",
277
+ f" * Typed LLM signature: {node_id}",
278
+ " *",
279
+ f" * {desc_first}",
280
+ " *",
281
+ f" * Auto-generated by {generated_by} from the IR's",
282
+ " * `signature_spec`. Uses OpenAI structured outputs with JSON Schema.",
283
+ " */",
284
+ "",
285
+ 'import OpenAI from "openai";',
286
+ 'import { z } from "zod";',
287
+ "",
288
+ f"export const {pascal}Input = {input_zod};",
289
+ f"export type {pascal}Input = z.infer<typeof {pascal}Input>;",
290
+ "",
291
+ f"export const {pascal}Output = {output_zod};",
292
+ f"export type {pascal}Output = z.infer<typeof {pascal}Output>;",
293
+ "",
294
+ f"const PROMPT = {quoted_prompt};",
295
+ f"const OUTPUT_JSON_SCHEMA = {output_schema_json};",
296
+ "",
297
+ _INTERPOLATE_HELPER.rstrip("\n"),
298
+ "",
299
+ f"export async function {fn_name}(",
300
+ " rawInput: unknown,",
301
+ " env: { OPENAI_API_KEY: string },",
302
+ f"): Promise<{pascal}Output> {{",
303
+ f" const input = {pascal}Input.parse(rawInput);",
304
+ " const client = new OpenAI({ apiKey: env.OPENAI_API_KEY });",
305
+ "",
306
+ " const response = await client.chat.completions.create({",
307
+ f" model: {quoted_model},",
308
+ ]
309
+ if temp_line:
310
+ parts.append(temp_line.rstrip("\n"))
311
+ schema_inline = (
312
+ f" json_schema: {{ name: {quoted_id}, "
313
+ "schema: OUTPUT_JSON_SCHEMA, strict: true },"
314
+ )
315
+ msg_line = (
316
+ ' { role: "user", '
317
+ "content: interpolate(PROMPT, input as Record<string, unknown>) },"
318
+ )
319
+ parts.extend(
320
+ [
321
+ " response_format: {",
322
+ ' type: "json_schema",',
323
+ schema_inline,
324
+ " },",
325
+ " messages: [",
326
+ msg_line,
327
+ " ],",
328
+ " });",
329
+ " const content = response.choices[0]?.message?.content;",
330
+ " if (!content) {",
331
+ f' throw new Error("OpenAI returned no content for {node_id}");',
332
+ " }",
333
+ f" return {pascal}Output.parse(JSON.parse(content));",
334
+ "}",
335
+ "",
336
+ ]
337
+ )
338
+ return "\n".join(parts)