#!/usr/bin/env python3
from __future__ import annotations
import ast, asyncio, json, logging, os, re, shlex, sqlite3, subprocess, time, uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from urllib.parse import urlparse
import httpx

try:
    from ddgs import DDGS
except ImportError:
    try:
        from duckduckgo_search import DDGS
    except ImportError:
        DDGS = None

ROOT = Path(os.getenv("CB26_PROJECT_ROOT", Path(__file__).resolve().parent.parent)).resolve()
RUNTIME_DIR = ROOT / "workspace" / "agent-runtime"
RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
DB_PATH = Path(os.getenv("CB26_AGENT_DB", str(RUNTIME_DIR / "agent.db")))

PROXY_URL = os.getenv("CB26_PROXY_URL", "http://127.0.0.1:4000").rstrip("/")
PROXY_KEY = os.getenv("CB26_PROXY_KEY", os.getenv("LITELLM_MASTER_KEY", "sk-cb26-local"))
MAX_STEPS = int(os.getenv("CB26_MAX_AGENT_STEPS", "24"))
MAX_FAILURES = int(os.getenv("CB26_MAX_TOOL_FAILURES", "5"))
MAX_SAME_FAILURE = int(os.getenv("CB26_MAX_SAME_TOOL_FAILURE", "2"))
COMMAND_TIMEOUT = int(os.getenv("CB26_COMMAND_TIMEOUT", "45"))
MAX_OUTPUT = int(os.getenv("CB26_MAX_TOOL_OUTPUT", "20000"))
HISTORY_LIMIT = int(os.getenv("CB26_HISTORY_LIMIT", "24"))
MEMORY_LIMIT = int(os.getenv("CB26_MEMORY_LIMIT", "8"))
SEARCH_RESULTS = int(os.getenv("CB26_SEARCH_RESULTS", "8"))

MODEL_ALIASES = {
    "default": "groq-gpt-oss-120b",
    "fast": "gemini-flash-lite",
    "gemini": "gemini-flash",
    "code": "mistral-codestral",
    "long": "openrouter-nemotron-3-ultra-550b-free",
    "vision": "openrouter-gemma-4-31b-free",
    "code-free": "openrouter-north-mini-code-free",
}
LEGACY_SAFE_OI_MODELS = {
    "groq": "openrouter/openai/gpt-oss-120b",
    "gemini": "openrouter/google/gemini-2.5-flash",
    "openrouter-nemotron": "nvidia/nemotron-3-ultra-550b-a55b:free",
    "openrouter-gemma": "google/gemma-4-26b-a4b-it:free",
    "mistral": "openrouter/mistralai/mistral-medium-3-5",
}

LOG_PATH = Path(os.getenv("CB26_AGENT_LOG", "/tmp/cb26-agent-v2.log"))
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[logging.FileHandler(LOG_PATH), logging.StreamHandler()],
)
log = logging.getLogger("cb26-agent")

STATUS_RUNNING = "running"
STATUS_WAITING = "waiting_approval"
STATUS_COMPLETED = "completed"
STATUS_FAILED = "failed"
STATUS_CANCELED = "canceled"

ALLOWED_COMMANDS = {
    "pwd", "ls", "find", "rg", "grep", "sed", "head", "tail", "cat",
    "wc", "sort", "uniq", "cut", "tr", "printf", "echo", "python",
    "python3", "git", "uname", "id", "whoami", "date", "file",
    "stat", "du", "df", "ps", "ss", "ip", "systemctl", "journalctl",
    "sudo",
}
ALWAYS_APPROVAL_COMMANDS = {
    "rm", "mv", "cp", "chmod", "chown", "dd", "mkfs",
    "reboot", "shutdown", "poweroff", "iptables", "nft", "ufw",
    "curl", "wget", "ssh", "scp", "rsync", "docker", "podman",
    "apt", "apt-get", "dnf", "yum", "pacman", "pip", "npm",
}
READ_ONLY_COMMANDS = {
    "pwd", "ls", "find", "rg", "grep", "sed", "head", "tail", "cat",
    "wc", "sort", "uniq", "cut", "tr", "uname", "id", "whoami", "date",
    "file", "stat", "du", "df", "ps", "ss", "ip", "git"
}
SHELL_META = ("|", ">", "<", ";", "&&", "||", "`", "$(", "${", "\n")

