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>
187 lines
5.7 KiB
Python
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)
|