tensorx adapter

This commit is contained in:
Christoph Schwörer
2026-08-28 07:38:12 +02:00
parent ea1f3caff7
commit 37275c96d6
3 changed files with 749 additions and 5 deletions
+121 -5
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 oder Codex CLI 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 dem Python-API-Adapter (GLM/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.
argument-hint: <Pfad zur Prompt-Datei> <Root-Verzeichnis>
version: 7.0.0
version: 8.0.0
---
# RunExperiment – Versuchslauf mit Messprotokoll
@@ -101,7 +101,7 @@ 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 und Codex CLI. Konkrete Flags und Rohfelder sind
Ausgearbeitet sind Adapter für Claude Code, Codex CLI und den Python-API-Adapter (GLM/Kimi). 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*
@@ -124,7 +124,8 @@ per stdin übergebenen Text angehängt (siehe Schritt „Prompt zusammenstellen"
frühere Läufe entstanden sind, und dürfen nicht nachträglich angepasst werden.
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.
`claude-*` verwendet Claude Code, OpenAI-IDs wie `gpt-*` oder `o*` verwenden Codex CLI,
`z-ai/*` und `moonshotai/*` verwenden den Python-API-Adapter ü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
@@ -162,6 +163,11 @@ per stdin übergebenen Text angehängt (siehe Schritt „Prompt zusammenstellen"
| `gpt-5.6-terra` | ausgewogenes Verhältnis aus Leistung und Verbrauch |
| `gpt-5.6-luna` | schnelle und günstige GPT-5.6-Variante |
| TensorX-Modell-ID | Einordnung |
|---|---|
| `z-ai/glm-5.2` | Z.AI GLM 5.2 über TensorX-Gateway |
| `moonshotai/kimi-k3` | Moonshot Kimi K3 – 1-Mio.-Kontext, 2,8T Parameter |
In der Frage den letzten verwendeten Stand nennen, damit der User bewusst wechseln oder
bewusst wiederholen kann (z. B. „Lauf 3 lief mit `claude-sonnet-5`, 11.516.200 Tokens, 23:40").
@@ -256,7 +262,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 = 'v7.0.0' # entspricht version: im Frontmatter dieses Skills
$skillVer = 'v8.0.0' # 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"
@@ -997,6 +1003,114 @@ 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 & Kimi-Modelle über TensorX
Dieser 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**.
**Verfügbare Modell-IDs über TensorX:**
| TensorX-Modell-ID | Hersteller | Effort-Parameter |
|---|---|---|
| `z-ai/glm-5.2` | Z.AI (Zhipu AI) | `thinking` (`{"type":"enabled","level":"…"}`) |
| `moonshotai/kimi-k3` | Moonshot AI | `reasoning_effort` (top-level) |
Das Modell-Präfix (`z-ai/` bzw. `moonshotai/`) bestimmt, welcher Effort-Parameter an die
API gesendet wird. Der Adapter wählt ihn automatisch anhand des Präfixes.
**Authentifizierung.** Der API-Key wird **automatisch aus der Cline providers.json**
gelesen (`~/.cline/data/settings/providers.json`, Provider `tensorx`). Alternativ kann
er per `--api-key` oder Umgebungsvariable `TENSORX_API_KEY` übergeben werden. Der Key
wird **nicht** in Laufartefakten gespeichert.
**Unterstützter Agentenmodus:** Nur `solo`. Der Adapter implementiert keine Subagenten;
`builtin` und `custom` sind nicht freigegeben und führen zum Abbruch.
**Isolation.** Der Adapter ist ein eigenständiges Python-Skript, das nur die
Python-Standardbibliothek und `requests` benötigt. Es liest die Codebasis über die
implementierten Tools (`read_file`, `list_directory`, `search_files`,
`execute_command`) und schreibt Ergebnisdateien ausschließlich über das `write_file`-Tool
ins Ausgabeverzeichnis. Schreibende und bauende Shell-Kommandos sind durch eine
Denylist gesperrt (analog zum Claude-Code-Adapter). Der bereinigte Snapshot bleibt
zusätzliche Pflicht.
**Effort-Steuerung.** Der Adapter mappt die Skill-Effort-Stufen auf beide APIs:
| Skill-Effort | GLM `thinking.level` | Kimi `reasoning_effort` |
|---|---|---|
| `low` | `low` | `low` |
| `medium` | `medium` | `medium` |
| `high` | `high` | `high` |
| `xhigh` | `xhigh` | `high` (höchste verfügbare Stufe) |
| `max` | `xhigh` | `high` |
**Der Aufruf** (als Background-Task starten):
```powershell
$skillDir = "<Verzeichnis des Skills>"
$lauf = "<absoluter Pfad zum Laufverzeichnis>"
$root = "<Root-Verzeichnis der Codebasis>"
$modell = "<vom User gewählte Modell-ID, z.B. z-ai/glm-5.2>"
$effort = "<vom User gewählte Stufe>"
# Prompt zusammenstellen (wie bei den anderen Adaptern)
$prompt = (Get-Content "<prompt-datei>" -Raw) + "`n`n<Werkzeugkontext-Block>`n`n<Ausgabe-Block>"
Set-Content -Path "$lauf\_meta\combined_prompt.md" -Value $prompt -Encoding utf8
Set-Content -Path "$lauf\_meta\startzeit.txt" -Value (Get-Date -Format o)
# API-Key wird automatisch aus Cline providers.json gelesen
python "$skillDir\glm-kimi-adapter.py" `
--prompt "$lauf\_meta\combined_prompt.md" `
--root $root `
--output "$lauf\Ergebnisse" `
--model $modell `
--effort $effort `
--max-turns 50 `
--result-dir $lauf `
2> "$lauf\Stderr.log"
Set-Content -Path "$lauf\_meta\endzeit.txt" -Value (Get-Date -Format o)
```
**Gelieferte Messgrößen** – vollständig aus `RawResult.json` (vom Adapter geschrieben):
| Messgröße | Feld |
|---|---|
| Abbruchstatus | `is_error`, `subtype`, `finish_reason` |
| Dauer | `duration_ms` (Wanduhr, selbst gemessen; keine separate API-Zeit) |
| Tokens gesamt | `usage.total_tokens` über alle Turns akkumuliert |
| Input-Tokens | `usage.prompt_tokens` |
| Output-Tokens | `usage.completion_tokens` |
| Reasoning-Tokens | `usage.reasoning_tokens` (aus `completion_tokens_details.reasoning_tokens`) |
| Cache-Read-Tokens | `usage.cached_tokens` (soweit von TensorX geliefert) |
| Cache-Write-Tokens | **nicht erfasst** – TensorX liefert keine |
| Agent-Turns | `num_turns` |
| Tatsächlich eingesetztes Modell | `model` (aus API-Antwort; mit `model_requested` abzugleichen) |
| Tool-Aufrufe | `tool_call_count`, `tool_call_types` (nach Werkzeugname) |
| Erzeugte Artefakte | `written_files` (Pfad und Größe je Datei) |
| Abschlusstext | `result` |
**Semantik der Token-Zählung.** `usage.total_tokens` wird über alle Turns aufsummiert.
Jeder Turn umfasst dabei den vollen Kontext (Eingabe + Ausgabe); die Token-Zahl ist
daher die Summe aller API-Aufrufe, nicht die eines einzelnen Austauschs.
Reasoning-Tokens sind eine Teilmenge von `completion_tokens` und werden nicht erneut
addiert.
**Nicht erfasst und niemals schätzen:** Cache-Write-Tokens, Permission-Denials (das
Deny-Modell ist ein hartes Blockieren, keine zählbaren Denials), Subagenten-Stats (keine
Subagenten), Session-ID (keine persistierte Session), API-Dauer (nicht von der API
geliefert). Sampling-Parameter: Temperatur ist steuerbar (`--temperature`),
Reasoning-Effort ist steuerbar (`--effort`).
**Pflichtprüfung nach dem Lauf:**
1. `Ergebnisse\` ist **nicht leer** – sonst Fehlmessung (analog zu MAJOR 6.1.0).
2. `Stderr.log` enthält keine Abbruchmeldung.
3. `model` in `RawResult.json` stimmt mit `model_requested` überein (Modellkontrolle).
4. `tool_call_count` > 0 (ein Lauf ohne Tool-Aufrufe hat die Codebasis nicht analysiert).
### Weitere Adapter (für Versuch 4 nachzurüsten)
Kapitel 4 der Arbeit sieht zusätzlich Qwen Code CLI über LM Studio und DeepSeek über die
@@ -1057,6 +1171,8 @@ der Historie unten – im selben Arbeitsschritt.
| Version | Änderung | Grund | Verwendet in |
|---|---|---|---|
| **8.0.0** | **Neuer Python-API-Adapter für GLM- und Kimi-Modelle über den TensorX-Gateway.** Das Skript `glm-kimi-adapter.py` implementiert einen minimalen Agent-Loop mit Tool-Calling (read_file, list_directory, search_files, execute_command, write_file) direkt gegen die OpenAI-kompatible TensorX-API (`https://api.tensorx.ai/v1`). Modelle: `z-ai/glm-5.2` und `moonshotai/kimi-k3`. Der API-Key wird automatisch aus der Cline providers.json gelesen. Token-Verbrauch wird pro Turn aus dem API-Response `usage`-Objekt akkumuliert, inkl. Reasoning-Tokens (`completion_tokens_details.reasoning_tokens`). Effort-Mapping anhand des Modell-Präfixes: `z-ai/*` nutzt `thinking.level`, `moonshotai/*` nutzt `reasoning_effort`. Nur Modus `solo` freigegeben. Neue Modell-Tabelle für TensorX-Modell-IDs im Vorbereitungsabschnitt. | Die Cline CLI (`npm i -g cline`) versprach einen Headless-Modus mit GLM-Support, ihr natives Binary (143 MB, Bun-kompiliert) wurde jedoch durch die Application-Control-Richtlinie der Maschine blockiert (EPERM/Zugriff verweigert). Der Python-Adapter umgeht dieses Problem und bietet zusätzliche Vorteile: exakte Token-Metriken direkt aus der API (inkl. Reasoning-Tokens, die Claude Code nur als `thinking_tokens` liefert), keine externen Binary-Abhängigkeiten, API-Key aus bestehender Cline-Konfiguration. MAJOR, da neues Werkzeug mit anderer Isolationsarchitektur und anderen Metrik-Quellen eine neue Versuchsbedingung bildet; der nächste Lauf eröffnet eine neue Iteration. | ab dem ersten GLM/Kimi-Lauf |
| **7.0.0** | **Isolationsmechanismus wird modusabhängig.** In den Modi `solo` und `builtin` unverändert `--safe-mode`; im Modus `custom` und bei jedem Lauf mit `--mcp-config` **kein** `--safe-mode`, stattdessen `--strict-mcp-config` plus `--disallowedTools Skill WebSearch WebFetch SlashCommand`. Neue Pflicht-Umgebungsprüfung auf Hooks, Plugins und Output-Styles im User-Profil; neue Protokollfelder für die MCP-Konfiguration und die Umgebungsprüfung. | `--safe-mode` schaltet ausweislich der CLI-Hilfe „MCP servers, custom commands and agents" ab – also genau das, was V2 und V3 untersuchen. Smoke-Test am 2026-08-26 (CLI 2.1.246, identischer Aufruf, nur `--safe-mode` variiert): mit Flag `spawned` = 0 und die Meldung, die Rollen seien „nicht in der Agent-Registry registriert"; ohne Flag `spawned` = 2 mit `{"modulinventar": 1, "konsistenzpruefer": 1}`. Ohne Ersatz hätte der erste V2-Lauf stillschweigend als V1-Lauf gemessen. Der Ersatz wurde gegengeprüft: nichts vorgeladen, keine Skills, kein Webzugriff, alle Rollen verfügbar. **MAJOR: Läufe im Modus `custom` sind hinsichtlich der Isolation nicht unmittelbar mit `solo`- und `builtin`-Läufen vergleichbar** – Plugins, Hooks und Output-Styles sind dort nicht durch das Flag, sondern nur durch die Umgebungsprüfung ausgeschlossen. | ab dem ersten V2-Lauf |
| **6.1.0** | Zwei adapterunabhängige Pflichtprüfungen nach jedem Lauf: **(a)** `Ergebnisse\` darf nicht leer sein – sonst Fehlmessung, unabhängig von `is_error`; **(b)** `Stderr.log` auf Abbruchmeldungen prüfen. Der Ausführungsabschnitt setzt `CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0`. Neues Protokollfeld „Gültigkeit". Dokumentiert, dass die Rückgabe eines Hintergrund-Subagenten eine Start-Quittung ist und ihre Länge kein Ertragsmaß. | Lauf `…160037_v4.5.0-116d` meldete `is_error: false`, `subtype: success` und lieferte **null Ergebnisdateien** bei 193,4 Mio. Tokens – der Headless-Modus hatte nach 600 s abgebrochen, während zehn Hintergrund-Subagenten noch liefen. Ohne die neue Prüfung wäre der Lauf als gültiger Messpunkt mit „0 Anforderungen" in den Modellvergleich eingegangen. MINOR: neue Prüfschritte; die Umgebungsvariable ändert die Versuchsbedingung für künftige `builtin`-Läufe und ist dort zu vermerken. | ab sofort |
| **6.0.0** | **Windows-taugliche Codex-Isolation.** Pro Lauf wird im System-Temp-Verzeichnis außerhalb des IDE-Projektbaums eine Kopie der versionierten und nicht ignorierten Dateien des Codebasis-Roots erstellt. Codex läuft ausschließlich dort mit `--sandbox workspace-write`; SHA-256-Manifeste vor und nach dem Lauf erkennen jede Änderung. Ergebnisse und Nachweise bleiben im Laufverzeichnis, die temporäre Kopie wird danach entfernt. | Der erste Codex-Lauf mit 5.0.0 konnte fachlich nicht starten, weil die Windows-Read-only-Sandbox sämtliche lesenden Kindprozesse vor dem Start blockierte. Eine Kopie im Laufverzeichnis wurde zudem vom C#-Projektservice automatisch mit ignorierten `obj`-Dateien verändert. MAJOR, weil Sandboxmodus und Arbeitsverzeichnis eine neue Versuchsbedingung bilden; der erste Lauf eröffnet deshalb eine neue Iteration. | ab dem ersten wiederholten Codex-Lauf |
@@ -0,0 +1,628 @@
#!/usr/bin/env python3
"""
TensorX API Adapter fuer den run-experiment Skill.
Dieser Adapter fuehrt einen Headless-Versuchslauf mit einem OpenAI-kompatiblen
Modell (Z.AI GLM oder Moonshot Kimi) ueber den TensorX API-Gateway durch.
Er implementiert einen minimalen Agent-Loop mit Tool-Calling und erfasst
Token-Metadaten (inkl. Reasoning-Tokens) aus jeder API-Antwort.
Der API-Key wird automatisch aus der Cline providers.json gelesen
(~/.cline/data/settings/providers.json, Provider "tensorx").
Verwendung:
python glm-kimi-adapter.py \
--prompt <Pfad zur combined_prompt.md> \
--root <Root-Verzeichnis der Codebasis> \
--output <Laufverzeichnis/Ergebnisse> \
--model <Modell-ID, z.B. z-ai/glm-5.2 oder moonshotai/kimi-k3> \
--effort <low|medium|high|xhigh> \
[--max-turns 50] \
[--temperature 1.0] \
[--timeout 0]
Ausgaben:
<Laufverzeichnis>/RawResult.json – normalisierte Messdaten
<Laufverzeichnis>/Stderr.log – Fehler- und Debug-Ausgaben
Der Adapter ist bewusst eigenstaendig (nur Python-Standardbibliothek + requests).
"""
import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
import time
import traceback
from datetime import datetime, timezone
from pathlib import Path
try:
import requests
except ImportError:
sys.stderr.write("FEHLER: 'requests' ist nicht installiert.\n")
sys.exit(2)
# ---------------------------------------------------------------------------
# Provider-Konfiguration
# ---------------------------------------------------------------------------
PROVIDERS = {
"tensorx": {
"name": "TensorX API Gateway",
"base_url": "https://api.tensorx.ai/v1",
"env_key": "TENSORX_API_KEY",
},
}
# Modell-Praefix -> Effort-Parameter-Typ
# z-ai/* Modelle nutzen den 'thinking'-Parameter (level: none|low|medium|high|xhigh)
# moonshotai/* Modelle nutzen 'reasoning_effort' (low|medium|high)
MODEL_EFFORT_TYPE = {
"z-ai": "thinking",
"moonshotai": "reasoning_effort",
}
# Effort-Mapping: Skill-Effort -> API-Wert je Effort-Typ
EFFORT_MAP = {
"low": {"thinking": "low", "reasoning_effort": "low"},
"medium": {"thinking": "medium", "reasoning_effort": "medium"},
"high": {"thinking": "high", "reasoning_effort": "high"},
"xhigh": {"thinking": "xhigh", "reasoning_effort": "high"},
"max": {"thinking": "xhigh", "reasoning_effort": "high"},
}
def load_cline_api_key():
"""
Liest den TensorX API-Key aus der Cline providers.json.
Pfad: ~/.cline/data/settings/providers.json
Rueckgabe: (api_key, base_url) oder (None, None).
"""
home = Path.home()
providers_file = home / ".cline" / "data" / "settings" / "providers.json"
if not providers_file.is_file():
return None, None
try:
data = json.loads(providers_file.read_text(encoding="utf-8"))
tx = data.get("providers", {}).get("tensorx", {}).get("settings", {})
return tx.get("apiKey"), tx.get("baseUrl")
except (json.JSONDecodeError, KeyError):
return None, None
# Denylist fuer schreibende/bauende Kommandos
DENIED_COMMAND_PATTERNS = [
r"\brm\b", r"\brmdir\b", r"\bmv\b", r"\bcp\b", r"\bdd\b",
r"\btruncate\b", r"\bchmod\b", r"\bchown\b", r"\bln\b", r"\btee\b",
r"\bsed\s+-i\b", r"\bgit\s+checkout\b", r"\bgit\s+restore\b",
r"\bgit\s+clean\b", r"\bgit\s+reset\b", r"\bgit\s+add\b",
r"\bgit\s+commit\b", r"\bgit\s+push\b", r"\bgit\s+fetch\b",
r"\bdotnet\b", r"\bmsbuild\b", r"\bnpm\s+install\b", r"\bnuget\b",
r"\bpip\s+install\b", r">\s*", r">>\s*",
]
# ---------------------------------------------------------------------------
# Tool-Definitionen (OpenAI Function Calling Format)
# ---------------------------------------------------------------------------
TOOLS = [
{
"type": "function",
"function": {
"name": "read_file",
"description": (
"Lies den Inhalt einer Textdatei. Der Pfad ist relativ zum "
"Arbeitsverzeichnis (Root der Codebasis)."
),
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Relativer Pfad zur Datei (z.B. 'src/Program.cs')",
},
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "list_directory",
"description": "Liste den Inhalt eines Verzeichnisses mit Typ-Kennzeichnung.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Relativer Pfad zum Verzeichnis (leer = Root)",
},
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "search_files",
"description": (
"Durchsuche Dateien mit einem Regex-Muster (aehnlich grep -rn). "
"Gibt Treffer mit Dateiname, Zeilennummer und Zeileninhalt zurueck."
),
"parameters": {
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "Regex-Suchmuster"},
"path": {
"type": "string",
"description": "Relativer Pfad zum Startverzeichnis (leer = Root)",
},
"file_pattern": {
"type": "string",
"description": "Dateifilter (z.B. '*.cs'), optional",
},
},
"required": ["pattern"],
},
},
},
{
"type": "function",
"function": {
"name": "execute_command",
"description": (
"Fuehre einen schreibgeschuetzten Shell-Befehl im Arbeitsverzeichnis "
"aus. Schreibende und bauende Kommandos werden abgelehnt."
),
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string", "description": "Der auszufuehrende Befehl"},
},
"required": ["command"],
},
},
},
{
"type": "function",
"function": {
"name": "write_file",
"description": (
"Schreibe eine Ergebnisdatei in das Ausgabeverzeichnis. Der Pfad "
"ist relativ zum Ausgabeverzeichnis (z.B. 'StRS.md')."
),
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Relativer Pfad"},
"content": {"type": "string", "description": "Vollstaendiger Dateiinhalt"},
},
"required": ["path", "content"],
},
},
},
]
# ---------------------------------------------------------------------------
# Pfad-Sicherheit
# ---------------------------------------------------------------------------
def safe_join(root: str, rel_path: str) -> Path:
"""Verbindet root und rel_path, verhindert Path-Traversal."""
root_resolved = Path(root).resolve()
target = (root_resolved / rel_path).resolve()
if not str(target).startswith(str(root_resolved)):
raise ValueError(f"Pfad '{rel_path}' verlaesst das Root-Verzeichnis")
return target
# ---------------------------------------------------------------------------
# Tool-Implementierungen
# ---------------------------------------------------------------------------
def tool_read_file(root: str, args: dict) -> str:
path = args.get("path", "")
try:
full = safe_join(root, path)
if not full.is_file():
return f"FEHLER: Datei nicht gefunden: {path}"
content = full.read_text(encoding="utf-8", errors="replace")
if len(content) > 200000:
content = content[:200000] + "\n\n[... Datei abgeschnitten bei 200.000 Zeichen ...]"
return content
except ValueError as e:
return f"FEHLER: {e}"
except Exception as e:
return f"FEHLER beim Lesen von {path}: {e}"
def tool_list_directory(root: str, args: dict) -> str:
path = args.get("path", "")
try:
full = safe_join(root, path) if path else Path(root).resolve()
if not full.is_dir():
return f"FEHLER: Verzeichnis nicht gefunden: {path}"
entries = []
for child in sorted(full.iterdir(), key=lambda c: (c.is_file(), c.name.lower())):
typ = "[DIR] " if child.is_dir() else "[FILE]"
size = ""
if child.is_file():
try:
size = f" ({child.stat().st_size:,} bytes)"
except OSError:
pass
entries.append(f"{typ} {child.name}{size}")
return "\n".join(entries) if entries else "(leeres Verzeichnis)"
except ValueError as e:
return f"FEHLER: {e}"
except Exception as e:
return f"FEHLER beim Auflisten von {path}: {e}"
def tool_search_files(root: str, args: dict) -> str:
pattern = args.get("pattern", "")
path = args.get("path", "")
file_pattern = args.get("file_pattern", "")
if not pattern:
return "FEHLER: Kein Suchmuster angegeben"
try:
regex = re.compile(pattern, re.IGNORECASE)
search_root = safe_join(root, path) if path else Path(root).resolve()
if not search_root.is_dir():
return f"FEHLER: Verzeichnis nicht gefunden: {path}"
results = []
max_results = 500
max_file_size = 5 * 1024 * 1024
for file_path in search_root.rglob("*"):
if not file_path.is_file():
continue
if file_pattern:
import fnmatch
if not fnmatch.fnmatch(file_path.name, file_pattern):
continue
try:
if file_path.stat().st_size > max_file_size:
continue
except OSError:
continue
try:
rel = file_path.relative_to(Path(root).resolve())
except ValueError:
continue
try:
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
for line_no, line in enumerate(f, 1):
if regex.search(line):
results.append(f"{rel}:{line_no}: {line.rstrip()[:300]}")
if len(results) >= max_results:
results.append(f"\n[... Suche bei {max_results} Treffern abgeschnitten ...]")
return "\n".join(results)
except Exception:
continue
return "\n".join(results) if results else "Keine Treffer."
except re.error as e:
return f"FEHLER: Ungueltiges Regex-Muster: {e}"
except ValueError as e:
return f"FEHLER: {e}"
except Exception as e:
return f"FEHLER bei der Suche: {e}"
def tool_execute_command(root: str, args: dict) -> str:
command = args.get("command", "")
if not command:
return "FEHLER: Kein Befehl angegeben"
for pat in DENIED_COMMAND_PATTERNS:
if re.search(pat, command, re.IGNORECASE):
return "ABGELEHNT: Befehl enthaelt verbotenes Muster. Schreibende und bauende Kommandos sind gesperrt."
try:
result = subprocess.run(
command, shell=True, cwd=root, capture_output=True, text=True, timeout=60,
)
output = result.stdout or ""
if result.stderr:
output += f"\n[STDERR]\n{result.stderr}"
if len(output) > 100000:
output = output[:100000] + "\n\n[... Ausgabe abgeschnitten ...]"
return output.strip() if output.strip() else "(keine Ausgabe)"
except subprocess.TimeoutExpired:
return "FEHLER: Befehl nach 60 Sekunden abgebrochen"
except Exception as e:
return f"FEHLER bei Befehlsausfuehrung: {e}"
def tool_write_file(output_dir: str, args: dict) -> str:
path = args.get("path", "")
content = args.get("content", "")
if not path:
return "FEHLER: Kein Dateipfad angegeben"
try:
base = Path(output_dir).resolve()
target = (base / path).resolve()
if not str(target).startswith(str(base)):
return f"FEHLER: Pfad '{path}' verlaesst das Ausgabeverzeichnis"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
return f"OK: Datei geschrieben: {path} ({len(content):,} Zeichen)"
except Exception as e:
return f"FEHLER beim Schreiben von {path}: {e}"
def execute_tool(name: str, args: dict, root: str, output_dir: str) -> str:
"""Dispatch eines Tool-Aufrufs."""
dispatch = {
"read_file": lambda a: tool_read_file(root, a),
"list_directory": lambda a: tool_list_directory(root, a),
"search_files": lambda a: tool_search_files(root, a),
"execute_command": lambda a: tool_execute_command(root, a),
"write_file": lambda a: tool_write_file(output_dir, a),
}
handler = dispatch.get(name)
if handler:
return handler(args)
return f"FEHLER: Unbekanntes Werkzeug: {name}"
# ---------------------------------------------------------------------------
# API-Aufruf
# ---------------------------------------------------------------------------
def call_api(provider, model, messages, api_key, effort, temperature, timeout):
"""Ruft die Chat-Completions-API auf und gibt die JSON-Antwort zurueck."""
url = f"{provider['base_url']}/chat/completions"
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
body = {
"model": model, "messages": messages, "tools": TOOLS,
"tool_choice": "auto", "temperature": temperature, "stream": False,
}
# Effort-Parameter anhand des Modell-Praefixes waehlen
model_prefix = model.split("/")[0] if "/" in model else ""
effort_type = MODEL_EFFORT_TYPE.get(model_prefix, "thinking")
effort_val = EFFORT_MAP.get(effort, {}).get(effort_type, "medium")
if effort_type == "thinking":
body["thinking"] = {"type": "enabled", "level": effort_val}
elif effort_type == "reasoning_effort":
body["reasoning_effort"] = effort_val
resp = requests.post(url, headers=headers, json=body,
timeout=timeout if timeout > 0 else 1800)
if resp.status_code != 200:
raise RuntimeError(f"API-Fehler {resp.status_code}: {resp.text[:2000]}")
return resp.json()
# ---------------------------------------------------------------------------
# Agent-Loop
# ---------------------------------------------------------------------------
def run_agent_loop(provider, model, system_prompt, user_prompt, api_key, effort,
root, output_dir, max_turns, temperature, timeout):
"""Fuehrt den Agent-Loop durch und sammelt Metriken."""
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
total_usage = {"prompt_tokens": 0, "completion_tokens": 0,
"total_tokens": 0, "cached_tokens": 0, "reasoning_tokens": 0}
turns = 0
tool_calls_log = []
final_content = ""
model_reported = model
finish_reason = None
errors = []
start_time = time.time()
while turns < max_turns:
turns += 1
try:
response = call_api(provider, model, messages, api_key,
effort, temperature, timeout)
except Exception as e:
errors.append(f"Turn {turns}: API-Fehler: {e}")
break
usage = response.get("usage", {})
total_usage["prompt_tokens"] += usage.get("prompt_tokens", 0)
total_usage["completion_tokens"] += usage.get("completion_tokens", 0)
total_usage["total_tokens"] += usage.get("total_tokens", 0)
cached = usage.get("prompt_tokens_details", {}).get("cached_tokens", 0)
total_usage["cached_tokens"] += cached
# Reasoning/Thinking-Tokens aus completion_tokens_details
comp_details = usage.get("completion_tokens_details", {})
total_usage["reasoning_tokens"] += comp_details.get("reasoning_tokens", 0)
if response.get("model"):
model_reported = response["model"]
choices = response.get("choices", [])
if not choices:
errors.append(f"Turn {turns}: Keine choices in API-Antwort")
break
choice = choices[0]
finish_reason = choice.get("finish_reason")
msg = choice.get("message", {})
messages.append(msg)
content = msg.get("content", "")
if content:
final_content = content
tool_calls = msg.get("tool_calls", [])
if not tool_calls:
break
for tc in tool_calls:
func = tc.get("function", {})
tool_name = func.get("name", "")
tool_args_str = func.get("arguments", "{}")
tc_id = tc.get("id", "")
try:
tool_args = json.loads(tool_args_str)
except json.JSONDecodeError:
tool_args = {}
tool_calls_log.append({"turn": turns, "name": tool_name, "args": tool_args})
result = execute_tool(tool_name, tool_args, root, output_dir)
messages.append({"role": "tool", "tool_call_id": tc_id,
"name": tool_name, "content": result})
end_time = time.time()
duration_s = end_time - start_time
written_files = []
if os.path.isdir(output_dir):
for f in sorted(Path(output_dir).rglob("*")):
if f.is_file():
try:
written_files.append({"path": str(f.relative_to(output_dir)),
"size": f.stat().st_size})
except OSError:
pass
tool_call_types = {}
for tc in tool_calls_log:
name = tc["name"]
tool_call_types[name] = tool_call_types.get(name, 0) + 1
return {
"is_error": len(errors) > 0 and not final_content,
"subtype": "success" if final_content else "error",
"duration_ms": int(duration_s * 1000),
"duration_api_ms": int(duration_s * 1000),
"num_turns": turns, "model": model_reported, "model_requested": model,
"provider": provider["__id"],
"usage": {
"prompt_tokens": total_usage["prompt_tokens"],
"completion_tokens": total_usage["completion_tokens"],
"total_tokens": total_usage["total_tokens"],
"cached_tokens": total_usage["cached_tokens"],
"cache_read_tokens": total_usage["cached_tokens"],
"cache_creation_tokens": 0,
"reasoning_tokens": total_usage["reasoning_tokens"],
"output_tokens_details": {
"thinking_tokens": total_usage["reasoning_tokens"],
},
},
"modelUsage": {
model_reported: {
"input_tokens": total_usage["prompt_tokens"],
"output_tokens": total_usage["completion_tokens"],
"cache_read_input_tokens": total_usage["cached_tokens"],
"cache_creation_input_tokens": 0,
"reasoning_tokens": total_usage["reasoning_tokens"],
}
},
"tool_calls": tool_calls_log,
"tool_call_count": len(tool_calls_log),
"tool_call_types": tool_call_types,
"written_files": written_files, "result": final_content,
"finish_reason": finish_reason, "errors": errors, "session_id": "",
"adapter": "python-glm-kimi", "adapter_version": "1.0.0",
}
# ---------------------------------------------------------------------------
# Hauptprogramm
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="TensorX API Adapter fuer run-experiment (GLM/Kimi)")
parser.add_argument("--prompt", required=True, help="Pfad zur combined_prompt.md")
parser.add_argument("--root", required=True, help="Root-Verzeichnis der Codebasis")
parser.add_argument("--output", required=True, help="Ausgabeverzeichnis (Ergebnisse)")
parser.add_argument("--model", required=True, help="Modell-ID (z.B. z-ai/glm-5.2, moonshotai/kimi-k3)")
parser.add_argument("--provider", default="tensorx", help="API-Provider (default: tensorx)")
parser.add_argument("--api-key", default=None, help="API-Key (default: aus Cline providers.json)")
parser.add_argument("--effort", default="high", choices=["low", "medium", "high", "xhigh", "max"])
parser.add_argument("--max-turns", type=int, default=50)
parser.add_argument("--temperature", type=float, default=1.0)
parser.add_argument("--timeout", type=int, default=0, help="Timeout in Sek (0=keins)")
parser.add_argument("--result-dir", default=None, help="Verzeichnis fuer RawResult.json")
args = parser.parse_args()
provider = PROVIDERS.get(args.provider, PROVIDERS["tensorx"]).copy()
provider["__id"] = args.provider
# API-Key: erst --api-key, dann Cline providers.json, dann Env-Var
api_key = args.api_key
base_url_override = None
if not api_key:
cline_key, cline_url = load_cline_api_key()
if cline_key:
api_key = cline_key
base_url_override = cline_url
sys.stderr.write("[glm-kimi-adapter] API-Key aus Cline providers.json gelesen.\n")
else:
api_key = os.environ.get(provider.get("env_key", ""), "")
if base_url_override:
provider["base_url"] = base_url_override
if not api_key:
sys.stderr.write(
"FEHLER: Kein API-Key gefunden. Weder --api-key, noch Cline providers.json, "
f"noch Umgebungsvariable {provider.get('env_key', '')}.\n"
)
sys.exit(2)
prompt_path = Path(args.prompt)
if not prompt_path.is_file():
sys.stderr.write(f"FEHLER: Prompt-Datei nicht gefunden: {args.prompt}\n")
sys.exit(2)
user_prompt = prompt_path.read_text(encoding="utf-8")
system_prompt = (
"Du bist ein Requirements Engineer im Reverse Requirements Engineering "
"eines Legacy-ERP-Systems. Du analysierst die Codebasis im Arbeitsverzeichnis "
"und erstellst eine Anforderungsspezifikation nach ISO/IEC/IEEE 29148:2018.\n\n"
"Werkzeuge: read_file, list_directory, search_files, execute_command, write_file.\n"
"Die Codebasis wird ausschliesslich GELESEN. Schreibe Ergebnisdateien mit "
"write_file ins Ausgabeverzeichnis. Sprache: Deutsch fuer Anforderungen."
)
output_dir = Path(args.output).resolve()
output_dir.mkdir(parents=True, exist_ok=True)
result_dir = Path(args.result_dir) if args.result_dir else output_dir.parent
result_dir.mkdir(parents=True, exist_ok=True)
start_iso = datetime.now(timezone.utc).isoformat()
sys.stderr.write(f"[glm-kimi-adapter] Start: {start_iso}\n")
sys.stderr.write(f"[glm-kimi-adapter] Provider: {provider['name']}\n")
sys.stderr.write(f"[glm-kimi-adapter] Modell: {args.model}\n")
sys.stderr.write(f"[glm-kimi-adapter] Effort: {args.effort}\n")
try:
result = run_agent_loop(
provider=provider, model=args.model, system_prompt=system_prompt,
user_prompt=user_prompt, api_key=api_key, effort=args.effort,
root=args.root, output_dir=str(output_dir), max_turns=args.max_turns,
temperature=args.temperature, timeout=args.timeout,
)
except Exception as e:
tb = traceback.format_exc()
sys.stderr.write(f"[glm-kimi-adapter] FEHLER: {e}\n{tb}\n")
result = {
"is_error": True, "subtype": "error", "error": str(e),
"duration_ms": 0, "num_turns": 0, "model": args.model,
"model_requested": args.model, "provider": args.provider,
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0,
"cached_tokens": 0, "cache_read_tokens": 0, "cache_creation_tokens": 0,
"reasoning_tokens": 0},
"modelUsage": {}, "tool_calls": [], "tool_call_count": 0,
"tool_call_types": {}, "written_files": [], "result": "",
"errors": [str(e)], "adapter": "python-glm-kimi", "adapter_version": "1.0.0",
}
end_iso = datetime.now(timezone.utc).isoformat()
result["start_time"] = start_iso
result["end_time"] = end_iso
raw_result_path = result_dir / "RawResult.json"
raw_result_path.write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8")
sys.stderr.write(f"[glm-kimi-adapter] Ende: {end_iso}\n")
sys.stderr.write(f"[glm-kimi-adapter] Turns: {result['num_turns']}\n")
sys.stderr.write(f"[glm-kimi-adapter] Tokens gesamt: {result['usage']['total_tokens']:,}\n")
sys.stderr.write(f"[glm-kimi-adapter] Tool-Calls: {result['tool_call_count']}\n")
sys.stderr.write(f"[glm-kimi-adapter] Ergebnisdateien: {len(result['written_files'])}\n")
sys.stderr.write(f"[glm-kimi-adapter] RawResult: {raw_result_path}\n")
sys.exit(1 if result["is_error"] else 0)
if __name__ == "__main__":
main()