TOOLS = [
    {"type": "function", "function": {"name": "shell", "description": "Execute a shell command. Requires approval.", "parameters": {"type": "object", "properties": {"command": {"type": "string"}, "cwd": {"type": "string"}, "timeout": {"type": "integer", "minimum": 1, "maximum": 180}}, "required": ["command"], "additionalProperties": False}}},
    {"type": "function", "function": {"name": "web_search", "description": "Search the public web.", "parameters": {"type": "object", "properties": {"query": {"type": "string"}, "max_results": {"type": "integer", "minimum": 1, "maximum": 10}, "timelimit": {"type": ["string", "null"]}}, "required": ["query"], "additionalProperties": False}}},
    {"type": "function", "function": {"name": "fetch_url", "description": "Fetch content from a URL.", "parameters": {"type": "object", "properties": {"url": {"type": "string"}}, "required": ["url"], "additionalProperties": False}}},
]

SYSTEM_PROMPT = """Jsi CyberBrain26, lokální AI agent pro správu systému.
Odpovídej česky a používej nástroje.

PRAVIDLA PRO SHELL:
- Když uživatel chce výpis, kontrolu, hledání nebo zjištění informací, zavolej shell s JEDNÍM nejkratším čistým příkazem.
- Pro výpis adresáře vždy preferuj: ls -la /cesta
- Nikdy pro obyčejný výpis nevytvářej soubor.
- Nikdy nevkládej do shell příkazu: if, then, elif, else, fi, for, while, do, done, case, function, echo, proměnné, přesměrování (> nebo >>), pipeline (|), &&, ||, středník (;), Markdown ani trojité zpětné apostrofy.
- Nepřidávej vlastní „bezpečnostní“ skripty ani náhradní logiku.
- Když potřebuješ více kroků, proveď je jako samostatné jednoduché tool calls.
- Nepoužívej shell bez důvodu; pro odpověď, kterou už znáš, odpověz běžným textem.

PŘÍKLAD:
Uživatel: „Vypiš obsah /home/oem/CyberBrain26“
Správný shell tool call: {"command":"ls -la /home/oem/CyberBrain26"}

Pouze čistý příkaz v argumentu command, například:
- ls -la /cesta
- pwd
- df -h
- git status
- grep -R "text" /cesta
"""

SENSITIVE_RE = re.compile(r"(sk-[A-Za-z0-9_-]{12,}|ghp_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{20,}|-----BEGIN (?:OPENSSH|RSA|EC|PRIVATE) KEY-----)", re.I)

def now_iso(): return datetime.now(timezone.utc).isoformat()
def redact(s): return SENSITIVE_RE.sub("[REDACTED_SECRET]", s or "")

