Skip to content

Commit d338da4

Browse files
committed
feat: Wire middleware chain — 3 new before_model middleware (P3/P4/P5)
Item 3 — Wire middleware chain: 3 new before_model middleware functions in chat-middleware.ts: 1. Tool policy middleware (P3): - Screens user messages for dangerous/approval-needing commands - Default rules: deny rm -rf /, mkfs, dd to device; approve npm/pip install, git push, curl pipe to shell - Uses scannableCommand() to extract embedded shell commands - Does NOT block — annotates context for approval inbox to act on - Custom rules can be provided via options 2. Memory inject middleware (P4): - Recalls persistent memories via a provided recallFn - Injects them into the system prompt as a [Persistent Memory] block - Adds a system message if none exists - Skips gracefully when no memories or no recallFn 3. Compaction middleware (P5): - Uses shouldCompact() + pickBoundary() + extractWorkingState() - Summarizes old conversation entries when near 80% of token budget - Supports both LLM summaryFn and mechanical extraction (default) - Estimates token budget from model name (128k/200k/1m fallbacks) - Preserves system messages and recent entries Chain order (createBeforeModelChain): 1. tool-policy → screen commands 2. memory-inject → inject memories into system prompt 3. headroom → compress via Headroom sidecar (existing) 4. compaction → summarize old entries (P5) 5. runtime-route → resolve runtime (existing) All middleware degrade gracefully — never throws, never blocks chat. 16 new tests (all pass): chain structure, tool policy screening (rm -rf deny, git push approve, npm install approve, safe allow), memory injection (system prompt, no-system case, empty skip), compaction (too-short skip, below-threshold skip, mechanical summary, LLM summaryFn). Total tests: 1398 pass + 16 new = 1414 (1 flaky Skills test, pre-existing).
1 parent 4514bb9 commit d338da4

2 files changed

Lines changed: 459 additions & 2 deletions

File tree

agent-desktop/src/main/chat-middleware.ts

