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/ir.py
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
"""Intermediate representation for graduated skills.
|
|
2
|
+
|
|
3
|
+
The IR is a runtime-agnostic DAG of typed nodes. Adapters
|
|
4
|
+
(``rote/adapters/temporal.py``, etc.) consume an :class:`Pipeline` and emit
|
|
5
|
+
runnable code for a specific durable execution engine.
|
|
6
|
+
|
|
7
|
+
The schema is intentionally driven by what real complex skills (BDR
|
|
8
|
+
outreach is the canonical example) need to express. Adding fields here
|
|
9
|
+
should always be motivated by a concrete skill that needs them, not
|
|
10
|
+
speculation.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from enum import StrEnum
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Self
|
|
20
|
+
|
|
21
|
+
import yaml
|
|
22
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
23
|
+
|
|
24
|
+
# ───────── Data-flow input references ─────────
|
|
25
|
+
#
|
|
26
|
+
# A node declares where its runtime inputs come from via ``inputs:``,
|
|
27
|
+
# a mapping of parameter name → source reference. The grammar is
|
|
28
|
+
# deliberately tiny — four forms, no expression language:
|
|
29
|
+
#
|
|
30
|
+
# pipeline.input the whole pipeline input payload
|
|
31
|
+
# pipeline.input.<field> one top-level field of the pipeline input
|
|
32
|
+
# <node_id>.output the whole output of an upstream node
|
|
33
|
+
# <node_id>.output.<field> one top-level field of an upstream node's output
|
|
34
|
+
#
|
|
35
|
+
# Anything fancier (aggregation, arithmetic, deep paths) belongs in an
|
|
36
|
+
# extracted pure_function node, not in the reference syntax.
|
|
37
|
+
|
|
38
|
+
_INPUT_REF_RE = re.compile(
|
|
39
|
+
r"^(?:pipeline\.input|(?P<node>[A-Za-z_][A-Za-z0-9_]*)\.output)"
|
|
40
|
+
r"(?:\.(?P<field>[A-Za-z_][A-Za-z0-9_]*))?$"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class InputRef:
|
|
46
|
+
"""A parsed ``inputs:`` source reference.
|
|
47
|
+
|
|
48
|
+
``node_id is None`` means the reference targets the pipeline input;
|
|
49
|
+
otherwise it targets the named node's output. ``field`` is the
|
|
50
|
+
optional top-level field selector.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
node_id: str | None
|
|
54
|
+
field: str | None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_input_ref(ref: str) -> InputRef:
|
|
58
|
+
"""Parse an ``inputs:`` source reference string.
|
|
59
|
+
|
|
60
|
+
This is the single source of truth for the reference grammar —
|
|
61
|
+
the IR validator and every adapter resolve references through it.
|
|
62
|
+
Raises ``ValueError`` for anything outside the four allowed forms.
|
|
63
|
+
"""
|
|
64
|
+
m = _INPUT_REF_RE.fullmatch(ref.strip())
|
|
65
|
+
if not m:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"Invalid input reference {ref!r}. Allowed forms: "
|
|
68
|
+
f"'pipeline.input', 'pipeline.input.<field>', "
|
|
69
|
+
f"'<node_id>.output', '<node_id>.output.<field>'."
|
|
70
|
+
)
|
|
71
|
+
return InputRef(node_id=m.group("node"), field=m.group("field"))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class NodeKind(StrEnum):
|
|
75
|
+
"""The five kinds a graduated step can be classified as.
|
|
76
|
+
|
|
77
|
+
Every node in a graduated pipeline is exactly one kind. Adapters
|
|
78
|
+
decide how to emit each kind for their target runtime.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
PURE_FUNCTION = "pure_function"
|
|
82
|
+
LLM_JUDGE = "llm_judge"
|
|
83
|
+
AGENT_LOOP = "agent_loop"
|
|
84
|
+
HITL_GATE = "hitl_gate"
|
|
85
|
+
EXTERNAL_CALL = "external_call"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class RetryPolicy(BaseModel):
|
|
89
|
+
"""Retry behavior for a node. Adapters map this onto runtime primitives."""
|
|
90
|
+
|
|
91
|
+
model_config = ConfigDict(extra="forbid")
|
|
92
|
+
|
|
93
|
+
max: int = Field(ge=0, description="Maximum retry attempts (0 = no retry)")
|
|
94
|
+
backoff: str = Field(default="exponential", description="linear | exponential | constant")
|
|
95
|
+
retry_on: list[str] | None = Field(
|
|
96
|
+
default=None,
|
|
97
|
+
description=(
|
|
98
|
+
"Error categories to retry on. None = retry all transient errors. "
|
|
99
|
+
"Named retry_on (not 'on') because YAML 1.1 parses bare 'on' as a boolean."
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class CacheConfig(BaseModel):
|
|
105
|
+
"""Caching for nodes whose output is stable across runs (e.g., taxonomy IDs)."""
|
|
106
|
+
|
|
107
|
+
model_config = ConfigDict(extra="forbid")
|
|
108
|
+
|
|
109
|
+
strategy: str = Field(description="persistent | memory")
|
|
110
|
+
ttl: str = Field(description="Duration string, e.g., '30d', '1h'")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class TerminationConfig(BaseModel):
|
|
114
|
+
"""Termination criteria for an agent_loop node."""
|
|
115
|
+
|
|
116
|
+
model_config = ConfigDict(extra="forbid")
|
|
117
|
+
|
|
118
|
+
condition: str = Field(description="Human-readable termination condition")
|
|
119
|
+
max_iterations: int = Field(ge=1, description="Hard upper bound on iterations")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class NotifyConfig(BaseModel):
|
|
123
|
+
"""How a hitl_gate notifies the human reviewer."""
|
|
124
|
+
|
|
125
|
+
model_config = ConfigDict(extra="forbid")
|
|
126
|
+
|
|
127
|
+
channel: str = Field(description="slack | email | webhook")
|
|
128
|
+
target: str = Field(description="Channel name, email address, or webhook URL")
|
|
129
|
+
message_template: str | None = Field(
|
|
130
|
+
default=None,
|
|
131
|
+
description="Template string with {placeholders} from upstream node outputs",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class LLMSignature(BaseModel):
|
|
136
|
+
"""Runtime-agnostic, structured form of an llm_judge signature.
|
|
137
|
+
|
|
138
|
+
Carries everything an adapter needs to emit a working LLM call in any
|
|
139
|
+
target language: JSON Schema for the I/O contract (which Pydantic and
|
|
140
|
+
Zod both derive cleanly from), a prompt template, and the vendor
|
|
141
|
+
client config. The legacy ``signature: 'path/to/file.py:Class'`` form
|
|
142
|
+
on :class:`Node` continues to work for Temporal back-compat; new
|
|
143
|
+
runtimes (Cloudflare Workflows, future TS targets) require this
|
|
144
|
+
structured form because there's no shared Python module to import.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
model_config = ConfigDict(extra="forbid")
|
|
148
|
+
|
|
149
|
+
input_schema: dict[str, Any] = Field(
|
|
150
|
+
description="JSON Schema for the typed input. Adapters convert to Pydantic / Zod.",
|
|
151
|
+
)
|
|
152
|
+
output_schema: dict[str, Any] = Field(
|
|
153
|
+
description="JSON Schema for the typed output. Adapters convert to Pydantic / Zod.",
|
|
154
|
+
)
|
|
155
|
+
prompt: str = Field(
|
|
156
|
+
description=(
|
|
157
|
+
"Prompt template (Jinja-style {{ var }} interpolation). "
|
|
158
|
+
"Variables resolve against the input schema's properties."
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
client: str = Field(
|
|
162
|
+
default="anthropic",
|
|
163
|
+
description="LLM vendor identifier. Currently 'anthropic' | 'openai'.",
|
|
164
|
+
)
|
|
165
|
+
model: str | None = Field(
|
|
166
|
+
default=None,
|
|
167
|
+
description="Vendor-specific model id. None = adapter chooses default.",
|
|
168
|
+
)
|
|
169
|
+
temperature: float | None = Field(
|
|
170
|
+
default=None,
|
|
171
|
+
ge=0.0,
|
|
172
|
+
le=2.0,
|
|
173
|
+
description="Sampling temperature; None = vendor default.",
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
@field_validator("client")
|
|
177
|
+
@classmethod
|
|
178
|
+
def _validate_client(cls, v: str) -> str:
|
|
179
|
+
allowed = {"anthropic", "openai"}
|
|
180
|
+
if v not in allowed:
|
|
181
|
+
raise ValueError(f"client must be one of {sorted(allowed)}, got {v!r}")
|
|
182
|
+
return v
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class Node(BaseModel):
|
|
186
|
+
"""A single step in the graduated pipeline.
|
|
187
|
+
|
|
188
|
+
The fields used depend on the node ``kind``. Cross-field validation
|
|
189
|
+
enforces that, e.g., ``llm_judge`` nodes have a ``signature`` and
|
|
190
|
+
``hitl_gate`` nodes have a ``signal``.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
model_config = ConfigDict(extra="forbid")
|
|
194
|
+
|
|
195
|
+
# Common fields
|
|
196
|
+
id: str = Field(description="Unique identifier within the pipeline")
|
|
197
|
+
kind: NodeKind
|
|
198
|
+
phase: str | None = Field(
|
|
199
|
+
default=None,
|
|
200
|
+
description="Metadata pointing back to the source skill's phase number",
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
@field_validator("phase", mode="before")
|
|
204
|
+
@classmethod
|
|
205
|
+
def _coerce_phase_to_string(cls, v: Any) -> str | None:
|
|
206
|
+
"""BDR has 'Phase 1.5' which YAML parses as a float; coerce to string."""
|
|
207
|
+
if v is None:
|
|
208
|
+
return None
|
|
209
|
+
return str(v)
|
|
210
|
+
|
|
211
|
+
description: str = Field(description="What this node does, in prose")
|
|
212
|
+
input: dict[str, str] = Field(
|
|
213
|
+
default_factory=dict,
|
|
214
|
+
description="Input field name → type name (free-form for v0)",
|
|
215
|
+
)
|
|
216
|
+
inputs: dict[str, str] | None = Field(
|
|
217
|
+
default=None,
|
|
218
|
+
description=(
|
|
219
|
+
"Data-flow bindings: parameter name → source reference. "
|
|
220
|
+
"References use the grammar in parse_input_ref: 'pipeline.input', "
|
|
221
|
+
"'pipeline.input.<field>', '<node_id>.output', or "
|
|
222
|
+
"'<node_id>.output.<field>'. Complements ``input`` (which documents "
|
|
223
|
+
"types); ``inputs`` binds where the values come from at runtime. "
|
|
224
|
+
"Nodes without ``inputs`` receive an empty payload (back-compat). "
|
|
225
|
+
"For loop_body sub-nodes the bindings describe what the parent "
|
|
226
|
+
"loop passes per iteration — adapters do not resolve them at the "
|
|
227
|
+
"top level."
|
|
228
|
+
),
|
|
229
|
+
)
|
|
230
|
+
output: dict[str, str] | str = Field(
|
|
231
|
+
default_factory=dict,
|
|
232
|
+
description="Output field name → type name, or a single type name",
|
|
233
|
+
)
|
|
234
|
+
timeout: str | None = Field(
|
|
235
|
+
default=None,
|
|
236
|
+
description="Duration string, e.g., '5m', '60s'",
|
|
237
|
+
)
|
|
238
|
+
retry: RetryPolicy | None = None
|
|
239
|
+
mandatory: bool = Field(
|
|
240
|
+
default=False,
|
|
241
|
+
description="If true, this node cannot be skipped or made conditional",
|
|
242
|
+
)
|
|
243
|
+
constants: dict[str, Any] | None = Field(
|
|
244
|
+
default=None,
|
|
245
|
+
description="Hard-coded constants extracted from the source skill",
|
|
246
|
+
)
|
|
247
|
+
cache: CacheConfig | None = None
|
|
248
|
+
fan_out: bool = Field(
|
|
249
|
+
default=False,
|
|
250
|
+
description="If true, this node is invoked once per input element",
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# pure_function / external_call fields
|
|
254
|
+
impl: str | None = Field(
|
|
255
|
+
default=None,
|
|
256
|
+
description="Path to extracted function, e.g., 'extracted/foo.py:bar'",
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# llm_judge fields
|
|
260
|
+
signature: str | None = Field(
|
|
261
|
+
default=None,
|
|
262
|
+
description=(
|
|
263
|
+
"Legacy: path to a Python signature class, e.g., 'signatures/foo.py:Foo'. "
|
|
264
|
+
"The Temporal adapter accepts this for back-compat. New runtimes "
|
|
265
|
+
"(Cloudflare, etc.) require ``signature_spec`` instead — see below."
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
signature_spec: LLMSignature | None = Field(
|
|
269
|
+
default=None,
|
|
270
|
+
description=(
|
|
271
|
+
"Structured, runtime-agnostic signature: JSON Schema in/out + prompt "
|
|
272
|
+
"+ client config. Required for non-Python adapters; optional for "
|
|
273
|
+
"Temporal (which can fall back to ``signature`` path)."
|
|
274
|
+
),
|
|
275
|
+
)
|
|
276
|
+
eval_set: str | None = Field(
|
|
277
|
+
default=None,
|
|
278
|
+
description="Path to seed eval set",
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# agent_loop fields
|
|
282
|
+
tools: list[str] | None = Field(
|
|
283
|
+
default=None,
|
|
284
|
+
description="MCP tool names the agent can call inside the loop",
|
|
285
|
+
)
|
|
286
|
+
loop_body: list[str] | None = Field(
|
|
287
|
+
default=None,
|
|
288
|
+
description="IDs of nodes invoked from inside each loop iteration",
|
|
289
|
+
)
|
|
290
|
+
termination: TerminationConfig | None = None
|
|
291
|
+
|
|
292
|
+
# hitl_gate fields
|
|
293
|
+
signal: str | None = Field(
|
|
294
|
+
default=None,
|
|
295
|
+
description="Signal name the workflow waits for (resume on signal)",
|
|
296
|
+
)
|
|
297
|
+
notify: NotifyConfig | None = None
|
|
298
|
+
|
|
299
|
+
@model_validator(mode="after")
|
|
300
|
+
def _validate_kind_specific_fields(self) -> Self:
|
|
301
|
+
"""Each node kind has required fields. Enforce them here."""
|
|
302
|
+
kind = self.kind
|
|
303
|
+
missing: list[str] = []
|
|
304
|
+
|
|
305
|
+
if kind in (NodeKind.PURE_FUNCTION, NodeKind.EXTERNAL_CALL):
|
|
306
|
+
if not self.impl:
|
|
307
|
+
missing.append("impl")
|
|
308
|
+
elif kind is NodeKind.LLM_JUDGE:
|
|
309
|
+
# Either the legacy path or the structured spec is acceptable.
|
|
310
|
+
# Both being absent is the failure mode.
|
|
311
|
+
if not self.signature and self.signature_spec is None:
|
|
312
|
+
missing.append("signature")
|
|
313
|
+
elif kind is NodeKind.AGENT_LOOP:
|
|
314
|
+
if not self.tools:
|
|
315
|
+
missing.append("tools")
|
|
316
|
+
elif kind is NodeKind.HITL_GATE and not self.signal:
|
|
317
|
+
missing.append("signal")
|
|
318
|
+
|
|
319
|
+
if missing:
|
|
320
|
+
raise ValueError(
|
|
321
|
+
f"Node {self.id!r} of kind {kind.value} is missing required field(s): "
|
|
322
|
+
f"{', '.join(missing)}"
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# mandatory only meaningful for non-agentic kinds (you can't make an
|
|
326
|
+
# agent loop "mandatory" — that's a no-op)
|
|
327
|
+
if self.mandatory and kind is NodeKind.AGENT_LOOP:
|
|
328
|
+
raise ValueError(f"Node {self.id!r}: mandatory=true is not allowed on agent_loop nodes")
|
|
329
|
+
|
|
330
|
+
return self
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class Edge(BaseModel):
|
|
334
|
+
"""A directed edge in the pipeline DAG."""
|
|
335
|
+
|
|
336
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
337
|
+
|
|
338
|
+
from_: str = Field(alias="from", description="Source node id")
|
|
339
|
+
to: str = Field(description="Destination node id")
|
|
340
|
+
on_signal: str | None = Field(
|
|
341
|
+
default=None,
|
|
342
|
+
description="If set, edge only activates when the named signal fires",
|
|
343
|
+
)
|
|
344
|
+
fan_out: bool = Field(
|
|
345
|
+
default=False,
|
|
346
|
+
description="If true, the destination is invoked once per element of the source's output",
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class ObservabilityConfig(BaseModel):
|
|
351
|
+
model_config = ConfigDict(extra="forbid")
|
|
352
|
+
|
|
353
|
+
traces: bool = True
|
|
354
|
+
eval_set_dir: str | None = None
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class HITLConfig(BaseModel):
|
|
358
|
+
model_config = ConfigDict(extra="forbid")
|
|
359
|
+
|
|
360
|
+
default_timeout: str = "7d"
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
class PipelineConfig(BaseModel):
|
|
364
|
+
model_config = ConfigDict(extra="forbid")
|
|
365
|
+
|
|
366
|
+
schedule: str | None = None
|
|
367
|
+
on_failure: str = "notify_owner"
|
|
368
|
+
observability: ObservabilityConfig = Field(default_factory=ObservabilityConfig)
|
|
369
|
+
hitl: HITLConfig = Field(default_factory=HITLConfig)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class PipelineInput(BaseModel):
|
|
373
|
+
"""The pipeline's input contract."""
|
|
374
|
+
|
|
375
|
+
model_config = ConfigDict(extra="forbid")
|
|
376
|
+
|
|
377
|
+
type: str = Field(description="Type name for the pipeline input")
|
|
378
|
+
required: list[str] = Field(default_factory=list)
|
|
379
|
+
optional: list[str] = Field(default_factory=list)
|
|
380
|
+
input_schema: dict[str, Any] | None = Field(
|
|
381
|
+
default=None,
|
|
382
|
+
description=(
|
|
383
|
+
"JSON Schema for the pipeline input payload (the same schema "
|
|
384
|
+
"Pydantic emits via model_json_schema()). Runtime-agnostic: "
|
|
385
|
+
"adapters may derive input validation from it before the "
|
|
386
|
+
"workflow starts. Optional for back-compat with pipelines "
|
|
387
|
+
"that only declare required/optional field names."
|
|
388
|
+
),
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
class Pipeline(BaseModel):
|
|
393
|
+
"""A graduated skill, ready to be emitted into a runtime adapter."""
|
|
394
|
+
|
|
395
|
+
model_config = ConfigDict(extra="forbid")
|
|
396
|
+
|
|
397
|
+
name: str
|
|
398
|
+
version: str = "0.1.0"
|
|
399
|
+
source_skill: str | None = Field(
|
|
400
|
+
default=None,
|
|
401
|
+
description="Path to the source skill bundle this was graduated from",
|
|
402
|
+
)
|
|
403
|
+
description: str = ""
|
|
404
|
+
config: PipelineConfig = Field(default_factory=PipelineConfig)
|
|
405
|
+
input: PipelineInput
|
|
406
|
+
nodes: list[Node]
|
|
407
|
+
edges: list[Edge]
|
|
408
|
+
entry_nodes: list[str] = Field(default_factory=list)
|
|
409
|
+
exit_nodes: list[str] = Field(default_factory=list)
|
|
410
|
+
|
|
411
|
+
@model_validator(mode="after")
|
|
412
|
+
def _validate_dag_integrity(self) -> Self:
|
|
413
|
+
"""Cross-references must point at real nodes; entry/exit must exist."""
|
|
414
|
+
node_ids = {n.id for n in self.nodes}
|
|
415
|
+
if len(node_ids) != len(self.nodes):
|
|
416
|
+
seen: set[str] = set()
|
|
417
|
+
dups = [n.id for n in self.nodes if n.id in seen or seen.add(n.id)] # type: ignore[func-returns-value]
|
|
418
|
+
raise ValueError(f"Duplicate node ids: {dups}")
|
|
419
|
+
|
|
420
|
+
for edge in self.edges:
|
|
421
|
+
if edge.from_ not in node_ids:
|
|
422
|
+
raise ValueError(f"Edge from unknown node: {edge.from_!r}")
|
|
423
|
+
if edge.to not in node_ids:
|
|
424
|
+
raise ValueError(f"Edge to unknown node: {edge.to!r}")
|
|
425
|
+
|
|
426
|
+
for entry in self.entry_nodes:
|
|
427
|
+
if entry not in node_ids:
|
|
428
|
+
raise ValueError(f"entry_nodes references unknown node: {entry!r}")
|
|
429
|
+
for exit_id in self.exit_nodes:
|
|
430
|
+
if exit_id not in node_ids:
|
|
431
|
+
raise ValueError(f"exit_nodes references unknown node: {exit_id!r}")
|
|
432
|
+
|
|
433
|
+
# loop_body references must also be valid node IDs
|
|
434
|
+
for node in self.nodes:
|
|
435
|
+
if node.loop_body:
|
|
436
|
+
for body_id in node.loop_body:
|
|
437
|
+
if body_id not in node_ids:
|
|
438
|
+
raise ValueError(
|
|
439
|
+
f"Node {node.id!r} loop_body references unknown node: {body_id!r}"
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
# inputs references must parse and point at real sources.
|
|
443
|
+
# Pipeline-input field names are checked against the declared
|
|
444
|
+
# contract (required + optional + input_schema properties) when
|
|
445
|
+
# one exists; a pipeline with an empty contract skips the check.
|
|
446
|
+
declared_fields = set(self.input.required) | set(self.input.optional)
|
|
447
|
+
if self.input.input_schema is not None:
|
|
448
|
+
props = self.input.input_schema.get("properties")
|
|
449
|
+
if isinstance(props, dict):
|
|
450
|
+
declared_fields |= set(props.keys())
|
|
451
|
+
|
|
452
|
+
for node in self.nodes:
|
|
453
|
+
if not node.inputs:
|
|
454
|
+
continue
|
|
455
|
+
for param, ref in node.inputs.items():
|
|
456
|
+
try:
|
|
457
|
+
parsed = parse_input_ref(ref)
|
|
458
|
+
except ValueError as e:
|
|
459
|
+
raise ValueError(f"Node {node.id!r} input {param!r}: {e}") from e
|
|
460
|
+
if parsed.node_id is None:
|
|
461
|
+
if parsed.field and declared_fields and parsed.field not in declared_fields:
|
|
462
|
+
raise ValueError(
|
|
463
|
+
f"Node {node.id!r} input {param!r} references pipeline input "
|
|
464
|
+
f"field {parsed.field!r}, which is not declared in the "
|
|
465
|
+
f"pipeline's input contract"
|
|
466
|
+
)
|
|
467
|
+
else:
|
|
468
|
+
if parsed.node_id not in node_ids:
|
|
469
|
+
raise ValueError(
|
|
470
|
+
f"Node {node.id!r} input {param!r} references unknown "
|
|
471
|
+
f"node: {parsed.node_id!r}"
|
|
472
|
+
)
|
|
473
|
+
if parsed.node_id == node.id:
|
|
474
|
+
raise ValueError(
|
|
475
|
+
f"Node {node.id!r} input {param!r} references its own output"
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
return self
|
|
479
|
+
|
|
480
|
+
def node_by_id(self, node_id: str) -> Node:
|
|
481
|
+
for n in self.nodes:
|
|
482
|
+
if n.id == node_id:
|
|
483
|
+
return n
|
|
484
|
+
raise KeyError(node_id)
|
|
485
|
+
|
|
486
|
+
def nodes_by_kind(self, kind: NodeKind) -> list[Node]:
|
|
487
|
+
return [n for n in self.nodes if n.kind is kind]
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def load_pipeline(path: str | Path) -> Pipeline:
|
|
491
|
+
"""Load and validate a pipeline.yaml file."""
|
|
492
|
+
p = Path(path)
|
|
493
|
+
with p.open("r", encoding="utf-8") as f:
|
|
494
|
+
raw = yaml.safe_load(f)
|
|
495
|
+
return Pipeline.model_validate(raw)
|
rote/serve/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""``rote serve`` — expose graduated pipelines as MCP tools.
|
|
2
|
+
|
|
3
|
+
Three modules:
|
|
4
|
+
|
|
5
|
+
* :mod:`rote.serve.registry` — the manifest registry (``~/.rote/registry.json``)
|
|
6
|
+
mapping tool names to graduated pipelines and their runtime trigger config.
|
|
7
|
+
* :mod:`rote.serve.backends` — runtime trigger backends (Temporal, Cloudflare)
|
|
8
|
+
that start a graduated workflow and poll its status.
|
|
9
|
+
* :mod:`rote.serve.server` — the FastMCP server that sources one MCP tool
|
|
10
|
+
(plus a ``<tool>_status`` companion) per registry entry.
|
|
11
|
+
|
|
12
|
+
The heavy dependency (``fastmcp``) is only imported by :mod:`rote.serve.server`
|
|
13
|
+
so that ``rote register`` works without the ``serve`` extra installed.
|
|
14
|
+
"""
|
rote/serve/backends.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Runtime trigger backends for ``rote serve``.
|
|
2
|
+
|
|
3
|
+
Each registry entry names a runtime trigger (Temporal or Cloudflare).
|
|
4
|
+
This module knows how to *start* a graduated workflow on that runtime
|
|
5
|
+
and how to *poll* its status. Tool calls never block on workflow
|
|
6
|
+
completion — graduated pipelines run for minutes to days (HITL gates),
|
|
7
|
+
and their durability lives in the workflow engine, not in this process.
|
|
8
|
+
Starting returns ``{workflow_id, status: "started", ...}`` immediately;
|
|
9
|
+
the companion ``<tool>_status`` MCP tool polls via :func:`workflow_status`.
|
|
10
|
+
|
|
11
|
+
Clients are connected lazily, per call, so ``rote serve`` starts (and
|
|
12
|
+
lists tools) even when the runtime is unreachable — the error surfaces
|
|
13
|
+
on the tool call that needs it, with a message that says exactly what
|
|
14
|
+
was unreachable.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import uuid
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from rote.serve.registry import CloudflareTrigger, RegistryEntry, TemporalTrigger
|
|
24
|
+
|
|
25
|
+
#: How long to wait for a Temporal frontend connection before declaring
|
|
26
|
+
#: it unreachable. Temporal's client otherwise retries indefinitely.
|
|
27
|
+
TEMPORAL_CONNECT_TIMEOUT_S = 10.0
|
|
28
|
+
|
|
29
|
+
#: HTTP timeout for Cloudflare worker trigger/status calls.
|
|
30
|
+
CLOUDFLARE_HTTP_TIMEOUT_S = 30.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BackendError(RuntimeError):
|
|
34
|
+
"""A runtime trigger failed in a way the MCP client should see verbatim."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ───────── Public dispatch ─────────
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def start_workflow(entry: RegistryEntry, payload: dict[str, Any]) -> dict[str, Any]:
|
|
41
|
+
"""Start the graduated workflow behind ``entry`` with ``payload`` as input."""
|
|
42
|
+
trigger = entry.trigger
|
|
43
|
+
if isinstance(trigger, TemporalTrigger):
|
|
44
|
+
return await _start_temporal(trigger, entry.name, payload)
|
|
45
|
+
return await _start_cloudflare(trigger, payload)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def workflow_status(entry: RegistryEntry, workflow_id: str) -> dict[str, Any]:
|
|
49
|
+
"""Poll the status of a previously started workflow."""
|
|
50
|
+
trigger = entry.trigger
|
|
51
|
+
if isinstance(trigger, TemporalTrigger):
|
|
52
|
+
return await _status_temporal(trigger, workflow_id)
|
|
53
|
+
return await _status_cloudflare(trigger, workflow_id)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ───────── Temporal ─────────
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def _temporal_client(trigger: TemporalTrigger) -> Any:
|
|
60
|
+
try:
|
|
61
|
+
from temporalio.client import Client
|
|
62
|
+
except ImportError as e: # pragma: no cover - environment-dependent
|
|
63
|
+
raise BackendError(
|
|
64
|
+
"temporalio is not installed. Install the Temporal extra: pip install 'rote[temporal]'"
|
|
65
|
+
) from e
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
return await asyncio.wait_for(
|
|
69
|
+
Client.connect(trigger.address, namespace=trigger.namespace),
|
|
70
|
+
timeout=TEMPORAL_CONNECT_TIMEOUT_S,
|
|
71
|
+
)
|
|
72
|
+
except TimeoutError as e:
|
|
73
|
+
raise BackendError(
|
|
74
|
+
f"Temporal at {trigger.address} (namespace {trigger.namespace!r}) is "
|
|
75
|
+
f"unreachable: connection timed out after {TEMPORAL_CONNECT_TIMEOUT_S:.0f}s"
|
|
76
|
+
) from e
|
|
77
|
+
except Exception as e:
|
|
78
|
+
raise BackendError(
|
|
79
|
+
f"Temporal at {trigger.address} (namespace {trigger.namespace!r}) is unreachable: {e}"
|
|
80
|
+
) from e
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def _start_temporal(
|
|
84
|
+
trigger: TemporalTrigger, tool_name: str, payload: dict[str, Any]
|
|
85
|
+
) -> dict[str, Any]:
|
|
86
|
+
client = await _temporal_client(trigger)
|
|
87
|
+
workflow_id = f"{tool_name}-{uuid.uuid4().hex[:12]}"
|
|
88
|
+
handle = await client.start_workflow(
|
|
89
|
+
trigger.workflow_name,
|
|
90
|
+
payload,
|
|
91
|
+
id=workflow_id,
|
|
92
|
+
task_queue=trigger.task_queue,
|
|
93
|
+
)
|
|
94
|
+
return {
|
|
95
|
+
"workflow_id": workflow_id,
|
|
96
|
+
"run_id": handle.first_execution_run_id or "",
|
|
97
|
+
"status": "started",
|
|
98
|
+
"runtime": "temporal",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def _status_temporal(trigger: TemporalTrigger, workflow_id: str) -> dict[str, Any]:
|
|
103
|
+
client = await _temporal_client(trigger)
|
|
104
|
+
handle = client.get_workflow_handle(workflow_id)
|
|
105
|
+
description = await handle.describe()
|
|
106
|
+
status = description.status.name.lower() if description.status else "unknown"
|
|
107
|
+
return {
|
|
108
|
+
"workflow_id": workflow_id,
|
|
109
|
+
"status": status,
|
|
110
|
+
"runtime": "temporal",
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ───────── Cloudflare ─────────
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def _start_cloudflare(trigger: CloudflareTrigger, payload: dict[str, Any]) -> dict[str, Any]:
|
|
118
|
+
import httpx
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
async with httpx.AsyncClient(timeout=CLOUDFLARE_HTTP_TIMEOUT_S) as http:
|
|
122
|
+
resp = await http.post(trigger.url, json=payload)
|
|
123
|
+
resp.raise_for_status()
|
|
124
|
+
data = resp.json()
|
|
125
|
+
except httpx.HTTPError as e:
|
|
126
|
+
raise BackendError(f"Cloudflare worker at {trigger.url} failed: {e}") from e
|
|
127
|
+
|
|
128
|
+
# The emitted src/index.ts responds with {id, status}.
|
|
129
|
+
return {
|
|
130
|
+
"workflow_id": str(data.get("id", "")),
|
|
131
|
+
"status": "started",
|
|
132
|
+
"runtime": "cloudflare",
|
|
133
|
+
"details": data.get("status"),
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def _status_cloudflare(trigger: CloudflareTrigger, workflow_id: str) -> dict[str, Any]:
|
|
138
|
+
if trigger.status_url is None:
|
|
139
|
+
raise BackendError(
|
|
140
|
+
"This Cloudflare worker has no status endpoint registered. The emitted "
|
|
141
|
+
"worker's default fetch handler only creates instances; check status with "
|
|
142
|
+
f"`wrangler workflows instances describe <workflow> {workflow_id}` or the "
|
|
143
|
+
"Cloudflare dashboard, or register with --cloudflare-status-url if the "
|
|
144
|
+
"worker exposes a status route."
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
import httpx
|
|
148
|
+
|
|
149
|
+
url = trigger.status_url.format(workflow_id=workflow_id)
|
|
150
|
+
try:
|
|
151
|
+
async with httpx.AsyncClient(timeout=CLOUDFLARE_HTTP_TIMEOUT_S) as http:
|
|
152
|
+
resp = await http.get(url)
|
|
153
|
+
resp.raise_for_status()
|
|
154
|
+
data = resp.json()
|
|
155
|
+
except httpx.HTTPError as e:
|
|
156
|
+
raise BackendError(f"Cloudflare status endpoint at {url} failed: {e}") from e
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
"workflow_id": workflow_id,
|
|
160
|
+
"status": data.get("status", data),
|
|
161
|
+
"runtime": "cloudflare",
|
|
162
|
+
}
|