Add OpenCode TensorX experiment adapter
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
---
|
---
|
||||||
name: run-experiment
|
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>
|
argument-hint: <Pfad zur Prompt-Datei> <Root-Verzeichnis>
|
||||||
version: 9.3.0
|
version: 10.0.2
|
||||||
---
|
---
|
||||||
|
|
||||||
# RunExperiment – Versuchslauf mit Messprotokoll
|
# 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
|
- **`## 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.
|
Teil gilt unabhängig davon, mit welchem LLM oder welcher CLI gearbeitet wird.
|
||||||
- **`## Werkzeugadapter`** beschreibt **wie** das mit einem konkreten Werkzeug umgesetzt 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.
|
ausschließlich dem gewählten Adapterabschnitt zu entnehmen.
|
||||||
|
|
||||||
**Arbeitsteilung mit der Prompt-Datei:** Der Prompt enthält ausschließlich die *Analyseanweisung*
|
**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.
|
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:
|
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,
|
`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.
|
Keine Modell-ID an eine CLI übergeben, die sie nicht unterstützt.
|
||||||
|
|
||||||
Unter Windows liegt `claude` in der Regel **nicht im PATH**. Erst
|
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`
|
`$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.
|
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.
|
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,
|
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
|
**immer** per `AskUserQuestion` nachfragen – auch dann, wenn frühere Läufe derselben
|
||||||
Versuchsreihe ein bestimmtes Modell verwendet haben. Es gibt bewusst keinen Default.
|
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','') }
|
Sort-Object { [int]($_.Name -replace '\D','') }
|
||||||
$iteration = if ($iterationen) { $iterationen[-1].Name } else { 'Iteration 1' }
|
$iteration = if ($iterationen) { $iterationen[-1].Name } else { 'Iteration 1' }
|
||||||
$zelle = Join-Path (Join-Path (Join-Path $iteration $modell) $modus) $effort
|
$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 {
|
do {
|
||||||
$id4 = '{0:x4}' -f (Get-Random -Maximum 65536)
|
$id4 = '{0:x4}' -f (Get-Random -Maximum 65536)
|
||||||
$lauf = Join-Path "<promptverzeichnis>\$zelle" "<NN>_Lauf_$(Get-Date -Format 'yyyy-MM-dd_HHmmss')_${skillVer}-$id4"
|
$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)
|
### 3. Ausführung (Headless-Lauf)
|
||||||
|
|
||||||
Den Adapter anhand der Modell-ID wählen. Der folgende Aufruf ist **ausschließlich der
|
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
|
Claude-Code-Adapter**. Der Codex-Aufruf steht vollständig unter `Adapter: Codex CLI`, der
|
||||||
nicht mit Claude-Flags vermischt werden.
|
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
|
Den kombinierten Prompt per stdin an `claude -p` übergeben. `--add-dir` gibt dem
|
||||||
Headless-Lauf Schreibrecht auf das Laufverzeichnis außerhalb seines Arbeitsverzeichnisses.
|
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
|
`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
|
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 |
|
| 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
|
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.
|
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`,
|
(`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
|
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**.
|
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 |
|
| 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.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.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 |
|
| **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()
|
||||||
Reference in New Issue
Block a user