Agent-Mode (Plan-Exec-Reflect)
Autonomer Multi-Tool-Agent für komplexe Anfragen. Kostenkontrolle, Reference-Pattern, Hintergrund-Jobs.
Der Agent-Mode ist eine Erweiterung des normalen Chat-Loops mit token- und latenzsparenden Optimierungen, die für autonome Mehr-Step-Aufgaben gedacht sind. Aktiviert via agent_mode: true in der Assistant-Config.
Beispiel: config/tenants/bloomify/assistants/agent_proto.php.
Wann Agent-Mode?
Aktiviere agent_mode: true wenn der Assistant:
- Mehrere Tools sequenziell aufruft (>3 Iterationen)
- Mit grossen Daten-Sets arbeitet (Listen, Tabellen, Auswertungen)
runPythonals zentrales Tool nutzt um Daten zu transformieren- Plan-Disziplin braucht (
createPlan/updatePlan)
Aktiviere nicht für:
- Klassische Frage-Antwort-Chats (Public, FAQ)
- Spezialisierte Single-Tool-Assistants (z. B. nur Bestellungen-Lookup)
Was Agent-Mode aktiviert
| Mechanismus | Beschreibung |
|---|---|
| Reference-Pattern | Tool-Results > 8 KB werden für das LLM auf einen Stub gekürzt. Volldaten bleiben im turnToolResults-Pool, zugreifbar über runPython via from tool_results import last, by_tool, all_results. Spart 95-99 % Token bei grossen Datasets. |
| Cache-aware History-Truncation | Solange der Prompt unter 60 KB ist, wird nichts gekürzt → Anthropic-Prompt-Cache bleibt valide. Erst ab 60 KB werden die ältesten Tool-Results gestubbt (mit tool_call_id als Identifier — cache-stabil). |
| Loop-Detection | Fingerprint pro Tool-Call-Batch. 3× identisch → System-Hint. 5× → Pause-Schwelle (hart abbrechen statt Token zu verbrennen). |
| Cost-Cap | Pro-Turn-Limit in EUR. Wenn überschritten → Pause-Schwelle mit „Weitermachen/Stop". |
Config-Beispiel
'assistants.my_agent' => [
'name' => 'Mein Agent',
'llm' => 'claude', // Sonnet 4.6
'max_iterations' => 15,
'agent_mode' => true,
'cost_cap_eur' => 2.00,
'tools' => [
// Plan-Disziplin
'createPlan', 'updatePlan', 'getPlan',
// Code-Execution
'runPython',
// Async-Jobs
'waitForAsyncJob',
// ... tenant-spezifische Tools
],
'greeting' => 'Hi, beschreib mir ein Ziel.',
],
Plan-Disziplin
Der Agent soll vor dem ersten anderen Tool einen Plan erstellen:
createPlan({steps: ["Bestellungen laden", "Filter anwenden", "CSV exportieren"]})
Pro Step:
updatePlan({step_index: N, status: "in_progress"})- Tool-Calls ausführen
updatePlan({step_index: N, status: "done", note: "1247 Bestellungen geladen"})
Frontend rendert eine Live-Checkliste (plan_update-SSE-Event). Mobile-App: sticky Card oben. Web: Status-Bubble.
Reference-Pattern in Aktion
Tool-Aufruf:
searchShopifyCustomers({first: 250, query: "orders_count:>=3"})
Tool-Result roh:
{"success": true, "count": 250, "customers": [/* 250 Items, ~100 KB */]}
Was das LLM sieht (Stub):
{
"_truncated_for_llm": true,
"_original_bytes": 102341,
"_tool_name": "searchShopifyCustomers",
"_hint": "Voller Tool-Result ist zu gross. Nutze `runPython` mit `from tool_results import last` um an die kompletten Daten zu kommen.",
"success": true,
"count": 250,
"_preview": [{...}, {...}, {...}]
}
Was Python sieht (über runPython):
from tool_results import last
data = last() # vollständiger Dict mit allen 250 Customers
for c in data['customers']:
# process all 250
...
Hintergrund-Jobs (waitForAsyncJob)
Tools mit async: true (z. B. importInventoryFromSheet) werden bei Agent-Aufruf in die task_executions-Queue geschoben. Der Cron-Worker (bin/cake.php run_async_jobs) führt sie sequenziell aus.
Pattern:
toolA() → {queued: true, execution_id: "uuid"}
waitForAsyncJob({execution_id: "uuid", max_wait_seconds: 90})
→ blockt 90s, schickt SSE-Heartbeats alle 6s
→ returnt {success: true, status: "ok", result: {...}}
→ oder bei Timeout: {still_running: true, execution_id: "..."}
Der Agent kann bei Timeout nochmal waitForAsyncJob aufrufen oder den User vertrösten.
Cost-Cap-Verhalten
Pro LLM-Call wird data.usage.prompt_tokens und completion_tokens akkumuliert. Schätzung mit Sonnet-Preisen ($3/$15 per 1M, 10% Discount auf cached):
estimated_eur = (prompt - cached) * 3 / 1M + cached * 0.3 / 1M + completion * 15 / 1M
Erreicht estimated_eur den cost_cap_eur, pausiert der Loop mit:
Ich habe das Kosten-Budget für diesen Turn erreicht (~2.14 EUR bei 12 Tool-Aufrufen). Soll ich weitermachen oder stoppen?
Suggestions: ["Weitermachen", "Stop, das reicht"]. Klick auf „Weitermachen" startet einen neuen Turn mit frischem Cap, History bleibt.
Token-Persistenz und Dashboard
Bei JEDEM LLM-Call (auch Tool-Call-Iterationen) wird usage in chat_messages.prompt_tokens/completion_tokens/total_tokens persistiert. Das Dashboard unter /manage/dashboard aggregiert das pro Tag/Chat mit EUR-Schätzung.
Historisch: vor dem Token-Bug-Fix (2026-05-17) wurden 96 % der Tool-Call-Iteration-Tokens nicht persistiert. Dashboards von Chats vor diesem Datum sind unterzählt.
Streaming + Heartbeats
Im Agent-Mode kommt echtes Anthropic-Streaming zum Einsatz (ClaudeProvider::sendStreamed). Während der LLM antwortet:
- Jeder
text_deltawird sofort alsmessage_delta-SSE-Event an den Client durchgereicht - Vor jedem LLM-Call:
: llm-start <ts>\n\nals SSE-Comment-Heartbeat - Während Tool-Execution: pcntl-alarm-Watchdog tickt alle 3s mit
tool_progress-Event
Damit ist die längste Idle-Phase typischerweise < 5s — Mittwald-Proxy und iOS-NSURLSession killen die Connection nicht mehr.
Tests
Backend-Tests für den Agent-Mode:
tests/TestCase/Service/Chat/ChatServiceTest.php— Basic loop, error pathstests/TestCase/Service/Chat/ChatServiceDbPersistenceTest.php— DB-Persistierung, Cost-Cap, Heartbeats, Token-Bug-Regressiontests/TestCase/Service/Chat/ChatServiceCacheTruncationTest.php— Cache-Aware Truncation, Idempotenztests/TestCase/Service/Chat/ChatServicePlanUpdateTest.php— plan_update-SSE-Eventtests/TestCase/AiProvider/ClaudeProviderStreamingTest.php— Anthropic-SSE-Frame-Parser
Verwandte Doks
- Streaming & SSE — Wire-Protokoll, Events
- runPython — Python-Sandbox +
tool_results-Helper - Async-Jobs — Background-Worker