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>
14 KiB
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— wie Claude Code intern aufgebaut istdocs/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
runPlannerLoops 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), 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 previouswerden erkannt und als Metrik markiert. Nicht blockiert (zu viele False Positives), aber sichtbar. - Ein Policy-Ort für beide Consumer.
mana-mcpundmana-airufen denselben Code — keine Drift mehr.
Heutiger Zustand (Problem)
services/mana-mcp/src/mcp-adapter.ts:34-37:
function isExposable(spec: AnyToolSpec): boolean {
return spec.scope === 'user-space';
}
Das ist der gesamte Gate. mana-ais onToolCall hat gar nichts.
Neuer Zustand (Lösung)
Neues Modul packages/mana-tool-registry/src/policy.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.tsruftevaluatePolicy()ininvoke()vorspec.handler().services/mana-ai/src/cron/tick.tsruft es imonToolCall-Callback.recentInvocationskommt 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-ailä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 einenreminder-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:
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:
- in den
systemPrompteingebacken → bleibt ewig stehen, veraltet schnell, - an den
userPromptper Concatenation → mutiert die History, landet in Logs.
Neuer Zustand (Lösung)
packages/shared-ai/src/planner/loop.ts bekommt neuen Input-Slot:
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):
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:
// 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 imresult.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 — 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 wird der
Tool-Exec-Block ersetzt:
// 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.allwird 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 inmessageserhalten.
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:
- Alle drei in einem Sprint zusammen mergen (eine PR pro Verbesserung, drei PRs gesamt).
POLICY_ENFORCE=falsestarten (log-only), eine Woche beobachten.- Im gleichen Zeitraum die ersten Reminder-Producer in
mana-ainachziehen (eigene kleine PRs, nicht Teil von M1). - 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=trueläuft eine Woche in Staging ohne False-Positive-Rate > 1 %.runPlannerLoophatreminderChannel-API, Tests grün, mindestens ein Real-Producer live (z. B. Token-Budget-Reminder inmana-ai).- Multi-Read-Mission in
mana-aizeigt messbare Wall-Clock-Verkürzung in der Metrikmana_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
§12 Roadmap.