Skip to content

Commit e5c1eab

Browse files
author
StackMemory Bot (CLI)
committed
feat(loops): 6 trigger-based feedback loops with engine + CLI
Add FeedbackLoopEngine with 6 composable loops: 1. contextPressure — context 70%+ triggers auto-digest of old frames 2. editRecovery — edit failure triggers sm_edit fuzzy fallback 3. retrievalQuality — empty results >20% triggers strategy switch 4. traceErrorChain — same error 3x surfaces anchor + memory alert 5. harnessRegression — approval rate drop triggers regression alert 6. sessionDrift — depth >5 or stale frames triggers auto-checkpoint Each loop has: trigger → detect → act → measure cycle with configurable cooldown, event emission, history tracking, and stats. Wire loop 5 into harness.ts (checks rolling 10-run window after each metrics append). Add `stackmemory bench loops` to view configuration and recent loop events.
1 parent 82fe18f commit e5c1eab

4 files changed

Lines changed: 479 additions & 0 deletions

File tree

src/cli/commands/bench.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ import {
1414
summarizeRuns,
1515
} from '../../orchestrators/multimodal/baselines.js';
1616
import type { HarnessRunMetrics } from '../../orchestrators/multimodal/baselines.js';
17+
import {
18+
feedbackLoops,
19+
DEFAULT_CONFIG,
20+
} from '../../core/monitoring/feedback-loops.js';
1721

