1315 lines
45 KiB
Python
1315 lines
45 KiB
Python
#!/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 = "2.2.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
|
||
|
||
|
||
# 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 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,
|
||
}
|
||
|
||
# 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(
|
||
"--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()
|
||
|
||
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,
|
||
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())
|