diff --git a/.claude/skills/run-experiment/SKILL.md b/.claude/skills/run-experiment/SKILL.md index aa54ef97..50905094 100644 --- a/.claude/skills/run-experiment/SKILL.md +++ b/.claude/skills/run-experiment/SKILL.md @@ -1,8 +1,8 @@ --- name: run-experiment -description: Führt einen Versuchs-Prompt aus einer Prompt-Datei als messbaren Headless-Lauf mit Claude Code oder Codex CLI aus und schreibt ein Messprotokoll mit Start-/Endzeit, Modell, Tokenverbrauch und weiteren Metriken. Verwenden bei "/run-experiment " oder wenn der User einen Versuch/ein Experiment ausführen und tracken will. +description: Führt einen Versuchs-Prompt aus einer Prompt-Datei als messbaren Headless-Lauf mit Claude Code, Codex CLI oder dem Python-API-Adapter (GLM/Kimi) aus und schreibt ein Messprotokoll mit Start-/Endzeit, Modell, Tokenverbrauch und weiteren Metriken. Verwenden bei "/run-experiment " oder wenn der User einen Versuch/ein Experiment ausführen und tracken will. argument-hint: -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 "\$zelle" "_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 = "" +$lauf = "" +$root = "" +$modell = "" +$effort = "" + +# Prompt zusammenstellen (wie bei den anderen Adaptern) +$prompt = (Get-Content "" -Raw) + "`n`n`n`n" +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 | diff --git a/.claude/skills/run-experiment/__pycache__/glm-kimi-adapter.cpython-313.pyc b/.claude/skills/run-experiment/__pycache__/glm-kimi-adapter.cpython-313.pyc new file mode 100644 index 00000000..33041543 Binary files /dev/null and b/.claude/skills/run-experiment/__pycache__/glm-kimi-adapter.cpython-313.pyc differ diff --git a/.claude/skills/run-experiment/glm-kimi-adapter.py b/.claude/skills/run-experiment/glm-kimi-adapter.py new file mode 100644 index 00000000..316faa65 --- /dev/null +++ b/.claude/skills/run-experiment/glm-kimi-adapter.py @@ -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 \ + --root \ + --output \ + --model \ + --effort \ + [--max-turns 50] \ + [--temperature 1.0] \ + [--timeout 0] + +Ausgaben: + /RawResult.json – normalisierte Messdaten + /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() + +