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/serve/registry.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Manifest registry for graduated pipelines served over MCP.
|
|
2
|
+
|
|
3
|
+
The registry is a single JSON file (default ``~/.rote/registry.json``)
|
|
4
|
+
listing every graduated pipeline the user wants exposed as an MCP tool:
|
|
5
|
+
tool name, description, the pipeline.yaml it came from, a JSON Schema
|
|
6
|
+
for the tool's input, and the runtime trigger config (Temporal address /
|
|
7
|
+
task queue, Cloudflare workflow URL, …).
|
|
8
|
+
|
|
9
|
+
``rote register`` writes entries; ``rote serve`` reads them. The file is
|
|
10
|
+
the contract between the two commands — same philosophy as the driver
|
|
11
|
+
layer's ``work_dir/pipeline.yaml`` contract.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
from datetime import UTC, datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Annotated, Any, Literal
|
|
21
|
+
|
|
22
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
23
|
+
|
|
24
|
+
from rote.ir import Pipeline, PipelineInput
|
|
25
|
+
|
|
26
|
+
REGISTRY_SCHEMA_VERSION = 1
|
|
27
|
+
|
|
28
|
+
#: MCP tool names must be safe for every client; Cloudflare's signal-name
|
|
29
|
+
#: constraint ([A-Za-z0-9_-]+) is the strictest charset we target, so the
|
|
30
|
+
#: registry enforces the same one for tool names.
|
|
31
|
+
_TOOL_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def default_registry_path() -> Path:
|
|
35
|
+
"""The registry location used when ``--registry`` is not given."""
|
|
36
|
+
return Path.home() / ".rote" / "registry.json"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TemporalTrigger(BaseModel):
|
|
40
|
+
"""How to start a graduated pipeline on a Temporal cluster."""
|
|
41
|
+
|
|
42
|
+
model_config = ConfigDict(extra="forbid")
|
|
43
|
+
|
|
44
|
+
runtime: Literal["temporal"] = "temporal"
|
|
45
|
+
address: str = Field(
|
|
46
|
+
default="localhost:7233",
|
|
47
|
+
description="Temporal frontend address, host:port",
|
|
48
|
+
)
|
|
49
|
+
namespace: str = Field(default="default")
|
|
50
|
+
task_queue: str = Field(description="Task queue the graduated worker polls")
|
|
51
|
+
workflow_name: str = Field(
|
|
52
|
+
description=(
|
|
53
|
+
"Registered workflow *type* name. The Temporal adapter emits a "
|
|
54
|
+
"versioned name (PascalCase pipeline name + pipeline hash), so "
|
|
55
|
+
"this must match the emitted @workflow.defn name exactly."
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CloudflareTrigger(BaseModel):
|
|
61
|
+
"""How to start a graduated pipeline deployed as a Cloudflare Worker.
|
|
62
|
+
|
|
63
|
+
The emitted ``src/index.ts`` fetch handler accepts a JSON POST body
|
|
64
|
+
(the workflow params) and responds with ``{id, status}``.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
model_config = ConfigDict(extra="forbid")
|
|
68
|
+
|
|
69
|
+
runtime: Literal["cloudflare"] = "cloudflare"
|
|
70
|
+
url: str = Field(description="Deployed worker URL (the fetch trigger endpoint)")
|
|
71
|
+
status_url: str | None = Field(
|
|
72
|
+
default=None,
|
|
73
|
+
description=(
|
|
74
|
+
"Optional status endpoint template containing '{workflow_id}'. "
|
|
75
|
+
"The emitted worker does not expose a status route by default, "
|
|
76
|
+
"so without this the companion status tool directs users to "
|
|
77
|
+
"`wrangler workflows instances describe` instead."
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
Trigger = Annotated[TemporalTrigger | CloudflareTrigger, Field(discriminator="runtime")]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class RegistryEntry(BaseModel):
|
|
86
|
+
"""One graduated pipeline exposed as one MCP tool."""
|
|
87
|
+
|
|
88
|
+
model_config = ConfigDict(extra="forbid")
|
|
89
|
+
|
|
90
|
+
name: str = Field(description="MCP tool name (unique within the registry)")
|
|
91
|
+
description: str = ""
|
|
92
|
+
pipeline_yaml: str = Field(description="Absolute path to the graduated pipeline.yaml")
|
|
93
|
+
input_schema: dict[str, Any] = Field(
|
|
94
|
+
description="JSON Schema for the tool input (the pipeline's input contract)",
|
|
95
|
+
)
|
|
96
|
+
trigger: Trigger
|
|
97
|
+
registered_at: str = Field(
|
|
98
|
+
default_factory=lambda: datetime.now(UTC).isoformat(),
|
|
99
|
+
description="ISO-8601 timestamp of the last register/update",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
@field_validator("name")
|
|
103
|
+
@classmethod
|
|
104
|
+
def _validate_tool_name(cls, v: str) -> str:
|
|
105
|
+
if not _TOOL_NAME_RE.match(v):
|
|
106
|
+
raise ValueError(
|
|
107
|
+
f"tool name {v!r} must match [A-Za-z0-9][A-Za-z0-9_-]* (MCP tool-name safe charset)"
|
|
108
|
+
)
|
|
109
|
+
return v
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Registry(BaseModel):
|
|
113
|
+
"""The full registry file."""
|
|
114
|
+
|
|
115
|
+
model_config = ConfigDict(extra="forbid")
|
|
116
|
+
|
|
117
|
+
version: int = REGISTRY_SCHEMA_VERSION
|
|
118
|
+
entries: list[RegistryEntry] = Field(default_factory=list)
|
|
119
|
+
|
|
120
|
+
def get(self, name: str) -> RegistryEntry | None:
|
|
121
|
+
for entry in self.entries:
|
|
122
|
+
if entry.name == name:
|
|
123
|
+
return entry
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
def upsert(self, entry: RegistryEntry) -> bool:
|
|
127
|
+
"""Insert or replace by tool name. Returns True if an entry was replaced."""
|
|
128
|
+
for i, existing in enumerate(self.entries):
|
|
129
|
+
if existing.name == entry.name:
|
|
130
|
+
self.entries[i] = entry
|
|
131
|
+
return True
|
|
132
|
+
self.entries.append(entry)
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def load(cls, path: str | Path) -> Registry:
|
|
137
|
+
"""Load the registry; a missing file is an empty registry."""
|
|
138
|
+
p = Path(path)
|
|
139
|
+
if not p.exists():
|
|
140
|
+
return cls()
|
|
141
|
+
raw = json.loads(p.read_text(encoding="utf-8"))
|
|
142
|
+
return cls.model_validate(raw)
|
|
143
|
+
|
|
144
|
+
def save(self, path: str | Path) -> None:
|
|
145
|
+
"""Write the registry atomically (tmp file + rename)."""
|
|
146
|
+
p = Path(path)
|
|
147
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
tmp = p.with_suffix(p.suffix + ".tmp")
|
|
149
|
+
tmp.write_text(self.model_dump_json(indent=2) + "\n", encoding="utf-8")
|
|
150
|
+
tmp.replace(p)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def input_schema_for(pipeline_input: PipelineInput) -> dict[str, Any]:
|
|
154
|
+
"""Derive the MCP tool's inputSchema from the pipeline's input contract.
|
|
155
|
+
|
|
156
|
+
Prefers a structured ``input_schema`` field if the loaded model carries
|
|
157
|
+
one (being added to :class:`rote.ir.PipelineInput` concurrently — coded
|
|
158
|
+
defensively via ``getattr`` so this works before and after that lands).
|
|
159
|
+
Otherwise synthesizes a permissive object schema from the required /
|
|
160
|
+
optional name lists, which are untyped in the current IR.
|
|
161
|
+
"""
|
|
162
|
+
explicit = getattr(pipeline_input, "input_schema", None)
|
|
163
|
+
if isinstance(explicit, dict) and explicit:
|
|
164
|
+
return dict(explicit)
|
|
165
|
+
|
|
166
|
+
properties: dict[str, Any] = {
|
|
167
|
+
name: {} for name in [*pipeline_input.required, *pipeline_input.optional]
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
"type": "object",
|
|
171
|
+
"title": pipeline_input.type,
|
|
172
|
+
"description": (
|
|
173
|
+
f"Input contract for the graduated pipeline "
|
|
174
|
+
f"(type {pipeline_input.type}; field types are untyped in the IR v0)"
|
|
175
|
+
),
|
|
176
|
+
"properties": properties,
|
|
177
|
+
"required": list(pipeline_input.required),
|
|
178
|
+
"additionalProperties": True,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def entry_from_pipeline(
|
|
183
|
+
pipeline: Pipeline,
|
|
184
|
+
pipeline_yaml: Path,
|
|
185
|
+
trigger: TemporalTrigger | CloudflareTrigger,
|
|
186
|
+
name: str | None = None,
|
|
187
|
+
) -> RegistryEntry:
|
|
188
|
+
"""Build a registry entry from a loaded pipeline IR.
|
|
189
|
+
|
|
190
|
+
Tool name defaults to ``pipeline.name`` with any charset-unsafe
|
|
191
|
+
characters replaced by ``-``; description comes from
|
|
192
|
+
``pipeline.description`` (first paragraph, whitespace-normalized).
|
|
193
|
+
"""
|
|
194
|
+
tool_name = name if name is not None else re.sub(r"[^A-Za-z0-9_-]+", "-", pipeline.name)
|
|
195
|
+
return RegistryEntry(
|
|
196
|
+
name=tool_name,
|
|
197
|
+
description=pipeline.description.strip(),
|
|
198
|
+
pipeline_yaml=str(pipeline_yaml.resolve()),
|
|
199
|
+
input_schema=input_schema_for(pipeline.input),
|
|
200
|
+
trigger=trigger,
|
|
201
|
+
)
|
rote/serve/server.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""The ``rote serve`` MCP server.
|
|
2
|
+
|
|
3
|
+
One FastMCP (>=3.4) server whose tools are sourced from the manifest
|
|
4
|
+
registry (:mod:`rote.serve.registry`) via a custom v3 ``Provider``:
|
|
5
|
+
|
|
6
|
+
* every registry entry becomes one MCP tool named ``entry.name`` whose
|
|
7
|
+
``inputSchema`` is the entry's stored JSON Schema, and
|
|
8
|
+
* a companion ``<entry.name>_status`` tool that polls the workflow.
|
|
9
|
+
|
|
10
|
+
Freshness has two layers:
|
|
11
|
+
|
|
12
|
+
1. The provider re-reads the registry file (cheap content-hash check)
|
|
13
|
+
on every ``tools/list`` / ``tools/call``, so a ``rote register``
|
|
14
|
+
issued while the server is running is visible on the next request.
|
|
15
|
+
2. A background watcher polls the registry file and, when it changes,
|
|
16
|
+
sends ``notifications/tools/list_changed`` to every connected
|
|
17
|
+
session so clients that honor the notification (Claude Code does)
|
|
18
|
+
refresh without a reconnect. FastMCP 3.4 does not emit this
|
|
19
|
+
notification itself when providers change, so we track sessions via
|
|
20
|
+
middleware and use the low-level SDK's
|
|
21
|
+
``ServerSession.send_tool_list_changed()`` directly.
|
|
22
|
+
|
|
23
|
+
Long-running handling: graduated pipelines run minutes to days (HITL
|
|
24
|
+
gates), and their durability lives in Temporal / Cloudflare — not in
|
|
25
|
+
this process. Trigger tools therefore return ``{workflow_id, status:
|
|
26
|
+
"started"}`` immediately and clients poll the ``_status`` companion.
|
|
27
|
+
FastMCP 3.4 does ship server-side MCP Tasks support (SEP-1686), but the
|
|
28
|
+
extension is still a spec RC and running the trigger as a task would
|
|
29
|
+
tie a multi-day workflow's observability to this process's lifetime,
|
|
30
|
+
so the poll-tool pattern is deliberate. Revisit once the 2026-07-28
|
|
31
|
+
spec lands and Claude clients request task augmentation.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import asyncio
|
|
37
|
+
import contextlib
|
|
38
|
+
import hashlib
|
|
39
|
+
import logging
|
|
40
|
+
import weakref
|
|
41
|
+
from collections.abc import AsyncIterator, Sequence
|
|
42
|
+
from contextlib import asynccontextmanager
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from typing import Any
|
|
45
|
+
|
|
46
|
+
import mcp.types as mt
|
|
47
|
+
from fastmcp import FastMCP
|
|
48
|
+
from fastmcp.exceptions import ToolError
|
|
49
|
+
from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
|
|
50
|
+
from fastmcp.server.providers import Provider
|
|
51
|
+
from fastmcp.tools import Tool, ToolResult
|
|
52
|
+
from mcp.server.session import ServerSession
|
|
53
|
+
|
|
54
|
+
from rote.serve import backends
|
|
55
|
+
from rote.serve.registry import Registry, RegistryEntry
|
|
56
|
+
|
|
57
|
+
logger = logging.getLogger(__name__)
|
|
58
|
+
|
|
59
|
+
#: How often the background watcher checks the registry file for changes.
|
|
60
|
+
DEFAULT_POLL_INTERVAL_S = 1.0
|
|
61
|
+
|
|
62
|
+
STATUS_TOOL_SUFFIX = "_status"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ───────── Tools ─────────
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class PipelineTool(Tool):
|
|
69
|
+
"""Triggers the graduated workflow behind one registry entry."""
|
|
70
|
+
|
|
71
|
+
entry: RegistryEntry
|
|
72
|
+
|
|
73
|
+
async def run(self, arguments: dict[str, Any]) -> ToolResult:
|
|
74
|
+
try:
|
|
75
|
+
result = await backends.start_workflow(self.entry, arguments)
|
|
76
|
+
except backends.BackendError as e:
|
|
77
|
+
raise ToolError(str(e)) from e
|
|
78
|
+
return ToolResult(structured_content=result)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class PipelineStatusTool(Tool):
|
|
82
|
+
"""Polls the status of a workflow started by the companion trigger tool."""
|
|
83
|
+
|
|
84
|
+
entry: RegistryEntry
|
|
85
|
+
|
|
86
|
+
async def run(self, arguments: dict[str, Any]) -> ToolResult:
|
|
87
|
+
workflow_id = arguments["workflow_id"]
|
|
88
|
+
try:
|
|
89
|
+
result = await backends.workflow_status(self.entry, workflow_id)
|
|
90
|
+
except backends.BackendError as e:
|
|
91
|
+
raise ToolError(str(e)) from e
|
|
92
|
+
return ToolResult(structured_content=result)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _trigger_tool(entry: RegistryEntry) -> PipelineTool:
|
|
96
|
+
return PipelineTool(
|
|
97
|
+
name=entry.name,
|
|
98
|
+
description=(
|
|
99
|
+
f"{entry.description}\n\n"
|
|
100
|
+
f"Starts the graduated '{entry.name}' pipeline on "
|
|
101
|
+
f"{entry.trigger.runtime} and returns immediately with a workflow_id. "
|
|
102
|
+
f"Poll progress with the {entry.name}{STATUS_TOOL_SUFFIX} tool."
|
|
103
|
+
).strip(),
|
|
104
|
+
parameters=entry.input_schema,
|
|
105
|
+
entry=entry,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _status_tool(entry: RegistryEntry) -> PipelineStatusTool:
|
|
110
|
+
return PipelineStatusTool(
|
|
111
|
+
name=f"{entry.name}{STATUS_TOOL_SUFFIX}",
|
|
112
|
+
description=(
|
|
113
|
+
f"Poll the status of a '{entry.name}' pipeline run previously "
|
|
114
|
+
f"started with the {entry.name} tool."
|
|
115
|
+
),
|
|
116
|
+
parameters={
|
|
117
|
+
"type": "object",
|
|
118
|
+
"properties": {
|
|
119
|
+
"workflow_id": {
|
|
120
|
+
"type": "string",
|
|
121
|
+
"description": f"The workflow_id returned by the {entry.name} tool",
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
"required": ["workflow_id"],
|
|
125
|
+
"additionalProperties": False,
|
|
126
|
+
},
|
|
127
|
+
entry=entry,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ───────── Provider ─────────
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class RegistryProvider(Provider):
|
|
135
|
+
"""Sources MCP tools from the registry file, re-reading it on change.
|
|
136
|
+
|
|
137
|
+
Also owns the background watcher (started via the provider
|
|
138
|
+
``lifespan``, which FastMCP scopes to the server's lifetime) and the
|
|
139
|
+
set of live sessions to notify. Sessions are held weakly so closed
|
|
140
|
+
connections never pin memory or receive sends.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def __init__(
|
|
144
|
+
self,
|
|
145
|
+
registry_path: str | Path,
|
|
146
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL_S,
|
|
147
|
+
) -> None:
|
|
148
|
+
super().__init__()
|
|
149
|
+
self.registry_path = Path(registry_path)
|
|
150
|
+
self.poll_interval = poll_interval
|
|
151
|
+
self.sessions: weakref.WeakSet[ServerSession] = weakref.WeakSet()
|
|
152
|
+
self._digest: bytes | None = None
|
|
153
|
+
self._registry = Registry()
|
|
154
|
+
self._load_if_changed()
|
|
155
|
+
|
|
156
|
+
# ── registry loading ──
|
|
157
|
+
|
|
158
|
+
def _current_digest(self) -> bytes | None:
|
|
159
|
+
try:
|
|
160
|
+
return hashlib.sha256(self.registry_path.read_bytes()).digest()
|
|
161
|
+
except FileNotFoundError:
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
def _load_if_changed(self) -> bool:
|
|
165
|
+
"""Reload the registry if the file content changed. Returns True on change."""
|
|
166
|
+
digest = self._current_digest()
|
|
167
|
+
if digest == self._digest:
|
|
168
|
+
return False
|
|
169
|
+
self._digest = digest
|
|
170
|
+
self._registry = Registry.load(self.registry_path)
|
|
171
|
+
return True
|
|
172
|
+
|
|
173
|
+
# ── Provider hooks ──
|
|
174
|
+
|
|
175
|
+
async def _list_tools(self) -> Sequence[Tool]:
|
|
176
|
+
self._load_if_changed()
|
|
177
|
+
tools: list[Tool] = []
|
|
178
|
+
for entry in self._registry.entries:
|
|
179
|
+
tools.append(_trigger_tool(entry))
|
|
180
|
+
tools.append(_status_tool(entry))
|
|
181
|
+
return tools
|
|
182
|
+
|
|
183
|
+
@asynccontextmanager
|
|
184
|
+
async def lifespan(self) -> AsyncIterator[None]:
|
|
185
|
+
watcher = asyncio.create_task(self._watch_registry())
|
|
186
|
+
try:
|
|
187
|
+
yield
|
|
188
|
+
finally:
|
|
189
|
+
watcher.cancel()
|
|
190
|
+
|
|
191
|
+
# ── change notification ──
|
|
192
|
+
|
|
193
|
+
async def _watch_registry(self) -> None:
|
|
194
|
+
while True:
|
|
195
|
+
await asyncio.sleep(self.poll_interval)
|
|
196
|
+
if self._load_if_changed():
|
|
197
|
+
await self._notify_tool_list_changed()
|
|
198
|
+
|
|
199
|
+
async def _notify_tool_list_changed(self) -> None:
|
|
200
|
+
for session in list(self.sessions):
|
|
201
|
+
try:
|
|
202
|
+
await session.send_tool_list_changed()
|
|
203
|
+
except Exception:
|
|
204
|
+
# A torn-down session must not kill the watcher or block
|
|
205
|
+
# notifying the remaining live sessions.
|
|
206
|
+
logger.debug("failed to notify session of tool list change", exc_info=True)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class SessionTrackingMiddleware(Middleware):
|
|
210
|
+
"""Records every session that talks to us so the watcher can notify it."""
|
|
211
|
+
|
|
212
|
+
def __init__(self, provider: RegistryProvider) -> None:
|
|
213
|
+
self._provider = provider
|
|
214
|
+
|
|
215
|
+
async def on_request(
|
|
216
|
+
self,
|
|
217
|
+
context: MiddlewareContext[mt.Request[Any, Any]],
|
|
218
|
+
call_next: CallNext[mt.Request[Any, Any], Any],
|
|
219
|
+
) -> Any:
|
|
220
|
+
fastmcp_ctx = context.fastmcp_context
|
|
221
|
+
if fastmcp_ctx is not None:
|
|
222
|
+
# No live session on this context (init edge cases) — skip.
|
|
223
|
+
with contextlib.suppress(AttributeError, ValueError):
|
|
224
|
+
self._provider.sessions.add(fastmcp_ctx.session)
|
|
225
|
+
return await call_next(context)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ───────── Server construction ─────────
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def build_server(
|
|
232
|
+
registry_path: str | Path,
|
|
233
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL_S,
|
|
234
|
+
) -> FastMCP[None]:
|
|
235
|
+
"""Build the ``rote serve`` FastMCP server for a registry file."""
|
|
236
|
+
provider = RegistryProvider(registry_path, poll_interval=poll_interval)
|
|
237
|
+
return FastMCP(
|
|
238
|
+
name="rote",
|
|
239
|
+
instructions=(
|
|
240
|
+
"Tools on this server trigger graduated rote pipelines — "
|
|
241
|
+
"deterministic workflows produced from Anthropic-style skills. "
|
|
242
|
+
"Each pipeline has a trigger tool (returns a workflow_id "
|
|
243
|
+
"immediately) and a companion <name>_status tool for polling, "
|
|
244
|
+
"because pipelines can run for minutes to days (human-in-the-"
|
|
245
|
+
"loop gates)."
|
|
246
|
+
),
|
|
247
|
+
providers=[provider],
|
|
248
|
+
middleware=[SessionTrackingMiddleware(provider)],
|
|
249
|
+
)
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rote-graduate
|
|
3
|
+
description: >-
|
|
4
|
+
Analyze an Anthropic-style skill bundle (SKILL.md + references) and produce
|
|
5
|
+
a graduation plan that separates deterministic work into extracted Python
|
|
6
|
+
modules, LLM-as-judge work into typed DSPy/BAML signatures, agentic work
|
|
7
|
+
into bounded agent loops, and human-in-the-loop gates into durable suspend
|
|
8
|
+
points. Emit the whole pipeline as a runtime-agnostic intermediate
|
|
9
|
+
representation (pipeline.yaml) plus runnable code for a target workflow
|
|
10
|
+
engine (Temporal, Inngest, Restate, or plain async) via pluggable adapters.
|
|
11
|
+
Trigger phrases: "graduate this skill", "turn this skill into a workflow",
|
|
12
|
+
"harden this skill", "extract deterministic parts of this skill", "compile
|
|
13
|
+
skill to pipeline", "make this skill reliable in production", "analyze
|
|
14
|
+
skill for codification", "emit this skill as a temporal workflow", "rote
|
|
15
|
+
this skill".
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# Skill Graduation
|
|
19
|
+
|
|
20
|
+
You are the rote graduator. You read fuzzy AI skills and produce reliable
|
|
21
|
+
workflows.
|
|
22
|
+
|
|
23
|
+
**Input:** a directory containing a `SKILL.md` and an optional `references/`
|
|
24
|
+
folder with sub-files.
|
|
25
|
+
|
|
26
|
+
**Output:** an intermediate representation (`pipeline.yaml`) plus
|
|
27
|
+
runtime-specific emitted code that the user can drop into their durable
|
|
28
|
+
execution engine. Along the way you also produce a human-readable graduation
|
|
29
|
+
report that highlights what was codified, what stayed agentic, and what
|
|
30
|
+
judgment calls you made.
|
|
31
|
+
|
|
32
|
+
The guiding philosophy: **keep the LLM at points where the input is unbounded
|
|
33
|
+
or ambiguous (parsing, classifying, drafting, summarizing). Codify everything
|
|
34
|
+
else — control flow, retries, parallelism, idempotency, side-effecting tool
|
|
35
|
+
calls, fixed batching — into deterministic code.** Every token spent
|
|
36
|
+
re-deriving a known-fixed procedure at runtime is waste.
|
|
37
|
+
|
|
38
|
+
## Phase Routing
|
|
39
|
+
|
|
40
|
+
| Entry Point | Start At | Example Triggers |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| Full graduation | Phase 1 → 7 | "graduate the bdr-outreach skill" |
|
|
43
|
+
| Report only | Phase 1 → 5, skip 6 | "analyze this skill, don't emit code" |
|
|
44
|
+
| Re-emit for different runtime | Phase 6 | "emit the existing pipeline.yaml for inngest" |
|
|
45
|
+
| Update graduation after skill changed | Phase 1 → 7, diff | "regraduate bdr-outreach, it changed" |
|
|
46
|
+
|
|
47
|
+
## Pipeline
|
|
48
|
+
|
|
49
|
+
### Phase 1: Intake
|
|
50
|
+
|
|
51
|
+
Read the target directory exhaustively:
|
|
52
|
+
|
|
53
|
+
1. `SKILL.md` — parse the frontmatter (name, description, trigger phrases),
|
|
54
|
+
then read the full body. Note every named phase, gate, and rule.
|
|
55
|
+
2. `references/` — read every sub-file. These are the rubric detail, the
|
|
56
|
+
API choreography, and the domain constraints.
|
|
57
|
+
3. Build a mental model of: the skill's goal, its preconditions, its tools,
|
|
58
|
+
its explicit HITL gates ("present to user", "wait for approval"), and
|
|
59
|
+
any MANDATORY checklists enforced only by prose.
|
|
60
|
+
|
|
61
|
+
Do not proceed to Phase 2 until you can restate the skill's phase structure
|
|
62
|
+
in your own words.
|
|
63
|
+
|
|
64
|
+
### Phase 2: Node Classification
|
|
65
|
+
|
|
66
|
+
Read `references/node-kinds.md` before starting this phase.
|
|
67
|
+
|
|
68
|
+
Walk through the skill from start to finish. For every distinct step, assign
|
|
69
|
+
it exactly one of the five node kinds:
|
|
70
|
+
|
|
71
|
+
| Kind | Signal |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `pure_function` | Fixed logic, deterministic I/O, no LLM reasoning needed |
|
|
74
|
+
| `llm_judge` | Fuzzy classification against a rubric, typed input/output |
|
|
75
|
+
| `agent_loop` | Exploratory tool use, variable path, genuinely needs LLM orchestration |
|
|
76
|
+
| `hitl_gate` | Explicit human review/approval required before proceeding |
|
|
77
|
+
| `external_call` | Deterministic API call that needs retry/timeout semantics |
|
|
78
|
+
|
|
79
|
+
When a step could be classified two ways, prefer the more deterministic
|
|
80
|
+
kind. The goal is to push as much as possible into `pure_function` and
|
|
81
|
+
`external_call`, and reserve `agent_loop` for steps where the procedure
|
|
82
|
+
genuinely varies from run to run.
|
|
83
|
+
|
|
84
|
+
Output: a table mapping each step to its kind, with a one-sentence
|
|
85
|
+
justification per row.
|
|
86
|
+
|
|
87
|
+
### Phase 3: Codification Scan
|
|
88
|
+
|
|
89
|
+
Read `references/crystallization-heuristics.md` before starting this phase.
|
|
90
|
+
|
|
91
|
+
For each node classified as `pure_function` or `external_call`, surface every
|
|
92
|
+
codification opportunity:
|
|
93
|
+
|
|
94
|
+
- **Literal pseudocode or Python pasted into the prompt.** The skill's
|
|
95
|
+
author already wrote it — you just need to lift it into a real module.
|
|
96
|
+
- **Fixed constants** (batch sizes, taxonomy IDs, thresholds, retry counts,
|
|
97
|
+
timeouts). If the prompt says "batch of 10", that 10 is a constant, not
|
|
98
|
+
a judgment call.
|
|
99
|
+
- **Deterministic loops** with clear termination conditions.
|
|
100
|
+
- **String templates** for reports, output formats, or structured
|
|
101
|
+
responses.
|
|
102
|
+
- **MANDATORY checklists** enforced by English prose. These are the highest
|
|
103
|
+
value extractions — moving a required check from prose into code makes it
|
|
104
|
+
impossible to skip accidentally.
|
|
105
|
+
|
|
106
|
+
For each extraction, produce a record of: source file and line, the current
|
|
107
|
+
prose, the proposed extracted function name and signature, and any constants
|
|
108
|
+
to lift into config.
|
|
109
|
+
|
|
110
|
+
### Phase 4: LLM-Judge Extraction
|
|
111
|
+
|
|
112
|
+
Read `references/llm-judge-extraction.md` before starting this phase.
|
|
113
|
+
|
|
114
|
+
For each `llm_judge` node, draft a typed signature using DSPy or BAML
|
|
115
|
+
conventions:
|
|
116
|
+
|
|
117
|
+
- **Input schema** (Pydantic / TypedDict): every field the LLM needs,
|
|
118
|
+
sourced from upstream node outputs.
|
|
119
|
+
- **Output schema**: enum-bounded decisions plus structured rationale.
|
|
120
|
+
Prefer enums over free-text wherever the skill's rubric implies a
|
|
121
|
+
discrete choice.
|
|
122
|
+
- **Seed examples**: harvest 3–5 examples directly from the skill's own
|
|
123
|
+
rubric. If the rubric says "discard MSLs", construct an example with an
|
|
124
|
+
MSL contact and the `discard` decision.
|
|
125
|
+
- **Eval stub**: scaffold a pytest-style test file that asserts the
|
|
126
|
+
signature against each rule the rubric names.
|
|
127
|
+
|
|
128
|
+
Output: a `signatures/<node_name>.py` file per `llm_judge` node, plus a
|
|
129
|
+
matching `evals/<node_name>.jsonl` seed.
|
|
130
|
+
|
|
131
|
+
### Phase 5: IR Assembly
|
|
132
|
+
|
|
133
|
+
Read `references/ir-schema.md` before starting this phase.
|
|
134
|
+
|
|
135
|
+
Assemble the full DAG as `pipeline.yaml`. Include:
|
|
136
|
+
|
|
137
|
+
- `input.input_schema`: the full JSON Schema for the pipeline input
|
|
138
|
+
payload, promoted from the entry nodes' signature design (see
|
|
139
|
+
`ir-schema.md`).
|
|
140
|
+
- `nodes`: every step from Phase 2, with its kind, input/output types,
|
|
141
|
+
`inputs:` data-flow bindings (param → `pipeline.input[.field]` /
|
|
142
|
+
`<node_id>.output[.field]`), timeout, retry policy, and (for
|
|
143
|
+
`pure_function` / `llm_judge`) a reference to the extracted module.
|
|
144
|
+
- `edges`: data flow between nodes, including fan-out for batch
|
|
145
|
+
processing.
|
|
146
|
+
- `hitl_gates`: for every `hitl_gate` node, the signal name, timeout,
|
|
147
|
+
and notification target.
|
|
148
|
+
- `config`: top-level schedule, on_failure handler, observability
|
|
149
|
+
settings.
|
|
150
|
+
|
|
151
|
+
The `pipeline.yaml` is the runtime-agnostic source of truth. Everything
|
|
152
|
+
Phase 6 emits is derived from it.
|
|
153
|
+
|
|
154
|
+
### Phase 6: Adapter Invocation
|
|
155
|
+
|
|
156
|
+
**You do not emit runtime code yourself.** The rote project ships
|
|
157
|
+
runtime adapters as Python code (`rote/adapters/temporal.py`,
|
|
158
|
+
`rote/adapters/inngest.py`, etc.) that consume the `pipeline.yaml` you
|
|
159
|
+
produced in Phase 5 and emit runnable code. Your job in this phase is
|
|
160
|
+
to **validate that your IR is complete enough for the adapter to
|
|
161
|
+
succeed**, then let the adapter run.
|
|
162
|
+
|
|
163
|
+
Checklist before declaring Phase 5 done:
|
|
164
|
+
|
|
165
|
+
- Every `pure_function` / `external_call` node has an `impl:` pointing
|
|
166
|
+
at a real file you created in Phase 3.
|
|
167
|
+
- Every `llm_judge` node has a `signature:` pointing at a real file
|
|
168
|
+
you created in Phase 4.
|
|
169
|
+
- Every `agent_loop` node has `tools:` listed.
|
|
170
|
+
- Every `hitl_gate` node has a `signal:`.
|
|
171
|
+
- Every node mentioned in `entry_nodes`, `exit_nodes`, `edges.from`,
|
|
172
|
+
`edges.to`, and `loop_body` exists in `nodes:`.
|
|
173
|
+
- `rote.ir.load_pipeline(pipeline.yaml)` loads without validation
|
|
174
|
+
errors. If you can call this function, run it as a dry check.
|
|
175
|
+
|
|
176
|
+
The `rote emit <pipeline.yaml> --runtime temporal --out <dir>` CLI
|
|
177
|
+
command (or its programmatic equivalent) takes it from there. You do
|
|
178
|
+
not need to read the adapter's implementation — treat it as a black
|
|
179
|
+
box that accepts a valid IR and produces code.
|
|
180
|
+
|
|
181
|
+
**Why the split:** the IR and its validation logic are the load-bearing
|
|
182
|
+
parts of graduation. Code emission is template substitution and belongs
|
|
183
|
+
in deterministic Python. Keeping the agent out of the emission step
|
|
184
|
+
means the emitted output is reproducible and reviewable — every change
|
|
185
|
+
to what Temporal code looks like is a change to the adapter, not a new
|
|
186
|
+
agent trajectory.
|
|
187
|
+
|
|
188
|
+
### Phase 7: Graduation Report
|
|
189
|
+
|
|
190
|
+
Produce a human-readable Markdown report in `graduation-report.md` covering:
|
|
191
|
+
|
|
192
|
+
- **Summary metrics**: total nodes, breakdown by kind, estimated token cost
|
|
193
|
+
reduction per run, list of HITL gates and what they block on.
|
|
194
|
+
- **Crystallization log**: every prose-to-code extraction from Phase 3,
|
|
195
|
+
with before/after snippets.
|
|
196
|
+
- **Open questions**: ambiguous judgment calls where you made a decision
|
|
197
|
+
but the human reviewer should verify. Be explicit about what you chose
|
|
198
|
+
and why.
|
|
199
|
+
- **Suggested next steps**: what to dogfood first, which evals to run,
|
|
200
|
+
which node to regraduate after the first production run reveals new
|
|
201
|
+
information.
|
|
202
|
+
|
|
203
|
+
## Invariants
|
|
204
|
+
|
|
205
|
+
These rules apply across every phase:
|
|
206
|
+
|
|
207
|
+
- **Never silently drop a MANDATORY check.** If the skill says a check is
|
|
208
|
+
required and you can't codify it, surface it as an open question in
|
|
209
|
+
Phase 7 — never as a silent omission.
|
|
210
|
+
- **Prefer deterministic over agentic.** When in doubt, codify. Leaving
|
|
211
|
+
something agentic is a last resort for genuinely exploratory work.
|
|
212
|
+
- **The IR is the source of truth, not the emitted code.** If the IR and
|
|
213
|
+
a runtime-specific artifact disagree, the IR wins and Phase 6 re-runs.
|
|
214
|
+
- **Two adapters minimum before declaring the IR generic.** Until two
|
|
215
|
+
runtimes work, assume the IR is secretly shaped like the first one.
|
|
216
|
+
- **Dogfood yourself.** Once a stable version exists, point rote-graduate
|
|
217
|
+
at its own SKILL.md. The parts that can be crystallized should be; the
|
|
218
|
+
parts that stay agentic are the real source of judgment.
|
|
219
|
+
|
|
220
|
+
## Related Concepts
|
|
221
|
+
|
|
222
|
+
- **"Workflow vs. agent"** — the distinction from Anthropic's *Building
|
|
223
|
+
effective agents* essay. This tool is fundamentally about moving steps
|
|
224
|
+
from the agent column to the workflow column when the data supports it.
|
|
225
|
+
- **"Blueprint First, Model Second"** — the EBF paper's framing. The
|
|
226
|
+
blueprint is the `pipeline.yaml`; the LLM is a specialized tool at
|
|
227
|
+
specific nodes.
|
|
228
|
+
- **DSPy compile** — what the `llm_judge` signatures graduate *into* once
|
|
229
|
+
you have enough examples. rote produces the signature; dspy.compile
|
|
230
|
+
optimizes the prompt and few-shots against your eval set.
|