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.
@@ -0,0 +1,568 @@
1
+ """Temporal adapter — emits Workflow + Activities Python from a Pipeline IR.
2
+
3
+ The adapter consumes a :class:`rote.ir.Pipeline` and produces two source
4
+ files:
5
+
6
+ * ``activities.py`` — one ``@activity.defn`` per non-HITL node, each a
7
+ thin wrapper around the corresponding ``extracted/`` function or
8
+ ``signatures/`` class. **No MCP runtime ever appears in this file.**
9
+ External calls are direct API calls (deterministic Python), and
10
+ LLM judge calls are typed signatures. The graduation of an MCP tool
11
+ call into a deterministic activity happens *here*.
12
+ * ``workflow.py`` — one ``@workflow.defn`` class with the orchestration:
13
+ topologically-sorted activity calls grouped into parallel waves,
14
+ signal handlers and ``wait_condition`` calls for HITL gates, and
15
+ the workflow class name versioned by a hash of the pipeline so
16
+ regenerated workflows become new types (avoiding determinism errors
17
+ on in-flight workflows).
18
+
19
+ The adapter is intentionally template-driven and not clever. The interesting
20
+ work is the IR design and the topological sort; the code emission itself
21
+ is plain string templates.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import textwrap
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+
30
+ from rote.adapters._common import (
31
+ _execution_waves,
32
+ _pipeline_hash,
33
+ _to_pascal_case,
34
+ check_input_refs_available,
35
+ )
36
+ from rote.ir import Node, NodeKind, Pipeline, parse_input_ref
37
+
38
+ # ───────── Adapter configuration ─────────
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class TemporalAdapterConfig:
43
+ """Per-emission configuration for the Temporal adapter.
44
+
45
+ These knobs are project-specific and should be set by the caller (or
46
+ eventually by an adapter config file in the source skill bundle).
47
+ Defaults match the BDR example so the adapter is usable out of the
48
+ box.
49
+ """
50
+
51
+ # Python import paths used by the emitted code.
52
+ types_module: str = "expected.types"
53
+ extracted_module: str = "expected.extracted"
54
+ signatures_module: str = "expected.signatures"
55
+
56
+ # Default activity timeouts when the IR doesn't specify one.
57
+ default_activity_timeout: str = "5m"
58
+ default_hitl_timeout: str = "7d"
59
+
60
+
61
+ # ───────── Helpers ─────────
62
+
63
+
64
+ def _impl_path_parts(impl: str) -> tuple[str, str]:
65
+ """Parse 'extracted/foo.py:bar' into ('foo', 'bar')."""
66
+ if ":" not in impl:
67
+ raise ValueError(f"impl path missing ':function_name': {impl!r}")
68
+ file_part, func = impl.split(":", 1)
69
+ # Strip leading directories and .py suffix
70
+ module_name = file_part.rsplit("/", 1)[-1].removesuffix(".py")
71
+ return module_name, func
72
+
73
+
74
+ def _signature_path_parts(signature: str) -> tuple[str, str]:
75
+ """Parse 'signatures/foo.py:Foo' into ('foo', 'Foo')."""
76
+ return _impl_path_parts(signature)
77
+
78
+
79
+ def _activity_timeout(node: Node, default: str) -> str:
80
+ return node.timeout or default
81
+
82
+
83
+ def _retry_policy_args(node: Node) -> str:
84
+ """Render a Temporal RetryPolicy from the node's retry config.
85
+
86
+ Returns a string like ``RetryPolicy(maximum_attempts=5, ...)`` or an
87
+ empty string if no retry policy is specified.
88
+ """
89
+ if not node.retry:
90
+ return ""
91
+ parts = [f"maximum_attempts={node.retry.max + 1}"] # Temporal counts initial + retries
92
+ backoff = node.retry.backoff
93
+ if backoff == "exponential":
94
+ parts.append("backoff_coefficient=2.0")
95
+ elif backoff == "linear":
96
+ parts.append("backoff_coefficient=1.0")
97
+ return f"RetryPolicy({', '.join(parts)})"
98
+
99
+
100
+ # ───────── Activities emission ─────────
101
+
102
+
103
+ def _emit_activity_for_pure_or_external(node: Node, cfg: TemporalAdapterConfig) -> str:
104
+ assert node.impl is not None
105
+ module_name, func_name = _impl_path_parts(node.impl)
106
+
107
+ constants_block = ""
108
+ if node.constants:
109
+ constants_block = (
110
+ " # Constants from the IR (extracted from the source skill prose):\n"
111
+ + "".join(f" # {k} = {v!r}\n" for k, v in node.constants.items())
112
+ )
113
+
114
+ mandatory_marker = ""
115
+ if node.mandatory:
116
+ mandatory_marker = (
117
+ " # MANDATORY: this node was marked mandatory in the source\n"
118
+ " # skill. The workflow always calls it; do not make it conditional.\n"
119
+ )
120
+
121
+ return textwrap.dedent(
122
+ f'''
123
+ @activity.defn(name="{node.id}")
124
+ async def {node.id}(payload: dict) -> dict:
125
+ """{node.description.strip().splitlines()[0]}
126
+
127
+ Graduated from MCP tool call → deterministic API call. See
128
+ ``{cfg.extracted_module}.{module_name}`` for implementation.
129
+ """
130
+ '''
131
+ ).lstrip("\n") + (
132
+ mandatory_marker
133
+ + constants_block
134
+ + f" from {cfg.extracted_module}.{module_name} import {func_name}\n"
135
+ + f" result = await {func_name}(**payload)\n"
136
+ + " return _serialize(result)\n"
137
+ )
138
+
139
+
140
+ def _emit_activity_for_llm_judge(node: Node, cfg: TemporalAdapterConfig) -> str:
141
+ assert node.signature is not None
142
+ module_name, class_name = _signature_path_parts(node.signature)
143
+ input_class = f"{class_name}Input"
144
+
145
+ return textwrap.dedent(
146
+ f'''
147
+ @activity.defn(name="{node.id}")
148
+ async def {node.id}(payload: dict) -> dict:
149
+ """{node.description.strip().splitlines()[0]}
150
+
151
+ LLM judge — typed input/output, bounded decision space. The
152
+ non-determinism lives inside this activity, not the workflow.
153
+ """
154
+ '''
155
+ ).lstrip("\n") + (
156
+ f" from {cfg.signatures_module}.{module_name} import (\n"
157
+ f" {class_name},\n"
158
+ f" {input_class},\n"
159
+ f" )\n"
160
+ f" judge = {class_name}()\n"
161
+ f" result = await judge.forward({input_class}(**payload))\n"
162
+ f" return result.model_dump()\n"
163
+ )
164
+
165
+
166
+ def _emit_activity_for_agent_loop(node: Node, cfg: TemporalAdapterConfig) -> str:
167
+ """Emit a stub activity for an agent_loop node.
168
+
169
+ Agent loops require an LLM agent runtime (e.g., the Anthropic SDK
170
+ with bounded iterations). The v0 adapter emits a stub that raises
171
+ NotImplementedError so the workflow at least imports cleanly. Real
172
+ implementations are a per-project decision and should call out to
173
+ whatever agent harness the project already uses.
174
+ """
175
+ tools_doc = "\n".join(f" # - {t}" for t in (node.tools or []))
176
+ sub_nodes_doc = ""
177
+ if node.loop_body:
178
+ sub_nodes_doc = (
179
+ " # Loop body sub-nodes (call these for each iteration):\n"
180
+ + "\n".join(f" # - {sn}" for sn in node.loop_body)
181
+ + "\n"
182
+ )
183
+
184
+ return (
185
+ textwrap.dedent(
186
+ f'''
187
+ @activity.defn(name="{node.id}")
188
+ async def {node.id}(payload: dict) -> dict:
189
+ """{node.description.strip().splitlines()[0]}
190
+
191
+ STUB — agent loops require an LLM agent runtime. Implement
192
+ this against the project's preferred agent harness (Anthropic
193
+ Agent SDK, OpenAI Agents SDK, LangGraph, etc.).
194
+ """
195
+ # Tools the agent should be allowed to call inside the loop:
196
+ '''
197
+ ).lstrip("\n")
198
+ + (tools_doc + "\n" if tools_doc else "")
199
+ + sub_nodes_doc
200
+ + (
201
+ f' raise NotImplementedError("agent_loop activity {node.id!r}: '
202
+ 'requires an agent runtime")\n'
203
+ )
204
+ )
205
+
206
+
207
+ def emit_activities(pipeline: Pipeline, cfg: TemporalAdapterConfig | None = None) -> str:
208
+ """Render the activities.py source for a pipeline."""
209
+ cfg = cfg or TemporalAdapterConfig()
210
+
211
+ parts: list[str] = []
212
+
213
+ parts.append(
214
+ textwrap.dedent(
215
+ f'''
216
+ """Auto-generated by rote.adapters.temporal.
217
+
218
+ Pipeline: {pipeline.name} v{pipeline.version}
219
+ Source skill: {pipeline.source_skill or "unknown"}
220
+
221
+ DO NOT EDIT BY HAND. Re-run ``rote emit`` to regenerate.
222
+
223
+ Architecture note: every external_call activity in this file
224
+ wraps a deterministic Python function from the ``extracted/``
225
+ package. None of them call MCP tools at runtime — the MCP
226
+ tool calls from the source skill have been graduated into
227
+ direct API calls during the rote emission step.
228
+ """
229
+
230
+ from __future__ import annotations
231
+
232
+ from typing import Any
233
+
234
+ from temporalio import activity
235
+ '''
236
+ ).lstrip("\n")
237
+ )
238
+
239
+ parts.append(
240
+ textwrap.dedent(
241
+ '''
242
+
243
+ def _serialize(obj: Any) -> Any:
244
+ """Convert pydantic models / dataclasses / lists to plain dicts.
245
+
246
+ Activities return JSON-serializable payloads so the workflow
247
+ doesn't carry typed objects through Temporal's history.
248
+ """
249
+ if hasattr(obj, "model_dump"):
250
+ return obj.model_dump()
251
+ if isinstance(obj, list):
252
+ return [_serialize(x) for x in obj]
253
+ if isinstance(obj, tuple):
254
+ return [_serialize(x) for x in obj]
255
+ if isinstance(obj, dict):
256
+ return {k: _serialize(v) for k, v in obj.items()}
257
+ return obj
258
+ '''
259
+ )
260
+ )
261
+
262
+ # Emit one activity per non-HITL node (including loop_body sub-nodes
263
+ # so they can be tested in isolation).
264
+ for node in pipeline.nodes:
265
+ if node.kind is NodeKind.HITL_GATE:
266
+ continue
267
+ parts.append("\n")
268
+ if node.kind in (NodeKind.PURE_FUNCTION, NodeKind.EXTERNAL_CALL):
269
+ parts.append(_emit_activity_for_pure_or_external(node, cfg))
270
+ elif node.kind is NodeKind.LLM_JUDGE:
271
+ parts.append(_emit_activity_for_llm_judge(node, cfg))
272
+ elif node.kind is NodeKind.AGENT_LOOP:
273
+ parts.append(_emit_activity_for_agent_loop(node, cfg))
274
+
275
+ return "".join(parts)
276
+
277
+
278
+ # ───────── Workflow emission ─────────
279
+
280
+
281
+ def _emit_signal_handlers(pipeline: Pipeline) -> str:
282
+ """Emit __init__ + signal handlers for every HITL gate.
283
+
284
+ The output is indented for class-body inclusion (4-space prefix on
285
+ every line). The caller concatenates this directly under the class
286
+ header.
287
+ """
288
+ gates = [n for n in pipeline.nodes if n.kind is NodeKind.HITL_GATE]
289
+ if not gates:
290
+ return " def __init__(self) -> None:\n pass\n\n"
291
+
292
+ init_lines = [" def __init__(self) -> None:"]
293
+ for gate in gates:
294
+ init_lines.append(f" self._{gate.signal}_payload: dict | None = None")
295
+ init_block = "\n".join(init_lines) + "\n\n"
296
+
297
+ handler_blocks: list[str] = []
298
+ for gate in gates:
299
+ # First describe at module-level indent, then re-indent everything
300
+ # by 4 spaces so the handlers land inside the class body.
301
+ raw = textwrap.dedent(
302
+ f'''
303
+ @workflow.signal(name="{gate.signal}")
304
+ def {gate.signal}(self, payload: dict) -> None:
305
+ """{gate.description.strip().splitlines()[0]}"""
306
+ self._{gate.signal}_payload = payload
307
+ '''
308
+ ).lstrip("\n")
309
+ handler_blocks.append(textwrap.indent(raw, prefix=" "))
310
+ return init_block + "\n".join(handler_blocks)
311
+
312
+
313
+ def _ref_to_python_expr(ref: str) -> str:
314
+ """Render an ``inputs:`` source reference as a Python expression.
315
+
316
+ The workflow binds the pipeline input to ``pipeline_input`` and each
317
+ node's result to ``<node_id>_result``, so references map directly:
318
+
319
+ | Reference | Expression |
320
+ |----------------------------|---------------------------------|
321
+ | ``pipeline.input`` | ``pipeline_input`` |
322
+ | ``pipeline.input.f`` | ``pipeline_input["f"]`` |
323
+ | ``foo.output`` | ``foo_result`` |
324
+ | ``foo.output.f`` | ``foo_result["f"]`` |
325
+ """
326
+ parsed = parse_input_ref(ref)
327
+ base = "pipeline_input" if parsed.node_id is None else f"{parsed.node_id}_result"
328
+ if parsed.field is None:
329
+ return base
330
+ return f'{base}["{parsed.field}"]'
331
+
332
+
333
+ def _payload_literal(node: Node, indent: str) -> str:
334
+ """Render the activity payload dict for a node's data-flow bindings.
335
+
336
+ Nodes without ``inputs`` keep the empty payload (back-compat).
337
+ ``indent`` is the indentation of the line the literal starts on;
338
+ continuation lines are indented one level deeper.
339
+ """
340
+ if not node.inputs:
341
+ return "{}"
342
+ inner = indent + " "
343
+ lines = ["{"]
344
+ for param, ref in node.inputs.items():
345
+ lines.append(f'{inner}"{param}": {_ref_to_python_expr(ref)},')
346
+ lines.append(indent + "}")
347
+ return "\n".join(lines)
348
+
349
+
350
+ def _emit_wave_call(node: Node, cfg: TemporalAdapterConfig) -> str:
351
+ """Emit a multi-line call to ``workflow.execute_activity`` for one node.
352
+
353
+ Produces something like::
354
+
355
+ foo_result = await workflow.execute_activity(
356
+ "foo",
357
+ {
358
+ "brief": pipeline_input,
359
+ "intel": target_research_result,
360
+ },
361
+ start_to_close_timeout=timedelta(minutes=...),
362
+ retry_policy=RetryPolicy(...),
363
+ )
364
+ """
365
+ if node.kind is NodeKind.HITL_GATE:
366
+ # HITL gates are not activities — they're handled separately.
367
+ return ""
368
+
369
+ timeout = _activity_timeout(node, cfg.default_activity_timeout)
370
+ retry = _retry_policy_args(node)
371
+ payload = _payload_literal(node, indent=" " * 12)
372
+
373
+ lines = [
374
+ f" {node.id}_result = await workflow.execute_activity(",
375
+ f' "{node.id}",',
376
+ f" {payload},",
377
+ f' start_to_close_timeout=timedelta(minutes=_parse_minutes("{timeout}")),',
378
+ ]
379
+ if retry:
380
+ lines.append(f" retry_policy={retry},")
381
+ lines.append(" )")
382
+ return "\n".join(lines) + "\n"
383
+
384
+
385
+ def _emit_hitl_block(node: Node) -> str:
386
+ assert node.kind is NodeKind.HITL_GATE
387
+ assert node.signal is not None
388
+ return (
389
+ f" # ─── HITL gate: {node.id} ───\n"
390
+ f" # Workflow suspends here until the {node.signal!r} signal\n"
391
+ f" # arrives. Survives worker restarts; resumes immediately\n"
392
+ f" # when the signal fires.\n"
393
+ f" await workflow.wait_condition(\n"
394
+ f" lambda: self._{node.signal}_payload is not None\n"
395
+ f" )\n"
396
+ f" {node.id}_result = self._{node.signal}_payload\n"
397
+ )
398
+
399
+
400
+ def _emit_workflow_run(pipeline: Pipeline, cfg: TemporalAdapterConfig) -> str:
401
+ waves = _execution_waves(pipeline)
402
+
403
+ body_lines: list[str] = [
404
+ " @workflow.run",
405
+ " async def run(self, pipeline_input: dict) -> dict:",
406
+ ]
407
+ desc = pipeline.description.strip().splitlines()[0] if pipeline.description else pipeline.name
408
+ body_lines.append(f' """{desc}"""')
409
+
410
+ # Node ids whose results are bound by the time each wave starts —
411
+ # used to reject inputs that reference a later wave at emit time.
412
+ available: set[str] = set()
413
+
414
+ for wave_idx, wave in enumerate(waves, start=1):
415
+ non_hitl = [n for n in wave if n.kind is not NodeKind.HITL_GATE]
416
+ hitl = [n for n in wave if n.kind is NodeKind.HITL_GATE]
417
+
418
+ for n in non_hitl:
419
+ check_input_refs_available(n, available)
420
+
421
+ body_lines.append("")
422
+ body_lines.append(f" # ─── Wave {wave_idx} ───")
423
+
424
+ if len(non_hitl) == 1:
425
+ body_lines.append(_emit_wave_call(non_hitl[0], cfg).rstrip("\n"))
426
+ elif len(non_hitl) > 1:
427
+ # Parallel via asyncio.gather
428
+ body_lines.append(" (")
429
+ for n in non_hitl:
430
+ body_lines.append(f" {n.id}_result,")
431
+ body_lines.append(" ) = await asyncio.gather(")
432
+ for n in non_hitl:
433
+ timeout = _activity_timeout(n, cfg.default_activity_timeout)
434
+ payload = _payload_literal(n, indent=" " * 16)
435
+ timeout_line = (
436
+ " start_to_close_timeout="
437
+ f'timedelta(minutes=_parse_minutes("{timeout}")),\n'
438
+ )
439
+ body_lines.append(
440
+ f" workflow.execute_activity(\n"
441
+ f' "{n.id}",\n'
442
+ f" {payload},\n" + timeout_line + " ),"
443
+ )
444
+ body_lines.append(" )")
445
+
446
+ for h in hitl:
447
+ body_lines.append(_emit_hitl_block(h).rstrip("\n"))
448
+
449
+ available.update(n.id for n in wave)
450
+
451
+ body_lines.append("")
452
+ body_lines.append(" return {")
453
+ for exit_id in pipeline.exit_nodes:
454
+ body_lines.append(f' "{exit_id}": {exit_id}_result,')
455
+ body_lines.append(" }")
456
+
457
+ return "\n".join(body_lines) + "\n"
458
+
459
+
460
+ def emit_workflow(pipeline: Pipeline, cfg: TemporalAdapterConfig | None = None) -> str:
461
+ """Render the workflow.py source for a pipeline."""
462
+ cfg = cfg or TemporalAdapterConfig()
463
+
464
+ pascal_name = _to_pascal_case(pipeline.name)
465
+ workflow_hash = _pipeline_hash(pipeline)
466
+ versioned_workflow_name = f"{pascal_name}_{workflow_hash}"
467
+ class_name = f"{pascal_name}Workflow"
468
+
469
+ parts: list[str] = []
470
+
471
+ parts.append(
472
+ textwrap.dedent(
473
+ f'''
474
+ """Auto-generated by rote.adapters.temporal.
475
+
476
+ Pipeline: {pipeline.name} v{pipeline.version}
477
+ Source skill: {pipeline.source_skill or "unknown"}
478
+ Pipeline hash: {workflow_hash}
479
+
480
+ DO NOT EDIT BY HAND. Re-run ``rote emit`` to regenerate.
481
+
482
+ Workflow versioning: the registered workflow type name
483
+ includes a hash of the pipeline so regenerated workflows
484
+ become a new type. In-flight workflows on the old type
485
+ continue running the old code; new workflows use the new
486
+ code. This is the simplest way to avoid Temporal's
487
+ determinism errors when regenerating from a changed skill.
488
+ """
489
+
490
+ from __future__ import annotations
491
+
492
+ import asyncio
493
+ from datetime import timedelta
494
+
495
+ from temporalio import workflow
496
+ from temporalio.common import RetryPolicy
497
+
498
+
499
+ def _parse_minutes(s: str) -> float:
500
+ """Parse a duration string like '5m', '30s', '7d' into minutes."""
501
+ s = s.strip()
502
+ if s.endswith("ms"):
503
+ return float(s[:-2]) / 60_000
504
+ if s.endswith("s"):
505
+ return float(s[:-1]) / 60
506
+ if s.endswith("m"):
507
+ return float(s[:-1])
508
+ if s.endswith("h"):
509
+ return float(s[:-1]) * 60
510
+ if s.endswith("d"):
511
+ return float(s[:-1]) * 60 * 24
512
+ return float(s)
513
+
514
+
515
+ @workflow.defn(name="{versioned_workflow_name}")
516
+ class {class_name}:
517
+ """Graduated workflow for {pipeline.name}."""
518
+
519
+ '''
520
+ ).lstrip("\n")
521
+ )
522
+
523
+ parts.append(_emit_signal_handlers(pipeline))
524
+ parts.append("\n")
525
+ parts.append(_emit_workflow_run(pipeline, cfg))
526
+
527
+ # Re-indent the workflow run + signal handlers under the class
528
+ # (they were emitted at module level above for readability).
529
+ return "".join(parts)
530
+
531
+
532
+ # ───────── TemporalAdapter facade ─────────
533
+
534
+
535
+ class TemporalAdapter:
536
+ """Facade that emits a Temporal workflow + activities pair from a Pipeline."""
537
+
538
+ def __init__(self, config: TemporalAdapterConfig | None = None) -> None:
539
+ self.config = config or TemporalAdapterConfig()
540
+
541
+ def emit_activities(self, pipeline: Pipeline) -> str:
542
+ return emit_activities(pipeline, self.config)
543
+
544
+ def emit_workflow(self, pipeline: Pipeline) -> str:
545
+ return emit_workflow(pipeline, self.config)
546
+
547
+ def emit(self, pipeline: Pipeline, output_dir: str | Path) -> dict[str, Path]:
548
+ """Write activities.py + workflow.py + __init__.py into output_dir."""
549
+ out = Path(output_dir)
550
+ out.mkdir(parents=True, exist_ok=True)
551
+
552
+ activities_path = out / "activities.py"
553
+ workflow_path = out / "workflow.py"
554
+ init_path = out / "__init__.py"
555
+
556
+ activities_path.write_text(self.emit_activities(pipeline), encoding="utf-8")
557
+ workflow_path.write_text(self.emit_workflow(pipeline), encoding="utf-8")
558
+ if not init_path.exists():
559
+ init_path.write_text(
560
+ f'"""Emitted Temporal artifacts for {pipeline.name}."""\n',
561
+ encoding="utf-8",
562
+ )
563
+
564
+ return {
565
+ "activities": activities_path,
566
+ "workflow": workflow_path,
567
+ "__init__": init_path,
568
+ }