rein-openweights/src/rein_openweights/loop.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

75 lines
2.5 KiB
Python

"""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")