Add OpenCode TensorX experiment adapter

This commit is contained in:
Christoph Schwörer
2026-08-31 18:42:25 +02:00
parent ca52aa4701
commit b369e6115e
5 changed files with 1152 additions and 10 deletions
@@ -0,0 +1,727 @@
#!/usr/bin/env python3
"""Headless-Adapter fuer TensorX-Versuchslaeufe ueber OpenCode.
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.
"""
from __future__ import annotations
import argparse
import copy
import json
import os
import queue
import shutil
import subprocess
import sys
import threading
import time
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
ADAPTER_VERSION = "1.0.2"
PROVIDER_ID = "tensorx"
EFFORTS = ("low", "medium", "high", "xhigh", "max")
MODES = ("solo", "builtin", "custom")
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) -> tuple[str, str]:
if model.startswith(f"{PROVIDER_ID}/"):
upstream = model[len(PROVIDER_ID) + 1 :]
return model, upstream
return f"{PROVIDER_ID}/{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,
) -> dict:
config = copy.deepcopy(base_config)
provider = config.setdefault("provider", {}).setdefault(PROVIDER_ID, {})
models = provider.setdefault("models", {})
if upstream_model not in models:
models[upstream_model] = {"name": upstream_model}
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],
) -> 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},
}
return {
"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_ID,
"effort": effort,
"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": "opencode-tensorx",
"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,
}
def main() -> int:
parser = argparse.ArgumentParser(
description="TensorX-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("--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(
"--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 TensorX")
args = parser.parse_args()
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("opencode-tensorx.json")
)
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)
base_config = json.loads(template_path.read_text(encoding="utf-8-sig"))
run_config = build_run_config(
base_config,
model_ref,
upstream_model,
args.mode,
root,
output_dir,
agents_file,
)
config_path.write_text(
json.dumps(run_config, indent=2, ensure_ascii=False), encoding="utf-8"
)
model_config = run_config["provider"][PROVIDER_ID]["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),
]
if args.effort in variants:
command.extend(["--variant", args.effort])
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}; Modell={model_ref}; Modus={args.mode}; "
f"Effort={args.effort}; 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
now = time.monotonic()
if args.stall_timeout > 0 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 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,
)
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())