1822
function loadRunMetrics(projectRoot: string): HarnessRunMetrics[] {
1923
const metricsFile = join(
@@ -236,5 +240,68 @@ export function createBenchCommand(): Command {
236240
console.log('');
237241
});
238242

243+
// Sub-command: bench loops
244+
bench
245+
.command('loops')
246+
.description('Show feedback loop configuration, status, and recent events')
247+
.option('--json', 'Output as JSON', false)
248+
.action((options) => {
249+
const config = feedbackLoops.getConfig();
250+
const stats = feedbackLoops.getStats();
251+
const history = feedbackLoops.getHistory(undefined, 20);
252+
253+
if (options.json) {
254+
console.log(JSON.stringify({ config, stats, history }, null, 2));
255+
return;
256+
}
257+
258+
console.log('\nFeedback Loops');
259+
console.log('═'.repeat(60));
260+
261+
const loopDescriptions: Record<string, string> = {
262+
contextPressure: 'Context 70%+ → auto-digest old frames',
263+
editRecovery: 'Edit failure → sm_edit fuzzy fallback → telemetry',
264+
retrievalQuality: 'Empty results > 20% → switch search strategy',
265+
traceErrorChain: 'Same error 3x → surface anchor + memory',
266+
harnessRegression: 'Approval rate drops → regression alert',
267+
sessionDrift: 'Depth > 5 or stale frames → auto-checkpoint',
268+
};
269+
270+
console.log('\nLoop Configuration:');
271+
for (const [name, cfg] of Object.entries(config)) {
272+
const icon = cfg.enabled ? ' ON' : 'OFF';
273+
const desc = loopDescriptions[name] || name;
274+
const cooldown =
275+
cfg.cooldownSec > 0 ? ` (cooldown ${cfg.cooldownSec}s)` : '';
276+
console.log(` [${icon}] ${name.padEnd(22)} ${desc}${cooldown}`);
277+
}
278+
279+
if (Object.keys(stats).length > 0) {
280+
console.log('\nLoop Stats (this session):');
281+
for (const [name, s] of Object.entries(stats)) {
282+
const ago = s.lastFired
283+
? `${Math.round((Date.now() - s.lastFired) / 1000)}s ago`
284+
: 'never';
285+
console.log(
286+
` ${name.padEnd(22)} ${s.fires} fires, ${s.successes} ok, ${s.errors} err (last: ${ago})`
287+
);
288+
}
289+
}
290+
291+
if (history.length > 0) {
292+
console.log(`\nRecent Events (${history.length}):`);
293+
for (const e of history.slice(-10)) {
294+
const time = new Date(e.timestamp).toISOString().slice(11, 19);
295+
console.log(
296+
` ${time} [${e.loop}] ${e.trigger}${e.action} (${e.outcome})`
297+
);
298+
}
299+
} else {
300+
console.log('\nNo loop events fired yet this session.');
301+
}
302+
303+
console.log('');
304+
});
305+
239306
return bench;
240307
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { FeedbackLoopEngine, DEFAULT_CONFIG } from '../feedback-loops.js';
3+
4+
describe('FeedbackLoopEngine', () => {
5+
it('fires enabled loops and respects cooldown', () => {
6+
const engine = new FeedbackLoopEngine();
7+
const listener = vi.fn();
8+
engine.on('loop', listener);
9+
10+
// First fire succeeds
11+
const e1 = engine.fire(
12+
'editRecovery',
13+
'PostToolUse',
14+
{ filePath: 'foo.ts', errorType: 'string_not_found' },
15+
'fuzzy_fallback'
16+
);
17+
expect(e1).not.toBeNull();
18+
expect(e1!.loop).toBe('editRecovery');
19+
expect(listener).toHaveBeenCalledTimes(1);
20+
21+
// editRecovery has cooldown=0, so fires again immediately
22+
const e2 = engine.fire(
23+
'editRecovery',
24+
'PostToolUse',
25+
{ filePath: 'bar.ts' },
26+
'fuzzy_fallback'
27+
);
28+
expect(e2).not.toBeNull();
29+
30+
// contextPressure has cooldown=60s, second fire within cooldown skips
31+
const e3 = engine.fire(
32+
'contextPressure',
33+
'context:high',
34+
{ percentage: 75 },
35+
'auto_digest'
36+
);
37+
expect(e3).not.toBeNull();
38+
const e4 = engine.fire(
39+
'contextPressure',
40+
'context:high',
41+
{ percentage: 78 },
42+
'auto_digest'
43+
);
44+
expect(e4).toBeNull(); // cooldown
45+
});
46+
47+
it('skips disabled loops', () => {
48+
const engine = new FeedbackLoopEngine({
49+
editRecovery: { enabled: false, cooldownSec: 0 },
50+
});
51+
52+
const result = engine.fire(
53+
'editRecovery',
54+
'PostToolUse',
55+
{},
56+
'fuzzy_fallback'
57+
);
58+
expect(result).toBeNull();
59+
});
60+
61+
it('tracks history and stats', () => {
62+
const engine = new FeedbackLoopEngine();
63+
64+
engine.fire('editRecovery', 'test', {}, 'act1', 'success');
65+
engine.fire('editRecovery', 'test', {}, 'act2', 'error');
66+
engine.fire('traceErrorChain', 'test', {}, 'alert', 'success');
67+
68+
const history = engine.getHistory();
69+
expect(history).toHaveLength(3);
70+
71+
const editHistory = engine.getHistory('editRecovery');
72+
expect(editHistory).toHaveLength(2);
73+
74+
const stats = engine.getStats();
75+
expect(stats['editRecovery'].fires).toBe(2);
76+
expect(stats['editRecovery'].successes).toBe(1);
77+
expect(stats['editRecovery'].errors).toBe(1);
78+
expect(stats['traceErrorChain'].fires).toBe(1);
79+
});
80+
81+
it('emits per-loop events', () => {
82+
const engine = new FeedbackLoopEngine();
83+
const editListener = vi.fn();
84+
const traceListener = vi.fn();
85+
engine.on('loop:editRecovery', editListener);
86+
engine.on('loop:traceErrorChain', traceListener);
87+
88+
engine.fire('editRecovery', 'test', {}, 'act');
89+
expect(editListener).toHaveBeenCalledTimes(1);
90+
expect(traceListener).not.toHaveBeenCalled();
91+
});
92+
93+
it('default config has all 6 loops', () => {
94+
expect(Object.keys(DEFAULT_CONFIG)).toHaveLength(6);
95+
for (const cfg of Object.values(DEFAULT_CONFIG)) {
96+
expect(cfg).toHaveProperty('enabled');
97+
expect(cfg).toHaveProperty('cooldownSec');
98+
}
99+
});
100+
});

0 commit comments

Comments
 (0)