Lines changed: 236 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,225 @@ export function createRuntimeRouteMiddleware(
235235
export const runtimeRouteMiddleware: BeforeModelMiddleware =
236236
createRuntimeRouteMiddleware();
237237

238-
// ── Middleware 3: Reflection (after_model) ─────────────────
238+
// ── Middleware 3: Compaction (before_model) ────────────────
239+
//
240+
// Uses P5 auto-compaction to summarize old conversation entries when
241+
// the context approaches the model's token budget. Keeps recent
242+
// entries as-is; replaces old entries with a context_summary.
243+
// Degrades gracefully: if compaction is not needed or fails, the
244+
// messages pass through unchanged.
245+
246+
import {
247+
shouldCompact,
248+
estimateTokens,
249+
extractWorkingState,
250+
pickBoundary,
251+
type TurnEntry,
252+
} from "@cubecloud/platform-core";
253+
254+
/** Default token budget if model info is unavailable. */
255+
const DEFAULT_TOKEN_BUDGET = 128_000;
256+
257+
export function createCompactionMiddleware(
258+
summaryFn?: (entries: TurnEntry[]) => Promise<string>,
259+
): BeforeModelMiddleware {
260+
return async (ctx) => {
261+
const { messages } = ctx;
262+
if (messages.length < 6) {
263+
return { messages, applied: false, label: "compaction:skip(too-short)" };
264+
}
265+
266+
// Convert ChatMessage[] to TurnEntry[] for the compaction module
267+
const history: TurnEntry[] = messages
268+
.filter((m) => m.content && m.role !== "system")
269+
.map((m) => ({
270+
role: m.role as TurnEntry["role"],
271+
content: m.content!,
272+
timestamp: Date.now(),
273+
}));
274+
275+
// Estimate budget from model name (rough heuristic)
276+
const budget = ctx.model.includes("128k") ? 128_000
277+
: ctx.model.includes("200k") ? 200_000
278+
: ctx.model.includes("1m") || ctx.model.includes("1000k") ? 1_000_000
279+
: DEFAULT_TOKEN_BUDGET;
280+
281+
if (!shouldCompact(history, budget)) {
282+
return { messages, applied: false, label: "compaction:skip(below-threshold)" };
283+
}
284+
285+
try {
286+
const boundary = pickBoundary(history, budget);
287+
if (boundary <= 0) {
288+
return { messages, applied: false, label: "compaction:skip(no-boundary)" };
289+
}
290+
291+
const workingState = extractWorkingState(history);
292+
const oldEntries = history.slice(0, boundary);
293+
const recentEntries = history.slice(boundary);
294+
295+
// Build summary — use provided summaryFn or mechanical extraction
296+
let summary: string;
297+
if (summaryFn) {
298+
summary = await summaryFn(oldEntries);
299+
} else {
300+
// Mechanical summary (no LLM call) — just list working state
301+
const parts: string[] = ["[Context Summary]"];
302+
if (workingState.pendingTodos.length > 0) {
303+
parts.push(`Pending: ${workingState.pendingTodos.map((t) => `[ ] ${t}`).join(", ")}`);
304+
}
305+
if (workingState.activeFiles.length > 0) {
306+
parts.push(`Files: ${workingState.activeFiles.join(", ")}`);
307+
}
308+
parts.push(`Compacted ${oldEntries.length} entries.`);
309+
summary = parts.join("\n");
310+
}
311+
312+
// Rebuild messages: system + summary + recent entries
313+
const systemMsgs = messages.filter((m) => m.role === "system");
314+
const compacted: ChatMessage[] = [
315+
...systemMsgs,
316+
{ role: "user", content: summary },
317+
...recentEntries.map((e) => ({
318+
role: e.role as ChatMessage["role"],
319+
content: e.content,
320+
})),
321+
];
322+
323+
const tokensBefore = history.reduce((s, e) => s + estimateTokens(e.content), 0);
324+
const tokensAfter = compacted.reduce((s, m) => s + estimateTokens(m.content ?? ""), 0);
325+
326+
return {
327+
messages: compacted,
328+
applied: true,
329+
label: "compaction:applied",
330+
stats: {
331+
tokensBefore,
332+
tokensAfter,
333+
entriesCompacted: oldEntries.length,
334+
},
335+
};
336+
} catch {
337+
return { messages, applied: false, label: "compaction:error" };
338+
}
339+
};
340+
}
341+
342+
// ── Middleware 4: Memory inject (before_model) ─────────────
343+
//
344+
// Uses P4 memory service to recall relevant facts and inject them
345+
// into the system prompt. This gives the agent persistent memory
346+
// across conversations without modifying the gateway.
347+
// Degrades gracefully: if no memories or service unavailable, passes through.
348+
349+
export function createMemoryInjectMiddleware(
350+
recallFn?: () => Array<{ content: string; label: string }>,
351+
): BeforeModelMiddleware {
352+
return async (ctx) => {
353+
if (!recallFn) {
354+
return { messages: ctx.messages, applied: false, label: "memory:skip(no-recall-fn)" };
355+
}
356+
357+
try {
358+
const memories = recallFn();
359+
if (memories.length === 0) {
360+
return { messages: ctx.messages, applied: false, label: "memory:skip(empty)" };
361+
}
362+
363+
// Build a memory context block to prepend to the system message
364+
const memoryBlock = memories
365+
.map((m) => `- ${m.content}`)
366+
.join("\n");
367+
const memoryPrefix = `[Persistent Memory]\n${memoryBlock}\n[/Persistent Memory]\n\n`;
368+
369+
const messages = ctx.messages.map((m) => {
370+
if (m.role === "system" && m.content) {
371+
return { ...m, content: memoryPrefix + m.content };
372+
}
373+
return m;
374+
});
375+
376+
// If no system message, prepend one
377+
if (!messages.some((m) => m.role === "system")) {
378+
messages.unshift({ role: "system", content: memoryPrefix.trim() });
379+
}
380+
381+
return {
382+
messages,
383+
applied: true,
384+
label: "memory:injected",
385+
stats: { memoryCount: memories.length },
386+
};
387+
} catch {
388+
return { messages: ctx.messages, applied: false, label: "memory:error" };
389+
}
390+
};
391+
}
392+
393+
// ── Middleware 5: Tool policy screen (before_model) ────────
394+
//
395+
// Uses P3 tool policy to screen the user's message for commands that
396+
// require approval or should be denied. Does NOT block the message —
397+
// just annotates the context so the after_model chain or the approval
398+
// inbox can act on it. Degrades gracefully.
399+
400+
import {
401+
createCommandPolicy,
402+
scannableCommand,
403+
type CommandPolicyRule,
404+
} from "@cubecloud/platform-core";
405+
406+
/** Default tool policy rules for the desktop. */
407+
const DEFAULT_TOOL_POLICY_RULES: CommandPolicyRule[] = [
408+
// Deny destructive shell commands
409+
{ pattern: /\brm\s+-rf\s+\//i, decision: "deny", label: "deny:rm-rf-root" },
410+
{ pattern: /\bmkfs\./i, decision: "deny", label: "deny:mkfs" },
411+
{ pattern: /\bdd\s+if=.*of=\/dev\//i, decision: "deny", label: "deny:dd-to-device" },
412+
// Require approval for package installs
413+
{ pattern: /\bnpm\s+install\b|\bpip\s+install\b|\bapt\s+install\b/i, decision: "require_approval", label: "approve:package-install" },
414+
// Require approval for git push
415+
{ pattern: /\bgit\s+push\b/i, decision: "require_approval", label: "approve:git-push" },
416+
// Require approval for network operations
417+
{ pattern: /\bcurl\s+.*\|\s*(bash|sh)\b/i, decision: "require_approval", label: "approve:curl-pipe" },
418+
];
419+
420+
export function createToolPolicyMiddleware(
421+
customRules?: CommandPolicyRule[],
422+
): BeforeModelMiddleware {
423+
const policy = createCommandPolicy(customRules ?? DEFAULT_TOOL_POLICY_RULES);
424+
425+
return async (ctx) => {
426+
const lastUserMsg = [...ctx.messages].reverse().find((m) => m.role === "user" && m.content);
427+
if (!lastUserMsg?.content) {
428+
return { messages: ctx.messages, applied: false, label: "tool-policy:skip(no-user-msg)" };
429+
}
430+
431+
try {
432+
// Scan the user message (including embedded commands) for policy violations
433+
const scannable = scannableCommand(lastUserMsg.content);
434+
const result = policy.evaluate(scannable);
435+
436+
if (result.decision === "allow") {
437+
return { messages: ctx.messages, applied: false, label: "tool-policy:allow" };
438+
}
439+
440+
// Annotate the context — don't block, just report
441+
return {
442+
messages: ctx.messages,
443+
applied: true,
444+
label: `tool-policy:${result.decision}:${result.label}`,
445+
stats: {
446+
decision: result.decision,
447+
rule: result.label,
448+
},
449+
};
450+
} catch {
451+
return { messages: ctx.messages, applied: false, label: "tool-policy:error" };
452+
}
453+
};
454+
}
455+
456+
// ── Middleware 6: Reflection (after_model) ─────────────────
239457
//
240458
// Runs a second LLM pass that critiques the response for accuracy,
241459
// completeness, and coherence. Opt-in via the reflectionEnabled
@@ -374,12 +592,28 @@ export async function runAfterModelChain(
374592

375593
/** Build the default before_model chain for the desktop.
376594
* When a harness registry is provided, the runtimeRoute middleware
377-
* resolves the active provider and annotates the context. */
595+
* resolves the active provider and annotates the context.
596+
*
597+
* Chain order (each step degrades gracefully):
598+
* 1. tool-policy — screen user message for dangerous/approval-needing commands
599+
* 2. memory-inject — inject persistent memories into system prompt
600+
* 3. headroom — compress context via Headroom sidecar
601+
* 4. compaction — summarize old entries when near token budget
602+
* 5. runtime-route — resolve active runtime/provider
603+
*/
378604
export function createBeforeModelChain(
379605
registry?: HarnessRegistry,
606+
options?: {
607+
memoryRecallFn?: () => Array<{ content: string; label: string }>;
608+
compactionSummaryFn?: (entries: TurnEntry[]) => Promise<string>;
609+
toolPolicyRules?: CommandPolicyRule[];
610+
},
380611
): BeforeModelMiddleware[] {
381612
return [
613+
createToolPolicyMiddleware(options?.toolPolicyRules),
614+
createMemoryInjectMiddleware(options?.memoryRecallFn),
382615
headroomCompressMiddleware,
616+
createCompactionMiddleware(options?.compactionSummaryFn),
383617
createRuntimeRouteMiddleware(registry),
384618
];
385619
}

0 commit comments

Comments
 (0)