From b369e6115eaac10112a20c5d824e815e85eea539 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20Schw=C3=B6rer?= Date: Mon, 31 Aug 2026 18:42:25 +0200 Subject: [PATCH] Add OpenCode TensorX experiment adapter --- .claude/skills/run-experiment/SKILL.md | 53 +- .../opencode-tensorx-adapter.py | 727 ++++++++++++++++++ .../run-experiment/opencode-tensorx.json | 84 ++ .../references/opencode-tensorx.md | 134 ++++ .../test_opencode_tensorx_adapter.py | 164 ++++ 5 files changed, 1152 insertions(+), 10 deletions(-) create mode 100644 .claude/skills/run-experiment/opencode-tensorx-adapter.py create mode 100644 .claude/skills/run-experiment/opencode-tensorx.json create mode 100644 .claude/skills/run-experiment/references/opencode-tensorx.md create mode 100644 .claude/skills/run-experiment/test_opencode_tensorx_adapter.py diff --git a/.claude/skills/run-experiment/SKILL.md b/.claude/skills/run-experiment/SKILL.md index ba477c1a..e352c236 100644 --- a/.claude/skills/run-experiment/SKILL.md +++ b/.claude/skills/run-experiment/SKILL.md @@ -1,8 +1,8 @@ --- name: run-experiment -description: Führt einen Versuchs-Prompt aus einer Prompt-Datei als messbaren Headless-Lauf mit Claude Code, Codex CLI oder dem Python-API-Adapter (GLM/Qwen/Kimi) aus und schreibt ein Messprotokoll mit Start-/Endzeit, Modell, Tokenverbrauch und weiteren Metriken. Verwenden bei "/run-experiment " oder wenn der User einen Versuch/ein Experiment ausführen und tracken will. +description: Führt einen Versuchs-Prompt aus einer Prompt-Datei als messbaren Headless-Lauf mit Claude Code, Codex CLI oder OpenCode über TensorX aus und schreibt ein Messprotokoll mit Start-/Endzeit, Modell, Tokenverbrauch und weiteren Metriken. Verwenden bei "/run-experiment " oder wenn der User einen Versuch/ein Experiment ausführen und tracken will. argument-hint: -version: 9.3.0 +version: 10.0.2 --- # RunExperiment – Versuchslauf mit Messprotokoll @@ -101,7 +101,8 @@ Der Skill ist zweigeteilt: - **`## Prozess`** beschreibt werkzeugneutral, **was** je Lauf zu tun und zu erheben ist. Dieser Teil gilt unabhängig davon, mit welchem LLM oder welcher CLI gearbeitet wird. - **`## Werkzeugadapter`** beschreibt **wie** das mit einem konkreten Werkzeug umgesetzt wird. - Ausgearbeitet sind Adapter für Claude Code, Codex CLI und den Python-API-Adapter (GLM/Qwen/Kimi). Konkrete Flags und Rohfelder sind + Ausgearbeitet sind Adapter für Claude Code, Codex CLI und OpenCode über TensorX. Der frühere + direkte Python-API-Adapter bleibt nur als ausdrücklich gewählter Legacy-Fallback erhalten. Konkrete Flags und Rohfelder sind ausschließlich dem gewählten Adapterabschnitt zu entnehmen. **Arbeitsteilung mit der Prompt-Datei:** Der Prompt enthält ausschließlich die *Analyseanweisung* @@ -125,7 +126,7 @@ per stdin übergebenen Text angehängt (siehe Schritt „Prompt zusammenstellen" 2. Aus dem Metadaten-Block der Prompt-Datei (falls vorhanden) Versuch/Prompt-Version übernehmen. 3. **Werkzeugadapter bestimmen und CLI-Pfad auflösen.** Die Modell-ID entscheidet eindeutig: `claude-*` verwendet Claude Code, OpenAI-IDs wie `gpt-*` oder `o*` verwenden Codex CLI, - `z-ai/*`, `qwen/*` und `moonshotai/*` verwenden den Python-API-Adapter über den TensorX-Gateway. + `z-ai/*`, `qwen/*` und `moonshotai/*` verwenden OpenCode über den TensorX-Gateway. Keine Modell-ID an eine CLI übergeben, die sie nicht unterstützt. Unter Windows liegt `claude` in der Regel **nicht im PATH**. Erst @@ -143,6 +144,12 @@ per stdin übergebenen Text angehängt (siehe Schritt „Prompt zusammenstellen" `$env:USERPROFILE\.vscode\extensions\openai.chatgpt-*-win32-x64\bin\windows-x86_64\codex.exe` wählen. Findet sich die benötigte ausführbare Datei nicht: abbrechen und den User informieren. Adapter und aufgelösten Pfad fürs Protokoll festhalten. + + Für TensorX-Modelle `Get-Command opencode -ErrorAction SilentlyContinue` versuchen. Der + Adapter löst zusätzlich die globale npm-Installation unter + `$env:APPDATA\npm\node_modules\opencode-ai\bin\opencode.exe` auf. Fehlt OpenCode, mit + `npm install -g opencode-ai` installieren; fehlt die TensorX-Anmeldung, `opencode auth login` + ausführen. Niemals dafür Cline-Konfigurationsdateien oder Cline-Credentials lesen. 4. **Modell beim User erfragen.** Hat der User das Modell nicht bereits im Aufruf genannt, **immer** per `AskUserQuestion` nachfragen – auch dann, wenn frühere Läufe derselben Versuchsreihe ein bestimmtes Modell verwendet haben. Es gibt bewusst keinen Default. @@ -288,7 +295,7 @@ per stdin übergebenen Text angehängt (siehe Schritt „Prompt zusammenstellen" Sort-Object { [int]($_.Name -replace '\D','') } $iteration = if ($iterationen) { $iterationen[-1].Name } else { 'Iteration 1' } $zelle = Join-Path (Join-Path (Join-Path $iteration $modell) $modus) $effort - $skillVer = 'v9.3.0' # entspricht version: im Frontmatter dieses Skills + $skillVer = 'v10.0.2' # entspricht version: im Frontmatter dieses Skills do { $id4 = '{0:x4}' -f (Get-Random -Maximum 65536) $lauf = Join-Path "\$zelle" "_Lauf_$(Get-Date -Format 'yyyy-MM-dd_HHmmss')_${skillVer}-$id4" @@ -441,8 +448,8 @@ und unter `content` den vollständigen Dateiinhalt. `summary` enthält nur eine ### 3. Ausführung (Headless-Lauf) Den Adapter anhand der Modell-ID wählen. Der folgende Aufruf ist **ausschließlich der -Claude-Code-Adapter**. Der Codex-Aufruf steht vollständig unter `Adapter: Codex CLI` und darf -nicht mit Claude-Flags vermischt werden. +Claude-Code-Adapter**. Der Codex-Aufruf steht vollständig unter `Adapter: Codex CLI`, der +TensorX-Aufruf unter `Adapter: OpenCode`; beide dürfen nicht mit Claude-Flags vermischt werden. Den kombinierten Prompt per stdin an `claude -p` übergeben. `--add-dir` gibt dem Headless-Lauf Schreibrecht auf das Laufverzeichnis außerhalb seines Arbeitsverzeichnisses. @@ -602,7 +609,9 @@ Regeln: `RawResult.json` im Laufverzeichnis lesen und defensiv parsen. Beim Claude-Adapter ist dies die unveränderte CLI-Antwort; beim Codex-Adapter erzeugt `normalise-codex-result.py` diese Datei aus -`RawEvents.jsonl` und `_meta\final_response.json`. Relevante Claude-Felder: +`RawEvents.jsonl` und `_meta\final_response.json`; beim OpenCode-Adapter erzeugt +`opencode-tensorx-adapter.py` sie aus dem Sessionexport und `OpenCodeEvents.jsonl` gemäß der +TensorX-Referenz. Relevante Claude-Felder: | Feld | Bedeutung | |---|---| @@ -1082,9 +1091,30 @@ die angeforderte ID; da `codex exec --json` sie im Ereignisstrom nicht wiederhol Modellkontrolle im Protokoll `nicht prüfbar`. Temperatur und weitere Sampling-Parameter sind in diesem CLI-Ablauf nicht steuerbar; Effort und Service-Tier werden dagegen explizit festgelegt. -### Adapter: Python API / GLM-, Qwen- und Kimi-Modelle über TensorX +### Adapter: OpenCode / GLM-, Qwen- und Kimi-Modelle über TensorX -Dieser Adapter wurde für Modelle entwickelt, die über den **TensorX API-Gateway** +Dies ist der **primäre Adapter für alle TensorX-Modell-IDs**. Er verwendet OpenCodes +OpenAI-kompatiblen Custom Provider und das Skript `opencode-tensorx-adapter.py`. OpenCode +verwaltet Authentifizierung, Agenten-Loop und Session; der Wrapper erzeugt eine isolierte +Konfiguration, streamt die Rohereignisse, erzwingt Berechtigungen und normalisiert das Ergebnis +nach `RawResult.json`. Es besteht keine Abhängigkeit zu Cline. + +Vor jedem TensorX-Lauf die vollständige Referenz +[`references/opencode-tensorx.md`](references/opencode-tensorx.md) lesen und deren Aufruf, +Timeouts, Artefakte und Pflichtprüfungen anwenden. Die Provider- und Modellvorlage liegt in +`opencode-tensorx.json`; API-Keys gehören ausschließlich in OpenCodes Credential-Store. + +Referenzstand bei Einführung: **OpenCode 1.18.25**. Ein Live-Smoke-Test mit +`qwen/qwen3.8-flash-next`, Variante `low`, bestätigte Headless-Ausführung, Sessionexport, +Reasoning-/Cache-Metriken und die erwartete Textantwort. + +### Legacy-Adapter: direkte Python API / GLM-, Qwen- und Kimi-Modelle über TensorX + +Dieser frühere Adapter darf nur verwendet werden, wenn der User ihn ausdrücklich verlangt oder +OpenCode nach dokumentierter Diagnose technisch nicht einsetzbar ist. Die Abweichung ist im +Messprotokoll festzuhalten; Läufe beider Adapter sind verschiedene Versuchsbedingungen. + +Der Legacy-Adapter wurde für Modelle entwickelt, die über den **TensorX API-Gateway** (`https://api.tensorx.ai/v1`) erreichbar sind. Er nutzt das Skript `glm-kimi-adapter.py`, das einen minimalen Agent-Loop mit Tool-Calling direkt gegen die OpenAI-kompatible REST-API implementiert. Referenzstand bei Einführung: **Python 3.13, requests 2.34**. @@ -1283,6 +1313,9 @@ der Historie unten – im selben Arbeitsschritt. | Version | Änderung | Grund | Verwendet in | |---|---|---|---| +| **10.0.2** | Ergebnis-Allowlist zusätzlich relativ zur per Git ermittelten Worktree-Wurzel; Adapter-Version 1.0.2. | Der erste Fix deckte den aktiven Root und den kanonischen Pfad ab. OpenCode 1.18.25 matcht ein Ziel innerhalb desselben Repositories jedoch gegen den Pfad relativ zur Worktree-Wurzel. PATCH: weitere Normalisierungsform desselben bereits autorisierten Zielverzeichnisses. | ab dem ersten OpenCode-V2-Lauf | +| **10.0.1** | Der OpenCode-Adapter autorisiert Ergebnisziele zusätzlich mit einem zum aktiven Root relativen Pfad, einschließlich notwendiger `..`-Segmente; Adapter-Version 1.0.1. Regressionstest für Root und Laufverzeichnis in verschiedenen Unterordnern desselben Windows-Git-Worktrees. | OpenCode normalisiert solche Ziele intern worktree-relativ. Die alleinige kanonische Allow-Regel griff daher nicht, obwohl der absolute Werkzeugpfad exakt im erlaubten Ergebnisordner lag. Ein Custom/max-Preflight startete den vorgesehenen Subagenten erfolgreich, konnte anschließend aber keine Ergebnisdatei schreiben. PATCH: korrigiert nur die beabsichtigte Schreibfreigabe. | ab dem ersten OpenCode-V2-Lauf | +| **10.0.0** | **OpenCode wird primärer TensorX-Adapter.** Neue keyfreie Provider-/Modellvorlage `opencode-tensorx.json`, Wrapper `opencode-tensorx-adapter.py`, Unit-Tests und Detailreferenz. Der Wrapper startet `opencode run --pure` mit einer isolierten Laufkonfiguration, streamt JSONL und stderr, exportiert die Session, normalisiert Token-, Tool- und Subagentenmetriken und beendet bei Inaktivität oder Benutzerabbruch den Prozessbaum. `solo`, `builtin` und aus `03_Agents.json` übersetztes `custom` werden unterstützt. Der direkte Python-Adapter bleibt als ausdrücklich gewählter Legacy-Fallback erhalten. | Der direkte Adapter hing bei `qwen/qwen3.8-flash-next` in einem nicht gestreamten HTTP-Aufruf ohne lokalisierbaren Fortschritt. OpenCode liefert inkrementelle Ereignisse, eine persistierte Session und einen klaren Prozesslebenszyklus; außerdem entfällt die Kopplung der TensorX-Authentifizierung an Cline. MAJOR, weil Agentenlaufzeit, Werkzeugsemantik und Metrikquelle eine neue Versuchsbedingung bilden. Live-Smoke-Test am 31.08.2026 mit Qwen/low: Exitcode 0, erwartete Antwort, Sessionexport und vollständige Tokenfelder. | ab dem nächsten TensorX-Lauf; vorherige direkte Python-Läufe bleiben Legacy-Bedingung | | **9.3.0** | **TensorX-Modellkatalog für Versuch 2 erweitert:** `qwen/qwen3.8-flash-next` und `z-ai/glm-5.3-flash`; Qwen erhält explizit das `thinking.level`-Effort-Mapping. Der TensorX-Aufruf dokumentiert nun `--agents`. Im Adapter stellt `custom` wie `builtin` das Werkzeug `spawn_subagent` bereit; Adapter-Version 2.1.0. | Beide IDs wurden am 31.08.2026 über `GET /v1/models` des konfigurierten TensorX-Gateways bestätigt. Ein Qwen-Smoke-Test bestätigte `thinking`, Reasoning-Tokens und Tool-Calling. Bei der Konfigurationsprüfung fiel außerdem auf, dass `custom` trotz geladener Rollen das Delegationswerkzeug ausblendete und der dokumentierte Aufruf die Agentendatei nicht übergab; damit wäre Versuch 2 über TensorX ohne spezialisierte Rollen gelaufen. MINOR für die Modellauswahl, funktionale Korrektur für V2. | ab dem ersten TensorX-Lauf in Versuch 2 | | **9.2.0** | **Delegationstiefe als Option im Modus `custom`.** Zwei gehashte Fassungen der Agentendatei: `verschachtelt` (Rollen dürfen selbst delegieren, bisheriges Verhalten) und `unverschachtelt` (Abschnitt *Keine Weiterdelegation* in jedem Rollenprompt). Die Fassung wird wie Modell, Modus und Effort vor dem Lauf beim User erfragt. Neues Protokollfeld *Delegationstiefe* mit Kontrolle über `subagent_stats.max_depth` und `spawned_by_subagents`. | Im ersten V2-Lauf (`v9.1.0-0c39`) entfielen 26 von 86 Subagenten auf Starts durch Subagenten (`max_depth` = 3). Das war nicht beabsichtigt – der `iso29148-orchestrator` ist als nicht-delegierende Rolle entworfen – und ist ein erheblicher Kostentreiber: Jede zusätzliche Ebene liest ihren Kontext erneut, und Cache-Reads stellten 46 % der Laufkosten. Es verwischt zudem die Zuständigkeit, weil ein von einer Rolle gestarteter Subagent keiner Teilaufgabe der Bindungstabelle mehr zuzuordnen ist. MINOR: neue Option und neue Messgröße; die Bedingung bestehender Läufe ändert sich nicht, sie gelten rückwirkend als `verschachtelt`. | ab dem zweiten V2-Lauf | | **9.1.0** | **Zuständigkeitsbindung im Modus `custom`.** Block 1 (Werkzeugkontext) wird bei `custom` um eine Tabelle `Teilaufgabe → vorgesehener Bearbeiter` ergänzt, abgeleitet aus der `--agents`-Datei. Der Prompt formuliert die Bindung abstrakt und nennt weiterhin keine Rolle, damit dieselbe Prompt-Datei für `solo` und `builtin` gültig bleibt. Neues Protokollfeld *Zuständigkeitsbindung*: Aufrufe je Rolle aus `subagent_stats.by_type`, Rollen mit null Aufrufen namentlich, Abgleich gegen die Dokumentationspflicht im `Analysebericht.md`. | V2 untersucht die Wirkung rollenspezialisierter Agentendateien. Bleibt die Nutzung freigestellt, wird die Bedingung nicht hergestellt: Im Smoke-Test vom 26.08. nutzte der Agent von acht beigestellten Rollen nur zwei (`modulinventar`, `konsistenzpruefer`) — der Lauf hätte die Rollen mitgeführt, ohne sie einzusetzen. Die Bindung ist statisch deklariert, in `_meta\combined_prompt.md` archiviert und über den SHA-256 der `--agents`-Datei versioniert; sie unterscheidet sich damit von den in 9.0.0 entfernten laufzeitabhängigen Adaptereingriffen. Anzahl der Aufrufe, Zerlegungstiefe, Reihenfolge und Turn-Anzahl bleiben unvorgegeben. MINOR, weil bislang **kein** Lauf im Modus `custom` existiert und deshalb keine Läufe unpoolbar werden; der erste V2-Lauf eröffnet ohnehin eine neue Iteration. | ab dem ersten V2-Lauf | diff --git a/.claude/skills/run-experiment/opencode-tensorx-adapter.py b/.claude/skills/run-experiment/opencode-tensorx-adapter.py new file mode 100644 index 00000000..2d738006 --- /dev/null +++ b/.claude/skills/run-experiment/opencode-tensorx-adapter.py @@ -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()) diff --git a/.claude/skills/run-experiment/opencode-tensorx.json b/.claude/skills/run-experiment/opencode-tensorx.json new file mode 100644 index 00000000..c47d6b39 --- /dev/null +++ b/.claude/skills/run-experiment/opencode-tensorx.json @@ -0,0 +1,84 @@ +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "tensorx": { + "npm": "@ai-sdk/openai-compatible", + "name": "TensorX", + "options": { + "baseURL": "https://api.tensorx.ai/v1" + }, + "models": { + "qwen/qwen3.8-flash-next": { + "name": "Qwen 3.8 Flash Next", + "limit": { + "context": 262144, + "output": 131072 + }, + "variants": { + "low": { + "thinking": { + "type": "enabled", + "level": "low" + } + }, + "medium": { + "thinking": { + "type": "enabled", + "level": "medium" + } + }, + "high": { + "thinking": { + "type": "enabled", + "level": "high" + } + }, + "xhigh": { + "thinking": { + "type": "enabled", + "level": "xhigh" + } + }, + "max": { + "thinking": { + "type": "enabled", + "level": "xhigh" + } + } + } + }, + "z-ai/glm-5.2": { + "name": "GLM 5.2", + "limit": { + "context": 1048576, + "output": 131072 + }, + "variants": { + "low": { "thinking": { "type": "enabled", "level": "low" } }, + "medium": { "thinking": { "type": "enabled", "level": "medium" } }, + "high": { "thinking": { "type": "enabled", "level": "high" } }, + "xhigh": { "thinking": { "type": "enabled", "level": "xhigh" } }, + "max": { "thinking": { "type": "enabled", "level": "xhigh" } } + } + }, + "z-ai/glm-5.3-flash": { + "name": "GLM 5.3 Flash", + "variants": { + "low": { "thinking": { "type": "enabled", "level": "low" } }, + "medium": { "thinking": { "type": "enabled", "level": "medium" } }, + "high": { "thinking": { "type": "enabled", "level": "high" } }, + "xhigh": { "thinking": { "type": "enabled", "level": "xhigh" } }, + "max": { "thinking": { "type": "enabled", "level": "xhigh" } } + } + }, + "moonshotai/kimi-k3": { + "name": "Kimi K3", + "limit": { + "context": 1048576, + "output": 131072 + } + } + } + } + } +} diff --git a/.claude/skills/run-experiment/references/opencode-tensorx.md b/.claude/skills/run-experiment/references/opencode-tensorx.md new file mode 100644 index 00000000..a1f299c1 --- /dev/null +++ b/.claude/skills/run-experiment/references/opencode-tensorx.md @@ -0,0 +1,134 @@ +# OpenCode-Adapter für TensorX + +Diese Referenz gilt für Modell-IDs mit `z-ai/`, `qwen/` oder `moonshotai/`. Der Skill ruft +TensorX nicht selbst auf, sondern startet OpenCode über `opencode-tensorx-adapter.py`. + +## Voraussetzungen und Authentifizierung + +1. OpenCode installieren und die Version protokollieren: + + ```powershell + npm install -g opencode-ai + opencode --version + ``` + +2. TensorX einmal im OpenCode-Credential-Store anmelden: + + ```powershell + opencode auth login + opencode auth list + ``` + + Die statische Datei `opencode-tensorx.json` enthält Provider, Basis-URL, Modelle und + Effort-Varianten, aber keinen API-Key. Der Adapter liest weder Cline-Dateien noch einen + Cline-Credential-Store. OpenCode löst das Credential selbst auf. Keys niemals in + Laufartefakte, Prompts oder die Konfigurationsvorlage schreiben. + +## Modell- und Effort-Mapping + +Der Adapter ergänzt intern das Providerpräfix `tensorx/`. Aus +`qwen/qwen3.8-flash-next` wird daher für OpenCode +`tensorx/qwen/qwen3.8-flash-next`; im Messprotokoll bleibt die ursprüngliche TensorX-ID. + +Die Stufen `low`, `medium`, `high`, `xhigh` und `max` werden als OpenCode-Varianten aus +`opencode-tensorx.json` übergeben. Für Qwen und GLM enthält die Variante +`thinking: {type: enabled, level: ...}`; `max` wird auf `xhigh` abgebildet, falls der Provider +keine eigene `max`-Stufe kennt. Nur in der Vorlage vorhandene Varianten werden per +`--variant` gesetzt. + +## Isolierte Laufkonfiguration + +Für jeden Lauf schreibt der Adapter `_meta/opencode-config.json` und setzt nur für den +Kindprozess `OPENCODE_CONFIG` auf diese Datei. Der Aufruf verwendet `opencode run --pure`, +damit keine interaktive Oberfläche benötigt wird. Der Prompt wird über stdin übergeben. + +Die Berechtigungen beginnen mit `deny` und erlauben gezielt: + +- Lesen, Suchen, Auflisten sowie eine kleine Read-only-Shell-Allowlist; +- Schreiben und externe Verzeichnisse ausschließlich für das angegebene Ergebnisverzeichnis; +- im Modus `solo` keine Tasks; +- im Modus `builtin` nur OpenCodes `general`- und `explore`-Subagenten; +- im Modus `custom` nur Rollen aus der mit `--agents` übergebenen JSON-Datei. + +Für das Ergebnisziel erzeugt der Wrapper kanonische sowie zum aktiven OpenCode-Root und zur +Git-Worktree-Wurzel relative Allow-Patterns. Das ist unter Windows notwendig, wenn Root und +Laufverzeichnis im selben Git-Worktree liegen, das Laufverzeichnis aber außerhalb des +Root-Unterordners liegt. + +Webzugriff, Skills, Rückfragen und Weiterdelegation durch Subagenten bleiben gesperrt. Bei +`custom` übersetzt der Adapter `description` und `prompt` jeder Rolle in eine explizite +OpenCode-Subagentenkonfiguration mit demselben Modell wie der Hauptagent. + +## Aufruf + +```powershell +$skillDir = "" +$lauf = "" +$root = "" +$modell = "" +$effort = "" +$modus = "" +$agents = "" + +python "$skillDir\opencode-tensorx-adapter.py" ` + --prompt "$lauf\_meta\combined_prompt.md" ` + --root $root ` + --output "$lauf\Ergebnisse" ` + --model $modell ` + --effort $effort ` + --mode $modus ` + --agents $agents ` + --stall-timeout 600 ` + --max-runtime 0 ` + --result-dir $lauf ` + --title "run-experiment $modell $modus $effort" +``` + +`--agents` bei `solo` und `builtin` weglassen. `--stall-timeout 600` beendet den gesamten +OpenCode-Prozessbaum, wenn zehn Minuten lang weder stdout noch stderr Aktivität zeigen. +`--stall-timeout 0` deaktiviert diese Sicherung. `--max-runtime 0` setzt kein absolutes +Laufzeitlimit. `Ctrl+C` beendet ebenfalls den Prozessbaum und persistiert soweit möglich das +Teilergebnis. Ein leeres `Ergebnisse`-Verzeichnis macht den Lauf standardmäßig zu einem Fehler. +Nur ein bewusst textueller Smoke-Test darf diese Prüfung mit `--allow-empty-output` abschalten. + +## Laufartefakte und Messfelder + +| Datei | Inhalt | +|---|---| +| `OpenCodeEvents.jsonl` | unveränderter, inkrementell geschriebener JSON-Ereignisstrom | +| `OpenCode.log` | OpenCode-stderr, inkrementell geschrieben | +| `Adapter.log` | Start, Lebenszyklus, Abbruchgrund und Abschluss des Wrappers | +| `_meta/opencode-config.json` | tatsächlich verwendete, keyfreie Laufkonfiguration | +| `_meta/opencode-session.json` | exportierte Session, soweit eine Session-ID vorliegt | +| `RawResult.json` | normalisierte Metriken für das gemeinsame Messprotokoll | + +Aus `RawResult.json` verwenden: + +| Messgröße | Feld | +|---|---| +| Erfolg/Abbruch | `is_error`, `subtype`, `timed_out`, `interrupted`, `exit_code`, `errors` | +| Modellkontrolle | `provider`, `model`, `model_requested` | +| Zeit | `duration_ms`, `start_time`, `end_time` | +| Tokens | `usage.prompt_tokens`, `completion_tokens`, `reasoning_tokens`, `cache_read_tokens`, `cache_creation_tokens`, `total_tokens` | +| Turns und Ende | `num_turns`, `finish_reason` | +| Tools | `tool_call_count`, `tool_call_types`, `tool_calls` | +| Subagenten | `subagent_stats`, `subagent_details` | +| Ergebnisdateien | `written_files` | +| Abschlusstext | `result` | +| Reproduzierbarkeit | `adapter_version`, `opencode_version`, `config_path`, `session_id` | + +`usage.total_tokens` übernimmt nach Möglichkeit OpenCodes Gesamtwert. Fehlt dieser, addiert +der Adapter Input, Output, Reasoning, Cache-Read und Cache-Write aus den gelieferten +OpenCode-Feldern. Kosten werden nur protokolliert, wenn OpenCode sie liefert; nicht schätzen. + +## Pflichtprüfung + +1. `RawResult.json` existiert und `is_error` ist `false`. +2. `timed_out` und `interrupted` sind `false`; `exit_code` ist `0`. +3. `OpenCode.log` und `errors` enthalten keinen Provider- oder Permissionfehler. +4. Für Analyseversuche ist `Ergebnisse/` nicht leer und `tool_call_count` größer null. +5. `model_requested` entspricht der angeforderten TensorX-ID; Provider ist `tensorx`. +6. Bei `custom` sind die erwarteten Rollen in `subagent_stats.by_type` nachweisbar. + +Ein absichtlich textueller Smoke-Test darf von den Prüfungen 4 und 6 abweichen, muss aber +Antwort, Exitcode, Modell, Sessionexport und Tokenmetriken bestätigen. diff --git a/.claude/skills/run-experiment/test_opencode_tensorx_adapter.py b/.claude/skills/run-experiment/test_opencode_tensorx_adapter.py new file mode 100644 index 00000000..878d15f2 --- /dev/null +++ b/.claude/skills/run-experiment/test_opencode_tensorx_adapter.py @@ -0,0 +1,164 @@ +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path + + +ADAPTER_PATH = Path(__file__).with_name("opencode-tensorx-adapter.py") +SPEC = importlib.util.spec_from_file_location("opencode_tensorx_adapter", ADAPTER_PATH) +ADAPTER = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(ADAPTER) + + +class OpenCodeTensorXAdapterTests(unittest.TestCase): + def test_model_reference_keeps_upstream_slashes(self): + self.assertEqual( + ( + "tensorx/qwen/qwen3.8-flash-next", + "qwen/qwen3.8-flash-next", + ), + ADAPTER.normalize_model("qwen/qwen3.8-flash-next"), + ) + self.assertEqual( + ( + "tensorx/qwen/qwen3.8-flash-next", + "qwen/qwen3.8-flash-next", + ), + ADAPTER.normalize_model("tensorx/qwen/qwen3.8-flash-next"), + ) + + def test_custom_mode_translates_agents_and_restricts_delegation(self): + base = { + "provider": { + "tensorx": { + "models": {"qwen/qwen3.8-flash-next": {"name": "Qwen"}} + } + } + } + with tempfile.TemporaryDirectory() as temp_dir: + temp = Path(temp_dir) + root = temp / "root" + output = root / "run" / "Ergebnisse" + root.mkdir() + output.mkdir(parents=True) + agents = temp / "agents.json" + agents.write_text( + json.dumps( + { + "reviewer": { + "description": "Prueft Fakten", + "prompt": "Pruefe nur die zugewiesenen Fakten.", + } + } + ), + encoding="utf-8", + ) + + config = ADAPTER.build_run_config( + base, + "tensorx/qwen/qwen3.8-flash-next", + "qwen/qwen3.8-flash-next", + "custom", + root, + output, + agents, + ) + + self.assertEqual("allow", config["permission"]["task"]["reviewer"]) + self.assertEqual("deny", config["permission"]["task"]["*"]) + self.assertEqual("subagent", config["agent"]["reviewer"]["mode"]) + self.assertEqual( + "tensorx/qwen/qwen3.8-flash-next", + config["agent"]["reviewer"]["model"], + ) + self.assertEqual("deny", config["agent"]["reviewer"]["permission"]["task"]) + self.assertIn( + ADAPTER.normalized_path(output) + "/**", + config["permission"]["edit"], + ) + self.assertIn( + "run/Ergebnisse/**", + config["permission"]["edit"], + ) + + def test_output_permissions_include_parent_relative_worktree_path(self): + with tempfile.TemporaryDirectory() as temp_dir: + temp = Path(temp_dir) + root = temp / "QuellCode" / "Product" + output = temp / "Versuche" / "Lauf" / "Ergebnisse" + root.mkdir(parents=True) + output.mkdir(parents=True) + + patterns = ADAPTER.output_permission_patterns(root, output, temp) + + self.assertIn("Versuche/Lauf/Ergebnisse/**", patterns) + self.assertIn("../../Versuche/Lauf/Ergebnisse", patterns) + self.assertIn("../../Versuche/Lauf/Ergebnisse/**", patterns) + self.assertIn(ADAPTER.normalized_path(output) + "/**", patterns) + + def test_normalize_result_uses_exported_metrics_and_tool_calls(self): + session = { + "info": { + "id": "ses_test", + "version": "1.18.25", + "model": { + "id": "qwen/qwen3.8-flash-next", + "providerID": "tensorx", + }, + "tokens": { + "input": 100, + "output": 10, + "reasoning": 5, + "cache": {"read": 20, "write": 0}, + }, + "cost": 0.1, + }, + "messages": [ + { + "info": {"role": "assistant", "finish": "stop"}, + "parts": [ + { + "type": "tool", + "tool": "task", + "state": { + "status": "completed", + "input": { + "subagent_type": "reviewer", + "description": "Pruefen", + }, + }, + }, + {"type": "text", "text": "Fertig"}, + ], + } + ], + } + with tempfile.TemporaryDirectory() as temp_dir: + output = Path(temp_dir) + (output / "StRS.md").write_text("Inhalt", encoding="utf-8") + result = ADAPTER.normalize_result( + session=session, + events=[], + model_ref="tensorx/qwen/qwen3.8-flash-next", + mode="custom", + effort="low", + exit_code=0, + timed_out=False, + interrupted=False, + duration_s=1.5, + output_dir=output, + errors=[], + ) + + self.assertFalse(result["is_error"]) + self.assertEqual(135, result["usage"]["total_tokens"]) + self.assertEqual(1, result["tool_call_count"]) + self.assertEqual(1, result["subagent_stats"]["spawned"]) + self.assertEqual({"reviewer": 1}, result["subagent_stats"]["by_type"]) + self.assertEqual("Fertig", result["result"]) + self.assertEqual(1, len(result["written_files"])) + + +if __name__ == "__main__": + unittest.main()