class Store:
    def __init__(self, path: Path):
        self.path = path
        self.conn = sqlite3.connect(self.path, check_same_thread=False)
        self.conn.row_factory = sqlite3.Row
        self.conn.executescript("""
        PRAGMA journal_mode=WAL;
        CREATE TABLE IF NOT EXISTS runs(run_id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, status TEXT NOT NULL, model TEXT NOT NULL, mode TEXT NOT NULL, step INTEGER NOT NULL DEFAULT 0, failure_count INTEGER NOT NULL DEFAULT 0, state_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
        CREATE TABLE IF NOT EXISTS approvals(approval_id TEXT PRIMARY KEY, run_id TEXT NOT NULL, tool_call_id TEXT NOT NULL, tool_name TEXT NOT NULL, arguments_json TEXT NOT NULL, status TEXT NOT NULL, result_json TEXT, created_at TEXT NOT NULL, decided_at TEXT);
        CREATE TABLE IF NOT EXISTS events(id INTEGER PRIMARY KEY AUTOINCREMENT, run_id TEXT NOT NULL, type TEXT NOT NULL, payload_json TEXT NOT NULL, created_at TEXT NOT NULL);
        CREATE INDEX IF NOT EXISTS idx_events_run ON events(run_id,id);
        """)
        self.conn.commit()

    def save_run(self, run):
        ts = now_iso()
        self.conn.execute("INSERT INTO runs(run_id,thread_id,status,model,mode,step,failure_count,state_json,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(run_id) DO UPDATE SET status=excluded.status, model=excluded.model, mode=excluded.mode, step=excluded.step, failure_count=excluded.failure_count, state_json=excluded.state_json, updated_at=excluded.updated_at", (run["run_id"], run["thread_id"], run["status"], run["model"], run["mode"], run["step"], run["failure_count"], json.dumps(run["state"], ensure_ascii=False), run["created_at"], ts))
        self.conn.commit()

    def load_run(self, run_id):
        row = self.conn.execute("SELECT * FROM runs WHERE run_id=?", (run_id,)).fetchone()
        if not row: return None
        d = dict(row)
        try: d["state"] = json.loads(d.pop("state_json"))
        except: d["state"] = {"messages": []}
        return d

    def create_approval(self, run_id, tool_call_id, tool_name, args):
        aid = uuid.uuid4().hex
        self.conn.execute("INSERT INTO approvals(approval_id,run_id,tool_call_id,tool_name,arguments_json,status,created_at) VALUES(?,?,?,?,?,?,?)", (aid, run_id, tool_call_id, tool_name, json.dumps(args, ensure_ascii=False), "pending", now_iso()))
        self.conn.commit()
        return aid

    def get_approval(self, aid):
        row = self.conn.execute("SELECT * FROM approvals WHERE approval_id=?", (aid,)).fetchone()
        if not row: return None
        d = dict(row)
        d["arguments"] = json.loads(d.pop("arguments_json"))
        if d.get("result_json"): d["result"] = json.loads(d.pop("result_json"))
        return d

    def decide_approval(self, aid, status, result=None):
        self.conn.execute("UPDATE approvals SET status=?, result_json=?, decided_at=? WHERE approval_id=?", (status, json.dumps(result, ensure_ascii=False) if result is not None else None, now_iso(), aid))
        self.conn.commit()

    def pending_for_run(self, run_id):
        row = self.conn.execute("SELECT approval_id FROM approvals WHERE run_id=? AND status='pending' ORDER BY created_at LIMIT 1", (run_id,)).fetchone()
        return self.get_approval(row["approval_id"]) if row else None

    def add_event(self, run_id, typ, payload):
        self.conn.execute("INSERT INTO events(run_id,type,payload_json,created_at) VALUES(?,?,?,?)", (run_id, typ, json.dumps(payload, ensure_ascii=False), now_iso()))
        self.conn.commit()

    def events(self, run_id, after_id=0):
        rows = self.conn.execute("SELECT * FROM events WHERE run_id=? AND id>? ORDER BY id", (run_id, after_id)).fetchall()
        return [{"id": r["id"], "type": r["type"], "payload": json.loads(r["payload_json"]), "created_at": r["created_at"]} for r in rows]

STORE = Store(DB_PATH)

