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,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
+ )