Plan-Act-Observe-Refine: Anatomie eines stabilen Loops
In der Plan-Phase zerlegt der Agent (oder ein Planungs-Sub-Agent) das Intent in atomare Tasks mit Abhängigkeiten. Jeder Task hat einen klaren Done-Zustand: „Unit-Tests für InvoiceCalculator grün“, nicht „Billing verbessern“. Vage Tasks sind die häufigste Ursache für instabile Loops.
Act bedeutet: Code schreiben, Tests ausführen, Diffs erzeugen – innerhalb der Policy-Grenzen. Observe sammelt Artefakte: Test-Output, Linter-Fehler, Diff-Statistik, Laufzeit. Refine entscheidet: Weiter im selben Task, Task abschließen, oder Escalation an den Menschen.
Der kritische Punkt ist Observe: Ohne strukturierte Beobachtung refiniert der Agent blind. Agenticode standardisiert Observe-Output als JSON, das maschinell auswertbar ist – Grundlage für agentische CI/CD und Multi-Agent-Handoffs.
<?php
declare(strict_types=1);
namespace App\Agentic\Loop;
final class AgentLoopState
{
public const PHASE_PLAN = 'plan';
public const PHASE_ACT = 'act';
public const PHASE_OBSERVE = 'observe';
public const PHASE_REFINE = 'refine';
public const PHASE_DONE = 'done';
public const PHASE_ESCALATE = 'escalate';
public function __construct(
public string $phase = self::PHASE_PLAN,
public int $iteration = 0,
public int $maxIterations = 8,
public array $tasks = [],
public int $currentTaskIndex = 0,
public array $observeLog = [],
) {}
public function shouldContinue(): bool
{
if ($this->phase === self::PHASE_DONE || $this->phase === self::PHASE_ESCALATE) {
return false;
}
return $this->iteration < $this->maxIterations;
}
public function advancePhase(): void
{
$cycle = [self::PHASE_PLAN, self::PHASE_ACT, self::PHASE_OBSERVE, self::PHASE_REFINE];
$idx = array_search($this->phase, $cycle, true);
$this->phase = $cycle[($idx + 1) % count($cycle)];
if ($this->phase === self::PHASE_PLAN) {
$this->iteration++;
}
}
}
Exit-Kriterien und Abbruchbedingungen
Jeder Loop braucht harte Exit-Kriterien. Softe Kriterien wie „Code sieht gut aus“ führen zu Regressionen. Agenticode definiert typischerweise: alle required_checks grün, Diff unter Limit, keine neuen PHPStan-Errors, Coverage-Schwelle eingehalten.
Abbruchbedingungen sind ebenso wichtig: Max-Iterationen erreicht, gleicher Fehler dreimal hintereinander, Policy-Verletzung, oder Scope-Eskalation (Agent will Dateien außerhalb des erlaubten Pfads ändern). In diesen Fällen stoppt der Loop und eskaliert – statt weiter zu raten.
In Cursor setzen wir das über Rules und Hooks um: Ein afterAgentResponse-Hook kann Test-Output parsen und den nächsten Schritt vorgeben. So wird der Loop deterministischer als reines Prompt-Chaining.
{
"loop_id": "billing-invoice-calc-2024-03",
"intent": "InvoiceCalculator mit MwSt-Sätzen DE/AT implementieren",
"exit_criteria": {
"phpunit": { "status": "pass", "failures": 0 },
"phpstan": { "level": 8, "errors": 0 },
"diff_lines": { "max": 350, "actual": 287 },
"forbidden_patterns": []
},
"abort_triggers": [
{ "type": "max_iterations", "value": 8, "current": 3 },
{ "type": "repeated_error", "signature": "TypeError: float + string", "count": 2 }
],
"phase": "refine",
"next_action": "complete_task_and_advance"
}
Praxis bei Agenticode: Loop-Templates für wiederholbare Runs
Wir pflegen Loop-Templates für wiederkehrende Aufgaben: Feature-Implementierung, Bugfix, Refactoring-Extraktion, Test-Nachziehen. Jedes Template definiert Plan-Prompt, Act-Constraints, Observe-Parser und Refine-Entscheidungsbaum.
Für komplexe Features nutzen wir verschachtelte Loops: Ein äußerer Plan-Loop zerlegt in Sub-Loops pro Modul. Der äußere Loop beobachtet nur Sub-Loop-Ergebnisse – nicht jeden einzelnen Diff. Das reduziert Kontext-Überladung und hält Cursor-Runs fokussiert.
Kunden in Köln profitieren davon, dass wir diese Templates nicht als Black Box liefern, sondern dokumentieren und anpassen. Ihr Team kann Loops für eigene Domains erweitern – mit denselben Stabilitäts-Garantien.
#!/usr/bin/env bash
# cursor-loop-observe.sh – nach jedem Agent-Act-Schritt
set -euo pipefail
RESULT_FILE=".agentic/observe-$(date +%s).json"
PHPUNIT_OUT=$(vendor/bin/phpunit --log-junit /tmp/junit.xml 2>&1 || true)
PHPSTAN_OUT=$(composer phpstan 2>&1 || true)
DIFF_LINES=$(git diff --numstat | awk '{s+=$1+$2} END {print s+0}')
php -r "
echo json_encode([
'timestamp' => time(),
'phpunit_pass' => strpos('$PHPUNIT_OUT', 'OK') !== false,
'phpstan_clean' => strpos('$PHPSTAN_OUT', '[OK]') !== false,
'diff_lines' => (int)'$DIFF_LINES',
], JSON_PRETTY_PRINT);
" > "$RESULT_FILE"
echo "Observe written to $RESULT_FILE"