def policy(command):
    command = command.strip()
    if not command: return {"allowed": False, "approval": False, "category": "deny", "reason": "Prázdný příkaz."}
    if any(x in command for x in SHELL_META): return {"allowed": True, "approval": True, "category": "shell-complex", "reason": "Shell obsahuje řetězení/pipeline."}
    try: parts = shlex.split(command)
    except ValueError as exc: return {"allowed": False, "approval": False, "category": "deny", "reason": f"Neplatná shell syntaxe: {exc}"}
    base = Path(parts[0]).name if parts else ""
    if base == "sudo": return {"allowed": True, "approval": True, "category": "sudo", "reason": "Příkaz vyžaduje administrátorská práva."}
    if base in ALWAYS_APPROVAL_COMMANDS: return {"allowed": True, "approval": True, "category": "high", "reason": f"Příkaz {base} mění systém."}
    if base in ALLOWED_COMMANDS:
        is_read_only = base in READ_ONLY_COMMANDS
        return {
            "allowed": True,
            "approval": not is_read_only,
            "category": "read" if is_read_only else "write",
            "reason": (
                "Bezpečný čtecí příkaz nevyžaduje potvrzení."
                if is_read_only
                else "Příkaz může měnit stav systému."
            ),
        }
    return {"allowed": True, "approval": True, "category": "unknown", "reason": f"Příkaz {base} vyžaduje potvrzení."}

def safe_cwd(cwd):
    p = Path(cwd or ROOT).expanduser()
    if not p.is_absolute(): p = ROOT / p
    return p.resolve()

def limit_text(text):
    text = redact(text or "")
    if len(text) <= MAX_OUTPUT: return text
    return text[:MAX_OUTPUT] + f"\n...[TRUNCATED {len(text)-MAX_OUTPUT} chars]"

def execute_shell(args):
    command = str(args.get("command", "")).strip()
    cwd = safe_cwd(args.get("cwd"))
    timeout = min(max(int(args.get("timeout", COMMAND_TIMEOUT)), 1), 180)
    pol = policy(command)
    if not pol["allowed"]: return {"status": "blocked", "exit_code": None, "stdout": "", "stderr": pol["reason"], "policy": pol}
    if not cwd.exists() or not cwd.is_dir(): return {"status": "failed", "exit_code": None, "stdout": "", "stderr": f"cwd does not exist: {cwd}", "policy": pol}
    started = time.monotonic()
    try:
        filtered_env = {k: v for k, v in os.environ.items() if not any(k.endswith(s) for s in ("_API_KEY", "_TOKEN", "_SECRET"))}
        cp = subprocess.run(command, cwd=str(cwd), shell=True, executable="/bin/bash", capture_output=True, text=True, timeout=timeout, env=filtered_env)
        status = "success" if cp.returncode == 0 else "failed"
        return {"status": status, "exit_code": cp.returncode, "stdout": limit_text(cp.stdout), "stderr": limit_text(cp.stderr), "cwd": str(cwd), "duration_ms": int((time.monotonic() - started) * 1000), "policy": pol}
    except subprocess.TimeoutExpired as exc:
        return {"status": "timeout", "exit_code": None, "stdout": limit_text(exc.stdout or ""), "stderr": limit_text((exc.stderr or "") + "\nTimed out."), "cwd": str(cwd), "duration_ms": int((time.monotonic() - started) * 1000), "policy": pol}
    except Exception as exc:
        return {"status": "error", "exit_code": None, "stdout": "", "stderr": str(exc), "cwd": str(cwd), "policy": pol}

def web_search(args):
    if DDGS is None: return {"status": "error", "error": "ddgs not installed."}
    query = str(args.get("query", "")).strip()
    if not query: return {"status": "error", "error": "query required"}
    max_results = min(max(int(args.get("max_results", SEARCH_RESULTS)), 1), 10)
    try:
        results = DDGS(timeout=10).text(query, max_results=max_results)
        cleaned = [{"title": str(r.get("title",""))[:300], "url": str(r.get("href", r.get("url","")))[:2000], "snippet": str(r.get("body",""))[:1500]} for r in results]
        return {"status": "success", "query": query, "results": cleaned}
    except Exception as exc: return {"status": "error", "error": str(exc)[:1000]}

def fetch_url(args):
    url = str(args.get("url", "")).strip()
    p = urlparse(url)
    if p.scheme not in {"http", "https"} or not p.netloc: return {"status": "error", "error": "Only http/https allowed."}
    try:
        with httpx.Client(timeout=12, follow_redirects=True) as c:
            r = c.get(url, headers={"User-Agent": "CyberBrain26/2.0"})
            r.raise_for_status()
            return {"status": "success", "url": str(r.url), "content": limit_text(r.text)}
    except Exception as exc: return {"status": "error", "error": str(exc)[:1000]}

