writeTenantConfig — Content-Modi (content / source_file / patch)
Drei Wege, den Datei-Inhalt an writeTenantConfig zu uebergeben. Wann welcher Modus? Faustregeln, Token-Kosten, Truncation-Bugs.
writeTenantConfig — drei Modi
writeTenantConfig ist das Werkzeug, mit dem der Builder-Bot (und andere Meta-Assistants) Config-Dateien schreibt. Es akzeptiert den Datei-Inhalt auf drei verschiedenen Wegen. Der falsche Modus verbrennt Tokens oder produziert stumme Daten-Verluste — daher die Faustregeln unten.
Die drei Modi im Ueberblick
| Modus | Wann | Token-Kosten | Truncation-Risiko |
|---|---|---|---|
content |
Neue Files, kleine Aenderungen (<10 KB) | Full-File-Groesse | Hoch bei >10 KB |
source_file |
Full-Rewrite grosser Files (>10 KB) | Nur der Filename | Kein Risiko |
patch |
Punktuelle Aenderungen an bestehendem File | Nur die geaenderten Zeilen | Kein Risiko |
Genau EINEN der drei Wege nutzen — patch und content zusammen sind ein Fehler.
Modus 1: content — Voller Datei-Inhalt
{
"path": "assistants/my_bot.php",
"content": "<?php\nreturn [...]"
}
Der klassische Weg. Der komplette Datei-Inhalt geht als String durch tool_call.arguments.
Faustregel: unter 10 KB ok. Ueber 10 KB fangen LLM-Provider (Anthropic vor allem) an, grosse String-Args auf Stubs zu reduzieren — die Datei landet unvollstaendig auf Platte. Wurzel des 2026-08-03 bloomify/apis.php-Bugs (162 → 66 Zeilen still verschwunden).
Der Shrink-Guard im Tool blockt >30% Verkleinerung + >20 Zeilen Drop, aber nicht jede Truncation triggert den Guard.
Modus 2: source_file — Aus dem Sandbox
{
"path": "assistants/my_bot.php",
"source_file": "my_bot_new.php"
}
Der Runner liest die Datei aus dem chat-lokalen runPython-Sandbox-Ordner. source_file ist nur der Basename (kein Pfad) — die Datei muss vorher via runPython in die Sandbox geschrieben worden sein.
# runPython
with open('my_bot_new.php', 'w') as f:
f.write(neuer_inhalt)
Umgeht LLM-Truncation komplett, weil das grosse Text-Blob nie durch die Tool-Args-Serialisierung geht. Empfohlen bei allen Full-Rewrites uber 10 KB.
Erlaubt sind auch: source_path, file_path, filepath (Aliasse, alle mit derselben Basename-Semantik).
Modus 3: patch — Unified-Diff
{
"path": "assistants/my_bot.php",
"patch": "@@ -42,3 +42,3 @@\n context line\n-old line\n+new line\n context line"
}
Ein Unified-Diff wird gegen die aktuelle Datei angewendet. Format:
@@ -N,M +N,M @@— Hunk-Header (nur die alte Startzeile wird gelesen, die Laengen werden ignoriert; Kontext-Match entscheidet)(Leerzeichen-Prefix) — Kontextzeile, muss exakt matchen-— Zeile aus Original entfernen+— Zeile neu einfuegen
Bevorzugt fuer punktuelle Aenderungen — Token-Ersparnis vs Full-Rewrite ist bei einer 100-Zeilen-Aenderung an einem 2000-Zeilen-File ~20x.
Strikter Kontext-Match, tolerante Position
Der Applier ist beim Inhalt strict:
- Kontext- und
--Zeilen muessen exakt matchen (auch Whitespace, auch Anfuehrungszeichen). - Kein Inhalts-Fuzz, keine Whitespace-Toleranz.
- Bei Mismatch: Fehler mit Zeilennummer und der erwarteten/gefundenen Zeile.
Bei der Position ist er tolerant wie patch (seit 2026-09-25): stimmt die Startzeile im Hunk-Header nicht (LLMs verzaehlen sich oft um ein paar Zeilen), sucht er dieselbe Zeilenfolge (Kontext + --Zeilen) im Umkreis von ±100 Zeilen, nie vor dem Ende des vorigen Hunks. Uebernommen wird nur ein eindeutiger Treffer; bei mehreren Treffern Fehler „stehen mehrfach im Umkreis … mehr Kontext mitgeben“. Verschobene Hunks stehen im Ergebnis unter patch_offsets (Hunk-Nr => Zeilen). Ein abschliessender Zeilenumbruch des Patches ist keine Kontext-Leerzeile.
Grund: still falsche Dateien schreiben ist der schlimmste Failure-Mode. Ein klarer Fehler mit Zeilennummer erlaubt dem Bot, die Datei neu zu lesen und den Diff frisch zu bauen.
Voraussetzungen
- Datei muss existieren. Fuer neue Files →
contentodersource_file. - Nur
@@-Hunk-Header + Body-Zeilen.diff --git/---/+++-Header werden still ueberlesen.
Beispiel: Regel in Prompt-File aendern
@@ -288,3 +288,4 @@
## `readTenantConfig` liefert nur ~2 KB pro File
+
Wenn du den vollen Code eines Handlers brauchst und die Preview abgeschnitten ist:
Diff = 4 Zeilen. Ohne patch-Modus muesste der Bot die ganze Prompt-Datei (1200+ Zeilen) neu schreiben.
Downstream-Pipeline bleibt gleich
Egal welcher Modus — nach dem Content-Assembly greifen dieselben Guards:
- Bei
.php:php -l-Syntaxcheck (Fehler = abort) ToolCodeAudit::analyze+analyzeSchema(verbotene Funktionen, OpenAI-Style-Schema)- Exposure-Guard: Literal-Verstoesse (
=> !0, berechnete Schluessel …) blockieren hart. Neu gesetzte Flags (public_api& Co.,allow_writeunterremote_databases) blockieren nicht separat, sondern stehen als Tragweite oben in der Gate-Vorschau (Felderexposure,tragweite); dafuer zaehlt nur das Ja auf genau diese Rueckfrage — keine direkte Anweisung, keine Auto-Freigabe. EINE Bestaetigung statt zwei;confirm_exposureist nicht mehr noetig. - Shrink-Guard (>30% + >20 Zeilen = abort, Bypass via
force=true) - Freigabe-Gate (siehe unten) — ohne Freigabe wird nichts geschrieben
- Backup nach
_backups/<file>.<ts>.bak - Write +
opcache_invalidate - In-process
reloadTenantConfig(Tool ist im gleichen Turn nutzbar)
Freigabe-Gate (seit 2026-09-25)
writeTenantConfig, deleteTenantConfig und — im Builder — runTool mit writeFile/deleteFile/renameFile schreiben erst nach einer Freigabe im Chat. Durchgesetzt im Server (App\Tooling\ApprovalGate, fail-closed), nicht nur per Prompt. Muster wie contaoSetPublished.
Ablauf:
- Erster Aufruf → nichts wird geschrieben. Antwort
{success:false, needs_confirmation:true, error:"freigabe_fehlt", preview:"<Diff>", suggestions:["Ja, speichern","Nein, verwerfen"]}. Das Gate merkt sich den Vorschlag (Pfad + Fingerprint des exakten Inhalts + Anzahl Nutzer-Nachrichten) und setzt die Buttons selbst. - Der Bot zeigt die Änderung kurz und wartet.
- Nächste Nutzer-Nachricht ist ein klares, kurzes Ja → derselbe Aufruf mit identischem Inhalt und
confirm: trueschreibt. BeirunToolstehtconfirmam runTool-Aufruf.
Was als Ja gilt (App\Tooling\UserApproval::isClearYes; derselbe Mechanismus steckt hinter confirmChatAction — contaoSetPublished, restoreDbBackup, deleteDbBackup, rollbackWrite): Button-Text oder eine kurze Nachricht, die mit einem Ja-Wort beginnt („ja“, „ok“, „passt“, „mach das“, „sieht gut aus“, 👍 …), höchstens ein paar Wörter danach, keine Frage, keine Einschränkung („aber“, „noch“, „anders“, „nicht“, „erst“, „auch“ …). Ein 🎤-Präfix (WhatsApp-Sprachnachricht) wird ignoriert.
Was blockiert (fail-closed):
- Keine neue Nutzer-Nachricht seit dem Vorschlag — das Modell kann sich nicht selbst bestätigen.
- Das Ja ist nicht die unmittelbar nächste Nachricht (dazwischen „nein, anders“ → alter Vorschlag verfällt).
- Inhalt anders als beim Vorschlag (neuer Fingerprint = neuer Vorschlag).
- Vorschlag älter als 24 h.
- Kein Chat (Scheduled Task, CLI,
chat_idleer) →freigabe_nicht_moeglich. getChatMessagesschlägt fehl oder State nicht lesbar →freigabe_pruefung_fehlgeschlagenbzw.freigabe_nicht_moeglich.
Ausnahmen:
Beide Ausnahmen gelten nur für reine Wert-Änderungen (ApprovalGate::classifyChange → change: "values"): bestehende PHP-Datei, Token-Folge bis auf String-/Zahl-Literale unverändert (Whitespace/Kommentare egal), kein geändertes Literal ist ein Array-Schlüssel, steht in einer Closure/Arrow-Function oder unter handler. Neue Tokens (function, fn, $ctx, =>, use …), geänderte Bezeichner/Flags (true→false), Nicht-PHP-Dateien, neue Dateien und Exposure-Flags sind code bzw. new → immer Vorschlag + Ja. Grund: Text aus gelesenen Inhalten (Mails, Webseiten, Tool-Results) darf nie über einen „Auftrag“ zu Code werden (Prompt-Injection). Das Tool-Result nennt change; bei Rückfrage erklärt message, warum Direktauftrag/Auto-Freigabe nicht gegriffen haben.
- Direkte Anweisung (
UserApproval::isDirectOrder): die letzte Nutzer-Nachricht ist selbst eine eindeutige Anweisung in einem Satz („ändere beim supportBot das Icon in 🤖“, „setz im Kundenservice die Temperatur auf 0.2“), ohne Frage/Hedging/Verneinung, und nennt die Datei, das Tool oder den Assistant (Basename ohne Endung oder ein in der Datei definiertertools.<name>/assistants.<name>-Schlüssel, mind. 3 Zeichen). Gilt nur für eine kleine Wert-Änderung (≤ 12 geänderte Zeilen) an einer bestehenden Datei und einmal pro Nachricht. Löschen per Anweisung nur, wenn die Nachricht die Datei benennt („lösch das Tool altesTool“). - Auto-Freigabe (
setAutoConfirm({on:true})): nur wenn die letzte Nutzer-Nachricht das ausdrücklich verlangt („frag nicht mehr“, „ohne Rückfrage“) — das Tool prüft das selbst. Gilt 12 h für diesen Chat und nur für Wert-Änderungen perwriteTenantConfig— nie fürdeleteTenantConfig, Dateitools, neuen Code/Handler, neue Dateien, Exposure-Flags oder Connector-Tools. Ergebnis mit Restlaufzeit (auto_confirm_remaining, z. B. „11 h 42 min“). „frag wieder“ bzw.on:falseschaltet zurück.
Manipulationsschutz: Der Vorschlags-State liegt im ToolState-Namespace _approval_gate; Namespaces mit führendem _ kann stateSet/stateDelete/stateClear nicht beschreiben.
Tests: tests/TestCase/Tooling/UserApprovalTest.php (Beispielsätze), ApprovalGateTest.php, BuilderApprovalFlowTest.php (echter ToolRunner).
Maskierte Zugangsdaten (seit 2026-10-04)
readTenantConfig, readSharedTool, describeTool, readLogs und writeTenantConfig tragen mask_secrets => true. Der ToolRunner schickt ihr Ergebnis durch App\Tooling\ConfigSecretMasker::maskResult(), bevor es ans Sprachmodell, in chat_messages und in task_executions geht. Anlass: Betriebsanalyse 04.10. #1, apis.php mit Trello-Key/-Token im Klartext.
- Maskiert wird nur der Wert, die Struktur bleibt:
'token' => '***masked(len=64)***'. Betroffen sind Werte unter Secret-Namen (token, secret, password, api_key, client_secret, private_key, signing …;key/*_keyab 12 Zeichen), Werte, die wie Schlüssel aussehen (sk-,xox,EAA…, JWT, lange Hex-/Base64-Strings),key=/token=in URLs und Credentials nachBearer/Token. In.envist jeder Wert maskiert. {{secret:…}}-Platzhalter, Bezeichner (meta_long_lived_token), Zahlen und ID-Felder (spreadsheetId,folder_id) bleiben lesbar.- Zurückschreiben: Enthält
content,source_fileoderpatchMasken, setztwriteTenantConfigdie echten Werte aus der aktuellen Datei wieder ein. Einpatchmit maskierten Kontextzeilen wird dafür gegen die maskierte Fassung angewendet. Jede Zeile mit Maske muss unverändert sein. Sonst kommterror: masked_secretund es wird nichts geschrieben. Das gilt auch für neue Dateien mit Maske. - Die Diff-Vorschau in der Freigabe-Rückfrage wird aus den maskierten Fassungen gebaut.
- Neue oder geänderte Zugangsdaten gehören nicht in
writeTenantConfig. Der Builder trägt{{secret:NAME}}ein, und den Wert setzt der Nutzer im Secrets-Bereich.
Tests: tests/TestCase/Tooling/ConfigSecretMaskerTest.php, BuilderApprovalFlowTest::testReadMasksSecretsAndWriteKeepsThem.
Faustregeln fuer den Builder-Bot
- Neue Datei →
content(klein) odersource_file(gross) - Existierende Datei, weniger als 10 Zeilen aendern →
patch - Existierende Datei, mehr als 30% neu →
source_file(Full-Rewrite via Sandbox) - Existierende Datei, unklar →
readTenantConfigerst, dann entscheiden
Verwandt
create-in-config.md— vollstaendiges Tool-Schemaagent-mode.md— Loop-Guard + Cost-Cap fuer Agent-Chats