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>
This commit is contained in:
parent
ac5407a83e
commit
4ee612140b
21 changed files with 1207 additions and 36 deletions
0
src/rein_openweights/__init__.py
Normal file
0
src/rein_openweights/__init__.py
Normal file
32
src/rein_openweights/budget.py
Normal file
32
src/rein_openweights/budget.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"""Minimal token budget tracker.
|
||||
|
||||
llm-connect ships a near-identical `BudgetTracker`; rein-openweights
|
||||
vendors its own so the base agentic loop never requires llm-connect as
|
||||
a hard dependency (glas-harness ADR-002: llm-connect stays optional,
|
||||
not load-bearing here).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class BudgetExceededError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class BudgetTracker:
|
||||
def __init__(self, total: int) -> None:
|
||||
if total <= 0:
|
||||
raise ValueError(f"BudgetTracker total must be positive, got {total}")
|
||||
self.total = total
|
||||
self.spent = 0
|
||||
|
||||
def remaining(self) -> int:
|
||||
return max(0, self.total - self.spent)
|
||||
|
||||
def consume(self, tokens: int) -> None:
|
||||
new_spent = self.spent + tokens
|
||||
if new_spent > self.total:
|
||||
raise BudgetExceededError(
|
||||
f"Token budget exceeded: {new_spent} tokens used, cap is {self.total}"
|
||||
)
|
||||
self.spent = new_spent
|
||||
41
src/rein_openweights/cli.py
Normal file
41
src/rein_openweights/cli.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"""rein-openweights CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import asdict
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="rein-openweights")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
run = sub.add_parser("run", help="Run one task via the OpenRouter agentic loop")
|
||||
run.add_argument("--task-file", required=True)
|
||||
run.add_argument("--model", default=None)
|
||||
run.add_argument("--max-turns", type=int, default=20)
|
||||
run.add_argument("--budget-tokens", type=int, default=60_000)
|
||||
run.add_argument("--no-hub", action="store_true", help="Skip hub reporting")
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.command == "run":
|
||||
from rein_openweights.runner import DEFAULT_MODEL, run_task
|
||||
|
||||
result = run_task(
|
||||
args.task_file,
|
||||
model=args.model or DEFAULT_MODEL,
|
||||
max_turns=args.max_turns,
|
||||
budget_tokens=args.budget_tokens,
|
||||
report_to_hub=not args.no_hub,
|
||||
)
|
||||
print(json.dumps(asdict(result), indent=2))
|
||||
return 0 if result.ok else 1
|
||||
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
75
src/rein_openweights/credentials.py
Normal file
75
src/rein_openweights/credentials.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""OpenRouter credential acquisition via OpenBao.
|
||||
|
||||
Per glas-harness ADR-002 (Option B): rein-openweights acquires its own
|
||||
credential, consistent with rein-aharness's principle that the harness
|
||||
is the only credential holder. glas-harness never brokers this.
|
||||
|
||||
Mirrors rein-aharness's mailscan.py pattern: AppRole (unattended lane)
|
||||
first, then an ambient bao token (operator lane), then a single kv read.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
_KV_PATH_ENV = "REIN_OPENWEIGHTS_OPENROUTER_KV_PATH"
|
||||
_DEFAULT_KV_PATH = "reins/rein-openweights/openrouter"
|
||||
_KV_FIELD = "api_key"
|
||||
_BAO_TIMEOUT = 30
|
||||
|
||||
|
||||
class CredentialError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _bao(*args: str, env: dict[str, str] | None = None) -> str:
|
||||
result = subprocess.run(
|
||||
["bao", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_BAO_TIMEOUT,
|
||||
env=env or os.environ.copy(),
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise CredentialError(f"bao {args[0]} failed: {result.stderr.strip()[:200]}")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _acquire_token() -> str | None:
|
||||
approle_dir = os.environ.get("REIN_OPENWEIGHTS_APPROLE_DIR")
|
||||
if not approle_dir:
|
||||
return None
|
||||
role_id_file = Path(approle_dir) / "role_id"
|
||||
secret_id_file = Path(approle_dir) / "secret_id"
|
||||
if not (role_id_file.is_file() and secret_id_file.is_file()):
|
||||
return None
|
||||
return _bao(
|
||||
"write",
|
||||
"-field=token",
|
||||
"auth/approle/login",
|
||||
f"role_id={role_id_file.read_text().strip()}",
|
||||
f"secret_id={secret_id_file.read_text().strip()}",
|
||||
)
|
||||
|
||||
|
||||
def resolve_openrouter_api_key() -> str | None:
|
||||
"""Resolution order: explicit env var, then OpenBao (AppRole or ambient token)."""
|
||||
explicit = os.environ.get("OPENROUTER_API_KEY")
|
||||
if explicit:
|
||||
return explicit
|
||||
|
||||
kv_path = os.environ.get(_KV_PATH_ENV, _DEFAULT_KV_PATH)
|
||||
try:
|
||||
token = _acquire_token()
|
||||
except CredentialError:
|
||||
return None
|
||||
|
||||
env = os.environ.copy()
|
||||
if token:
|
||||
env["BAO_TOKEN"] = token
|
||||
try:
|
||||
return _bao("kv", "get", f"-field={_KV_FIELD}", kv_path, env=env)
|
||||
except CredentialError:
|
||||
return None
|
||||
65
src/rein_openweights/hub.py
Normal file
65
src/rein_openweights/hub.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"""Custodian State Hub reporting (REST, no MCP) — mirrors rein-aharness's hub.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
_DEFAULT_URL = "http://127.0.0.1:8000"
|
||||
_TIMEOUT = 10.0
|
||||
|
||||
|
||||
def _base_url() -> str:
|
||||
return os.environ.get("STATE_HUB_URL", _DEFAULT_URL).rstrip("/")
|
||||
|
||||
|
||||
def post_progress_event(
|
||||
summary: str,
|
||||
event_type: str,
|
||||
detail: dict[str, Any],
|
||||
task_id: str | None = None,
|
||||
) -> bool:
|
||||
payload: dict[str, Any] = {
|
||||
"summary": summary,
|
||||
"event_type": event_type,
|
||||
"detail": detail,
|
||||
"author": "agt-rein-openweights",
|
||||
}
|
||||
if task_id:
|
||||
payload["task_id"] = task_id
|
||||
try:
|
||||
resp = httpx.post(f"{_base_url()}/progress/", json=payload, timeout=_TIMEOUT)
|
||||
resp.raise_for_status()
|
||||
return True
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
|
||||
|
||||
def post_token_event(
|
||||
repo: str,
|
||||
tokens: int,
|
||||
*,
|
||||
budget: int | None = None,
|
||||
ok: bool = True,
|
||||
detail: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
payload: dict[str, Any] = {
|
||||
"repo": repo,
|
||||
"tokens": tokens,
|
||||
"source": "rein-openweights",
|
||||
"ok": ok,
|
||||
}
|
||||
if budget is not None:
|
||||
payload["budget"] = budget
|
||||
if detail:
|
||||
payload["detail"] = detail
|
||||
try:
|
||||
resp = httpx.post(f"{_base_url()}/token-events/upsert", json=payload, timeout=_TIMEOUT)
|
||||
if resp.status_code >= 400:
|
||||
resp = httpx.post(f"{_base_url()}/token-events/", json=payload, timeout=_TIMEOUT)
|
||||
resp.raise_for_status()
|
||||
return True
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
75
src/rein_openweights/loop.py
Normal file
75
src/rein_openweights/loop.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""Minimal agentic tool-use loop: plan -> tool call -> observe -> repeat."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from rein_openweights.budget import BudgetExceededError, BudgetTracker
|
||||
from rein_openweights.openrouter_client import OpenRouterClient
|
||||
from rein_openweights.tools import call_tool, openai_tool_schemas
|
||||
|
||||
_SYSTEM_PROMPT = (
|
||||
"You are a bounded coding agent. You may only act through the provided "
|
||||
"tools. When the task is complete, call git_add_commit with a "
|
||||
"descriptive message, then reply with a final message containing the "
|
||||
"word DONE and no further tool calls."
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoopResult:
|
||||
turns: int
|
||||
finished: bool
|
||||
transcript: list[dict[str, Any]] = field(default_factory=list)
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def run_loop(
|
||||
client: OpenRouterClient,
|
||||
repo_root: Path,
|
||||
title: str,
|
||||
description: str,
|
||||
*,
|
||||
max_turns: int = 20,
|
||||
budget: BudgetTracker | None = None,
|
||||
) -> LoopResult:
|
||||
messages: list[dict[str, Any]] = [
|
||||
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||
{"role": "user", "content": f"# {title}\n\n{description}"},
|
||||
]
|
||||
tools = openai_tool_schemas()
|
||||
|
||||
for turn in range(1, max_turns + 1):
|
||||
data = client.chat(messages, tools=tools)
|
||||
|
||||
if budget is not None:
|
||||
usage = data.get("usage", {})
|
||||
try:
|
||||
budget.consume(usage.get("total_tokens", 0))
|
||||
except BudgetExceededError as exc:
|
||||
return LoopResult(turns=turn, finished=False, transcript=messages, error=str(exc))
|
||||
|
||||
message = data["choices"][0]["message"]
|
||||
messages.append(message)
|
||||
|
||||
tool_calls = message.get("tool_calls") or []
|
||||
if not tool_calls:
|
||||
finished = "DONE" in (message.get("content") or "")
|
||||
return LoopResult(turns=turn, finished=finished, transcript=messages)
|
||||
|
||||
for call in tool_calls:
|
||||
fn = call["function"]
|
||||
name = fn["name"]
|
||||
try:
|
||||
args = json.loads(fn.get("arguments") or "{}")
|
||||
result = call_tool(repo_root, name, args)
|
||||
except Exception as exc: # noqa: BLE001 - report failure to the model, keep looping
|
||||
result = f"error: {exc}"
|
||||
messages.append(
|
||||
{"role": "tool", "tool_call_id": call.get("id", name), "content": result}
|
||||
)
|
||||
|
||||
return LoopResult(turns=max_turns, finished=False, transcript=messages, error="max_turns exceeded")
|
||||
64
src/rein_openweights/openrouter_client.py
Normal file
64
src/rein_openweights/openrouter_client.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""Minimal, independent OpenRouter chat-completions client.
|
||||
|
||||
Deliberately does not depend on llm-connect's `OpenRouterAdapter`: its
|
||||
`execute_prompt(prompt: str, config)` takes a single prompt string,
|
||||
builds a fixed [system, user] message pair itself, and never surfaces
|
||||
`message.tool_calls` in its response — it cannot drive a multi-turn
|
||||
tool-calling loop without a breaking change to llm-connect's frozen
|
||||
Core `LLMAdapter` ABC (`execute_prompt` signature). Rather than force
|
||||
that change onto every llm-connect consumer, rein-openweights owns this
|
||||
thin transport directly. See glas-harness ADR-002.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
_DEFAULT_API_BASE = "https://openrouter.ai/api/v1"
|
||||
|
||||
|
||||
class OpenRouterError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class OpenRouterClient:
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
model: str,
|
||||
api_base: str = _DEFAULT_API_BASE,
|
||||
timeout: float = 120.0,
|
||||
) -> None:
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.api_base = api_base.rstrip("/")
|
||||
self.timeout = timeout
|
||||
|
||||
def chat(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 4096,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
payload["tool_choice"] = "auto"
|
||||
|
||||
response = httpx.post(
|
||||
f"{self.api_base}/chat/completions",
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
raise OpenRouterError(f"OpenRouter {response.status_code}: {response.text[:300]}")
|
||||
return response.json()
|
||||
93
src/rein_openweights/runner.py
Normal file
93
src/rein_openweights/runner.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"""rein-openweights run — bounded agentic session, commit-verified.
|
||||
|
||||
Success criterion mirrors rein-aharness: a local git commit landing is
|
||||
the sole success signal. Reports a State Hub progress + token event on
|
||||
completion unless --no-hub is set.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from rein_openweights import hub
|
||||
from rein_openweights.budget import BudgetTracker
|
||||
from rein_openweights.credentials import resolve_openrouter_api_key
|
||||
from rein_openweights.loop import run_loop
|
||||
from rein_openweights.openrouter_client import OpenRouterClient
|
||||
from rein_openweights.taskspec import TaskSpec
|
||||
|
||||
DEFAULT_MODEL = "meta-llama/llama-3.1-70b-instruct"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunResult:
|
||||
ok: bool
|
||||
committed: bool
|
||||
commit_sha: str
|
||||
turns: int
|
||||
tokens_spent: int
|
||||
reason: str = ""
|
||||
|
||||
|
||||
def _git_head(repo: Path) -> str:
|
||||
proc = subprocess.run(
|
||||
["git", "-C", str(repo), "rev-parse", "HEAD"], capture_output=True, text=True
|
||||
)
|
||||
return proc.stdout.strip() if proc.returncode == 0 else ""
|
||||
|
||||
|
||||
def run_task(
|
||||
task_file: str,
|
||||
*,
|
||||
model: str = DEFAULT_MODEL,
|
||||
max_turns: int = 20,
|
||||
budget_tokens: int = 60_000,
|
||||
api_key: str | None = None,
|
||||
report_to_hub: bool = True,
|
||||
) -> RunResult:
|
||||
task = TaskSpec.load(task_file)
|
||||
repo = Path(task.target_repo).expanduser().resolve()
|
||||
head_before = _git_head(repo)
|
||||
|
||||
key = api_key or resolve_openrouter_api_key()
|
||||
if not key:
|
||||
result = RunResult(
|
||||
ok=False, committed=False, commit_sha="", turns=0, tokens_spent=0,
|
||||
reason="no OpenRouter credential resolved",
|
||||
)
|
||||
_report(result, task, report_to_hub)
|
||||
return result
|
||||
|
||||
client = OpenRouterClient(api_key=key, model=model)
|
||||
budget = BudgetTracker(total=budget_tokens)
|
||||
|
||||
loop_result = run_loop(
|
||||
client, repo, task.title, task.description, max_turns=max_turns, budget=budget
|
||||
)
|
||||
|
||||
head_after = _git_head(repo)
|
||||
committed = bool(head_after) and head_after != head_before
|
||||
|
||||
result = RunResult(
|
||||
ok=committed,
|
||||
committed=committed,
|
||||
commit_sha=head_after,
|
||||
turns=loop_result.turns,
|
||||
tokens_spent=budget.spent,
|
||||
reason=loop_result.error or ("" if committed else "no commit produced"),
|
||||
)
|
||||
_report(result, task, report_to_hub)
|
||||
return result
|
||||
|
||||
|
||||
def _report(result: RunResult, task: TaskSpec, report_to_hub: bool) -> None:
|
||||
if not report_to_hub:
|
||||
return
|
||||
hub.post_progress_event(
|
||||
summary=f"rein-openweights run {'ok' if result.ok else 'failed'}: {task.title}",
|
||||
event_type="executor_run",
|
||||
detail=asdict(result),
|
||||
)
|
||||
hub.post_token_event(repo=task.target_repo, tokens=result.tokens_spent, ok=result.ok)
|
||||
29
src/rein_openweights/taskspec.py
Normal file
29
src/rein_openweights/taskspec.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"""TaskSpec — local JSON task definition, mirrors rein-aharness's taskspec.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskSpec:
|
||||
title: str
|
||||
description: str
|
||||
target_repo: str
|
||||
agent: str = "rein-openweights"
|
||||
labels: list[str] = field(default_factory=list)
|
||||
timeout_seconds: int = 600
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: str) -> TaskSpec:
|
||||
data = json.loads(Path(path).expanduser().read_text())
|
||||
return cls(
|
||||
title=data["title"],
|
||||
description=data["description"],
|
||||
target_repo=data["target_repo"],
|
||||
agent=data.get("agent", "rein-openweights"),
|
||||
labels=data.get("labels", []),
|
||||
timeout_seconds=data.get("timeout_seconds", 600),
|
||||
)
|
||||
187
src/rein_openweights/tools.py
Normal file
187
src/rein_openweights/tools.py
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
"""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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue