managarten/docs/plans/agent-loop-improvements-m1.md
Till JS e5d230e599 feat(agent-loop): M1 — policy gate + reminder channel + parallel reads
Three Claude-Code-inspired primitives for runPlannerLoop, derived from the
reverse-engineering reports in docs/reports/:

1. **Policy gate** (@mana/tool-registry) — evaluatePolicy() gates every tool
   dispatch: denies admin-scope, denies destructive tools not in the user's
   opt-in list, rate-limits per tool (30/60s default), flags prompt-injection
   markers in freetext without blocking. Wired into mana-mcp with a
   per-user rolling invocation log and POLICY_MODE env (off|log-only|enforce,
   default log-only). mana-ai uses detectInjectionMarker only — tool dispatch
   there is plan-only, so rate-limit/destructive checks don't apply yet.

2. **Reminder channel** (packages/shared-ai/src/planner/loop.ts) — new
   reminderChannel callback in PlannerLoopInput. Called once per round with
   LoopState snapshot (round, toolCallCount, usage, lastCall); returned
   strings wrap in <reminder> tags and inject as transient system messages
   into THIS LLM request only. Never pushed to messages[] — the Claude-Code
   <system-reminder> pattern that keeps the KV-cache prefix stable.

3. **Parallel reads** (loop.ts) — isParallelSafe predicate enables
   Promise.all dispatch when every tool_call in a round is parallel-safe,
   in batches of PARALLEL_TOOL_BATCH_SIZE=10. Any non-safe call downgrades
   the whole round to sequential. messages[] always appends in source
   order, never completion order, so the debug log stays linear.
   Default-off (undefined predicate) preserves pre-M1 behaviour.

Tests: 21 new in tool-registry (policy), 9 new in shared-ai (5 parallel,
4 reminder). All 74 green, type-check clean across 4 packages.

Design/plan: docs/plans/agent-loop-improvements-m1.md
Reports: docs/reports/claude-code-architecture.md,
         docs/reports/mana-agent-improvements-from-claude-code.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:56:40 +02:00

385 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Agent-Loop Improvements — M1
_Started 2026-04-23._
Drei kleine, voneinander unabhängige Verbesserungen am Mana-Agent-Stack,
abgeleitet aus der Claude-Code-Architektur-Analyse. Alle drei zusammen
~1.5 Arbeitstage, mit hohem qualitativem und Sicherheits-Impact.
**Hintergrund:**
- [`docs/reports/claude-code-architecture.md`](../reports/claude-code-architecture.md) — wie Claude Code intern aufgebaut ist
- [`docs/reports/mana-agent-improvements-from-claude-code.md`](../reports/mana-agent-improvements-from-claude-code.md) — vollständige Gap-Analyse mit 8 Verbesserungen; dies hier ist die priorisierte M1-Teilmenge
## Ziel in einem Satz
Den `runPlannerLoop` um drei Primitive erweitern, die Claude Code hat und
wir nicht haben: einen **Permission-Gate vor Tool-Execution**, einen
**transienten Reminder-Channel** für Per-Round-Hinweise, und
**Parallelisierung für reine Read-Tools**.
## Nicht-Ziele
- **Kein** Umbau von `runPlannerLoop`s Grundstruktur — nur Erweiterungen.
- **Keine** Änderung am Message-Log-Format — Iterations bleiben binärkompatibel.
- **Keine** neue LLM-Route, kein neues Modell, kein Haiku-Tier (das ist M2).
- **Kein** Context-Compressor (das ist M2, braucht eigene Archiv-Tabelle).
- **Kein** Sub-Agent-Pattern (das ist M3, zusammen mit dem Persona-Runner).
## Wer profitiert
| Konsument | Nutzen |
|-----------------------|----------------------------------------------------------|
| `services/mana-ai` | bessere Mission-Pläne + schnellere Multi-Read-Ticks |
| `services/mana-mcp` | Schutz gegen missbräuchliche MCP-Clients |
| Webapp Companion-Chat | bessere Antworten durch Per-Round-Context-Hinweise |
| Persona-Runner (M3) | Fundament — braucht Permission-Gate bevor es live darf |
---
## Verbesserung 1 — Permission-Gate vor Tool-Execution
### Was es macht
Bevor ein Tool-Handler aufgerufen wird, läuft ein zentrales `evaluatePolicy()`
aus `@mana/tool-registry`. Das Gate entscheidet anhand von Tool-Scope,
Policy-Hint, Usage-History und User-Settings, ob die Ausführung erlaubt ist.
### Was es ermöglicht
- **Destructive-Tools werden per Default blockiert.** Heute ist `policyHint:
'destructive'` nur dokumentiert ([types.ts:48](../../packages/mana-tool-registry/src/types.ts#L48)),
nicht durchgesetzt. Künftig: User muss in Settings explizit opt-in pro
Tool oder Scope.
- **Rate-Limiting pro User pro Tool.** Heute kann ein entwendeter JWT in
10 Sekunden hunderte Calls machen. Künftig: Cap 30 Calls/Tool/Minute
(konfigurierbar pro Tool).
- **Freitext-Input-Inspektion.** Für Tools mit String-Feldern (`content`,
`description`, `note`): Marker wie `{{`, `<system`, `ignore previous`
werden erkannt und als Metrik markiert. Nicht blockiert (zu viele False
Positives), aber sichtbar.
- **Ein Policy-Ort für beide Consumer.** `mana-mcp` und `mana-ai` rufen
denselben Code — keine Drift mehr.
### Heutiger Zustand (Problem)
[`services/mana-mcp/src/mcp-adapter.ts:34-37`](../../services/mana-mcp/src/mcp-adapter.ts#L34):
```ts
function isExposable(spec: AnyToolSpec): boolean {
return spec.scope === 'user-space';
}
```
Das ist der gesamte Gate. `mana-ai`s `onToolCall` hat gar nichts.
### Neuer Zustand (Lösung)
Neues Modul `packages/mana-tool-registry/src/policy.ts`:
```ts
export interface PolicyDecision {
readonly allow: boolean;
readonly reason?: string;
/** Optional hint, wird von M1 Verbesserung 2 als Reminder-Tag
* an den nächsten LLM-Turn angehängt. */
readonly reminder?: string;
}
export interface PolicyInput {
readonly spec: AnyToolSpec;
readonly ctx: ToolContext;
readonly rawInput: unknown;
readonly userSettings: {
readonly allowDestructive: readonly string[]; // Tool-Names Whitelist
readonly perToolRateLimit?: number; // default 30/min
};
readonly recentInvocations: readonly { toolName: string; at: number }[];
}
export function evaluatePolicy(input: PolicyInput): PolicyDecision;
```
Integration:
- [`services/mana-mcp/src/mcp-adapter.ts`](../../services/mana-mcp/src/mcp-adapter.ts) ruft `evaluatePolicy()` in `invoke()` **vor** `spec.handler()`.
- [`services/mana-ai/src/cron/tick.ts`](../../services/mana-ai/src/cron/tick.ts) ruft es im `onToolCall`-Callback.
- `recentInvocations` kommt aus einer In-Memory-Ringbuffer pro User (beide Services).
### Aufwand
~1 Arbeitstag (6-8h).
### Tests
- Unit-Tests in `packages/mana-tool-registry/src/policy.test.ts`: je ein
Case für allow/deny pro Policy-Regel.
- MCP-Integration-Test: Destructive-Tool-Call ohne Opt-In → 403 mit
klarer Fehlermeldung.
- Rate-Limit-Test: 31 Calls in 60s → letzter wird geblockt.
### Rollout
Flag-gated per ENV `POLICY_ENFORCE=true` (default off). Erst eine Woche
**log-only** (alle Decisions werden geloggt, nichts blockiert), dann
enforcement flippen.
---
## Verbesserung 2 — Reminder-Channel im Planner-Loop
### Was es macht
`runPlannerLoop` bekommt einen optionalen `reminderChannel`-Callback. Vor
jedem LLM-Call fragt die Loop den Channel nach aktuellen Per-Round-Hinweisen
(„du hast 80 % deines Token-Budgets verbraucht", „Mission ist in 2 min
überfällig"). Die Hinweise werden als **transiente** System-Message vor den
API-Call gesetzt und danach **wieder entfernt**. Sie leben nie in der
persistierten Message-History.
### Was es ermöglicht
- **Per-Round-Steering ohne History-Mutation.** Der Loop sieht den Zustand,
die Iteration speichert aber nur die Entscheidungen — kein KV-Cache-
Invalidation, kein Log-Rauschen.
- **Token-Budget-Awareness.** Aktuell weiß das LLM nicht, wie viele Calls
es noch hat. Künftig: „du hast 2 von 5 Rounds noch".
- **Stale-Data-Warnings.** Wenn `mana-ai` länger nicht sync'd hat, kann
das LLM warnen statt zu halluzinieren.
- **Zero-Knowledge-Hinweise.** Bei ZK-Usern: „verbotene Tabellen sind
nicht resolvable — frag nicht nach". Heute muss das im System-Prompt
stehen und bleibt dort ewig.
- **Policy-Feedback.** `evaluatePolicy()` (Verbesserung 1) kann einen
`reminder`-String zurückgeben, der dem LLM in der nächsten Runde erklärt,
warum ein Tool-Call geblockt wurde — statt nur einen Fehler zu werfen.
### Heutiger Zustand (Problem)
[`packages/shared-ai/src/planner/loop.ts:131-135`](../../packages/shared-ai/src/planner/loop.ts#L131):
```ts
const messages: ChatMessage[] = [
{ role: 'system', content: input.systemPrompt },
...(input.priorMessages ?? []),
{ role: 'user', content: input.userPrompt },
];
```
Transienter Context geht heute auf einem von zwei schlechten Wegen rein:
1. in den `systemPrompt` eingebacken → bleibt ewig stehen, veraltet schnell,
2. an den `userPrompt` per Concatenation → mutiert die History, landet in Logs.
### Neuer Zustand (Lösung)
[`packages/shared-ai/src/planner/loop.ts`](../../packages/shared-ai/src/planner/loop.ts) bekommt neuen Input-Slot:
```ts
export interface LoopState {
readonly round: number;
readonly toolCallCount: number;
readonly tokensUsed: TokenUsage;
readonly lastCall?: ExecutedCall;
}
export interface PlannerLoopInput {
// … bestehende Felder …
/** Called before each LLM request. Return an array of transient
* system-message strings to inject into THIS request only. They
* are removed from `messages` before the next iteration and never
* appear in the returned message log. */
readonly reminderChannel?: (state: LoopState) => readonly string[];
}
```
Implementation skizziert (in der Loop):
```ts
while (rounds < maxRounds) {
rounds++;
const reminders = input.reminderChannel?.({ round: rounds, /* … */ }) ?? [];
const reminderMessages: ChatMessage[] = reminders.map(text => ({
role: 'system',
content: `<reminder>${text}</reminder>`,
}));
const response = await llm.complete({
messages: [...messages, ...reminderMessages], // transient, nicht an messages push
// …
});
// … bestehende Logik (messages.push für assistant/tool, NICHT für reminder) …
}
```
### Erste Producer (Beispiele, nicht Scope von M1)
Die Channel-API kommt in M1; die konkreten Reminder-Producer können
inkrementell danach entstehen. Niedrig hängende Früchte:
```ts
// services/mana-ai/src/planner/reminders.ts (später)
export function tokenBudgetReminder(agent: ServerAgent, usage24h: number) {
if (!agent.maxTokensPerDay) return null;
const pct = usage24h / agent.maxTokensPerDay;
if (pct < 0.75) return null;
return `Agent ${agent.name} hat ${Math.round(pct * 100)}% des Tagesbudgets verbraucht. Plane sparsam.`;
}
```
### Aufwand
4h für die Loop-Änderung + Test. Producer sind eigene kleine PRs danach.
### Tests
- `loop.test.ts`: Reminder wird injiziert, erscheint im LLM-Call, **nicht**
im `result.messages`.
- `loop.test.ts`: Reminder ist pro Round unabhängig — Round 2 kriegt nicht
Round 1's Reminder zurück.
### Rollout
Keine Flag-Gating nötig — Channel ist optional. Bestehende Caller, die
ihn nicht setzen, verhalten sich identisch zu heute.
---
## Verbesserung 3 — Parallel-Execution für Read-Tools
### Was es macht
Wenn das LLM in einer Runde mehrere Tool-Calls zurückgibt und **alle**
davon `policyHint: 'read'` sind, führt `runPlannerLoop` sie mit
`Promise.all` parallel aus, Cap bei 10 gleichzeitigen Calls. Sobald
ein Write oder Destructive im Batch ist: wie heute sequenziell.
Die Reihenfolge in `messages` bleibt **Source-Order** (wie das LLM sie
gesendet hat), nicht Completion-Order. Debug-Log bleibt linear lesbar.
### Was es ermöglicht
- **Schnellere Multi-Read-Missions.** Eine Research-Mission mit 5 Read-
Tools: heute 5× Read-Latenz sequenziell, künftig ~1× Latenz parallel.
Realer Gewinn: Wall-Clock-Zeit pro Tick halbiert sich in den Fällen,
wo es zählt.
- **Freie Kapazität für Compactor und Policy-Gate.** Beide Verbesserungen
von M1/M2 kosten Latenz; der Parallel-Gain gleicht das aus.
- **Kein Risiko bei Writes.** Die Regel „Read-only parallel, Writes
seriell" ist dieselbe wie in Claude Codes `gW5` — sie macht Consistency
trivial, ohne dass das Modell darüber nachdenken muss.
### Heutiger Zustand (Problem)
[`packages/shared-ai/src/planner/loop.ts:172-188`](../../packages/shared-ai/src/planner/loop.ts#L172) — expliziter Code-Kommentar:
> „Parallel execution is a perfectly valid optimisation for pure-read tools
> but we keep order here so the message log tells a linear story when the
> user debugs a failure."
Das Argument ist legitim, aber der Message-Log kann Source-Order behalten,
auch wenn die Calls parallel laufen. Wir verlieren nichts an Debug-Ergonomie.
### Neuer Zustand (Lösung)
In [`loop.ts`](../../packages/shared-ai/src/planner/loop.ts) wird der
Tool-Exec-Block ersetzt:
```ts
// Bestimme Parallel-Eligibility aus der Registry
const policyHints = response.toolCalls.map(c => getPolicyHintByName(c.name));
const allRead = policyHints.every(h => h === 'read');
if (allRead && response.toolCalls.length > 1) {
// Cap 10: bei mehr Tools in Batches à 10
const BATCH_SIZE = 10;
const allResults: ExecutedCall[] = [];
for (let i = 0; i < response.toolCalls.length; i += BATCH_SIZE) {
const batch = response.toolCalls.slice(i, i + BATCH_SIZE);
const results = await Promise.all(
batch.map(async (call) => ({
round: rounds,
call,
result: await onToolCall(call),
})),
);
allResults.push(...results);
}
// Append in Source-Order (nicht Completion-Order)
for (const ex of allResults) {
executedCalls.push(ex);
messages.push({
role: 'tool',
toolCallId: ex.call.id,
content: JSON.stringify({ /* … */ }),
});
}
} else {
// Sequenziell wie heute
for (const call of response.toolCalls) {
/* bestehend */
}
}
```
Helper `getPolicyHintByName` kommt aus der Registry (lesbar, da in M1 eh
integriert — Verbesserung 1 zieht die Policy-Information schon an die
Loop-Grenze).
### Abhängigkeit
Braucht **Verbesserung 1** vorher, damit `policyHint` autoritativ
verfügbar ist. Ohne Policy-Gate müsste die Loop die Hints aus der Registry
direkt nachschlagen — nicht schlimm, aber die Abfolge ist sauberer.
### Aufwand
~2h Code + Test.
### Tests
- `loop.test.ts`: 3 Read-Calls → `Promise.all` wird aufgerufen, Wall-Clock
~= max(read) statt sum(reads).
- `loop.test.ts`: 2 Read + 1 Write → sequenzielle Abarbeitung.
- `loop.test.ts`: 11 Read-Calls → 2 Batches (10 + 1), aber Source-Order in
`messages` erhalten.
### Rollout
Keine Flag-Gating nötig. Verhalten ist strikt additiv (sequenzieller Pfad
bleibt unverändert für gemischte Batches und für bestehende Caller, die
keine Registry haben).
---
## Reihenfolge & Zeitplan
| Reihenfolge | Verbesserung | Aufwand | Voraussetzung |
|-------------|---------------------------|-------------|---------------------|
| 1. | Permission-Gate (§1) | 1 Tag | — |
| 2. | Reminder-Channel (§2) | 4 h | — (parallel zu §1) |
| 3. | Parallel-Reads (§3) | 2 h | §1 (für policyHint) |
**Gesamt: ~1.5 Arbeitstage.**
Die drei Verbesserungen sind bewusst *klein*. Der Plan ist:
1. Alle drei in einem Sprint zusammen mergen (eine PR pro Verbesserung,
drei PRs gesamt).
2. `POLICY_ENFORCE=false` starten (log-only), eine Woche beobachten.
3. Im gleichen Zeitraum die ersten Reminder-Producer in `mana-ai`
nachziehen (eigene kleine PRs, nicht Teil von M1).
4. Flag flippen, Metriken prüfen (`policy_deny_total`, `parallel_read_batches_total`).
## Exit-Kriterien für M1
- [ ] `evaluatePolicy()` existiert in `@mana/tool-registry`, wird von beiden Consumern aufgerufen.
- [ ] `POLICY_ENFORCE=true` läuft eine Woche in Staging ohne False-Positive-Rate > 1 %.
- [ ] `runPlannerLoop` hat `reminderChannel`-API, Tests grün, mindestens ein Real-Producer live (z. B. Token-Budget-Reminder in `mana-ai`).
- [ ] Multi-Read-Mission in `mana-ai` zeigt messbare Wall-Clock-Verkürzung in der Metrik `mana_ai_tick_duration_seconds` (Ziel: -30 % p95 bei Research-Missions).
## Danach
M2 (Context-Compressor + Haiku-Tier) und M3 (In-Process Sub-Agents +
Persona-Runner) bauen auf allen drei M1-Primitiven auf — besonders der
Reminder-Channel ist das Vehikel, über das M2's Compactor dem LLM mitteilen
kann, dass komprimiert wurde. Details: siehe
[`docs/reports/mana-agent-improvements-from-claude-code.md`](../reports/mana-agent-improvements-from-claude-code.md)
§12 Roadmap.