def normalize_message_item(m):
    out = {}
    for k in ("role", "content", "tool_calls", "tool_call_id", "name"):
        if k in m: out[k] = m[k]
    return out

def parse_tool_calls(message):
    calls = message.get("tool_calls") or []
    parsed = []
    for c in calls:
        fn = c.get("function") or {}
        name = fn.get("name")
        raw = fn.get("arguments", "{}")
        try: args = json.loads(raw) if isinstance(raw, str) else raw
        except: args = {}
        if name:
            parsed.append({"id": c.get("id") or f"call_{uuid.uuid4().hex}", "name": name, "arguments": args})
    return parsed

def fallback_json_action(content):
    if not content: return None
    
    # JSON detekce
    candidates = re.findall(r'```(?:json)?\s*(\{.*?\})\s*```', content, re.S | re.I)
    if not candidates:
        candidates = re.findall(r'(\{[\s\S]{0,5000}\})', content)
    for raw in reversed(candidates):
        try:
            obj = json.loads(raw)
        except: continue
        if obj.get("action") == "tool" and obj.get("tool"):
            return {"id": "fallback_" + uuid.uuid4().hex, "name": obj["tool"], "arguments": obj.get("arguments", {})}
    
    # ====== KRITICKÁ OPRAVA ======
    # Hledáme POUZE čistý příkaz na jednom řádku, NE celý skript
    lines = content.split('\n')
    for line in lines:
        line = line.strip()
        if len(line) < 3: continue
        
        # Ignorujeme české věty
        if re.match(r'^(Jsem|Ahoj|Prosím|Dobrý|Děkuji|Rozumím|Můžu|Můžeš|Potřebuji|Zde|Nyní|Zjisti|Spočítej|Najdi|Ukaž|Vypiš|Řekni|Plán|Příkaz|Čeká|V|Napiš|Celkem|Plán|Krok|Ověření|Pokud|pak|Poté|Následně)', line, re.I):
            continue
        
        # Ignorujeme řádky začínající na # (komentáře)
        if line.startswith('#'):
            continue
        
        # Ignorujeme řádky s if/elif/else/for/while/case/function
        if re.match(r'^(if|elif|else|for|while|case|function|then|do|done|fi|esac)', line, re.I):
            continue
        
        # Hledáme příkaz - musí začínat typickým slovem a obsahovat mezeru
        cmd_match = re.match(r'^(sudo\s+)?(du|df|ls|find|grep|cat|echo|pwd|whoami|uname|date|ps|free|uptime|ifconfig|ping|netstat|ss|ip|systemctl|journalctl|apt|apt-get|pip|npm|git|docker|python|python3|which|wc|sort|uniq|cut|tr|printf|head|tail|file|stat|du|mkdir|touch|chmod|chown|ln|cp|mv|rm|rmdir)\s+', line, re.I)
        if cmd_match:
            return {"id": "fallback_" + uuid.uuid4().hex, "name": "shell", "arguments": {"command": line}}
    
    # Pokud nic, zkusíme backticks
    backtick = re.search(r'`([^`]+)`', content)
    if backtick:
        cmd = backtick.group(1).strip()
        if len(cmd) > 3 and not re.match(r'^(if|elif|else|for|while|case|function)', cmd, re.I):
            return {"id": "fallback_" + uuid.uuid4().hex, "name": "shell", "arguments": {"command": cmd}}
    
    return None

def build_messages(run):
    state = run["state"]
    return [normalize_message_item(m) for m in state["messages"][-(HISTORY_LIMIT*3):]]

