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
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""Anthropic API driver — in-process tool-use loop via the bare SDK.
|
|
2
|
+
|
|
3
|
+
For users who prefer (or are required to use) Anthropic API-key auth.
|
|
4
|
+
Runs the graduator agent in-process using the ``anthropic`` Python
|
|
5
|
+
SDK's Messages API with a minimal filesystem tool surface.
|
|
6
|
+
|
|
7
|
+
# Why the bare SDK and not ``claude-agent-sdk``?
|
|
8
|
+
|
|
9
|
+
Anthropic's terms of service explicitly forbid third-party agents
|
|
10
|
+
built on the Claude Agent SDK from using claude.ai login credentials
|
|
11
|
+
without prior approval. That's a policy blocker, not a technical
|
|
12
|
+
limitation. Since we already have a separate subscription path via
|
|
13
|
+
the ``claude`` driver (which spawns Claude Code directly), depending
|
|
14
|
+
on ``claude-agent-sdk`` would only add weight without adding value.
|
|
15
|
+
|
|
16
|
+
# Optional dependency
|
|
17
|
+
|
|
18
|
+
The ``anthropic`` package is an optional extra. Users who only use
|
|
19
|
+
the subprocess drivers (``claude`` / ``codex``) don't pay the import
|
|
20
|
+
cost::
|
|
21
|
+
|
|
22
|
+
pip install "rote[api]"
|
|
23
|
+
|
|
24
|
+
# Filesystem tool surface
|
|
25
|
+
|
|
26
|
+
The driver implements three minimal tools for the LLM:
|
|
27
|
+
|
|
28
|
+
* ``read_file(path)`` — scoped to ``skill_dir`` and
|
|
29
|
+
``graduator_skill_dir`` (read-only).
|
|
30
|
+
* ``list_directory(path)`` — read-only listing in the same scope.
|
|
31
|
+
* ``write_file(path, content)`` — scoped to ``work_dir`` (write-only).
|
|
32
|
+
|
|
33
|
+
This is the same effective capability set the subprocess drivers give
|
|
34
|
+
their CLIs via ``--add-dir`` and ``--allowedTools``, just implemented
|
|
35
|
+
as a small Python shim instead of deferring to a heavier runtime.
|
|
36
|
+
|
|
37
|
+
Path traversal is blocked at the tool level: every path is resolved
|
|
38
|
+
to an absolute form and then checked against the list of allowed
|
|
39
|
+
roots via ``Path.relative_to``.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import os
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
47
|
+
|
|
48
|
+
from rote.graduator.drivers import DriverError, DriverResult, GraduatorDriver
|
|
49
|
+
|
|
50
|
+
if TYPE_CHECKING:
|
|
51
|
+
from anthropic.types import MessageParam, ToolParam, ToolResultBlockParam
|
|
52
|
+
|
|
53
|
+
# Detect the optional dep at import time so is_available() can report
|
|
54
|
+
# a specific "pip install rote[api]" message instead of a cryptic
|
|
55
|
+
# ImportError.
|
|
56
|
+
try:
|
|
57
|
+
import anthropic # noqa: F401
|
|
58
|
+
|
|
59
|
+
_ANTHROPIC_AVAILABLE = True
|
|
60
|
+
except ImportError:
|
|
61
|
+
_ANTHROPIC_AVAILABLE = False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ───────── Defaults ─────────
|
|
65
|
+
|
|
66
|
+
DEFAULT_MODEL = "claude-sonnet-4-6"
|
|
67
|
+
"""Default model for the in-process graduator loop.
|
|
68
|
+
|
|
69
|
+
Matches the ``ClaudeDriver`` default for consistency: Sonnet 4.6
|
|
70
|
+
follows structured rubrics reliably and is ~5× cheaper than Opus.
|
|
71
|
+
Users with complex skills can override via
|
|
72
|
+
``AnthropicApiDriver(model="claude-opus-4-6")`` or the CLI's
|
|
73
|
+
``--model`` flag.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
DEFAULT_MAX_ITERATIONS = 60
|
|
77
|
+
DEFAULT_MAX_TOKENS_PER_TURN = 8192
|
|
78
|
+
|
|
79
|
+
#: Hard cap on a single ``read_file`` response. Source skills can have
|
|
80
|
+
#: surprisingly large reference files; we don't want a single tool call
|
|
81
|
+
#: to blow the context window.
|
|
82
|
+
MAX_FILE_READ_BYTES = 200_000
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ───────── Tool schemas ─────────
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _build_tool_schemas() -> list[ToolParam]:
|
|
89
|
+
return [
|
|
90
|
+
{
|
|
91
|
+
"name": "read_file",
|
|
92
|
+
"description": (
|
|
93
|
+
"Read a file from the source skill or the rote-graduate skill. "
|
|
94
|
+
"Returns the file contents as text. Use this to read SKILL.md, "
|
|
95
|
+
"references/*.md, and any other source skill files. The path "
|
|
96
|
+
"must be absolute and inside one of the allowed read roots "
|
|
97
|
+
"(communicated in the system prompt)."
|
|
98
|
+
),
|
|
99
|
+
"input_schema": {
|
|
100
|
+
"type": "object",
|
|
101
|
+
"properties": {
|
|
102
|
+
"path": {
|
|
103
|
+
"type": "string",
|
|
104
|
+
"description": "Absolute path to a file inside an allowed read root.",
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
"required": ["path"],
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"name": "list_directory",
|
|
112
|
+
"description": (
|
|
113
|
+
"List entries in a directory (one per line, with a trailing "
|
|
114
|
+
"slash on directories). Use this to discover the structure of "
|
|
115
|
+
"the source skill (e.g., what's in references/)."
|
|
116
|
+
),
|
|
117
|
+
"input_schema": {
|
|
118
|
+
"type": "object",
|
|
119
|
+
"properties": {
|
|
120
|
+
"path": {
|
|
121
|
+
"type": "string",
|
|
122
|
+
"description": "Absolute path to a directory inside an allowed read root.",
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
"required": ["path"],
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"name": "write_file",
|
|
130
|
+
"description": (
|
|
131
|
+
"Write a file into the work directory. Use this to produce "
|
|
132
|
+
"the final pipeline.yaml deliverable, plus any extracted "
|
|
133
|
+
"Python modules or signature stubs. The path must be absolute "
|
|
134
|
+
"and inside the work directory."
|
|
135
|
+
),
|
|
136
|
+
"input_schema": {
|
|
137
|
+
"type": "object",
|
|
138
|
+
"properties": {
|
|
139
|
+
"path": {
|
|
140
|
+
"type": "string",
|
|
141
|
+
"description": "Absolute path inside the work directory.",
|
|
142
|
+
},
|
|
143
|
+
"content": {
|
|
144
|
+
"type": "string",
|
|
145
|
+
"description": "Full file contents to write (overwrites existing).",
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
"required": ["path", "content"],
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ───────── Tool implementations (security-sensitive) ─────────
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _path_within(candidate: Path, root: Path) -> bool:
|
|
158
|
+
"""Return True if ``candidate`` resolves inside ``root`` (or equals it).
|
|
159
|
+
|
|
160
|
+
Both paths are resolved before comparison so symlinks and ``..``
|
|
161
|
+
segments are followed. Used as the gate for every file tool call.
|
|
162
|
+
"""
|
|
163
|
+
try:
|
|
164
|
+
candidate.resolve().relative_to(root.resolve())
|
|
165
|
+
return True
|
|
166
|
+
except ValueError:
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _check_read_path(path_str: str, read_roots: list[Path]) -> Path:
|
|
171
|
+
p = Path(path_str)
|
|
172
|
+
for root in read_roots:
|
|
173
|
+
if _path_within(p, root):
|
|
174
|
+
return p
|
|
175
|
+
roots_str = ", ".join(str(r) for r in read_roots)
|
|
176
|
+
raise PermissionError(f"Path {path_str!r} is not within allowed read roots: {roots_str}")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _check_write_path(path_str: str, write_root: Path) -> Path:
|
|
180
|
+
p = Path(path_str)
|
|
181
|
+
if not _path_within(p, write_root):
|
|
182
|
+
raise PermissionError(f"Path {path_str!r} is not within allowed write root: {write_root}")
|
|
183
|
+
return p
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _handle_read_file(path_str: str, read_roots: list[Path]) -> str:
|
|
187
|
+
p = _check_read_path(path_str, read_roots)
|
|
188
|
+
if not p.is_file():
|
|
189
|
+
raise FileNotFoundError(f"Not a file: {path_str}")
|
|
190
|
+
data = p.read_bytes()
|
|
191
|
+
if len(data) > MAX_FILE_READ_BYTES:
|
|
192
|
+
raise ValueError(
|
|
193
|
+
f"File too large ({len(data)} bytes > limit {MAX_FILE_READ_BYTES}). "
|
|
194
|
+
f"Consider using a different approach (e.g., grep + targeted reads)."
|
|
195
|
+
)
|
|
196
|
+
return data.decode("utf-8", errors="replace")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _handle_list_directory(path_str: str, read_roots: list[Path]) -> str:
|
|
200
|
+
p = _check_read_path(path_str, read_roots)
|
|
201
|
+
if not p.is_dir():
|
|
202
|
+
raise NotADirectoryError(f"Not a directory: {path_str}")
|
|
203
|
+
entries = sorted(f"{entry.name}{'/' if entry.is_dir() else ''}" for entry in p.iterdir())
|
|
204
|
+
return "\n".join(entries) if entries else "(empty)"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _handle_write_file(path_str: str, content: str, write_root: Path) -> str:
|
|
208
|
+
p = _check_write_path(path_str, write_root)
|
|
209
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
210
|
+
p.write_text(content, encoding="utf-8")
|
|
211
|
+
return f"Wrote {len(content)} bytes to {path_str}"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ───────── System prompt assembly ─────────
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _build_system_prompt(
|
|
218
|
+
skill_dir: Path,
|
|
219
|
+
graduator_skill_dir: Path,
|
|
220
|
+
work_dir: Path,
|
|
221
|
+
skill_md_text: str,
|
|
222
|
+
) -> str:
|
|
223
|
+
return f"""You are the rote graduator. Your job is to read a source AI skill
|
|
224
|
+
bundle and produce a runnable, deterministic pipeline IR (`pipeline.yaml`)
|
|
225
|
+
plus any extracted Python modules and typed signature stubs the IR refers to.
|
|
226
|
+
|
|
227
|
+
Available paths for this run:
|
|
228
|
+
|
|
229
|
+
- Source skill (read-only): {skill_dir}
|
|
230
|
+
- Rote-graduate rubric (read-only): {graduator_skill_dir}
|
|
231
|
+
- Work directory (write): {work_dir}
|
|
232
|
+
|
|
233
|
+
You have three tools:
|
|
234
|
+
|
|
235
|
+
- `read_file(path)` — read any file in the source skill or rubric directory
|
|
236
|
+
- `list_directory(path)` — list entries in an allowed directory
|
|
237
|
+
- `write_file(path, content)` — write a file into the work directory only
|
|
238
|
+
|
|
239
|
+
The rote-graduate skill below tells you the procedure to follow. Reference
|
|
240
|
+
files for each phase live at:
|
|
241
|
+
|
|
242
|
+
- {graduator_skill_dir}/references/node-kinds.md
|
|
243
|
+
- {graduator_skill_dir}/references/crystallization-heuristics.md
|
|
244
|
+
- {graduator_skill_dir}/references/ir-schema.md
|
|
245
|
+
- {graduator_skill_dir}/references/llm-judge-extraction.md
|
|
246
|
+
|
|
247
|
+
Read each one when the SKILL.md instructs you to.
|
|
248
|
+
|
|
249
|
+
Your final deliverable is `{work_dir}/pipeline.yaml`. Once you have written
|
|
250
|
+
it (and any extracted modules / signatures it references), end your turn.
|
|
251
|
+
Do not call any further tools after writing the final pipeline.yaml.
|
|
252
|
+
|
|
253
|
+
==================== ROTE GRADUATE SKILL ====================
|
|
254
|
+
{skill_md_text}
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# ───────── The driver ─────────
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class AnthropicApiDriver(GraduatorDriver):
|
|
262
|
+
name: str = "api"
|
|
263
|
+
|
|
264
|
+
def __init__(
|
|
265
|
+
self,
|
|
266
|
+
model: str = DEFAULT_MODEL,
|
|
267
|
+
max_iterations: int = DEFAULT_MAX_ITERATIONS,
|
|
268
|
+
max_tokens_per_turn: int = DEFAULT_MAX_TOKENS_PER_TURN,
|
|
269
|
+
) -> None:
|
|
270
|
+
self.model = model
|
|
271
|
+
self.max_iterations = max_iterations
|
|
272
|
+
self.max_tokens_per_turn = max_tokens_per_turn
|
|
273
|
+
|
|
274
|
+
def is_available(self) -> tuple[bool, str]:
|
|
275
|
+
if not _ANTHROPIC_AVAILABLE:
|
|
276
|
+
return (
|
|
277
|
+
False,
|
|
278
|
+
"The `anthropic` package is not installed. "
|
|
279
|
+
"Install the optional extra with: `pip install rote[api]`.",
|
|
280
|
+
)
|
|
281
|
+
if not os.environ.get("ANTHROPIC_API_KEY"):
|
|
282
|
+
return (
|
|
283
|
+
False,
|
|
284
|
+
"The ANTHROPIC_API_KEY environment variable is not set. "
|
|
285
|
+
"Export it or use the `claude` / `codex` driver to authenticate "
|
|
286
|
+
"via your existing Claude Max/Pro or ChatGPT subscription.",
|
|
287
|
+
)
|
|
288
|
+
return (True, "")
|
|
289
|
+
|
|
290
|
+
async def run(
|
|
291
|
+
self,
|
|
292
|
+
skill_dir: Path,
|
|
293
|
+
graduator_skill_dir: Path,
|
|
294
|
+
work_dir: Path,
|
|
295
|
+
) -> DriverResult:
|
|
296
|
+
if not _ANTHROPIC_AVAILABLE:
|
|
297
|
+
raise DriverError("anthropic package is not installed. Run: pip install rote[api]")
|
|
298
|
+
|
|
299
|
+
skill_dir = skill_dir.resolve()
|
|
300
|
+
graduator_skill_dir = graduator_skill_dir.resolve()
|
|
301
|
+
work_dir = work_dir.resolve()
|
|
302
|
+
work_dir.mkdir(parents=True, exist_ok=True)
|
|
303
|
+
|
|
304
|
+
skill_md = graduator_skill_dir / "SKILL.md"
|
|
305
|
+
if not skill_md.is_file():
|
|
306
|
+
raise DriverError(
|
|
307
|
+
f"rote-graduate SKILL.md not found at {skill_md}. "
|
|
308
|
+
f"Pass an explicit graduator_skill_dir to the orchestrator."
|
|
309
|
+
)
|
|
310
|
+
skill_md_text = skill_md.read_text(encoding="utf-8")
|
|
311
|
+
|
|
312
|
+
read_roots = [skill_dir, graduator_skill_dir]
|
|
313
|
+
write_root = work_dir
|
|
314
|
+
|
|
315
|
+
system_prompt = _build_system_prompt(
|
|
316
|
+
skill_dir, graduator_skill_dir, work_dir, skill_md_text
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
client = anthropic.AsyncAnthropic()
|
|
320
|
+
tool_schemas = _build_tool_schemas()
|
|
321
|
+
|
|
322
|
+
messages: list[MessageParam] = [
|
|
323
|
+
{
|
|
324
|
+
"role": "user",
|
|
325
|
+
"content": (
|
|
326
|
+
f"Graduate the skill at {skill_dir}. "
|
|
327
|
+
f"Begin by reading {skill_dir}/SKILL.md and the reference "
|
|
328
|
+
f"files in {graduator_skill_dir}/references/, then produce "
|
|
329
|
+
f"the pipeline.yaml at {work_dir}/pipeline.yaml."
|
|
330
|
+
),
|
|
331
|
+
}
|
|
332
|
+
]
|
|
333
|
+
|
|
334
|
+
total_input_tokens = 0
|
|
335
|
+
total_output_tokens = 0
|
|
336
|
+
last_text = ""
|
|
337
|
+
completed_iterations = 0
|
|
338
|
+
|
|
339
|
+
for iteration in range(1, self.max_iterations + 1):
|
|
340
|
+
completed_iterations = iteration
|
|
341
|
+
|
|
342
|
+
response = await client.messages.create(
|
|
343
|
+
model=self.model,
|
|
344
|
+
max_tokens=self.max_tokens_per_turn,
|
|
345
|
+
system=system_prompt,
|
|
346
|
+
tools=tool_schemas,
|
|
347
|
+
messages=messages,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
usage = getattr(response, "usage", None)
|
|
351
|
+
if usage is not None:
|
|
352
|
+
total_input_tokens += getattr(usage, "input_tokens", 0) or 0
|
|
353
|
+
total_output_tokens += getattr(usage, "output_tokens", 0) or 0
|
|
354
|
+
|
|
355
|
+
messages.append({"role": "assistant", "content": response.content})
|
|
356
|
+
|
|
357
|
+
if response.stop_reason != "tool_use":
|
|
358
|
+
# Capture the final text for diagnostics
|
|
359
|
+
for block in response.content:
|
|
360
|
+
if block.type == "text":
|
|
361
|
+
last_text = block.text or last_text
|
|
362
|
+
break
|
|
363
|
+
|
|
364
|
+
# Dispatch tool calls
|
|
365
|
+
tool_results: list[ToolResultBlockParam] = []
|
|
366
|
+
for block in response.content:
|
|
367
|
+
if block.type != "tool_use":
|
|
368
|
+
continue
|
|
369
|
+
|
|
370
|
+
tool_name = block.name
|
|
371
|
+
tool_input = cast(dict[str, Any], block.input or {})
|
|
372
|
+
try:
|
|
373
|
+
if tool_name == "read_file":
|
|
374
|
+
result_text = _handle_read_file(tool_input["path"], read_roots)
|
|
375
|
+
elif tool_name == "list_directory":
|
|
376
|
+
result_text = _handle_list_directory(tool_input["path"], read_roots)
|
|
377
|
+
elif tool_name == "write_file":
|
|
378
|
+
result_text = _handle_write_file(
|
|
379
|
+
tool_input["path"],
|
|
380
|
+
tool_input["content"],
|
|
381
|
+
write_root,
|
|
382
|
+
)
|
|
383
|
+
else:
|
|
384
|
+
raise ValueError(f"Unknown tool: {tool_name}")
|
|
385
|
+
is_error = False
|
|
386
|
+
except Exception as e:
|
|
387
|
+
result_text = f"Error: {type(e).__name__}: {e}"
|
|
388
|
+
is_error = True
|
|
389
|
+
|
|
390
|
+
tool_results.append(
|
|
391
|
+
{
|
|
392
|
+
"type": "tool_result",
|
|
393
|
+
"tool_use_id": block.id,
|
|
394
|
+
"content": result_text,
|
|
395
|
+
"is_error": is_error,
|
|
396
|
+
}
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
messages.append({"role": "user", "content": tool_results})
|
|
400
|
+
else:
|
|
401
|
+
raise DriverError(
|
|
402
|
+
f"Agent did not complete within {self.max_iterations} iterations.",
|
|
403
|
+
details=f"Last assistant text: {last_text!r}",
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
pipeline_yaml = work_dir / "pipeline.yaml"
|
|
407
|
+
if not pipeline_yaml.is_file():
|
|
408
|
+
raise DriverError(
|
|
409
|
+
f"Agent finished but did not produce {pipeline_yaml}.",
|
|
410
|
+
details=f"Last assistant text: {last_text!r}",
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
return DriverResult(
|
|
414
|
+
pipeline_yaml_path=pipeline_yaml,
|
|
415
|
+
work_dir=work_dir,
|
|
416
|
+
driver_name=self.name,
|
|
417
|
+
metadata={
|
|
418
|
+
"model": self.model,
|
|
419
|
+
"input_tokens": total_input_tokens,
|
|
420
|
+
"output_tokens": total_output_tokens,
|
|
421
|
+
"iterations": completed_iterations,
|
|
422
|
+
},
|
|
423
|
+
)
|