#!/usr/bin/env python3 """Headless-Adapter fuer OpenCode-Versuchslaeufe. OpenCode verwaltet Provider-Credentials und Agentensitzungen. Dieser Wrapper erzeugt pro Lauf eine isolierte OpenCode-Konfiguration, streamt JSON-Ereignisse direkt in den Laufordner und normalisiert die Session nach RawResult.json. Unterstuetzte Provider (``--provider``): * ``tensorx`` – Remote-Gateway https://api.tensorx.ai/v1 (GLM, Qwen, Kimi) * ``lmstudio`` – lokaler LM-Studio-Server http://localhost:1234/v1 Beide Provider durchlaufen denselben Agenten-, Berechtigungs- und Metrikpfad. Fuer ``lmstudio`` kommt ein Preflight hinzu, der Server, Modellzustand, Tool-Faehigkeit und geladenes Kontextfenster prueft und die lokale Runtime fuer die Reproduzierbarkeitsangaben protokolliert. """ from __future__ import annotations import argparse import copy import json import os import queue import re import shutil import subprocess import sys import threading import time import urllib.error import urllib.request from collections import Counter from datetime import datetime, timezone from pathlib import Path ADAPTER_VERSION = "1.1.0" DEFAULT_PROVIDER = "tensorx" PROVIDER_ID = DEFAULT_PROVIDER PROVIDERS: dict[str, dict] = { "tensorx": { "template": "opencode-tensorx.json", "adapter": "opencode-tensorx", "local": False, }, "lmstudio": { "template": "opencode-lmstudio.json", "adapter": "opencode-lmstudio", "local": True, "base_url": "http://localhost:1234", }, } EFFORTS = ("low", "medium", "high", "xhigh", "max") MODES = ("solo", "builtin", "custom") LMSTUDIO_MIN_CONTEXT = 32768 def utc_now() -> str: return datetime.now(timezone.utc).isoformat() def resolve_opencode(explicit: str | None = None) -> Path: candidates: list[Path] = [] if explicit: candidates.append(Path(explicit)) which_exe = shutil.which("opencode.exe") if which_exe: candidates.append(Path(which_exe)) appdata = os.environ.get("APPDATA") if appdata: candidates.append( Path(appdata) / "npm" / "node_modules" / "opencode-ai" / "bin" / "opencode.exe" ) for candidate in candidates: if candidate.is_file(): return candidate.resolve() raise FileNotFoundError( "OpenCode nicht gefunden. Erwartet wird 'opencode.exe' im PATH oder " "die npm-Installation 'npm install -g opencode-ai'." ) def normalize_model(model: str, provider: str = DEFAULT_PROVIDER) -> tuple[str, str]: if model.startswith(f"{provider}/"): upstream = model[len(provider) + 1 :] return model, upstream return f"{provider}/{model}", model def normalized_path(path: Path) -> str: return path.resolve().as_posix() def git_worktree_root(root: Path) -> Path | None: completed = subprocess.run( ["git", "-C", str(root), "rev-parse", "--show-toplevel"], capture_output=True, text=True, encoding="utf-8", errors="replace", check=False, ) if completed.returncode != 0 or not completed.stdout.strip(): return None candidate = Path(completed.stdout.strip()).resolve() return candidate if candidate.is_dir() else None def output_permission_patterns( root: Path, output_dir: Path, worktree_root: Path | None = None, ) -> list[str]: """Return both canonical and worktree-relative patterns used by OpenCode. OpenCode matches paths inside the active worktree as location-relative resources, even when a tool call supplied an absolute Windows path. That relative resource may contain ``..`` when the active location is a worktree subdirectory. OpenCode may instead use a path relative to the Git worktree root, so that form is included when available. Truly external outputs are matched canonically. Both the directory itself and descendants are allowed. """ root = root.resolve() output_dir = output_dir.resolve() bases = [output_dir.as_posix()] try: bases.insert(0, output_dir.relative_to(root).as_posix()) except ValueError: try: bases.insert(0, Path(os.path.relpath(output_dir, root)).as_posix()) except ValueError: # Verschiedene Windows-Laufwerke. pass if worktree_root is not None: try: bases.insert(0, output_dir.relative_to(worktree_root.resolve()).as_posix()) except ValueError: pass patterns: list[str] = [] for base in bases: normalized = base.rstrip("/") for pattern in (normalized, normalized + "/**"): if pattern not in patterns: patterns.append(pattern) return patterns def readonly_shell_permissions() -> dict[str, str]: return { "*": "deny", "rg *": "allow", "git status*": "allow", "git ls-files*": "allow", "git rev-parse*": "allow", "Get-ChildItem *": "allow", "Get-Content *": "allow", "Select-String *": "allow", "Test-Path *": "allow", "Resolve-Path *": "allow", "where.exe *": "allow", } def task_permissions(mode: str, custom_names: list[str]) -> str | dict[str, str]: if mode == "solo": return "deny" if mode == "builtin": return { "*": "deny", "general": "allow", "explore": "allow", } permissions = {"*": "deny"} permissions.update({name: "allow" for name in custom_names}) return permissions def load_custom_agents(path: Path) -> dict[str, dict]: data = json.loads(path.read_text(encoding="utf-8-sig")) if not isinstance(data, dict) or not data: raise ValueError("Agentendatei muss ein nicht-leeres JSON-Objekt sein") for name, definition in data.items(): if not isinstance(definition, dict): raise ValueError(f"Agent '{name}' ist kein JSON-Objekt") if not definition.get("description") or not definition.get("prompt"): raise ValueError(f"Agent '{name}' benoetigt description und prompt") return data def build_run_config( base_config: dict, model_ref: str, upstream_model: str, mode: str, root: Path, output_dir: Path, agents_file: Path | None, provider: str = DEFAULT_PROVIDER, context_limit: int | None = None, ) -> dict: config = copy.deepcopy(base_config) provider_config = config.setdefault("provider", {}).setdefault(provider, {}) models = provider_config.setdefault("models", {}) if upstream_model not in models: models[upstream_model] = {"name": upstream_model} if context_limit: # Lokale Server halten nur das tatsaechlich geladene Fenster vor. Ein # groesseres Limit in der Vorlage wuerde zu serverseitigem Abschneiden # fuehren und die Messung entwerten. models[upstream_model].setdefault("limit", {})["context"] = context_limit config["model"] = model_ref output_patterns = output_permission_patterns( root, output_dir, git_worktree_root(root), ) edit_permissions = {"*": "deny"} edit_permissions.update({pattern: "allow" for pattern in output_patterns}) custom_agents: dict[str, dict] = {} if mode == "custom": if agents_file is None: raise ValueError("Modus custom erfordert --agents") custom_agents = load_custom_agents(agents_file) config["permission"] = { "*": "deny", "read": "allow", "glob": "allow", "grep": "allow", "list": "allow", "edit": edit_permissions, "external_directory": copy.deepcopy(edit_permissions), "bash": readonly_shell_permissions(), "task": task_permissions(mode, list(custom_agents)), "webfetch": "deny", "websearch": "deny", "skill": "deny", "question": "deny", } agents = config.setdefault("agent", {}) agents["build"] = {"model": model_ref, "mode": "primary"} agents["general"] = {"model": model_ref, "mode": "subagent"} agents["explore"] = {"model": model_ref, "mode": "subagent"} child_permission = { "edit": "deny", "task": "deny", "webfetch": "deny", "websearch": "deny", "skill": "deny", "bash": readonly_shell_permissions(), } for name, definition in custom_agents.items(): agents[name] = { "description": definition["description"], "mode": "subagent", "model": model_ref, "prompt": definition["prompt"], "permission": child_permission, } config["default_agent"] = "build" return config def terminate_process_tree(process: subprocess.Popen) -> None: if process.poll() is not None: return if os.name == "nt": subprocess.run( ["taskkill", "/PID", str(process.pid), "/T", "/F"], capture_output=True, text=True, check=False, ) else: process.terminate() try: process.wait(timeout=5) except subprocess.TimeoutExpired: process.kill() def stream_reader(stream, source: str, sink: Path, updates: queue.Queue) -> None: with sink.open("a", encoding="utf-8", newline="") as handle: for line in iter(stream.readline, ""): handle.write(line) handle.flush() updates.put((source, line, time.monotonic())) stream.close() updates.put((source, None, time.monotonic())) def parse_event(line: str) -> dict | None: try: event = json.loads(line) except json.JSONDecodeError: return None return event if isinstance(event, dict) else None def json_from_mixed_output(text: str) -> dict | None: text = text.strip() if not text: return None try: return json.loads(text) except json.JSONDecodeError: start = text.find("{") if start < 0: return None try: return json.loads(text[start:]) except json.JSONDecodeError: return None def export_session( opencode: Path, session_id: str, env: dict[str, str], root: Path, destination: Path, log, ) -> dict | None: completed = subprocess.run( [str(opencode), "export", session_id, "--pure"], cwd=root, env=env, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=120, check=False, ) if completed.stderr.strip(): log("OpenCode export: " + completed.stderr.strip()) data = json_from_mixed_output(completed.stdout) if data is not None: destination.write_text( json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8" ) return data def collect_written_files(output_dir: Path) -> list[dict]: if not output_dir.is_dir(): return [] return [ { "path": str(path.relative_to(output_dir)), "size": path.stat().st_size, } for path in sorted(output_dir.rglob("*")) if path.is_file() ] def normalize_result( session: dict | None, events: list[dict], model_ref: str, mode: str, effort: str, exit_code: int, timed_out: bool, interrupted: bool, duration_s: float, output_dir: Path, errors: list[str], provider: str = DEFAULT_PROVIDER, effort_applied: bool = True, local_runtime: dict | None = None, ) -> dict: info = (session or {}).get("info", {}) messages = (session or {}).get("messages", []) assistants = [m for m in messages if m.get("info", {}).get("role") == "assistant"] tools: list[dict] = [] result_text = "" finish_reason = "" for message in assistants: finish_reason = message.get("info", {}).get("finish", finish_reason) for part in message.get("parts", []): if part.get("type") == "text" and part.get("text"): result_text = part["text"] if part.get("type") == "tool": state = part.get("state", {}) tools.append( { "name": part.get("tool", ""), "status": state.get("status", ""), "input": state.get("input", {}), "title": state.get("title", ""), } ) tokens = info.get("tokens", {}) cache = tokens.get("cache", {}) input_tokens = int(tokens.get("input", 0) or 0) output_tokens = int(tokens.get("output", 0) or 0) reasoning_tokens = int(tokens.get("reasoning", 0) or 0) cache_read = int(cache.get("read", 0) or 0) cache_write = int(cache.get("write", 0) or 0) total_tokens = int(tokens.get("total", 0) or 0) if total_tokens == 0: total_tokens = ( input_tokens + output_tokens + reasoning_tokens + cache_read + cache_write ) model_info = info.get("model", {}) reported_model = model_info.get("id") or model_ref.split("/", 1)[-1] task_calls = [tool for tool in tools if tool["name"] in ("task", "subagent")] subagent_details = [ { "id": index, "type": call.get("input", {}).get("subagent_type") or call.get("input", {}).get("agent") or call.get("input", {}).get("type", ""), "description": call.get("input", {}).get("description") or call.get("input", {}).get("prompt", ""), "status": call.get("status", ""), } for index, call in enumerate(task_calls, start=1) ] by_type = Counter(detail["type"] for detail in subagent_details if detail["type"]) completed_subagents = sum( 1 for detail in subagent_details if detail["status"] == "completed" ) failed_subagents = len(subagent_details) - completed_subagents aborted = timed_out or interrupted is_error = exit_code != 0 or aborted or bool(errors) subtype = "aborted" if aborted else ("error" if is_error else "success") event_counts = Counter(event.get("type", "unknown") for event in events) usage = { "prompt_tokens": input_tokens, "completion_tokens": output_tokens, "total_tokens": total_tokens, "cached_tokens": cache_read, "cache_read_tokens": cache_read, "cache_creation_tokens": cache_write, "reasoning_tokens": reasoning_tokens, "output_tokens_details": {"thinking_tokens": reasoning_tokens}, } result = { "is_error": is_error, "subtype": subtype, "duration_ms": int(duration_s * 1000), "duration_api_ms": 0, "num_turns": len(assistants) or sum(1 for event in events if event.get("type") == "step_finish"), "model": reported_model, "model_requested": model_ref.split("/", 1)[-1], "provider": provider, "effort": effort, "effort_applied": effort_applied, "usage": usage, "modelUsage": { reported_model: { "input_tokens": input_tokens, "output_tokens": output_tokens, "cache_read_input_tokens": cache_read, "cache_creation_input_tokens": cache_write, "reasoning_tokens": reasoning_tokens, } }, "cost": info.get("cost", 0), "tool_calls": tools, "tool_call_count": len(tools), "tool_call_types": dict(Counter(tool["name"] for tool in tools)), "event_counts": dict(event_counts), "written_files": collect_written_files(output_dir), "result": result_text, "finish_reason": finish_reason, "errors": errors, "session_id": info.get("id", ""), "adapter": PROVIDERS.get(provider, {}).get("adapter", f"opencode-{provider}"), "adapter_version": ADAPTER_VERSION, "opencode_version": info.get("version", ""), "mode": mode, "subagent_stats": { "spawned": len(subagent_details), "completed": completed_subagents, "failed": failed_subagents, "by_type": dict(by_type), }, "subagent_details": subagent_details, "timed_out": timed_out, "interrupted": interrupted, "exit_code": exit_code, } if local_runtime is not None: result["local_runtime"] = local_runtime result["context_window"] = local_runtime.get("loaded_context_length", 0) # Lokale Inferenz erzeugt keine Providerkosten. Der Wert ist damit # keine Messgroesse, sondern definitionsgemaess null. result["cost"] = 0 result["cost_source"] = "nicht erfasst (lokaler Betrieb)" return result # -------------------------------------------------------------------------- # LM Studio: Preflight und Runtime-Metadaten # -------------------------------------------------------------------------- def resolve_lms(explicit: str | None = None) -> Path | None: candidates: list[Path] = [] if explicit: candidates.append(Path(explicit)) for name in ("lms.exe", "lms"): found = shutil.which(name) if found: candidates.append(Path(found)) home = os.environ.get("USERPROFILE") or os.environ.get("HOME") if home: candidates.append(Path(home) / ".lmstudio" / "bin" / "lms.exe") candidates.append(Path(home) / ".lmstudio" / "bin" / "lms") for candidate in candidates: if candidate.is_file(): return candidate.resolve() return None def http_get_json(url: str, timeout: int = 15) -> dict: request = urllib.request.Request(url, headers={"Accept": "application/json"}) with urllib.request.urlopen(request, timeout=timeout) as response: return json.loads(response.read().decode("utf-8")) def lmstudio_catalog(base_url: str, timeout: int = 15) -> list[dict]: """Modellkatalog des lokalen Servers samt Zustand und Kontextfenster. ``/api/v0/models`` ist die LM-Studio-eigene Erweiterung; sie liefert zusaetzlich zu ``/v1/models`` Zustand, Quantisierung, Architektur, Faehigkeiten sowie maximales und geladenes Kontextfenster. """ data = http_get_json(f"{base_url.rstrip('/')}/api/v0/models", timeout=timeout) entries = data.get("data", []) return [entry for entry in entries if isinstance(entry, dict)] ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]") def lms_version(lms: Path | None) -> str: """Versionskennung der lms-CLI. ``lms --version`` gibt ein ANSI-eingefaerbtes Banner aus; verwertbar ist allein die Zeile mit der Commit-Kennung. """ if lms is None: return "" completed = subprocess.run( [str(lms), "--version"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60, check=False, ) for line in ANSI_ESCAPE.sub("", completed.stdout + completed.stderr).splitlines(): cleaned = line.strip() if cleaned.lower().startswith(("cli commit", "version", "lms ")) and any( char.isdigit() for char in cleaned ): return cleaned return "" def lmstudio_instances(catalog: list[dict], model: str) -> list[dict]: """Alle Katalogeintraege zu einem Modell. LM Studio vergibt beim wiederholten Laden desselben Modells die Bezeichner ``modell``, ``modell:2``, ``modell:3``. Alle Instanzen beantworten dieselbe ``model``-Angabe der OpenAI-API, weshalb mehrere geladene Instanzen das Routing mehrdeutig machen. """ prefix = f"{model}:" return [ entry for entry in catalog if entry.get("id") == model or str(entry.get("id", "")).startswith(prefix) ] def run_lms(lms: Path, arguments: list[str], log, timeout: int = 1800) -> None: command = [str(lms)] + arguments log("LM Studio: " + " ".join(command)) completed = subprocess.run( command, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, check=False, ) if completed.returncode != 0: raise RuntimeError( f"'lms {' '.join(arguments)}' schlug fehl " f"(Exitcode {completed.returncode}): " + (completed.stderr or completed.stdout).strip() ) def lmstudio_reload_model( lms: Path, model: str, context_length: int, loaded_ids: list[str], log ) -> None: """Alle Instanzen des Modells entladen und genau eine neu laden.""" for identifier in loaded_ids: run_lms(lms, ["unload", identifier], log, timeout=300) run_lms( lms, ["load", model, "--context-length", str(context_length), "--yes"], log, ) def lmstudio_preflight( model: str, base_url: str, min_context: int, autoload: bool, lms_path: str | None, catalog_dump: Path, log, ) -> dict: """Prueft den lokalen Server und liefert die Runtime-Metadaten des Laufs. Bricht mit einer handlungsfaehigen Meldung ab, wenn Server, Modell, Tool-Faehigkeit oder Kontextfenster einen gueltigen Messpunkt unmoeglich machen. Ein zu kleines Fenster wuerde der Server stillschweigend abschneiden und die Messung entwerten. """ lms = resolve_lms(lms_path) try: catalog = lmstudio_catalog(base_url) except (urllib.error.URLError, OSError) as exc: hint = f"'{lms}' server start" if lms else "lms server start" raise RuntimeError( f"LM-Studio-Server unter {base_url} nicht erreichbar ({exc}). " f"Server starten mit: {hint}" ) from exc catalog_dump.write_text( json.dumps(catalog, indent=2, ensure_ascii=False), encoding="utf-8" ) def loaded_ids(entries: list[dict]) -> list[str]: return [ str(item.get("id", "")) for item in lmstudio_instances(entries, model) if item.get("state") == "loaded" ] def select(entries: list[dict]) -> dict | None: instances = lmstudio_instances(entries, model) if not instances: return None loaded = [item for item in instances if item.get("state") == "loaded"] if len(loaded) > 1 and not autoload: raise RuntimeError( f"Modell '{model}' ist mehrfach geladen " f"({', '.join(item.get('id', '') for item in loaded)}). Die " "OpenAI-API kann den Lauf dann keiner Instanz eindeutig zuordnen. " "Ueberzaehlige Instanzen entladen mit 'lms unload ' " "oder den Adapter mit --lmstudio-autoload aufrufen." ) return loaded[0] if loaded else instances[0] entry = select(catalog) if entry is None: available = ", ".join( item.get("id", "") for item in catalog if item.get("type") != "embeddings" ) raise RuntimeError( f"Modell '{model}' ist in LM Studio nicht vorhanden. " f"Verfuegbar: {available or 'keine'}. " f"Herunterladen mit: lms get {model}" ) capabilities = entry.get("capabilities") or [] if "tool_use" not in capabilities: raise RuntimeError( f"Modell '{model}' meldet keine Tool-Faehigkeit (capabilities=" f"{capabilities or 'leer'}). Ein Analyselauf ohne Tool-Calling ist " "kein gueltiger Messpunkt." ) max_context = int(entry.get("max_context_length") or 0) if max_context and max_context < min_context: raise RuntimeError( f"Modell '{model}' unterstuetzt hoechstens {max_context} Kontexttokens, " f"gefordert sind {min_context}. Mit --min-context bewusst absenken " "und die Abweichung im Protokoll vermerken." ) loaded_context = int(entry.get("loaded_context_length") or 0) needs_reload = ( entry.get("state") != "loaded" or loaded_context < min_context or len(loaded_ids(catalog)) > 1 ) if needs_reload and autoload: if lms is None: raise RuntimeError( "--lmstudio-autoload benoetigt die 'lms'-CLI; sie wurde weder im " "PATH noch unter ~/.lmstudio/bin gefunden." ) target_context = min(min_context, max_context) if max_context else min_context lmstudio_reload_model(lms, model, target_context, loaded_ids(catalog), log) catalog = lmstudio_catalog(base_url) catalog_dump.write_text( json.dumps(catalog, indent=2, ensure_ascii=False), encoding="utf-8" ) entry = select(catalog) or entry loaded_context = int(entry.get("loaded_context_length") or 0) if entry.get("state") != "loaded": raise RuntimeError( f"Modell '{model}' ist nicht geladen (state={entry.get('state')}). " f"Laden mit: lms load {model} --context-length {min_context} --yes " "oder den Adapter mit --lmstudio-autoload aufrufen." ) if loaded_context < min_context: raise RuntimeError( f"Modell '{model}' ist mit nur {loaded_context} Kontexttokens geladen, " f"gefordert sind {min_context}. Ein zu kleines Fenster schneidet die " "Codebasis stillschweigend ab. Neu laden mit: " f"lms load {model} --context-length {min_context} --yes" ) instances = loaded_ids(catalog) if len(instances) != 1: raise RuntimeError( f"Modell '{model}' muss mit genau einer Instanz geladen sein, " f"gefunden: {', '.join(instances) or 'keine'}. Ueberzaehlige Instanzen " "mit 'lms unload ' entfernen." ) runtime = { "provider": "lmstudio", "base_url": base_url, "lms_path": str(lms) if lms else "", "lms_version": lms_version(lms), "model_id": model, "instance_id": entry.get("id", model), "publisher": entry.get("publisher", ""), "arch": entry.get("arch", ""), "quantization": entry.get("quantization", ""), "compatibility_type": entry.get("compatibility_type", ""), "state": entry.get("state", ""), "capabilities": capabilities, "max_context_length": max_context, "loaded_context_length": loaded_context, } log( "LM-Studio-Preflight bestanden: " f"{runtime['model_id']}; Quantisierung={runtime['quantization'] or 'unbekannt'}; " f"Kontext={loaded_context}/{max_context or '?'}; " f"Runtime={runtime['compatibility_type'] or 'unbekannt'}" ) return runtime def main() -> int: parser = argparse.ArgumentParser(description="Versuchslauf ueber OpenCode") parser.add_argument("--prompt", required=True) parser.add_argument("--root", required=True) parser.add_argument("--output", required=True) parser.add_argument("--model", required=True) parser.add_argument( "--provider", default=DEFAULT_PROVIDER, choices=sorted(PROVIDERS), help="tensorx = Remote-Gateway, lmstudio = lokaler LM-Studio-Server", ) parser.add_argument("--effort", default="low", choices=EFFORTS) parser.add_argument("--mode", default="solo", choices=MODES) parser.add_argument("--agents") parser.add_argument("--result-dir") parser.add_argument("--opencode") parser.add_argument("--config-template") parser.add_argument( "--base-url", help="Basis-URL des lokalen Servers; Standard http://localhost:1234", ) parser.add_argument("--lms", help="Pfad zur lms-CLI (nur --provider lmstudio)") parser.add_argument( "--min-context", type=int, default=LMSTUDIO_MIN_CONTEXT, help="Mindestgroesse des geladenen Kontextfensters (nur lmstudio)", ) parser.add_argument( "--lmstudio-autoload", action="store_true", help="Modell bei Bedarf per 'lms load' mit --min-context laden", ) parser.add_argument( "--stall-timeout", type=int, default=600, help="Sekunden ohne stdout/stderr bis zum Abbruch; 0 deaktiviert", ) parser.add_argument( "--max-runtime", type=int, default=0, help="Maximale Gesamtlaufzeit in Sekunden; 0 deaktiviert", ) parser.add_argument( "--allow-empty-output", action="store_true", help="Leeres Ergebnisse-Verzeichnis nicht als Fehler werten (nur Smoke-Tests)", ) parser.add_argument("--title", default="run-experiment OpenCode") args = parser.parse_args() provider = args.provider provider_spec = PROVIDERS[provider] prompt_path = Path(args.prompt).resolve() root = Path(args.root).resolve() output_dir = Path(args.output).resolve() result_dir = Path(args.result_dir).resolve() if args.result_dir else output_dir.parent agents_file = Path(args.agents).resolve() if args.agents else None template_path = ( Path(args.config_template).resolve() if args.config_template else Path(__file__).with_name(provider_spec["template"]) ) if not prompt_path.is_file(): parser.error(f"Prompt-Datei fehlt: {prompt_path}") if not root.is_dir(): parser.error(f"Root-Verzeichnis fehlt: {root}") if not template_path.is_file(): parser.error(f"OpenCode-Konfiguration fehlt: {template_path}") opencode = resolve_opencode(args.opencode) output_dir.mkdir(parents=True, exist_ok=True) result_dir.mkdir(parents=True, exist_ok=True) meta_dir = result_dir / "_meta" meta_dir.mkdir(parents=True, exist_ok=True) events_path = result_dir / "OpenCodeEvents.jsonl" stderr_path = result_dir / "OpenCode.log" adapter_log_path = result_dir / "Adapter.log" config_path = meta_dir / "opencode-config.json" session_path = meta_dir / "opencode-session.json" raw_result_path = result_dir / "RawResult.json" for path in (events_path, stderr_path, adapter_log_path): path.write_text("", encoding="utf-8") def log(message: str) -> None: line = f"[{utc_now()}] {message}" with adapter_log_path.open("a", encoding="utf-8") as handle: handle.write(line + "\n") handle.flush() sys.stderr.write(line + "\n") sys.stderr.flush() model_ref, upstream_model = normalize_model(args.model, provider) base_config = json.loads(template_path.read_text(encoding="utf-8-sig")) local_runtime: dict | None = None context_limit: int | None = None if provider_spec.get("local"): base_url = args.base_url or provider_spec["base_url"] try: local_runtime = lmstudio_preflight( model=upstream_model, base_url=base_url, min_context=args.min_context, autoload=args.lmstudio_autoload, lms_path=args.lms, catalog_dump=meta_dir / "lmstudio-modelle.json", log=log, ) except RuntimeError as exc: log(f"Preflight fehlgeschlagen: {exc}") return 2 context_limit = local_runtime["loaded_context_length"] base_config.setdefault("provider", {}).setdefault(provider, {}).setdefault( "options", {} )["baseURL"] = f"{base_url.rstrip('/')}/v1" run_config = build_run_config( base_config, model_ref, upstream_model, args.mode, root, output_dir, agents_file, provider=provider, context_limit=context_limit, ) config_path.write_text( json.dumps(run_config, indent=2, ensure_ascii=False), encoding="utf-8" ) model_config = run_config["provider"][provider]["models"][upstream_model] variants = model_config.get("variants", {}) command = [ str(opencode), "run", "--pure", "--auto", "--format", "json", "--model", model_ref, "--agent", "build", "--title", args.title, "--dir", str(root), ] effort_applied = args.effort in variants if effort_applied: command.extend(["--variant", args.effort]) else: log( f"Effort '{args.effort}' wird nicht an den Provider uebergeben: " f"'{upstream_model}' kennt keine passende Variante. Im Protokoll als " "nicht steuerbar ausweisen." ) env = os.environ.copy() env["OPENCODE_CONFIG"] = str(config_path) prompt_text = prompt_path.read_text(encoding="utf-8-sig") start_time = time.monotonic() start_iso = utc_now() timed_out = False interrupted = False events: list[dict] = [] errors: list[str] = [] session_id = "" exit_code = -1 log( f"Start OpenCode {opencode}; Provider={provider}; Modell={model_ref}; " f"Modus={args.mode}; Effort={args.effort} (uebergeben={effort_applied}); " f"Stall-Timeout={args.stall_timeout}s" ) process = subprocess.Popen( command, cwd=root, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", bufsize=1, ) assert process.stdin is not None assert process.stdout is not None assert process.stderr is not None process.stdin.write(prompt_text) process.stdin.close() updates: queue.Queue = queue.Queue() threads = [ threading.Thread( target=stream_reader, args=(process.stdout, "stdout", events_path, updates), daemon=True, ), threading.Thread( target=stream_reader, args=(process.stderr, "stderr", stderr_path, updates), daemon=True, ), ] for thread in threads: thread.start() last_activity = time.monotonic() closed_streams = 0 try: while process.poll() is None or closed_streams < 2: try: source, line, activity_time = updates.get(timeout=1) last_activity = activity_time if line is None: closed_streams += 1 continue if source == "stdout": event = parse_event(line) if event: events.append(event) session_id = event.get("sessionID", session_id) except queue.Empty: pass # Der Abbruchgrund wird nur einmal vermerkt: Bis der Prozessbaum # tatsaechlich endet, laeuft die Schleife weiter und wuerde die # Meldung sonst je Sekunde erneut anhaengen. now = time.monotonic() if ( args.stall_timeout > 0 and not timed_out and now - last_activity > args.stall_timeout ): timed_out = True errors.append( f"Keine OpenCode-Ausgabe seit {args.stall_timeout} Sekunden" ) log(errors[-1] + "; Prozessbaum wird beendet") terminate_process_tree(process) if ( args.max_runtime > 0 and not timed_out and now - start_time > args.max_runtime ): timed_out = True errors.append( f"Maximale Laufzeit von {args.max_runtime} Sekunden ueberschritten" ) log(errors[-1] + "; Prozessbaum wird beendet") terminate_process_tree(process) except KeyboardInterrupt: interrupted = True errors.append("Lauf durch Benutzer unterbrochen") log(errors[-1] + "; Prozessbaum wird beendet") terminate_process_tree(process) finally: for thread in threads: thread.join(timeout=5) try: exit_code = process.wait(timeout=5) except subprocess.TimeoutExpired: terminate_process_tree(process) exit_code = process.wait(timeout=5) duration_s = time.monotonic() - start_time session = None if session_id: try: session = export_session( opencode, session_id, env, root, session_path, log ) except Exception as exc: # Sessionexport darf RawResult nicht verhindern. errors.append(f"Sessionexport fehlgeschlagen: {exc}") log(errors[-1]) if exit_code != 0 and not timed_out and not interrupted: errors.append(f"OpenCode beendete sich mit Exitcode {exit_code}") result = normalize_result( session, events, model_ref, args.mode, args.effort, exit_code, timed_out, interrupted, duration_s, output_dir, errors, provider=provider, effort_applied=effort_applied, local_runtime=local_runtime, ) if not args.allow_empty_output and not result["written_files"]: result["errors"].append("Ergebnisse-Verzeichnis ist leer") result["is_error"] = True if result["subtype"] == "success": result["subtype"] = "error" result["start_time"] = start_iso result["end_time"] = utc_now() result["opencode_path"] = str(opencode) result["config_path"] = str(config_path) raw_result_path.write_text( json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8" ) log( f"Ende: Exitcode={exit_code}; Status={result['subtype']}; " f"Turns={result['num_turns']}; Tokens={result['usage']['total_tokens']}; " f"Dateien={len(result['written_files'])}; RawResult={raw_result_path}" ) return 1 if result["is_error"] else 0 if __name__ == "__main__": raise SystemExit(main())