def call_llm(model, messages):
    base = {"model": model, "messages": messages, "max_tokens": int(os.getenv("CB26_MAX_TOKENS", "1536")), "temperature": float(os.getenv("CB26_TEMPERATURE", "0.15"))}
    payload = dict(base, tools=TOOLS, tool_choice="auto")
    headers = {"Authorization": f"Bearer {PROXY_KEY}", "Content-Type": "application/json"}
    with httpx.Client(timeout=90.0) as client:
        r = client.post(f"{PROXY_URL}/v1/chat/completions", headers=headers, json=payload)
        if r.status_code != 200 and r.status_code in {400, 404, 422}:
            fallback_messages = list(messages)
            fallback_messages.insert(1, {"role": "system", "content": 'Tool-calling fallback. If you need a tool, output ONLY JSON {"action":"tool","tool":"shell|web_search|fetch_url","arguments":{...}}.'})
            r2 = client.post(f"{PROXY_URL}/v1/chat/completions", headers=headers, json=dict(base, messages=fallback_messages))
            if r2.status_code != 200:
                raise RuntimeError(f"LiteLLM HTTP {r.status_code}: {r.text[:1200]} | fallback HTTP {r2.status_code}: {r2.text[:1200]}")
            r = r2
        if r.status_code != 200: raise RuntimeError(f"LiteLLM HTTP {r.status_code}: {r.text[:1500]}")
        data = r.json()
        choices = data.get("choices") or []
        if not choices: raise RuntimeError(f"No choices in response: {data}")
        return choices[0]["message"]

def memory_context(query):
    try:
        from memory_core import retrieve_active_context
        return retrieve_active_context(query=query, limit=MEMORY_LIMIT)
    except: return []

def resolve_model(model): return MODEL_ALIASES.get(model, LEGACY_SAFE_OI_MODELS.get(model, model))

def start_run(thread_id, model, mode, user_text):
    run_id = uuid.uuid4().hex
    tid = thread_id or uuid.uuid4().hex
    model = resolve_model(model)
    try:
        mem = memory_context(user_text)
        memory_text = "\n".join(f"- {x.get('content','')}" for x in mem if isinstance(x, dict))
    except: memory_text = ""
    sys_content = SYSTEM_PROMPT
    if memory_text: sys_content += "\n\nPaměť:\n" + memory_text
    run = {"run_id": run_id, "thread_id": tid, "status": STATUS_RUNNING, "model": model, "mode": mode, "step": 0, "failure_count": 0, "created_at": now_iso(), "state": {"messages": [{"role": "system", "content": sys_content}, {"role": "user", "content": user_text}], "last_tool_fingerprints": [], "pending_approval_id": None}}
    STORE.save_run(run)
    STORE.add_event(run_id, "run.started", {"thread_id": tid, "model": model, "mode": mode})
    return run

def fingerprint(name, args): return name + ":" + json.dumps(args, sort_keys=True, ensure_ascii=False)

