ZeroClaw 0.7.5 → 0.8.0: Das Upgrade, das den `<tool_call>`-Spam beendet hat

Eine schwierige, brechende Pre-1.0-Migration: Schema V3, Multi-Agent-Runtime und ein neues On-Disk-Layout — angetrieben von einem einzigen, hartnäckigen Bug. Mein KI-Agent kippte rohe <tool_call>-Blöcke in Matrix, die nie ausgeführt wurden. Warum v0.8.0 der eigentliche Fix war, was alles dabei zerbrach und welche drei Fehler erst zur Laufzeit auftauchten.
Table of contents

In meinem Lab läuft seit Wochen ein KI-Agent mit — ZeroClaw , im Chat nur Claw —, der die Cluster überwacht, mir auf Matrix Bescheid gibt und Reparaturen als Radicle-Patches vorschlägt statt sie selbst durchzudrücken. Über genau einen Bug bin ich dabei so lange gestolpert, dass er mich am Ende zu einer der unangenehmsten Migrationen des ganzen Setups getrieben hat: dem Sprung von v0.7.5 auf v0.8.0. Das ist ein brechendes Pre-1.0-Release — ein von Grund auf neu geschriebenes Config-Schema, eine Multi-Agent-Runtime und ein neues On-Disk-Layout. Dieser Beitrag erzählt, warum ich diese Migration trotzdem unbedingt wollte, was dabei alles zerbrochen ist und welche Fehler mir erst zur Laufzeit um die Ohren geflogen sind.

Der Bug, der alles antrieb: <tool_call>-Spam

Das Symptom war hässlich und gut sichtbar: In meinen Matrix- und IRC-Räumen tauchten plötzlich rohe Blöcke der Form

<tool_call>{"name":"shell","arguments":{"command":"kubectl get nodes"}}</tool_call>

mitten in den Antworten auf. Schlimmer noch — diese Tool-Aufrufe wurden nie ausgeführt. Claw behauptete zu handeln, kippte den Aufruf als Text in den Kanal und tat dann nichts. Ein Agent, der Werkzeug-Aufrufe nur narriert statt sie auszuführen, ist als Operator wertlos. Damit verletzte er obendrein die eine Regel, die in seinem Workspace festgeschrieben steht: Jede Antwort im Kanal muss mindestens einen menschenlesbaren Satz enthalten — kein nacktes JSON, keine rohen Tool-Aufrufe.

Die Ursache saß tief im Provider-Pfad. Der Ollama-Provider ist in ZeroClaw als OpenAI-kompatibler Client (/v1) gebaut, mit nativem Tool-Calling per Default. Der Compat-Pfad in v0.7.5 hatte aber keinen prompt-geführten XML-Parser: Jedes Modell, das seine Tool-Aufrufe als inline-<tool_call>-Text im Content-Feld zurückgab — statt im strukturierten tool_calls-Feld —, landete damit unverändert im Kanal und wurde nie ausgeführt.

Welches Modell das tat, hing am Routing:

ModellVerhalten
minimax-m2.7:cloudgab Tool-Aufrufe konsequent als inline-<tool_call> zurück
qwen3.5:cloud500er-Stürme, die den Failover auf kimi erzwangen
kimi-k2.5:cloudsauber — lapste nur als model_fallbacks-Failover-Ziel, wenn es einen qwen-500 auffing

