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.
- {rote_cli-0.3.0 → rote_cli-0.4.0}/PKG-INFO +40 -16
- {rote_cli-0.3.0 → rote_cli-0.4.0}/README.md +39 -15
- {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/__init__.py +1 -1
- {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/adapters/__init__.py +7 -0
- {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/adapters/_common.py +54 -0
- rote_cli-0.4.0/src/rote/adapters/_ts_common.py +338 -0
- {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/adapters/cloudflare.py +21 -302
- {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/adapters/dbos.py +11 -11
- rote_cli-0.4.0/src/rote/adapters/dbos_ts.py +906 -0
- {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/adapters/temporal.py +6 -5
- {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/cli.py +8 -4
- {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/ir.py +85 -0
- {rote_cli-0.3.0 → rote_cli-0.4.0}/.gitignore +0 -0
- {rote_cli-0.3.0 → rote_cli-0.4.0}/LICENSE +0 -0
- {rote_cli-0.3.0 → rote_cli-0.4.0}/pyproject.toml +0 -0
- {rote_cli-0.3.0 → rote_cli-0.4.0}/skills/rote-graduate/SKILL.md +0 -0
- {rote_cli-0.3.0 → rote_cli-0.4.0}/skills/rote-graduate/references/crystallization-heuristics.md +0 -0
- {rote_cli-0.3.0 → rote_cli-0.4.0}/skills/rote-graduate/references/ir-schema.md +0 -0
- {rote_cli-0.3.0 → rote_cli-0.4.0}/skills/rote-graduate/references/llm-judge-extraction.md +0 -0
- {rote_cli-0.3.0 → rote_cli-0.4.0}/skills/rote-graduate/references/node-kinds.md +0 -0
- {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/graduator/__init__.py +0 -0
- {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/graduator/drivers/__init__.py +0 -0
- {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/graduator/drivers/anthropic_api.py +0 -0
- {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/graduator/drivers/claude.py +0 -0
- {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/graduator/drivers/codex.py +0 -0
- {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/serve/__init__.py +0 -0
- {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/serve/backends.py +0 -0
- {rote_cli-0.3.0 → rote_cli-0.4.0}/src/rote/serve/registry.py +0 -0
- {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
|
+
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
|
-
|
|
63
|
-
|
|
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/
|
|
324
|
-
├── workflow.
|
|
325
|
-
├──
|
|
326
|
-
|
|
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 (
|
|
397
|
-
plus
|
|
398
|
-
|
|
399
|
-
|
|
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
|
|
417
|
-
| Distribution via PyPI |
|
|
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
|
-
|
|
17
|
-
|
|
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/
|
|
278
|
-
├── workflow.
|
|
279
|
-
├──
|
|
280
|
-
|
|
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 (
|
|
351
|
-
plus
|
|
352
|
-
|
|
353
|
-
|
|
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
|
|
371
|
-
| Distribution via PyPI |
|
|
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
|
|
@@ -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)
|