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
+43 -10
View File
@@ -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 <Pfad-zur-Prompt-Datei>" 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 <Pfad-zur-Prompt-Datei>" oder wenn der User einen Versuch/ein Experiment ausführen und tracken will.
argument-hint: <Pfad zur Prompt-Datei> <Root-Verzeichnis>
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 "<promptverzeichnis>\$zelle" "<NN>_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 |
@@ -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())
@@ -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
}
}
}
}
}
}
@@ -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 = "<Verzeichnis des Skills>"
$lauf = "<absoluter Pfad zum Laufverzeichnis>"
$root = "<Root-Verzeichnis der Codebasis>"
$modell = "<TensorX-Modell-ID>"
$effort = "<low|medium|high|xhigh|max>"
$modus = "<solo|builtin|custom>"
$agents = "<Agenten-JSON; nur bei custom>"
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.
@@ -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()