"""Single-agent tool loop. The model is injected so tests do not need a network."""

from __future__ import annotations


def run_agent(task, complete, tools, *, memory=None, max_steps=6, trace=None):
    """Run until the model stops calling tools, or max_steps is hit.

    complete(messages, tool_names) -> {"content": str|None, "tool_calls": [...]}
    A tool call is {"id", "name", "arguments"}.
    tools maps a name to a function(arguments: dict) -> JSON-able value.

    Unknown tools are not executed. The model is told, and the loop continues
    until it stops or the step budget runs out. Hitting the budget is a
    finished turn with error=step_budget_exhausted, not an exception, so a
    caller can show the user a failure instead of hanging.
    """
    if max_steps < 1:
        raise ValueError("max_steps must be >= 1")
    messages = list(memory or [])
    messages.append({"role": "user", "content": task})
    known = set(tools)
    for step in range(1, max_steps + 1):
        if trace:
            trace.span("model", {"step": step})
        msg = complete(messages, sorted(known))
        calls = list(msg.get("tool_calls") or [])
        if not calls:
            final = msg.get("content")
            if trace:
                trace.event("final", {"steps": step})
            return {
                "final": final,
                "error": None,
                "steps": step,
                "messages": messages,
            }
        messages.append(
            {
                "role": "assistant",
                "content": msg.get("content"),
                "tool_calls": calls,
            }
        )
        for call in calls:
            name = call.get("name")
            # A missing field is "no arguments". null, "", [], and a bare
            # string are values of the wrong type. They are falsy, and
            # `or {}` would hide them by calling the tool.
            if "arguments" not in call:
                args = {}
            else:
                args = call["arguments"]
            if not isinstance(args, dict):
                result = {"error": "arguments_must_be_object"}
            elif name not in tools:
                result = {"error": "unknown_tool", "tool": name}
            else:
                try:
                    result = tools[name](args)
                except Exception as exc:  # tool bugs must not kill the process
                    result = {"error": "tool_failed", "detail": type(exc).__name__}
            if trace:
                trace.span(
                    "tool",
                    {"name": name, "ok": "error" not in result, "step": step},
                )
            messages.append(
                {
                    "role": "tool",
                    "name": name,
                    "tool_call_id": call.get("id"),
                    "content": result,
                }
            )
    if trace:
        trace.event("budget", {"steps": max_steps})
    return {
        "final": None,
        "error": "step_budget_exhausted",
        "steps": max_steps,
        "messages": messages,
    }
