Tool-State
Per-Chat-Scratch-Pad für Tools. Codes sammeln, Wizard-Schritte, Multi-Turn-Auswahl ohne History-Extraktion.
Manche Workflows brauchen State, der über mehrere Chat-Turns lebt: Codes scannen und am Ende abschicken, einen Wizard durchlaufen, Auswahl-Listen aufbauen. Das LLM aus der History rekonstruieren zu lassen ist fragil — Eingaben sehen aus wie Smalltalk (z.B. GS1-Präfixe ]C1...), Eingabe-Formate wechseln, das Modell kapituliert irgendwann mit „keine Daten erfasst".
Dafür gibt es App\Tooling\ToolState. Ein Tool-Aufruf schreibt, der nächste liest. Der State liegt im Tenant-Ordner (config/tenants/{tenant}/tmp/state/), wird also über die tm-CLI zum Entwickler gesynct und ist beim Debuggen direkt einsehbar.
Wann verwenden
- Sammeln über Turns — Tracking-Codes, SKUs, Anhänge, Optionen.
- Wizard-Schritte — „Schritt 2 von 4 abgeschlossen, gehe zu 3".
- Voriges Ergebnis halten — Tool A gibt etwas zurück, Tool B braucht es später.
- Confirm/Undo-Flows — „Letzter Eintrag war X, soll ich den entfernen?"
Wann NICHT
- Domänen-Daten, die in die DB gehören — Bestellungen, User-Profile, Audit-Logs.
- Settings, die mehrere Chats teilen — gehört in
tenantConfig. - Etwas, das das Modell sich auch merken kann — wenn die letzten 2 Turns reichen, mach keinen State.
API
use App\Tooling\ToolState;
$state = ToolState::ns($ctx, 'mein_namespace');
$state->get('key', $default = null);
$state->set('key', $value, $ttl = '+24 hours');
$state->delete('key');
$state->all(); // alle Keys als array
$state->clear(); // ganzen Namespace im aktuellen Chat loeschen
$ctx ist der Standard-Tool-Context (kommt mit tmp_path, chat_id, tenant).
Der Namespace muss [a-zA-Z0-9_-]+ matchen.
Lifecycle
- TTL pro Set — jedes
set()schreibt ein neuesexpires_atins File. Aktive Chats verlängern sich also automatisch. - Auto-Expiry beim Read — abgelaufene Files werden beim nächsten
get()gelöscht. - Lazy GC — beim ersten ToolState-Aufruf eines Tages räumt der Helper alle abgelaufenen State-Files im Tenant-
tmp/state/weg (Marker.last_gc).
Default-TTL ist 24h — lang genug für Pausen, kurz genug damit Schrott von gestern verschwindet.
Beispiel: Carrier-Wechsel
Drei Tools teilen sich einen Namespace relabeling:
// addTrackingCode
$state = ToolState::ns($ctx, 'relabeling');
$codes = $state->get('codes', []);
$codes[] = $cleanCode;
$state->set('codes', $codes);
// setRelabelDirection
$state = ToolState::ns($ctx, 'relabeling');
$state->set('direction', 'DHL->DPD');
// submitRelabeling
$state = ToolState::ns($ctx, 'relabeling');
$direction = $state->get('direction');
$codes = $state->get('codes', []);
// ...CSV bauen...
$state->clear(); // sauberer Start fuer den naechsten Run
Beim Debuggen liegt das State-File unter
config/tenants/{tenant}/tmp/state/relabeling_{chat_id}.json und sieht so aus:
{
"expires_at": "2026-05-05T16:00:00+02:00",
"data": {
"direction": "DHL->DPD",
"codes": ["00340434757211819883", "8806096235386"]
}
}
Anti-Patterns
- Keine generischen
setState/getState-Tools an das LLM geben. Das Modell nutzt sie undiszipliniert — Schema und Lifecycle entgleiten dir. Stattdessen Domain-Tools (addTrackingCode,selectOption) bauen, die intern ToolState benutzen. - Keine grossen Daten reinpacken. Es ist ein Scratch-Pad, kein Ersatz-DB. Mehr als ein paar KB → was anderes nutzen.
- Keine Secrets. Tenant-Config wird gesynct; State auch. Nichts reinschreiben, was nicht in den Logs landen darf.