def run_until_pause_or_done(run_id):
    run = STORE.load_run(run_id)
    if not run: raise RuntimeError("run not found")
    while run["step"] < MAX_STEPS:
        pending = STORE.pending_for_run(run_id)
        if pending:
            run["status"] = STATUS_WAITING
            run["state"]["pending_approval_id"] = pending["approval_id"]
            STORE.save_run(run)
            return serialize_run(run)
        run["step"] += 1
        STORE.save_run(run)
        STORE.add_event(run_id, "agent.step", {"step": run["step"]})
        try:
            msg = call_llm(run["model"], build_messages(run))
        except Exception as exc:
            run["status"] = STATUS_FAILED
            STORE.save_run(run)
            STORE.add_event(run_id, "run.failed", {"error": str(exc)})
            return serialize_run(run)
        run["state"]["messages"].append(msg)
        calls = parse_tool_calls(msg)
        if not calls:
            fallback = fallback_json_action(str(msg.get("content") or ""))
            if fallback:
                synthetic = {"role": "assistant", "content": None, "tool_calls": [{"id": fallback["id"], "type": "function", "function": {"name": fallback["name"], "arguments": json.dumps(fallback["arguments"], ensure_ascii=False)}}]}
                if run["state"]["messages"] and run["state"]["messages"][-1] is msg:
                    run["state"]["messages"][-1] = synthetic
                else:
                    run["state"]["messages"].append(synthetic)
                calls = [fallback]
        if not calls:
            text = str(msg.get("content") or "")
            run["status"] = STATUS_COMPLETED
            STORE.save_run(run)
            STORE.add_event(run_id, "run.completed", {"reply": text})
            return serialize_run(run)
        needs_approval_pause = False
        for call in calls:
            name = call["name"]
            args = call["arguments"] if isinstance(call["arguments"], dict) else {}
            fp = fingerprint(name, args)
            recent = run["state"].get("last_tool_fingerprints", [])
            if recent.count(fp) >= MAX_SAME_FAILURE:
                result = {"status": "blocked_repeat", "error": "Repeated failure."}
                tool_msg = {"role": "tool", "tool_call_id": call["id"], "name": name, "content": json.dumps(result, ensure_ascii=False)}
                run["state"]["messages"].append(tool_msg)
                STORE.add_event(run_id, "tool.blocked_repeat", {"tool": name, "arguments": args})
                continue
            if name == "shell":
                is_trusted = any(msg.get("role") == "user" and any(phrase in msg.get("content", "").lower() for phrase in ["povol", "můžeš", "trust", "go ahead", "automaticky", "bez schválení"]) for msg in run["state"]["messages"])
                if is_trusted and run["mode"] == "FULL":
                    result = execute_shell(args)
                    run["state"]["messages"].append({"role": "tool", "tool_call_id": call["id"], "name": name, "content": json.dumps(result, ensure_ascii=False)})
                    STORE.add_event(run_id, "tool.completed", {"tool_call_id": call["id"], "tool": name, "result": result})
                    if result.get("status") != "success":
                        run["failure_count"] += 1
                        run["state"]["last_tool_fingerprints"].append(fp)
                        run["state"]["last_tool_fingerprints"] = run["state"]["last_tool_fingerprints"][-20:]
                        if run["failure_count"] >= MAX_FAILURES:
                            run["status"] = STATUS_FAILED
                            STORE.save_run(run)
                            STORE.add_event(run_id, "run.failed", {"error": "Max failures"})
                            return serialize_run(run)
                    continue
                else:
                    pol = policy(str(args.get("command", "")))

                    if not pol["approval"]:
                        result = execute_shell(args)
                        run["state"]["messages"].append({
                            "role": "tool",
                            "tool_call_id": call["id"],
                            "name": name,
                            "content": json.dumps(result, ensure_ascii=False),
                        })
                        STORE.add_event(run_id, "tool.completed", {
                            "tool_call_id": call["id"],
                            "tool": name,
                            "result": result,
                        })

                        if result.get("status") != "success":
                            run["failure_count"] += 1
                            run["state"]["last_tool_fingerprints"].append(fp)
                            run["state"]["last_tool_fingerprints"] = run["state"]["last_tool_fingerprints"][-20:]

                            if run["failure_count"] >= MAX_FAILURES:
                                run["status"] = STATUS_FAILED
                                STORE.save_run(run)
                                STORE.add_event(run_id, "run.failed", {"error": "Max failures"})
                                return serialize_run(run)

                        continue

                    approval_id = STORE.create_approval(run_id, call["id"], name, args)
                    run["state"]["pending_approval_id"] = approval_id
                    run["status"] = STATUS_WAITING
                    STORE.save_run(run)
                    STORE.add_event(run_id, "approval.required", {
                        "approval_id": approval_id,
                        "tool_call_id": call["id"],
                        "tool": name,
                        "arguments": args,
                        "policy": pol,
                    })
                    needs_approval_pause = True
                    break
            if name == "web_search":
                result = web_search(args)
            elif name == "fetch_url":
                result = fetch_url(args)
            else:
                result = {"status": "error", "error": f"Unknown tool: {name}"}
            run["state"]["messages"].append({"role": "tool", "tool_call_id": call["id"], "name": name, "content": json.dumps(result, ensure_ascii=False)})
            STORE.add_event(run_id, "tool.completed", {"tool_call_id": call["id"], "tool": name, "result": result})
            if result.get("status") not in {"success"}:
                run["failure_count"] += 1
                run["state"]["last_tool_fingerprints"].append(fp)
                run["state"]["last_tool_fingerprints"] = run["state"]["last_tool_fingerprints"][-20:]
                if run["failure_count"] >= MAX_FAILURES:
                    run["status"] = STATUS_FAILED
                    STORE.save_run(run)
                    STORE.add_event(run_id, "run.failed", {"error": "Max tool failures"})
                    return serialize_run(run)
        if needs_approval_pause: return serialize_run(run)
        run["state"]["pending_approval_id"] = None
        run["status"] = STATUS_RUNNING
        STORE.save_run(run)
    run["status"] = STATUS_FAILED
    STORE.save_run(run)
    STORE.add_event(run_id, "run.failed", {"error": "Max steps"})
    return serialize_run(run)

