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