Files
Masterarbeit/.claude/skills/run-experiment/opencode-adapter.py
T
Christoph SchwörerandClaude Opus 5 6c5c26a2e4 Lokale Matrix liefert Ergebnisse: 119 Anforderungen aus Gemma und Qwen
Skill 13.0.0/13.1.0, Adapter 2.5.2.

Spiegel-Arbeitsverzeichnis: OpenCode laeuft nicht mehr direkt im
Codebasis-Root, sondern in einem Verzeichnis aus Junctions auf die
Top-Level-Eintraege, mit einem echten Ergebnisverzeichnis, dessen Inhalt
nach dem Lauf uebernommen wird. Damit landen relative wie absolute
Ausgabepfade am richtigen Ort, ohne dass der Prompt vom Wortlaut der
Claude-Laeufe abweichen muss. Der Spiegel liegt in _meta des Laufs; die
Codebasis bleibt unberuehrt.

Der Spiegel allein genuegte nicht. Sechs Laeufe schrieben mit korrektem
absolutem Pfad und wurden dennoch abgewiesen, weil OpenCode Ziele im
Repository gegen den worktree-relativen Pfad abgleicht - ein Befund, der
seit Skill 10.0.2 dokumentiert war und erst sichtbar wurde, als der
Spiegel vom Temp-Verzeichnis ins Projekt wanderte.

analyse-anforderungen.py erkennt Kennungen als Markdown-Ueberschrift, auch
ohne Feldnamen, sofern die Pflichtfelder folgen. Regressionsprobe an fuenf
Claude-Laeufen unveraendert.

Ergebnisse der gueltigen Laeufe: Qwen 3.5-9B liefert 107 der 119
Anforderungen, davon 75 im Modus custom mit nur drei Subagenten. Gemma
kommt auf 12. Der Modus builtin fiel bei beiden Modellen aus - drei von
drei Laeufen endeten nach einem Turn ohne einen Werkzeugaufruf. Die 13
Laeufe mit Adapter 2.3.0 bis 2.5.1 sind Artefakte der Fehlersuche und
als adapterbedingte Fehlmessungen gekennzeichnet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 22:49:53 +02:00