Der native_tools-Knopf war eine Sackgasse. Die naheliegende Hoffnung war ein per-Provider-Schalter, der inline-Tool-Calls erzwingt oder unterbindet. Die Upstream-PRs, die genau das hinzufügen wollten (

#5523 ,

#4295 ,

#5764 ), wurden allesamt ungemergt geschlossen. Diesen Config-Key zu jagen, war vergeblich.

Die Notlösung in v0.7.5 — und warum sie nicht reichte

Solange kein Fix existierte, habe ich den Leak mit roher Gewalt zugeklebt. Drei Schichten, alle defensiv:

  • Alles auf ein sauberes Modell routen. Jeder Hint (hint:monitoring, hint:reasoning, hint:tools) zeigte auf kimi-k2.5:cloud. minimax und qwen flogen komplett raus.
  • Failover eliminieren. model_fallbacks entfernt, fallback_providers = []. Wenn es keinen Failover-Pfad gibt, kann auch keiner auf ein leakendes Modell lapsen.
  • Den Cron-Store bei jedem Boot wischen. Der Init-Container löschte cron/jobs.db* bei jedem Start, damit keine imperativ angelegten Cron-Jobs (via zeroclaw cron add) überlebten, die heimlich noch ein verbanntes Modell pinnten.

Das funktionierte — aber es war eine Mauer aus Workarounds um ein Loch, das ich nicht stopfen konnte. Festgehalten war das Ganze als Radicle-Issue b3e0719, ausdrücklich blockiert auf „ein Upstream-Release > v0.7.5, das die inline-Tool-Call-Form behandelt oder den Channel-Output säubert". Mit anderen Worten: Der Bug war in v0.7.5 strukturell unfixbar. Ich brauchte das nächste Major.

Warum v0.8.0 der eigentliche Fix ist

Mit v0.8.0 (stable seit 2026-06-12) kam der Fix, auf den b3e0719 gewartet hat — und es ist nicht der native_tools-Knopf, sondern ein Runtime-Parser:

**#6675 `strict_tool_parsing`** (gemergt 2026-05-22) tut zwei Dinge:

  1. Es behält inline-XML/Text-„Tool-Aufrufe" als finalen Assistant-Text, statt sie durch den permissiven Fallback-Parser zu jagen.
  2. Es versteckt die Text-Tool-Prompt-Anweisungen, wenn keine nativen Tool-Specs verfügbar sind — ein Modell mit sauberem strukturiertem Tool-Calling (mein kimi-k2.5:cloud) wird also gar nicht erst dazu verleitet, inline-<tool_call> zu emittieren.

Gepaart mit #6544 (omit native tool prompt catalog) schließt das die Lücke an der Wurzel, nicht erst am Kanal-Filter. Damit wird meine kimi-only-Routing-Entscheidung von der einzigen Verteidigung zur Defense-in-depth — sie darf bleiben, ist aber nicht mehr das, was den Spam verhindert.

Skeptisch bleiben, bis es bewiesen ist. Das v0.8.0-Changelog listet keinen expliziten „parse inline <tool_call> text"-Fix. Die nächstliegenden Einträge sind „Ollama über compatible.rs an /v1/chat/completions" und der __-Separator in Tool-Namen (

#6732 ). Ob das b3e0719 löst, war vorab nicht garantiert. Deshalb stand vor dem Live-Upgrade ein Wegwerf-Smoke-Test mit genau einer Akzeptanzfrage: Ist der Leak weg und werden Tools wirklich ausgeführt?

Warum die Migration schwierig war

v0.8.0 ist kein Patch-Bump. Es ist ein breaking Pre-1.0-Release, das gleich drei tragende Säulen austauscht: das Config-Schema (V2 → V3), das Laufzeitmodell (Single- → Multi-Agent) und das On-Disk-Layout. Die Migration berührte fast jede Datei im ZeroClaw-Stack. Hier die Änderungen, nach Blast-Radius sortiert.

Schema V3 — und warum GitOps das Auto-Migrate verbietet

v0.8.0 migriert die Config bei jedem Load in-memory von V2 nach V3; zeroclaw config migrate schreibt die aufgewertete Datei mit einem .backup auf Platte. Klingt bequem — ist in einem GitOps-Setup aber eine Falle. Meine Config wird bei jedem Boot vom Init-Container aus dem GitOps-Template neu gerendert (schema_version = 2). Würde ich mich aufs In-Memory-Migrate verlassen, liefe bei jedem Start eine Migration, und das On-Disk-Template würde von dem abweichen, was der Daemon tatsächlich fährt.

Also: einmal off-cluster das V2-Template rendern, zeroclaw config migrate laufen lassen und das resultierende V3-zeroclaw.toml als neue GitOps-Quelle committen. Git ist die Autorität, nicht der Daemon.

[autonomy] ist weg → Risk- und Runtime-Profile

Der größte Einzel-Umbau. Der fette [autonomy]-Block wird ersatzlos aufgelöst und auf zwei Profil-Arten verteilt:

Alt: [autonomy]-FeldNeu
allowed_commands, allowed_roots, forbidden_paths, auto_approve, block_high_risk_commands, Sandbox[risk_profiles.<alias>] (Autorisierung)
max_actions_per_hour, max_cost_per_day_cents, shell_timeout_secs (Budgets/Timeouts)[runtime_profiles.<alias>]

Jeder Agent referenziert dann je eines via agents.<alias>.risk_profile / runtime_profile.

Sandbox und Ressourcen-Limits

[security.sandbox] wandert flach aufs Risk-Profil (risk_profiles.default.sandbox_enabled + sandbox_backend = "auto"). Interessanter ist, was verschwindet: [security.resources] (max_memory_mb, max_cpu_time_seconds, max_subprocesses) wird ersatzlos gestrichen — kein Feld auf dem Risk-Profil ersetzt es. Die Ressourcen-Decke kommt jetzt ausschließlich aus den Container-Limits in deployment.yaml (der Pod cappt ohnehin bei 2Gi). [security.audit]/otp/estop bleiben dagegen unverändert unter [security].

On-Disk-Layout — das schreibt den Init-Container neu

Das ist der Deployment-seitige Blast-Radius. v0.8.0 splittet den Baum:

v0.7.5v0.8.0
<config-dir>/workspace/ (einzeln)<install>/agents/<alias>/workspace/ (pro Agent = Sicherheitsgrenze)
Config.workspace_dir, [workspace]Config.data_dir<config-dir>/data/; geteilte DBs darunter
Skills im Workspacehost-weite Skills unter <install>/shared/skills/

Konkret hieß das im deployment.yaml: die init-workspace-rsync-Ziele von /seed/workspaces/ auf agents/default/workspace/ umbiegen, die PVC-subPaths/Mounts auf den neuen state-Baum anpassen — und zwei alte Hacks ersatzlos entfernen:

  • den FIXME-Symlink workspaces/main → workspace (ein v0.7.5-Workaround dafür, dass der Daemon Multi-Workspace-Settings ignorierte — v0.8.0 hat jetzt echte Multi-Agent-Unterstützung), und
  • den Cron-Store-Wipe bei jedem Boot (der existierte nur, um den <tool_call>-Leak einzudämmen — den fixt jetzt der Parser).

Der Rest der brechenden Kleinarbeit

  • Provider-Nesting: [providers.models.ollama][providers.models.ollama.default] (typisiert <type>.<alias>), und base_url heißt jetzt uri. Die [[providers.model_routes]] wandern auf top-level [[model_routes]].
  • Channels: Die per-Channel-allowed_users fallen über alle Kanäle weg; Inbound-Auth lebt jetzt in [peer_groups], und das Migrate synthetisiert [peer_groups.<type>_default] aus den alten Allowlists.
  • Cron-Bindung: Aus [[cron.jobs]] werden benannte Tabellen [cron.<name>]; die Agent→Cron-Bindung ist die agent-seitige Liste agents.default.cron_jobs = [...].
  • Identity & Env-Grammatik: [identity] wird per-Agent; alle Legacy-Env-Overrides folgen jetzt der Grammatik ZEROCLAW_<lowercase_dotted_path>=<value> (nur ZEROCLAW_WORKSPACE/ZEROCLAW_CONFIG_DIR behalten die Uppercase-Form).
  • Provider-Failover ist eradiziert — und das war bei mir schon erledigt: V3 entfernt reliability.fallback_providers und model_fallbacks ganz; ein fehlschlagender Call retried nur noch 3× dasselbe Modell. Meine „alles auf kimi, kein Failover"-Entscheidung deckt sich exakt mit dem neuen Verhalten — kein Behavior-Change.

So sah der Kern der Umstellung im zeroclaw.toml aus — vorher/nachher, auf das Wesentliche eingedampft:

# --- v0.7.5 (Schema V2) ---
[autonomy]
auto_approve = true
max_actions_per_hour = 120
shell_timeout_secs = 90

[security.sandbox]
enabled = true
[security.resources]
max_memory_mb = 1536

[providers.models.ollama]
base_url = "http://ollama.ollama:11434"

[[providers.model_routes]]
hint = "monitoring"
model = "kimi-k2.5:cloud"

[[cron.jobs]]
name = "platform_pulse"
[channels.matrix]
allowed_users = ["@ff0x:…"]
# --- v0.8.0 (Schema V3) ---
[risk_profiles.default]
sandbox_enabled = true
sandbox_backend = "auto"
auto_approve = true

[runtime_profiles.default]
max_actions_per_hour = 120
shell_timeout_secs = 90
strict_tool_parsing = true        # <-- der b3e0719-Fix (opt-in!)

[providers.models.ollama.default]
name = "ollama"                   # NICHT kind = "ollama" (s.u.)
uri = "http://ollama.ollama:11434"

[[model_routes]]
hint = "monitoring"
model = "kimi-k2.5:cloud"
model_provider = "ollama"

[agents.default]
model_provider = "ollama.default"
risk_profile   = "default"
runtime_profile = "default"
cron_jobs = ["platform_pulse", "flux_health_check", "…"]
channels  = ["matrix.default", "irc.default"]

[cron.platform_pulse]
[cron.platform_pulse.delivery]
channel = "matrix.default"

[peer_groups.matrix_default]
members = ["@ff0x:…"]
strict_tool_parsing ist opt-in. config migrate setzt es nicht — es muss von Hand ins Runtime-Profil und liegt im GitOps-Template. Das ist genau der Punkt, der den ganzen Aufwand rechtfertigt; entsprechend war es das Erste, was im Smoke-Test geprüft wurde.

Die Prozedur

Die Reihenfolge war bewusst vorsichtig — ein Layout-Split, der schiefgeht, forkt die signierte Ref-Kette des Bots im Radicle -Store und kann den Matrix-E2EE -State zerlegen.

flowchart TB A["1. Precondition prüfen<br/>arm64 -debian Image in ghcr?"] --> B["2. State sichern<br/>PVC-Snapshot (VolSync) + Radicle-Identity"] B --> C["3. V3-Config off-cluster generieren<br/>config migrate → committen"] C --> D["4. Wegwerf-Smoke-Test<br/>strict_tool_parsing=true, Test-Matrix-Raum<br/>Leak weg? Tools laufen?"] D --> E["5. deployment.yaml umbauen<br/>data/ · agents/default/ · Symlink-Hack raus"] E --> F["6. ZEROCLAW_V bumpen<br/>SourceHut-CI, Image auf git-SHA pinnen"] F --> G["7. GitOps-Deploy<br/>Flux → Reloader restart"] G --> H["8. Verifizieren"]

Die Precondition war erfüllt: Mein Cluster ist arm64 (Turing RK1), und das v0.8.0-debian-Image liegt multi-arch (linux/amd64 + linux/arm64) in der ghcr. Renovate beobachtet die ARG ZEROCLAW_V-Zeile im Dockerfile.zeroclaw ohnehin und hat den v0.8.0-Tag von selbst als PR hochgespült.

Die ehrliche Wahrheit: drei Fehler tauchten erst zur Laufzeit auf

Hier wird es interessant — und unangenehm. Das config migrate lief sauber durch, die Ausgabe bestand config list (Exit 0, 1003 Properties). Trotzdem hat der Daemon die Config zur Laufzeit abgelehnt. Drei Probleme, die ein statischer Check schlicht nicht sieht:

  1. Unknown model_provider family "ollama.default". Das Migrate hatte das V2-kind = "ollama" auf dem Provider stehen lassen, statt es auf V3s name = "ollama" umzubenennen. Ohne name findet die Route-Resolution die Familie nicht. (Plus: ein leerer [providers.models.ollama]-Stub, den das Migrate als Müll hinterließ, musste raus.)
  2. Alle Cron-Jobs wurden stillschweigend übersprungen (no [agents.<x>].cron_jobs entry claims this id). V3 verlangt, dass der Agent seine Jobs explizit beansprucht. Lösung: agents.default.cron_jobs = [… alle 11 IDs].
  3. peer_groups … has no entry of type "irc". Behoben mit agents.default.channels = ["matrix.default", "irc.default"].

Und dann der empfindlichste Teil: Matrix-E2EE brach beim ersten V3-Boot. Der Krypto-Store war nach state/matrix/default/store (per-Alias) gewandert, und der Daemon legte stur ein frisches, leeres Device an — das hätte alle verifizierten Sessions weggeworfen. Den Fix gab es erst manuell (den originalen state/matrix/store + session.json nach …/default/ ziehen), dann als idempotenten Schritt im Init-Container festgeschrieben, damit er auch eine PVC-Restore übersteht.

Die Lehre: Ein config migrate, das config list besteht, ist kein Beweis, dass der Daemon die Config akzeptiert. Der statische Validator und der Laufzeit-Loader sind zwei verschiedene Codepfade. Ohne den Wegwerf-Smoke-Test gegen einen echten Daemon (Schritt 4) wären diese drei Fehler erst live im Produktiv-Bot aufgeschlagen — mit ausgefallenem Monitoring und kaputtem E2EE.

zeroclaw doctor — zwei echte, zwei falsche Alarme

Nach dem Deploy meldete zeroclaw doctor fünf Warnungen. Sortiert:

  • „model route … invalid model_provider ollama.default" (×3) — echter Format-Nit, behoben. In V3 will [[model_routes]].model_provider die Provider-Familie (ollama), während [agents.default] die Instanz (ollama.default) nutzt. Der Daemon akzeptiert beides zur Laufzeit (kein Route-Fehler im Log, der Self-Test führte Tools aus), aber doctors statischer Check ist strenger. Routes auf ollama umgestellt → doctor [config] ist warnungsfrei.
  • „SOUL.md / AGENTS.md not found (optional)" (×2) — False Positive, ignoriert. doctor sucht im Data-Dir, der V3-Agent-Workspace liegt aber unter agents/default/workspace/, wo die Dateien sehr wohl existieren und gelesen werden (live verifiziert: Agent-pwd = dieser Pfad, 34 Skills geladen).

Netto: 5 Warnungen → 2 harmlose False Positives, 0 Errors.

Die Auszahlung

Der Moment, auf den die ganze Migration zielte: Ein Tool-erzwingender One-Shot lief den Shell-Tool wirklich aus (uname -srLinux 6.18.33-talos) und antwortete in sauberer Prosa — null <tool_call>-Leakage im Channel-Output. b3e0719 ist verifiziert geschlossen.

Damit fielen die Workarounds reihenweise:

  • Die 7 stummgeschalteten Monitoring-Crons wurden wieder auf delivery.mode = "announce" geflippt — sie dürfen wieder in den Kanal sprechen.
  • Der Cron-Store-Wipe bei jedem Boot ist gestrichen.
  • Der FIXME-Workspace-Symlink ist Geschichte.

Das Routing-auf-kimi bleibt — aber jetzt als bewusste Defense-in-depth, nicht als einzige Mauer gegen einen unfixbaren Bug. Geschlossen wurden damit gleich zwei Radicle-Issues: b3e0719 (der Leak) und cc2886f (der Tracking-Issue für den Versions-Bump).

Offene Baustelle, ehrlich gesagt: Während des Tests erreichte der in-Pod-kubectl des Bots die Cluster nicht (node count 0) — vermutlich derselbe Tailnet/Headscale-Logout, der gerade auch meine Workstations betrifft, und unabhängig von dieser Migration. Das Upgrade selbst sitzt; die Tailnet-Reachability ist ein separates Follow-up.

Was bleibt

Der Rückblick ist nüchtern: Ein einziger, hartnäckiger Bug — Tool-Aufrufe, die als Text in den Chat leakten statt ausgeführt zu werden — hat eine Migration erzwungen, die fast jede Datei im Stack angefasst hat. Schema-Rewrite, Layout-Split, drei Laufzeitfehler, ein zerlegter E2EE-Store. Es war genau die Sorte Upgrade, die man aufschiebt, bis man nicht mehr kann.

Und es hat sich gelohnt. Nicht nur, weil der Spam weg ist, sondern weil die Lösung jetzt an der richtigen Stelle sitzt — im Parser, nicht in einer Mauer aus Routing-Tricks und Boot-Time-Wipes. v0.8.0 schaltet außerdem Dinge frei, die vorher nicht gingen: echte Multi-Agent-Trennung (Monitoring vs. Ops vs. Renovate als getrennte, scoped Agenten), ein classifier_provider für günstigere Intent-Vorprüfung, eine zerocode-TUI gegen den lokalen Socket. Das sind Follow-up-Issues für ruhigere Tage.

Wer den ganzen Stack nachlesen will: Lab und ZeroClaw liegen offen auf meinem Radicle -Seed-Node seed.this-is-fine.io. Die Migration ist dort als Runbook, als Issue-Historie und als Config-Diff dokumentiert — inklusive der drei Laufzeitfehler, die in keinem Changelog stehen.