rein-openweights/src/rein_openweights/tools.py
tegwick 4ee612140b Bootstrap rein-openweights: minimal agentic loop over OpenRouter (REIN-OW-WP-0001)
Implements T01-T04:
- tools.py: green-commit-only-equivalent tool surface (read/write/edit/
  glob/grep + git status/diff/log/add+commit), path-traversal guarded.
- openrouter_client.py: own minimal chat-completions client — llm-connect's
  OpenRouterAdapter takes a single prompt string and never surfaces
  tool_calls, so it can't drive a multi-turn tool-calling loop without a
  breaking change to its frozen Core ABC. llm-connect stays an optional
  dependency (pyproject.toml), not load-bearing.
- loop.py: plan -> tool call -> observe -> repeat, budget- and
  turn-bounded, tool errors reported back to the model instead of
  crashing the loop.
- credentials.py: own OpenBao AppRole/ambient-token acquisition, per
  glas-harness ADR-002 (Option B) — glas-harness does not broker this.
- runner.py/hub.py: commit-verified success criterion + State Hub
  progress/token reporting, mirroring rein-aharness's model.

26 tests, all mocked at the httpx/subprocess boundary — no real
OpenRouter or OpenBao calls made. T05 (Forgejo repo creation) stays
open, deferred to the operator.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 13:37:09 +02:00

187 lines
5.7 KiB
Python

"""Tool surface — mirrors rein-aharness's `green-commit-only` allow-list.
Read/Write/Edit/Glob/Grep + local git status/diff/log/add+commit. No
push, no network, no shell escape hatch — the same bounded surface, just
invoked by a different rein.
"""
from __future__ import annotations
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
class ToolPolicyError(RuntimeError):
pass
def _resolve(repo_root: Path, rel_path: str) -> Path:
repo_root = repo_root.resolve()
candidate = (repo_root / rel_path).resolve()
if candidate != repo_root and repo_root not in candidate.parents:
raise ToolPolicyError(f"path escapes repo root: {rel_path}")
return candidate
def read_file(repo_root: Path, path: str) -> str:
return _resolve(repo_root, path).read_text()
def write_file(repo_root: Path, path: str, content: str) -> str:
target = _resolve(repo_root, path)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content)
return f"wrote {len(content)} bytes to {path}"
def edit_file(repo_root: Path, path: str, old: str, new: str) -> str:
target = _resolve(repo_root, path)
text = target.read_text()
if old not in text:
raise ToolPolicyError(f"old string not found in {path}")
target.write_text(text.replace(old, new, 1))
return f"replaced 1 occurrence in {path}"
def glob_files(repo_root: Path, pattern: str) -> str:
repo_root = repo_root.resolve()
matches = sorted(str(p.relative_to(repo_root)) for p in repo_root.rglob(pattern) if p.is_file())
return "\n".join(matches) or "(no matches)"
def grep_files(repo_root: Path, pattern: str, path: str = ".") -> str:
base = _resolve(repo_root, path)
hits: list[str] = []
for file in sorted(base.rglob("*")):
if not file.is_file() or ".git" in file.parts:
continue
try:
text = file.read_text()
except (UnicodeDecodeError, OSError):
continue
for lineno, line in enumerate(text.splitlines(), start=1):
if pattern in line:
hits.append(f"{file.relative_to(repo_root)}:{lineno}:{line}")
return "\n".join(hits) or "(no matches)"
def _git(repo_root: Path, *args: str) -> str:
proc = subprocess.run(["git", "-C", str(repo_root), *args], capture_output=True, text=True)
if proc.returncode != 0:
raise ToolPolicyError(f"git {' '.join(args)} failed: {proc.stderr.strip()}")
return proc.stdout
def git_status(repo_root: Path) -> str:
return _git(repo_root, "status", "--short") or "(clean)"
def git_diff(repo_root: Path) -> str:
return _git(repo_root, "diff") or "(no diff)"
def git_log(repo_root: Path, max_count: int = 10) -> str:
return _git(repo_root, "log", f"-{max_count}", "--oneline")
def git_add_commit(repo_root: Path, message: str) -> str:
_git(repo_root, "add", "-A")
return _git(repo_root, "commit", "-m", message)
@dataclass
class Tool:
name: str
description: str
parameters: dict[str, Any]
fn: Callable[..., str]
TOOLS: list[Tool] = [
Tool(
"read_file",
"Read a file's contents.",
{"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]},
read_file,
),
Tool(
"write_file",
"Write (overwrite) a file's contents, creating parent dirs as needed.",
{
"type": "object",
"properties": {"path": {"type": "string"}, "content": {"type": "string"}},
"required": ["path", "content"],
},
write_file,
),
Tool(
"edit_file",
"Replace the first occurrence of `old` with `new` in a file.",
{
"type": "object",
"properties": {
"path": {"type": "string"},
"old": {"type": "string"},
"new": {"type": "string"},
},
"required": ["path", "old", "new"],
},
edit_file,
),
Tool(
"glob_files",
"List files under the repo matching a glob pattern (e.g. '**/*.py').",
{"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]},
glob_files,
),
Tool(
"grep_files",
"Search file contents under `path` for a literal substring.",
{
"type": "object",
"properties": {"pattern": {"type": "string"}, "path": {"type": "string"}},
"required": ["pattern"],
},
grep_files,
),
Tool("git_status", "Show `git status --short`.", {"type": "object", "properties": {}}, git_status),
Tool("git_diff", "Show `git diff`.", {"type": "object", "properties": {}}, git_diff),
Tool(
"git_log",
"Show recent commit log (oneline).",
{"type": "object", "properties": {"max_count": {"type": "integer"}}},
git_log,
),
Tool(
"git_add_commit",
"Stage all changes (`git add -A`) and commit with a message. "
"Call this once the task is complete.",
{"type": "object", "properties": {"message": {"type": "string"}}, "required": ["message"]},
git_add_commit,
),
]
TOOLS_BY_NAME = {t.name: t for t in TOOLS}
def openai_tool_schemas() -> list[dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": t.name,
"description": t.description,
"parameters": t.parameters,
},
}
for t in TOOLS
]
def call_tool(repo_root: Path, name: str, args: dict[str, Any]) -> str:
tool = TOOLS_BY_NAME.get(name)
if tool is None:
raise ToolPolicyError(f"unknown tool: {name}")
return tool.fn(repo_root, **args)