neues modell
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
name: run-experiment
|
||||
description: Führt einen Versuchs-Prompt aus einer Prompt-Datei als messbaren Headless-Lauf mit Claude Code, Codex CLI oder OpenCode (TensorX oder lokales LM Studio) 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: 10.1.0
|
||||
version: 12.0.0
|
||||
---
|
||||
|
||||
# RunExperiment – Versuchslauf mit Messprotokoll
|
||||
@@ -1354,6 +1354,9 @@ der Historie unten – im selben Arbeitsschritt.
|
||||
|
||||
| Version | Änderung | Grund | Verwendet in |
|
||||
|---|---|---|---|
|
||||
| **12.0.0** | **Die Shell-Rechte des OpenCode-Adapters werden eine Denylist statt einer Allowlist** – spiegelbildlich zu den Claude-Eintraegen: alles erlaubt ausser den ausdruecklich gesperrten schreibenden und bauenden Kommandos (`rm`, `mv`, `sed -i`, schreibende `git`-Kommandos inkl. `fetch`/`pull`/`remote`, `dotnet`, `msbuild`, `npm install`, `Remove-Item`, `Set-Content`, `Out-File` u. a.). Das Catch-all `"*": "allow"` steht zuerst, weil OpenCode die **zuletzt passende** Regel gewinnen laesst. Zusaetzlich lehnt der Adapter `--stall-timeout > 0` jetzt fuer **jeden** lokalen Provider ab, nicht nur fuer die delegierenden Modi. Adapter-Version 2.0.0, vier neue Regressionstests. | Der Claude-Adapter erlaubt ueber eine Denylist jedes nicht gesperrte Kommando, der OpenCode-Adapter nur explizit Gelistetes. Die Werkzeugfreiheit war zwischen beiden **nie aequivalent** – ein Confounder fuer jeden Werkzeugvergleich. Ausloeser war der erste Qwen-Lauf (`v11.1.0-45b1`): Dessen **einzige beide** Werkzeugaufrufe, `Get-ChildItem ... | Format-Table ...`, wurden verweigert, weil die Allowlist nur Praefixe trifft und an Pipelines scheitert. Bei Gemma war das ein Randfall (1 von 106 Aufrufen), bei Qwen legte es den Lauf still. Der zweite Ausloeser: Qwen 27B benoetigte fuer einen einzelnen Schritt mehr als 15 Minuten, sodass der Stall-Timeout auch in `solo` Modellgeschwindigkeit als Haenger wertete. **Kontrolltest am 01.09.2026 bestaetigte beide Richtungen:** `Get-ChildItem ... | Format-Table Name` lief durch, `rm opfer.txt` wurde verweigert, und die Datei blieb auf der Platte. MAJOR: Die Toolfreigabe ist eine unabhaengige Variable; Laeufe ab dieser Version sind mit allen frueheren OpenCode- und TensorX-Laeufen nicht poolbar. | ab dem naechsten OpenCode-Lauf; Iterationen 10 und 11 bleiben unter der Allowlist |
|
||||
| **11.1.0** | **Der Adapter lehnt `--stall-timeout > 0` in den Modi `builtin` und `custom` ab.** Die Laufzeit wird dort ausschliesslich ueber `--max-runtime` begrenzt. Adapter-Version 1.3.0; Referenz und Aufrufbeschreibung entsprechend ergaenzt. | Der erste `builtin`-Lauf mit LM Studio (`v11.0.0-9ad0`) wurde nach 15:48 min abgebrochen, obwohl das Limit bei 60 min lag. Ursache war nicht das Modell: OpenCode sendet **keine Ereignisse, solange ein Subagent arbeitet**. Nach 8 Ereignissen in den ersten 39 Sekunden schwieg der Strom, waehrend der gestartete `explore`-Subagent lief; der Stall-Timeout deutete das als Haenger. Jeder Lauf mit Subagenten waere so zuverlaessig zu frueh gestorben. Die Kombination wird abgelehnt statt stillschweigend korrigiert, damit die Entscheidung bewusst faellt. MINOR: Es existierte noch **kein** gueltiger OpenCode-Lauf in `builtin` oder `custom`, dessen Bedingung sich dadurch aendern koennte; die `solo`-Laeufe waren nie betroffen, weil bei ihnen der Stall-Timeout nie griff. **Zu pruefen:** ob die als Fehler protokollierten TensorX-V2-Laeufe (Modus `custom`, `--stall-timeout 600`) dieselbe Ursache haben. | ab dem naechsten OpenCode-Lauf in `builtin` oder `custom` |
|
||||
| **11.0.0** | **Read-only-Shell-Allowlist des OpenCode-Adapters erweitert.** Neben `rg` und den lesenden `git`-Kommandos sind jetzt die verbreiteten POSIX-Werkzeuge `ls`, `cat`, `head`, `tail`, `find`, `grep`, `wc`, `file`, `stat`, `tree` sowie `dir`, `type`, `Get-Item` und `Measure-Object` freigegeben; `git log` und `git show` kommen hinzu. Alles Übrige bleibt `deny`. Zwei Regressionstests sichern die Liste ab: Sie muss die lesenden Kommandos enthalten und darf kein schreibendes enthalten. Adapter-Version 1.2.0. | Der erste LM-Studio-Lauf (`v10.1.0-b00a`, Gemma/solo) erzeugte zwei Permission-Denials auf `ls src` – das Kommando fehlte auf der Allowlist. Der Agent hatte damit kein POSIX-Mittel, Verzeichnisse aufzulisten, obwohl genau das laut Werkzeugkontext zur Bedingung gehört. Die Lücke benachteiligte OpenCode-Läufe gegenüber den Claude-Läufen, die mit einer Denylist arbeiten und deshalb jedes nicht ausdrücklich gesperrte Lesekommando erlauben. **MAJOR: Die Toolfreigabe ist eine unabhängige Variable.** Läufe ab dieser Version sind mit den bisherigen OpenCode- und TensorX-Läufen nicht poolbar; der nächste Lauf eröffnet eine neue Iteration. | ab dem nächsten OpenCode-Lauf; Iteration 10 bleibt unter der alten Allowlist |
|
||||
| **10.1.0** | **Lokaler LM-Studio-Adapter für `google/gemma-4-e4b` und `qwen/qwen3.8-27b`.** `opencode-tensorx-adapter.py` heißt jetzt `opencode-adapter.py` und wählt über `--provider {tensorx,lmstudio}` Gateway und Modellvorlage; die Referenz heißt entsprechend `references/opencode-adapter.md`. Neue keyfreie Vorlage `opencode-lmstudio.json` (`http://localhost:1234/v1`). Ein Preflight über `/api/v0/models` prüft Servererreichbarkeit, Modellverfügbarkeit, `tool_use`-Fähigkeit, geladenes Kontextfenster (`--min-context`, Standard 32768) und dass genau **eine** Modellinstanz geladen ist; `--lmstudio-autoload` stellt den Sollzustand per `lms unload`/`lms load` selbst her. Das geladene Fenster wird als `limit.context` in die Laufkonfiguration gepinnt. `RawResult.json` erhält `local_runtime` (Quantisierung, Architektur, Runtime, `lms`-Version, Instanzbezeichner, Kontextfenster), `context_window`, `cost_source` und providerübergreifend `effort_applied`. Neue Artefaktdatei `_meta/lmstudio-modelle.json`. Adapter-Version 1.1.0, fünf zusätzliche Unit-Tests. Zwei Korrekturen am gemeinsamen Pfad: Der Abbruchgrund wird nur noch einmal in `errors` vermerkt statt je Sekunde bis zum Prozessende, und die `lms`-Version wird aus dem ANSI-Banner der CLI sauber extrahiert. | Kapitel 4 sieht lokalen Betrieb als eigene Bedingung vor und fordert nach Kap. 4.3 Runtime samt Version und Quantisierungsstufe – beides liefert erst der Preflight. Drei Befunde aus der Inbetriebnahme sind direkt in den Adapter eingeflossen: LM Studio lädt Modelle standardmäßig mit nur 8192 Kontexttokens, was eine Codebasisanalyse stillschweigend abschneiden würde; ein erneutes `lms load` erzeugt eine **zweite** Instanz (`modell:2`), womit die `model`-Angabe der OpenAI-API nicht mehr eindeutig routet; und der lokale Endpunkt nimmt keinen Thinking-Level entgegen, weshalb Effort als nicht steuerbar auszuweisen ist statt als gesetzt. MINOR: neuer Provider und neue Messgrößen; für `--provider tensorx` bleiben Aufruf, Berechtigungen und Metriken unverändert – die vier bestehenden TensorX-Regressionstests laufen unverändert durch, sodass laufende V2-Läufe vergleichbar bleiben. Live-Smoke-Test am 31.08.2026 mit `google/gemma-4-e4b` (Q4_K_M, gguf, 32768 Tokens): Preflight bestanden, Providerauflösung, Streaming und Tool-Calling bestätigt. | ab dem ersten LM-Studio-Lauf |
|
||||
| **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 |
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -36,7 +36,7 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ADAPTER_VERSION = "1.1.0"
|
||||
ADAPTER_VERSION = "2.2.0"
|
||||
DEFAULT_PROVIDER = "tensorx"
|
||||
PROVIDER_ID = DEFAULT_PROVIDER
|
||||
PROVIDERS: dict[str, dict] = {
|
||||
@@ -155,20 +155,78 @@ def output_permission_patterns(
|
||||
return patterns
|
||||
|
||||
|
||||
# Schreibende und bauende Kommandos, spiegelbildlich zur Denylist des
|
||||
# Claude-Code-Adapters. Reihenfolge ist bedeutsam: OpenCode wertet die Regeln
|
||||
# der Reihe nach aus, die zuletzt passende gewinnt. Das Catch-all steht deshalb
|
||||
# zuerst, die Sperren danach.
|
||||
SHELL_DENY_COMMANDS = (
|
||||
# Dateien loeschen, verschieben, ueberschreiben (POSIX)
|
||||
"rm *",
|
||||
"rmdir *",
|
||||
"mv *",
|
||||
"cp *",
|
||||
"dd *",
|
||||
"truncate *",
|
||||
"chmod *",
|
||||
"chown *",
|
||||
"ln *",
|
||||
"tee *",
|
||||
"sed -i*",
|
||||
# Git, schreibend
|
||||
"git checkout*",
|
||||
"git restore*",
|
||||
"git clean*",
|
||||
"git reset*",
|
||||
"git add*",
|
||||
"git commit*",
|
||||
"git push*",
|
||||
"git fetch*",
|
||||
"git pull*",
|
||||
"git remote*",
|
||||
# Bauen und Paketverwaltung
|
||||
"dotnet *",
|
||||
"msbuild *",
|
||||
"npm install*",
|
||||
"nuget *",
|
||||
# PowerShell, schreibend
|
||||
"Remove-Item *",
|
||||
"Move-Item *",
|
||||
"Copy-Item *",
|
||||
"New-Item *",
|
||||
"Set-Content *",
|
||||
"Add-Content *",
|
||||
"Clear-Content *",
|
||||
"Out-File *",
|
||||
"Set-ItemProperty *",
|
||||
"Rename-Item *",
|
||||
)
|
||||
|
||||
|
||||
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",
|
||||
}
|
||||
"""Denylist schreibender und bauender Shell-Kommandos.
|
||||
|
||||
Ab Skill 12.0.0 eine **Denylist** statt der vorherigen Allowlist: Alles ist
|
||||
erlaubt, ausser den ausdruecklich gesperrten schreibenden und bauenden
|
||||
Kommandos. Damit entspricht die Werkzeugfreiheit der des Claude-Code-
|
||||
Adapters, der ebenfalls mit einer Denylist arbeitet - Voraussetzung dafuer,
|
||||
dass Laeufe beider Adapter als Werkzeugvergleich lesbar sind.
|
||||
|
||||
Die Allowlist konnte Kommandos nur als Praefix treffen und scheiterte
|
||||
deshalb an Pipelines: ``Get-ChildItem ... | Format-Table ...`` blieb
|
||||
gesperrt, obwohl ``Get-ChildItem`` erlaubt war.
|
||||
|
||||
OpenCode wertet die Regeln der Reihe nach aus; die **zuletzt passende Regel
|
||||
gewinnt**. Das Catch-all muss deshalb zuerst stehen. Python-Dicts erhalten
|
||||
ihre Einfuegereihenfolge, ``json.dump`` schreibt sie unveraendert.
|
||||
|
||||
Wie beim Claude-Adapter gilt: Mustervergleich auf Kommandozeilen ist nicht
|
||||
lueckenlos. Die belastbare Read-only-Garantie bleibt der Vorher/Nachher-
|
||||
Vergleich per ``git status``; die Denylist senkt das Risiko, sie ersetzt
|
||||
die Verifikation nicht.
|
||||
"""
|
||||
permissions = {"*": "allow"}
|
||||
permissions.update({muster: "deny" for muster in SHELL_DENY_COMMANDS})
|
||||
return permissions
|
||||
|
||||
|
||||
def task_permissions(mode: str, custom_names: list[str]) -> str | dict[str, str]:
|
||||
@@ -507,6 +565,27 @@ def normalize_result(
|
||||
"exit_code": exit_code,
|
||||
}
|
||||
|
||||
# Ein laufender Subagent taucht in der exportierten Session weder mit
|
||||
# eigenen Nachrichten noch mit Tokens auf. Wird der Lauf abgebrochen,
|
||||
# waehrend ein `task` noch laeuft, meldet OpenCode deshalb 0 Tokens - obwohl
|
||||
# gearbeitet wurde. Diese Null ist kein Messwert und darf nicht als solcher
|
||||
# ins Protokoll wandern.
|
||||
laufende_tasks = [
|
||||
tool
|
||||
for tool in tools
|
||||
if tool["name"] in ("task", "subagent") and tool["status"] == "running"
|
||||
]
|
||||
result["usage_captured"] = not (total_tokens == 0 and (tools or assistants))
|
||||
if not result["usage_captured"]:
|
||||
result["usage_note"] = (
|
||||
"nicht erfasst: OpenCode weist der Session keine Tokens zu"
|
||||
+ (
|
||||
f"; {len(laufende_tasks)} Subagent(en) liefen beim Abbruch noch"
|
||||
if laufende_tasks
|
||||
else ""
|
||||
)
|
||||
)
|
||||
|
||||
if local_runtime is not None:
|
||||
result["local_runtime"] = local_runtime
|
||||
result["context_window"] = local_runtime.get("loaded_context_length", 0)
|
||||
@@ -624,14 +703,39 @@ def run_lms(lms: Path, arguments: list[str], log, timeout: int = 1800) -> None:
|
||||
|
||||
|
||||
def lmstudio_reload_model(
|
||||
lms: Path, model: str, context_length: int, loaded_ids: list[str], log
|
||||
lms: Path,
|
||||
model: str,
|
||||
context_length: int,
|
||||
log,
|
||||
parallel: int = 4,
|
||||
gpu: str = "max",
|
||||
) -> None:
|
||||
"""Alle Instanzen des Modells entladen und genau eine neu laden."""
|
||||
for identifier in loaded_ids:
|
||||
run_lms(lms, ["unload", identifier], log, timeout=300)
|
||||
"""Saemtliche Modelle entladen und genau das angeforderte neu laden.
|
||||
|
||||
Entladen wird **alles**, nicht nur andere Instanzen desselben Modells: Ein
|
||||
nebenher geladenes zweites Modell belegt VRAM, das dem Lauf dann fehlt.
|
||||
Gemessen wurde der Extremfall - Gemma und Qwen 27B gleichzeitig geladen,
|
||||
15.836 von 16.303 MiB belegt, der Lauf fiel auf Bruchteile seines
|
||||
Durchsatzes zurueck.
|
||||
|
||||
``parallel=1`` gibt dem einen Agentenlauf den gesamten KV-Cache; jeder
|
||||
weitere Slot teilt ihn auf, ohne dass ein Einzellauf davon profitiert.
|
||||
``gpu=max`` erzwingt die vollstaendige Auslagerung auf die GPU.
|
||||
"""
|
||||
run_lms(lms, ["unload", "--all"], log, timeout=300)
|
||||
run_lms(
|
||||
lms,
|
||||
["load", model, "--context-length", str(context_length), "--yes"],
|
||||
[
|
||||
"load",
|
||||
model,
|
||||
"--context-length",
|
||||
str(context_length),
|
||||
"--parallel",
|
||||
str(parallel),
|
||||
"--gpu",
|
||||
gpu,
|
||||
"--yes",
|
||||
],
|
||||
log,
|
||||
)
|
||||
|
||||
@@ -644,6 +748,9 @@ def lmstudio_preflight(
|
||||
lms_path: str | None,
|
||||
catalog_dump: Path,
|
||||
log,
|
||||
parallel: int = 4,
|
||||
gpu: str = "max",
|
||||
context_target: str = "max",
|
||||
) -> dict:
|
||||
"""Prueft den lokalen Server und liefert die Runtime-Metadaten des Laufs.
|
||||
|
||||
@@ -673,6 +780,21 @@ def lmstudio_preflight(
|
||||
if item.get("state") == "loaded"
|
||||
]
|
||||
|
||||
def fremde_geladene(entries: list[dict]) -> list[str]:
|
||||
"""Geladene Modelle, die nicht das angeforderte sind.
|
||||
|
||||
Sie belegen VRAM, das dem Lauf fehlt, und veraendern damit dessen
|
||||
Durchsatz - eine Versuchsbedingung, die nicht unbemerkt bleiben darf.
|
||||
"""
|
||||
eigene = {str(item.get("id", "")) for item in lmstudio_instances(entries, model)}
|
||||
return [
|
||||
str(item.get("id", ""))
|
||||
for item in entries
|
||||
if item.get("state") == "loaded"
|
||||
and str(item.get("id", "")) not in eigene
|
||||
and item.get("type") != "embeddings"
|
||||
]
|
||||
|
||||
def select(entries: list[dict]) -> dict | None:
|
||||
instances = lmstudio_instances(entries, model)
|
||||
if not instances:
|
||||
@@ -720,6 +842,7 @@ def lmstudio_preflight(
|
||||
entry.get("state") != "loaded"
|
||||
or loaded_context < min_context
|
||||
or len(loaded_ids(catalog)) > 1
|
||||
or bool(fremde_geladene(catalog))
|
||||
)
|
||||
if needs_reload and autoload:
|
||||
if lms is None:
|
||||
@@ -727,8 +850,19 @@ def lmstudio_preflight(
|
||||
"--lmstudio-autoload benoetigt die 'lms'-CLI; sie wurde weder im "
|
||||
"PATH noch unter ~/.lmstudio/bin gefunden."
|
||||
)
|
||||
target_context = min(min_context, max_context) if max_context else min_context
|
||||
lmstudio_reload_model(lms, model, target_context, loaded_ids(catalog), log)
|
||||
# Freien VRAM in Kontext investieren statt verfallen lassen: Gemessen
|
||||
# kostet das Modellmaximum kaum Speicher und kein Tempo (gemma-4-e4b:
|
||||
# 131.072 statt 32.768 Kontext -> 6.854 statt 5.162 MiB, 46,8 statt
|
||||
# 48,3 tok/s). Ein groesseres Fenster ist fuer eine Codebasisanalyse
|
||||
# unmittelbar wirksam.
|
||||
if context_target == "max":
|
||||
target_context = max_context or min_context
|
||||
else:
|
||||
target_context = int(context_target)
|
||||
target_context = max(target_context, min_context)
|
||||
lmstudio_reload_model(
|
||||
lms, model, target_context, log, parallel=parallel, gpu=gpu
|
||||
)
|
||||
catalog = lmstudio_catalog(base_url)
|
||||
catalog_dump.write_text(
|
||||
json.dumps(catalog, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
@@ -749,6 +883,15 @@ def lmstudio_preflight(
|
||||
"Codebasis stillschweigend ab. Neu laden mit: "
|
||||
f"lms load {model} --context-length {min_context} --yes"
|
||||
)
|
||||
fremde = fremde_geladene(catalog)
|
||||
if fremde:
|
||||
raise RuntimeError(
|
||||
"Neben '" + model + "' sind weitere Modelle geladen ("
|
||||
+ ", ".join(fremde)
|
||||
+ "). Sie belegen VRAM, das dem Lauf fehlt, und veraendern dessen "
|
||||
"Durchsatz. Mit 'lms unload --all' entladen oder den Adapter mit "
|
||||
"--lmstudio-autoload aufrufen."
|
||||
)
|
||||
instances = loaded_ids(catalog)
|
||||
if len(instances) != 1:
|
||||
raise RuntimeError(
|
||||
@@ -772,6 +915,9 @@ def lmstudio_preflight(
|
||||
"capabilities": capabilities,
|
||||
"max_context_length": max_context,
|
||||
"loaded_context_length": loaded_context,
|
||||
"parallel_slots": parallel,
|
||||
"gpu_offload": gpu,
|
||||
"alleiniges_modell": True,
|
||||
}
|
||||
log(
|
||||
"LM-Studio-Preflight bestanden: "
|
||||
@@ -811,6 +957,29 @@ def main() -> int:
|
||||
default=LMSTUDIO_MIN_CONTEXT,
|
||||
help="Mindestgroesse des geladenen Kontextfensters (nur lmstudio)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lmstudio-parallel",
|
||||
type=int,
|
||||
default=4,
|
||||
help=(
|
||||
"Gleichzeitige Vorhersage-Slots beim Laden (nur lmstudio). Jeder "
|
||||
"Slot teilt den KV-Cache; ein einzelner Agentenlauf profitiert von "
|
||||
"1 und verliert bei mehr."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lmstudio-context",
|
||||
default="max",
|
||||
help=(
|
||||
"Kontextfenster beim Laden: 'max' fuer das Modellmaximum oder eine "
|
||||
"Tokenzahl (nur lmstudio). Nie kleiner als --min-context."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lmstudio-gpu",
|
||||
default="max",
|
||||
help="GPU-Offload beim Laden: off, max oder 0..1 (nur lmstudio)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lmstudio-autoload",
|
||||
action="store_true",
|
||||
@@ -820,7 +989,11 @@ def main() -> int:
|
||||
"--stall-timeout",
|
||||
type=int,
|
||||
default=600,
|
||||
help="Sekunden ohne stdout/stderr bis zum Abbruch; 0 deaktiviert",
|
||||
help=(
|
||||
"Sekunden ohne stdout/stderr bis zum Abbruch; 0 deaktiviert. "
|
||||
"In den Modi builtin und custom zwingend 0 - waehrend ein Subagent "
|
||||
"arbeitet, sendet OpenCode keine Ereignisse."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-runtime",
|
||||
@@ -855,6 +1028,35 @@ def main() -> int:
|
||||
if not template_path.is_file():
|
||||
parser.error(f"OpenCode-Konfiguration fehlt: {template_path}")
|
||||
|
||||
# OpenCode meldet keinen Fortschritt, solange ein Subagent arbeitet: Der
|
||||
# Ereignisstrom schweigt fuer die gesamte Dauer des Subagentenlaufs. Ein
|
||||
# positiver Stall-Timeout beendet einen Lauf mit Subagenten deshalb
|
||||
# zuverlaessig zu frueh und erzeugt eine Fehlmessung, die wie ein Haenger
|
||||
# aussieht. Der Fall wird nicht stillschweigend korrigiert, sondern
|
||||
# abgelehnt, damit die Entscheidung bewusst faellt.
|
||||
if args.mode != "solo" and args.stall_timeout > 0:
|
||||
parser.error(
|
||||
f"Modus '{args.mode}' laesst Subagenten zu; waehrend deren Laufzeit "
|
||||
"sendet OpenCode keine Ereignisse. Ein Stall-Timeout von "
|
||||
f"{args.stall_timeout}s wuerde den Lauf abbrechen, sobald ein "
|
||||
"Subagent arbeitet. '--stall-timeout 0' setzen und die Laufzeit "
|
||||
"ueber '--max-runtime' begrenzen."
|
||||
)
|
||||
# Lokale Inferenz ist um Groessenordnungen langsamer als ein Cloud-Gateway.
|
||||
# OpenCode sendet Ereignisse nur an Schrittgrenzen, sodass ein einzelner
|
||||
# Schritt die Stille beliebig lange ausdehnen kann - bei qwen/qwen3.8-27b
|
||||
# ueber 15 Minuten. Ein Stall-Timeout misst dann Modellgeschwindigkeit
|
||||
# statt Haenger.
|
||||
if provider_spec.get("local") and args.stall_timeout > 0:
|
||||
parser.error(
|
||||
f"Provider '{provider}' laeuft lokal; ein einzelner Schritt kann "
|
||||
"laenger dauern als jeder sinnvolle Stall-Timeout, ohne dass ein "
|
||||
f"Ereignis faellt. Ein Stall-Timeout von {args.stall_timeout}s "
|
||||
"wuerde die Modellgeschwindigkeit als Haenger werten. "
|
||||
"'--stall-timeout 0' setzen und die Laufzeit ueber "
|
||||
"'--max-runtime' begrenzen."
|
||||
)
|
||||
|
||||
opencode = resolve_opencode(args.opencode)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
result_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -895,6 +1097,9 @@ def main() -> int:
|
||||
lms_path=args.lms,
|
||||
catalog_dump=meta_dir / "lmstudio-modelle.json",
|
||||
log=log,
|
||||
parallel=args.lmstudio_parallel,
|
||||
gpu=args.lmstudio_gpu,
|
||||
context_target=args.lmstudio_context,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
log(f"Preflight fehlgeschlagen: {exc}")
|
||||
|
||||
@@ -65,7 +65,7 @@ damit keine interaktive Oberfläche benötigt wird. Der Prompt wird über stdin
|
||||
|
||||
Die Berechtigungen beginnen mit `deny` und erlauben gezielt:
|
||||
|
||||
- Lesen, Suchen, Auflisten sowie eine kleine Read-only-Shell-Allowlist;
|
||||
- Lesen, Suchen, Auflisten sowie eine 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;
|
||||
@@ -76,6 +76,27 @@ Git-Worktree-Wurzel relative Allow-Patterns. Das ist unter Windows notwendig, we
|
||||
Laufverzeichnis im selben Git-Worktree liegen, das Laufverzeichnis aber außerhalb des
|
||||
Root-Unterordners liegt.
|
||||
|
||||
Die Shell-Rechte sind seit Skill 12.0.0 eine **Denylist**, spiegelbildlich zum
|
||||
Claude-Code-Adapter: Erlaubt ist alles, gesperrt sind ausdrücklich die schreibenden und
|
||||
bauenden Kommandos – `rm`, `rmdir`, `mv`, `cp`, `dd`, `truncate`, `chmod`, `chown`, `ln`,
|
||||
`tee`, `sed -i`, die schreibenden `git`-Kommandos einschließlich `fetch`, `pull` und `remote`,
|
||||
`dotnet`, `msbuild`, `npm install`, `nuget` sowie `Remove-Item`, `Move-Item`, `Copy-Item`,
|
||||
`New-Item`, `Set-Content`, `Add-Content`, `Clear-Content`, `Out-File`, `Set-ItemProperty` und
|
||||
`Rename-Item`.
|
||||
|
||||
**Die Reihenfolge ist bedeutsam.** OpenCode wertet die Regeln der Reihe nach aus; die *zuletzt
|
||||
passende* Regel gewinnt – nicht die spezifischste, und `deny` gewinnt nicht automatisch. Das
|
||||
Catch-all `"*": "allow"` muss deshalb **zuerst** stehen, die Sperren danach. Python-Dicts
|
||||
erhalten ihre Einfügereihenfolge, `json.dump` schreibt sie unverändert.
|
||||
|
||||
Die frühere Allowlist konnte Kommandos nur als Präfix treffen und scheiterte deshalb an
|
||||
Pipelines: `Get-ChildItem ... | Format-Table ...` blieb gesperrt, obwohl `Get-ChildItem`
|
||||
erlaubt war. Zugleich war die Werkzeugfreiheit nicht mit der des Claude-Adapters vergleichbar.
|
||||
|
||||
Wie beim Claude-Adapter gilt: Mustervergleich auf Kommandozeilen ist **nicht lückenlos**. Die
|
||||
belastbare Read-only-Garantie bleibt der Vorher/Nachher-Vergleich per `git status`; die
|
||||
Denylist senkt das Risiko, sie ersetzt die Verifikation nicht.
|
||||
|
||||
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.
|
||||
@@ -120,6 +141,37 @@ Codebasisanalyse nicht.
|
||||
Das geladene Fenster wird zusätzlich als `limit.context` in die Laufkonfiguration geschrieben,
|
||||
damit OpenCode nicht mehr Kontext sendet, als der Server vorhält.
|
||||
|
||||
### Speicherhygiene und Ladeparameter
|
||||
|
||||
Der lokale Durchsatz haengt fast vollstaendig davon ab, ob Gewichte und KV-Cache in den VRAM
|
||||
passen. Gemessen am 01.09.2026 auf einer RTX 5080 Laptop GPU (16.303 MiB):
|
||||
|
||||
| Konfiguration | VRAM belegt | Generierung |
|
||||
|---|---:|---:|
|
||||
| `gemma-4-e4b`, 32k, parallel 1 | 5.162 MiB | 48,3 tok/s |
|
||||
| `gemma-4-e4b`, 32k, parallel 4 | 5.222 MiB | 47,7 tok/s |
|
||||
| `gemma-4-e4b`, **131k**, parallel 4 | 6.854 MiB | 46,8 tok/s |
|
||||
| `qwen3.8-27b`, 32k, `--gpu max` | 15.696 MiB (308 frei) | Abbruch nach 10 min |
|
||||
| beide Modelle gleichzeitig geladen | 15.836 MiB (168 frei) | 0,028 Mio. Tokens/h |
|
||||
|
||||
Daraus die drei Regeln, die der Preflight seit Adapter 2.2.0 durchsetzt:
|
||||
|
||||
1. **Genau ein Modell ist geladen.** `--lmstudio-autoload` entlaedt `--all` und laedt nur das
|
||||
angeforderte Modell; ein fremdes geladenes Modell fuehrt sonst zum Abbruch. Ein nebenher
|
||||
geladenes Modell belegt VRAM, das dem Lauf fehlt, und veraendert dessen Durchsatz um
|
||||
Groessenordnungen.
|
||||
2. **Der Kontext wird auf das Modellmaximum geladen** (`--lmstudio-context max`, Standard).
|
||||
Freier VRAM ist in Kontext besser investiert als ungenutzt: vierfaches Fenster fuer 1,7 GB
|
||||
und 1,5 tok/s. `--min-context` bleibt die Gueltigkeitsschwelle, nicht die Ladegroesse.
|
||||
3. **`--lmstudio-gpu max`** erzwingt die vollstaendige Auslagerung, **`--lmstudio-parallel 4`**
|
||||
ist messbar kostenneutral und hilft, wenn Subagenten nebenlaeufig anfragen.
|
||||
|
||||
**Modelle jenseits der VRAM-Grenze sind nicht messbar.** `qwen3.8-27b` belegt mit 17,74 GB
|
||||
Gewichten mehr, als die Karte hat; der Rest laeuft auf der CPU. Auch eine kleinere
|
||||
Quantisierung loest das nicht, weil der KV-Cache eines 27B-Modells bei brauchbarem Kontext
|
||||
mehrere GB zusaetzlich fordert. Fuer eine 16-GB-Karte ist die 9B-Klasse die groesste, die mit
|
||||
vollem Fenster hineinpasst - dann aber in hoher Quantisierung, um den Speicher zu nutzen.
|
||||
|
||||
### Zusätzliche Laufartefakte und Messfelder
|
||||
|
||||
| Datei | Inhalt |
|
||||
@@ -190,7 +242,14 @@ Port oder Installationsort abweichen.
|
||||
|
||||
`--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
|
||||
`--stall-timeout 0` deaktiviert diese Sicherung.
|
||||
|
||||
**In den Modi `builtin` und `custom` ist `--stall-timeout 0` zwingend.** OpenCode sendet
|
||||
**keine Ereignisse, solange ein Subagent arbeitet** – der Ereignisstrom schweigt für dessen
|
||||
gesamte Laufzeit. Jeder positive Stall-Timeout bricht den Lauf deshalb ab, sobald der erste
|
||||
Subagent startet, und erzeugt eine Fehlmessung, die wie ein Hänger aussieht. Der Adapter lehnt
|
||||
diese Kombination seit Version 1.3.0 mit einer Fehlermeldung ab, statt sie stillschweigend zu
|
||||
korrigieren. Die Laufzeit wird in diesen Modi ausschließlich über `--max-runtime` begrenzt. `--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.
|
||||
|
||||
@@ -160,6 +160,91 @@ class OpenCodeAdapterTests(unittest.TestCase):
|
||||
self.assertEqual(1, len(result["written_files"]))
|
||||
|
||||
|
||||
class ReadonlyShellTests(unittest.TestCase):
|
||||
"""Denylist: alles erlaubt ausser schreibenden und bauenden Kommandos."""
|
||||
|
||||
def test_catch_all_allow_comes_first(self):
|
||||
# OpenCode wertet der Reihe nach aus, die letzte passende Regel gewinnt.
|
||||
# Stuende das Catch-all hinten, waere jede Sperre wirkungslos.
|
||||
perms = ADAPTER.readonly_shell_permissions()
|
||||
self.assertEqual("allow", perms["*"])
|
||||
self.assertEqual("*", next(iter(perms)))
|
||||
|
||||
def test_writing_and_building_commands_are_denied(self):
|
||||
perms = ADAPTER.readonly_shell_permissions()
|
||||
for muster in ("rm *", "mv *", "sed -i*", "git commit*", "git push*",
|
||||
"dotnet *", "msbuild *", "npm install*",
|
||||
"Remove-Item *", "Set-Content *", "Out-File *"):
|
||||
self.assertEqual("deny", perms[muster], muster)
|
||||
|
||||
def test_reading_commands_need_no_rule(self):
|
||||
# Der Kern der Umstellung: Lesekommandos - auch als Pipeline - sind
|
||||
# erlaubt, ohne einzeln aufgefuehrt zu sein. Genau daran scheiterte
|
||||
# 'Get-ChildItem ... | Format-Table ...' unter der Allowlist.
|
||||
perms = ADAPTER.readonly_shell_permissions()
|
||||
for kommando in ("ls -la", "cat foo.cs", "rg muster",
|
||||
"Get-ChildItem -Recurse | Format-Table Name",
|
||||
"find . -name *.cs"):
|
||||
self.assertNotIn(kommando, perms)
|
||||
self.assertEqual("allow", perms["*"])
|
||||
|
||||
def test_no_reading_command_is_denied(self):
|
||||
verboten_praefixe = ("ls", "cat", "rg", "grep", "find", "head", "tail",
|
||||
"Get-ChildItem", "Get-Content", "Select-String",
|
||||
"git status", "git log", "dir", "type")
|
||||
for muster, aktion in ADAPTER.readonly_shell_permissions().items():
|
||||
if aktion != "deny":
|
||||
continue
|
||||
for praefix in verboten_praefixe:
|
||||
self.assertFalse(
|
||||
muster.lower().startswith(praefix.lower()),
|
||||
f"Lesekommando gesperrt: {muster}",
|
||||
)
|
||||
|
||||
|
||||
class UsageCapturedTests(unittest.TestCase):
|
||||
"""Eine Null aus einem laufenden Subagenten ist kein Messwert."""
|
||||
|
||||
def _result(self, session, output):
|
||||
return ADAPTER.normalize_result(
|
||||
session=session, events=[], model_ref="lmstudio/google/gemma-4-e4b",
|
||||
mode="builtin", effort="high", exit_code=1, timed_out=True,
|
||||
interrupted=False, duration_s=3600.0, output_dir=output, errors=[],
|
||||
provider="lmstudio", effort_applied=False,
|
||||
)
|
||||
|
||||
def test_running_subagent_with_zero_tokens_is_not_captured(self):
|
||||
session = {
|
||||
"info": {"model": {"id": "google/gemma-4-e4b"},
|
||||
"tokens": {"input": 0, "output": 0, "reasoning": 0,
|
||||
"cache": {"read": 0, "write": 0}}},
|
||||
"messages": [{
|
||||
"info": {"role": "assistant"},
|
||||
"parts": [{"type": "tool", "tool": "task",
|
||||
"state": {"status": "running",
|
||||
"input": {"subagent_type": "explore"}}}],
|
||||
}],
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
result = self._result(session, Path(temp_dir))
|
||||
self.assertFalse(result["usage_captured"])
|
||||
self.assertIn("nicht erfasst", result["usage_note"])
|
||||
self.assertIn("1 Subagent(en)", result["usage_note"])
|
||||
|
||||
def test_real_token_counts_are_marked_captured(self):
|
||||
session = {
|
||||
"info": {"model": {"id": "google/gemma-4-e4b"},
|
||||
"tokens": {"input": 100, "output": 10, "reasoning": 0,
|
||||
"cache": {"read": 0, "write": 0}}},
|
||||
"messages": [{"info": {"role": "assistant"},
|
||||
"parts": [{"type": "text", "text": "ok"}]}],
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
result = self._result(session, Path(temp_dir))
|
||||
self.assertTrue(result["usage_captured"])
|
||||
self.assertNotIn("usage_note", result)
|
||||
|
||||
|
||||
class OpenCodeLmStudioTests(unittest.TestCase):
|
||||
def test_model_reference_uses_lmstudio_provider(self):
|
||||
self.assertEqual(
|
||||
|
||||
Reference in New Issue
Block a user