1500 lines
53 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 tempfile
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 = "2.5.2"
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
# Schreibende und bauende Kommandos, spiegelbildlich zur Denylist des
# Claude-Code-Adapters. Reihenfolge ist bedeutsam: OpenCode wertet die Regeln
# der Reihe nach aus, die zuletzt passende gewinnt. Das Catch-all steht deshalb
# zuerst, die Sperren danach.
SHELL_DENY_COMMANDS = (
# Dateien loeschen, verschieben, ueberschreiben (POSIX)
"rm *",
"rmdir *",
"mv *",
"cp *",
"dd *",
"truncate *",
"chmod *",
"chown *",
"ln *",
"tee *",
"sed -i*",
# Git, schreibend
"git checkout*",
"git restore*",
"git clean*",
"git reset*",
"git add*",
"git commit*",
"git push*",
"git fetch*",
"git pull*",
"git remote*",
# Bauen und Paketverwaltung
"dotnet *",
"msbuild *",
"npm install*",
"nuget *",
# PowerShell, schreibend
"Remove-Item *",
"Move-Item *",
"Copy-Item *",
"New-Item *",
"Set-Content *",
"Add-Content *",
"Clear-Content *",
"Out-File *",
"Set-ItemProperty *",
"Rename-Item *",
)
def readonly_shell_permissions() -> dict[str, str]:
"""Denylist schreibender und bauender Shell-Kommandos.
Ab Skill 12.0.0 eine **Denylist** statt der vorherigen Allowlist: Alles ist
erlaubt, ausser den ausdruecklich gesperrten schreibenden und bauenden
Kommandos. Damit entspricht die Werkzeugfreiheit der des Claude-Code-
Adapters, der ebenfalls mit einer Denylist arbeitet - Voraussetzung dafuer,
dass Laeufe beider Adapter als Werkzeugvergleich lesbar sind.
Die Allowlist konnte Kommandos nur als Praefix treffen und scheiterte
deshalb an Pipelines: ``Get-ChildItem ... | Format-Table ...`` blieb
gesperrt, obwohl ``Get-ChildItem`` erlaubt war.
OpenCode wertet die Regeln der Reihe nach aus; die **zuletzt passende Regel
gewinnt**. Das Catch-all muss deshalb zuerst stehen. Python-Dicts erhalten
ihre Einfuegereihenfolge, ``json.dump`` schreibt sie unveraendert.
Wie beim Claude-Adapter gilt: Mustervergleich auf Kommandozeilen ist nicht
lueckenlos. Die belastbare Read-only-Garantie bleibt der Vorher/Nachher-
Vergleich per ``git status``; die Denylist senkt das Risiko, sie ersetzt
die Verifikation nicht.
"""
permissions = {"*": "allow"}
permissions.update({muster: "deny" for muster in SHELL_DENY_COMMANDS})
return permissions
def _mklink(argument: str, verknuepfung: Path, ziel: Path) -> bool:
"""Windows-Verknuepfung anlegen; /J = Verzeichnis-Junction, /H = Hardlink."""
fertig = subprocess.run(
["cmd", "/c", "mklink", argument, str(verknuepfung), str(ziel)],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
return fertig.returncode == 0
def erstelle_spiegel(root: Path, spiegel: Path, output_dir: Path, log) -> Path:
"""Arbeitsverzeichnis, das wie die Codebasis aussieht, aber beschreibbar ist.
Der Agent loest ``Ergebnisse/StRS.md`` relativ zu seinem Arbeitsverzeichnis
auf. Steht dort die eingefrorene Codebasis, geht jeder solche Schreibversuch
ins Leere - gemessen an sechs Laeufen, die kein einziges Artefakt ablegten,
obwohl sie die richtigen Dateinamen erzeugten.
Statt die Codebasis zu kopieren (ueber zehn Minuten fuer 24.663 Dateien)
entsteht ein Spiegel aus Verknuepfungen: je eine Junction auf jeden
Top-Level-Ordner, je ein Hardlink auf jede Top-Level-Datei, und ``Ergebnisse``
als Junction auf das echte Laufverzeichnis. Der Spiegel ist damit in
Sekunden angelegt, sieht fuer den Agenten aus wie die Codebasis, und
``Ergebnisse/...`` landet ohne Zutun am richtigen Ort.
Die Codebasis selbst bleibt unberuehrt - die Verknuepfungen liegen im
Spiegel, nicht im Snapshot. Schreibzugriffe *durch* eine Junction bleiben
ueber die Editierrechte gesperrt; die belastbare Kontrolle ist wie bisher
der Vorher/Nachher-Vergleich per ``git status``.
"""
if spiegel.exists():
entferne_spiegel(spiegel, log)
spiegel.mkdir(parents=True)
ordner = dateien = 0
for eintrag in sorted(root.iterdir()):
ziel = spiegel / eintrag.name
if eintrag.is_dir():
if _mklink("/J", ziel, eintrag):
ordner += 1
elif _mklink("/H", ziel, eintrag):
dateien += 1
else: # Hardlinks scheitern ueber Laufwerksgrenzen hinweg.
shutil.copy2(eintrag, ziel)
dateien += 1
# Bewusst ein **echtes** Verzeichnis, keine Junction: Zeigt `Ergebnisse`
# per Junction nach aussen, weist OpenCode jeden Schreibvorgang ab - auch
# dann, wenn die passenden Muster (relativ, kanonisch, ueber den Spiegel)
# allesamt freigegeben sind. Nachgewiesen an Lauf v13.0.0-9b01, dessen
# Fehlermeldung genau diese Regeln auflistet und den Aufruf dennoch
# verweigert. Als echtes Verzeichnis innerhalb des Arbeitsverzeichnisses
# entfaellt die Sonderbehandlung; der Inhalt wird nach dem Lauf umgezogen.
ergebnisse = spiegel / output_dir.name
ergebnisse.mkdir()
log(
f"Spiegel angelegt: {spiegel} ({ordner} Junctions, {dateien} Dateien, "
f"{output_dir.name} -> {output_dir})"
)
return spiegel
def uebernimm_ergebnisse(spiegel: Path, output_dir: Path, log) -> int:
"""Erzeugte Dateien aus dem Spiegel ins Laufverzeichnis uebernehmen."""
quelle = spiegel / output_dir.name
if not quelle.is_dir():
return 0
anzahl = 0
for pfad in sorted(quelle.rglob("*")):
if not pfad.is_file():
continue
ziel = output_dir / pfad.relative_to(quelle)
ziel.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(pfad), str(ziel))
anzahl += 1
log(f"{anzahl} Ergebnisdatei(en) aus dem Spiegel uebernommen")
return anzahl
def entferne_spiegel(spiegel: Path, log) -> None:
"""Spiegel abbauen, ohne durch die Verknuepfungen hindurch zu loeschen.
``shutil.rmtree`` wuerde Junctions unter Umstaenden verfolgen und damit die
Codebasis loeschen. Deshalb wird jeder Eintrag einzeln behandelt: Junctions
per ``os.rmdir`` (entfernt nur die Verknuepfung), echte Dateien per
``unlink``.
"""
if not spiegel.exists():
return
for eintrag in spiegel.iterdir():
try:
if eintrag.is_dir():
# Junctions loest os.rmdir nur auf; ein echtes (leeres)
# Ergebnisverzeichnis verschwindet damit ebenfalls.
os.rmdir(eintrag)
else:
eintrag.unlink()
except OSError as exc:
log(f"Spiegeleintrag {eintrag} nicht entfernbar: {exc}")
try:
spiegel.rmdir()
except OSError as exc:
log(f"Spiegelverzeichnis {spiegel} nicht entfernbar: {exc}")
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,
zusatz_ausgaben: list[Path] | 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),
)
# Beim Spiegel-Arbeitsverzeichnis zeigt `Ergebnisse` per Junction auf das
# Laufverzeichnis. Das Dateisystem loest das korrekt auf - OpenCode prueft
# die Berechtigung aber gegen den *Pfadstring* des Werkzeugaufrufs, also
# gegen "Ergebnisse/StRS.md" relativ zum Arbeitsverzeichnis. Ohne dieses
# Muster wird der Schreibvorgang abgewiesen, obwohl das Ziel erlaubt ist.
#
# Bewusst ohne `resolve()`: Unter Windows folgt das der Junction und liefert
# wieder den kanonischen Laufpfad - genau die relative Form, auf die es hier
# ankommt, ginge dabei verloren.
worktree = git_worktree_root(root)
for zusatz in zusatz_ausgaben or []:
formen = [zusatz.as_posix()]
try:
formen.append(zusatz.relative_to(root).as_posix())
except ValueError:
pass
# Liegt der Spiegel innerhalb des Arbeitsrepositories, prueft OpenCode
# gegen den Pfad relativ zur Git-Worktree-Wurzel - derselbe Befund, der
# schon Skill 10.0.2 ausgeloest hat. Ohne diese Form wird ein Ziel
# abgewiesen, obwohl kanonische und relative Form freigegeben sind.
if worktree is not None:
try:
formen.append(zusatz.relative_to(worktree).as_posix())
except ValueError:
pass
for form in formen:
normalisiert = form.rstrip("/")
for muster in (normalisiert, normalisiert + "/**"):
if muster not in output_patterns:
output_patterns.append(muster)
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,
}
# Ein laufender Subagent taucht in der exportierten Session weder mit
# eigenen Nachrichten noch mit Tokens auf. Wird der Lauf abgebrochen,
# waehrend ein `task` noch laeuft, meldet OpenCode deshalb 0 Tokens - obwohl
# gearbeitet wurde. Diese Null ist kein Messwert und darf nicht als solcher
# ins Protokoll wandern.
laufende_tasks = [
tool
for tool in tools
if tool["name"] in ("task", "subagent") and tool["status"] == "running"
]
result["usage_captured"] = not (total_tokens == 0 and (tools or assistants))
if not result["usage_captured"]:
result["usage_note"] = (
"nicht erfasst: OpenCode weist der Session keine Tokens zu"
+ (
f"; {len(laufende_tasks)} Subagent(en) liefen beim Abbruch noch"
if laufende_tasks
else ""
)
)
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,
log,
parallel: int = 4,
gpu: str = "max",
) -> None:
"""Saemtliche Modelle entladen und genau das angeforderte neu laden.
Entladen wird **alles**, nicht nur andere Instanzen desselben Modells: Ein
nebenher geladenes zweites Modell belegt VRAM, das dem Lauf dann fehlt.
Gemessen wurde der Extremfall - Gemma und Qwen 27B gleichzeitig geladen,
15.836 von 16.303 MiB belegt, der Lauf fiel auf Bruchteile seines
Durchsatzes zurueck.
``parallel=1`` gibt dem einen Agentenlauf den gesamten KV-Cache; jeder
weitere Slot teilt ihn auf, ohne dass ein Einzellauf davon profitiert.
``gpu=max`` erzwingt die vollstaendige Auslagerung auf die GPU.
"""
run_lms(lms, ["unload", "--all"], log, timeout=300)
run_lms(
lms,
[
"load",
model,
"--context-length",
str(context_length),
"--parallel",
str(parallel),
"--gpu",
gpu,
"--yes",
],
log,
)
def lmstudio_preflight(
model: str,
base_url: str,
min_context: int,
autoload: bool,
lms_path: str | None,
catalog_dump: Path,
log,
parallel: int = 4,
gpu: str = "max",
context_target: str = "max",
) -> 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 fremde_geladene(entries: list[dict]) -> list[str]:
"""Geladene Modelle, die nicht das angeforderte sind.
Sie belegen VRAM, das dem Lauf fehlt, und veraendern damit dessen
Durchsatz - eine Versuchsbedingung, die nicht unbemerkt bleiben darf.
"""
eigene = {str(item.get("id", "")) for item in lmstudio_instances(entries, model)}
return [
str(item.get("id", ""))
for item in entries
if item.get("state") == "loaded"
and str(item.get("id", "")) not in eigene
and item.get("type") != "embeddings"
]
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 <Bezeichner>' "
"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
or bool(fremde_geladene(catalog))
)
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."
)
# Freien VRAM in Kontext investieren statt verfallen lassen: Gemessen
# kostet das Modellmaximum kaum Speicher und kein Tempo (gemma-4-e4b:
# 131.072 statt 32.768 Kontext -> 6.854 statt 5.162 MiB, 46,8 statt
# 48,3 tok/s). Ein groesseres Fenster ist fuer eine Codebasisanalyse
# unmittelbar wirksam.
if context_target == "max":
target_context = max_context or min_context
else:
target_context = int(context_target)
target_context = max(target_context, min_context)
lmstudio_reload_model(
lms, model, target_context, log, parallel=parallel, gpu=gpu
)
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"
)
fremde = fremde_geladene(catalog)
if fremde:
raise RuntimeError(
"Neben '" + model + "' sind weitere Modelle geladen ("
+ ", ".join(fremde)
+ "). Sie belegen VRAM, das dem Lauf fehlt, und veraendern dessen "
"Durchsatz. Mit 'lms unload --all' entladen oder den Adapter mit "
"--lmstudio-autoload aufrufen."
)
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 <Bezeichner>' 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,
"parallel_slots": parallel,
"gpu_offload": gpu,
"alleiniges_modell": True,
}
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-parallel",
type=int,
default=4,
help=(
"Gleichzeitige Vorhersage-Slots beim Laden (nur lmstudio). Jeder "
"Slot teilt den KV-Cache; ein einzelner Agentenlauf profitiert von "
"1 und verliert bei mehr."
),
)
parser.add_argument(
"--spiegel-basis",
default=None,
help=(
"Basisverzeichnis fuer das Spiegel-Arbeitsverzeichnis; Standard ist "
"'_meta' im Laufverzeichnis. Damit bleibt der Lauf selbstenthalten "
"und alle Artefakte liegen unterhalb der Versuchsreihe. Ein Spiegel "
"im Benutzerprofil (%TEMP%) waere zudem dem Zugriffsbereich von "
"Virenschutz und Controlled Folder Access ausgesetzt."
),
)
parser.add_argument(
"--arbeitsverzeichnis",
default="root",
choices=("root", "spiegel"),
help=(
"root: OpenCode arbeitet direkt im Codebasis-Root (bisheriges "
"Verhalten). spiegel: ein Verzeichnis aus Verknuepfungen auf die "
"Codebasis, in dem 'Ergebnisse/' auf das Laufverzeichnis zeigt - "
"loest relative Ausgabepfade auf, ohne den Snapshot zu beruehren."
),
)
parser.add_argument(
"--lmstudio-context",
default="max",
help=(
"Kontextfenster beim Laden: 'max' fuer das Modellmaximum oder eine "
"Tokenzahl (nur lmstudio). Nie kleiner als --min-context."
),
)
parser.add_argument(
"--lmstudio-gpu",
default="max",
help="GPU-Offload beim Laden: off, max oder 0..1 (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. "
"In den Modi builtin und custom zwingend 0 - waehrend ein Subagent "
"arbeitet, sendet OpenCode keine Ereignisse."
),
)
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 meldet keinen Fortschritt, solange ein Subagent arbeitet: Der
# Ereignisstrom schweigt fuer die gesamte Dauer des Subagentenlaufs. Ein
# positiver Stall-Timeout beendet einen Lauf mit Subagenten deshalb
# zuverlaessig zu frueh und erzeugt eine Fehlmessung, die wie ein Haenger
# aussieht. Der Fall wird nicht stillschweigend korrigiert, sondern
# abgelehnt, damit die Entscheidung bewusst faellt.
if args.mode != "solo" and args.stall_timeout > 0:
parser.error(
f"Modus '{args.mode}' laesst Subagenten zu; waehrend deren Laufzeit "
"sendet OpenCode keine Ereignisse. Ein Stall-Timeout von "
f"{args.stall_timeout}s wuerde den Lauf abbrechen, sobald ein "
"Subagent arbeitet. '--stall-timeout 0' setzen und die Laufzeit "
"ueber '--max-runtime' begrenzen."
)
# Lokale Inferenz ist um Groessenordnungen langsamer als ein Cloud-Gateway.
# OpenCode sendet Ereignisse nur an Schrittgrenzen, sodass ein einzelner
# Schritt die Stille beliebig lange ausdehnen kann - bei qwen/qwen3.8-27b
# ueber 15 Minuten. Ein Stall-Timeout misst dann Modellgeschwindigkeit
# statt Haenger.
if provider_spec.get("local") and args.stall_timeout > 0:
parser.error(
f"Provider '{provider}' laeuft lokal; ein einzelner Schritt kann "
"laenger dauern als jeder sinnvolle Stall-Timeout, ohne dass ein "
f"Ereignis faellt. Ein Stall-Timeout von {args.stall_timeout}s "
"wuerde die Modellgeschwindigkeit als Haenger werten. "
"'--stall-timeout 0' setzen und die Laufzeit ueber "
"'--max-runtime' begrenzen."
)
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()
spiegel: Path | None = None
arbeitswurzel = root
if args.arbeitsverzeichnis == "spiegel":
# resolve() ist hier wesentlich: Unter Windows liefern sowohl
# tempfile.gettempdir() als auch verkuerzte Nutzerpfade die
# 8.3-Kurzform ("C:/Users/CHRIST~1/..."), OpenCode prueft seine Regeln
# aber gegen den aufgeloesten Langpfad. Ohne die Aufloesung passt kein
# absolutes Freigabemuster.
basis = (
Path(args.spiegel_basis).resolve() if args.spiegel_basis else meta_dir
)
basis.mkdir(parents=True, exist_ok=True)
spiegel = basis / "spiegel"
arbeitswurzel = erstelle_spiegel(root, spiegel, output_dir, log)
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,
parallel=args.lmstudio_parallel,
gpu=args.lmstudio_gpu,
context_target=args.lmstudio_context,
)
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,
arbeitswurzel,
output_dir,
agents_file,
provider=provider,
context_limit=context_limit,
zusatz_ausgaben=[spiegel / output_dir.name] if spiegel else None,
)
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(arbeitswurzel),
]
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=arbeitswurzel,
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
if spiegel is not None:
uebernimm_ergebnisse(spiegel, output_dir, log)
session = None
if session_id:
try:
session = export_session(
opencode, session_id, env, arbeitswurzel, 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["arbeitsverzeichnis"] = args.arbeitsverzeichnis
result["arbeitswurzel"] = str(arbeitswurzel)
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"
)
if spiegel is not None:
entferne_spiegel(spiegel, log)
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())