def resume_approval(approval_id, action, edited_arguments=None):
    approval = STORE.get_approval(approval_id)
    if not approval: raise KeyError("approval not found")
    if approval["status"] != "pending": raise ValueError(f"approval is {approval['status']}")
    run = STORE.load_run(approval["run_id"])
    if not run: raise RuntimeError("run not found")
    args = edited_arguments if isinstance(edited_arguments, dict) else approval["arguments"]
    if action == "deny":
        result = {"status": "denied", "reason": "User rejected."}
        STORE.decide_approval(approval_id, "denied", result)
        run["state"]["messages"].append({"role": "tool", "tool_call_id": approval["tool_call_id"], "name": approval["tool_name"], "content": json.dumps(result, ensure_ascii=False)})
        STORE.add_event(run["run_id"], "approval.denied", {"approval_id": approval_id, "result": result})
    else:
        if approval["tool_name"] != "shell": raise ValueError("Only shell is approval-gated")
        result = execute_shell(args)
        STORE.decide_approval(approval_id, "approved", result)
        run["state"]["messages"].append({"role": "tool", "tool_call_id": approval["tool_call_id"], "name": approval["tool_name"], "content": json.dumps(result, ensure_ascii=False)})
        if result.get("status") != "success":
            run["failure_count"] += 1
            run["state"]["last_tool_fingerprints"].append(fingerprint(approval["tool_name"], args))
        STORE.add_event(run["run_id"], "approval.executed", {"approval_id": approval_id, "tool_call_id": approval["tool_call_id"], "arguments": args, "result": result})
    run["state"]["pending_approval_id"] = None
    run["status"] = STATUS_RUNNING
    STORE.save_run(run)
    return run_until_pause_or_done(run["run_id"])

def serialize_run(run):
    events = STORE.events(run["run_id"])
    assistant = ""
    for m in reversed(run["state"]["messages"]):
        if m.get("role") == "assistant" and m.get("content"):
            assistant = str(m["content"])
            break
    pending = STORE.pending_for_run(run["run_id"])
    return {"run_id": run["run_id"], "thread_id": run["thread_id"], "status": run["status"], "reply": assistant, "pending_approval": {"approval_id": pending["approval_id"], "tool_name": pending["tool_name"], "arguments": pending["arguments"], "created_at": pending["created_at"]} if pending else None, "step": run["step"], "failure_count": run["failure_count"], "events": events[-50:]}

def health():
    try:
        with httpx.Client(timeout=5) as c:
            r = c.get(f"{PROXY_URL}/v1/models", headers={"Authorization": f"Bearer {PROXY_KEY}"})
            ok = r.status_code == 200
            models = [m.get("id") for m in r.json().get("data", [])] if ok else []
            return {"ok": ok, "proxy": PROXY_URL, "models": models}
    except Exception as exc:
        return {"ok": False, "proxy": PROXY_URL, "error": str(exc)}
