feat(commands,audit): add /context command and proactive compaction audit events

This commit is contained in:
William Valentin
2026-02-16 16:08:21 -08:00
parent 9c8da41610
commit 21d57d991c
11 changed files with 173 additions and 20 deletions
+10 -4
View File
@@ -1,6 +1,6 @@
{ {
"version": "1.0", "version": "1.0",
"updated_at": "2026-02-16", "updated_at": "2026-02-17",
"description": "Tracks the status of all Flynn plans and implementation phases", "description": "Tracks the status of all Flynn plans and implementation phases",
"plans": { "plans": {
"automation-daily-briefing-and-cron-backup-scheduling": { "automation-daily-briefing-and-cron-backup-scheduling": {
@@ -3504,8 +3504,8 @@
"proactive-context-usage-and-compaction-signals": { "proactive-context-usage-and-compaction-signals": {
"status": "completed", "status": "completed",
"date": "2026-02-16", "date": "2026-02-16",
"updated": "2026-02-16", "updated": "2026-02-17",
"summary": "Implemented proactive context-window management end-to-end: orchestrator now exposes estimated context budget, emits staged context alerts, writes checkpoint summaries to memory near threshold, and can auto-compact proactively. Gateway now emits `context_warning` stream events during `agent.send`, serves `system.contextUsage` snapshots, and dashboard usage UI includes context budget visibility. Added config schema support under `compaction.proactive`, mapped runtime wiring in both WS SessionBridge and channel routing paths, and updated protocol/docs/default config examples with focused tests.", "summary": "Implemented proactive context-window management end-to-end: orchestrator now exposes estimated context budget, emits staged context alerts, writes checkpoint summaries to memory near threshold, and can auto-compact proactively. Gateway now emits `context_warning` stream events during `agent.send`, serves `system.contextUsage` snapshots, and dashboard usage UI includes context budget visibility. Added config schema support under `compaction.proactive`, mapped runtime wiring in both WS SessionBridge and channel routing paths, and updated protocol/docs/default config examples with focused tests. Follow-up added `/context` command fast-path visibility and dedicated audit events for proactive checkpoint writes and proactive auto-compaction.",
"files_modified": [ "files_modified": [
"src/context/compaction.ts", "src/context/compaction.ts",
"src/backends/native/prompts.ts", "src/backends/native/prompts.ts",
@@ -3525,13 +3525,19 @@
"src/daemon/services.ts", "src/daemon/services.ts",
"src/gateway/ui/pages/chat.js", "src/gateway/ui/pages/chat.js",
"src/gateway/ui/pages/usage.js", "src/gateway/ui/pages/usage.js",
"src/commands/builtin/index.ts",
"src/commands/types.ts",
"src/commands/index.ts",
"src/commands/builtin/index.test.ts",
"src/audit/types.ts",
"src/audit/logger.ts",
"docs/api/PROTOCOL.md", "docs/api/PROTOCOL.md",
"README.md", "README.md",
"docs/performance/TUNING.md", "docs/performance/TUNING.md",
"config/default.yaml", "config/default.yaml",
"docs/plans/state.json" "docs/plans/state.json"
], ],
"test_status": "pnpm test:run src/backends/native/orchestrator.test.ts src/config/schema.test.ts src/gateway/handlers/agent.test.ts src/gateway/handlers/handlers.test.ts src/gateway/protocol.test.ts + pnpm typecheck passing" "test_status": "pnpm test:run src/backends/native/orchestrator.test.ts src/config/schema.test.ts src/gateway/handlers/agent.test.ts src/gateway/handlers/handlers.test.ts src/gateway/protocol.test.ts src/commands/builtin/index.test.ts + pnpm typecheck passing"
} }
}, },
"overall_progress": { "overall_progress": {
+12
View File
@@ -16,6 +16,8 @@ import type {
SessionMessageEvent, SessionMessageEvent,
SessionDeleteEvent, SessionDeleteEvent,
SessionCompactEvent, SessionCompactEvent,
SessionCheckpointEvent,
SessionAutoCompactEvent,
UserActionEvent, UserActionEvent,
CronTriggerEvent, CronTriggerEvent,
WebhookReceiveEvent, WebhookReceiveEvent,
@@ -175,6 +177,16 @@ export class AuditLogger {
this.write({ level: 'debug', event_type: 'session.compact', event: event as unknown as Record<string, unknown> }); this.write({ level: 'debug', event_type: 'session.compact', event: event as unknown as Record<string, unknown> });
} }
sessionCheckpoint(event: SessionCheckpointEvent): void {
if (!this.shouldLog('sessions', 'debug')) {return;}
this.write({ level: 'debug', event_type: 'session.checkpoint', event: event as unknown as Record<string, unknown> });
}
sessionAutoCompact(event: SessionAutoCompactEvent): void {
if (!this.shouldLog('sessions', 'debug')) {return;}
this.write({ level: 'debug', event_type: 'session.auto_compact', event: event as unknown as Record<string, unknown> });
}
userAction(event: UserActionEvent): void { userAction(event: UserActionEvent): void {
if (!this.shouldLog('sessions', 'info')) {return;} if (!this.shouldLog('sessions', 'info')) {return;}
this.write({ level: 'info', event_type: 'user.action', event: event as unknown as Record<string, unknown> }); this.write({ level: 'info', event_type: 'user.action', event: event as unknown as Record<string, unknown> });
+17 -1
View File
@@ -10,7 +10,7 @@ export type AuditEventType =
// Skills installer // Skills installer
| 'skills.installer.execution_blocked' | 'skills.installer.command_result' | 'skills.registry_install' | 'skills.installer.execution_blocked' | 'skills.installer.command_result' | 'skills.registry_install'
// Session lifecycle // Session lifecycle
| 'session.create' | 'session.message' | 'session.delete' | 'session.transfer' | 'session.compact' | 'user.action' | 'session.create' | 'session.message' | 'session.delete' | 'session.transfer' | 'session.compact' | 'session.checkpoint' | 'session.auto_compact' | 'user.action'
// Automation - Cron // Automation - Cron
| 'cron.trigger' | 'cron.sent' | 'cron.add' | 'cron.remove' | 'cron.trigger' | 'cron.sent' | 'cron.add' | 'cron.remove'
// Automation - Webhook // Automation - Webhook
@@ -182,6 +182,22 @@ export interface SessionCompactEvent {
tokens_after: number; tokens_after: number;
} }
export interface SessionCheckpointEvent {
session_id: string;
namespace: string;
chars_written: number;
usage_pct: number;
}
export interface SessionAutoCompactEvent {
session_id: string;
usage_pct_before: number;
usage_pct_after: number;
compacted: boolean;
tokens_before: number;
tokens_after: number;
}
export interface UserActionEvent { export interface UserActionEvent {
session_id: string; session_id: string;
channel: string; channel: string;
+24 -1
View File
@@ -927,6 +927,10 @@ describe('AgentOrchestrator', () => {
deleteConfig: vi.fn(), deleteConfig: vi.fn(),
}; };
const sessionCheckpoint = vi.fn();
const previousAuditLogger = auditLogger;
initAuditLogger({ sessionCheckpoint } as unknown as AuditLogger);
const orchestrator = new AgentOrchestrator({ const orchestrator = new AgentOrchestrator({
modelRouter: mockRouter, modelRouter: mockRouter,
systemPrompt: 'You are helpful.', systemPrompt: 'You are helpful.',
@@ -958,6 +962,7 @@ describe('AgentOrchestrator', () => {
}, },
}); });
try {
await orchestrator.process('ping'); await orchestrator.process('ping');
const alert = orchestrator.consumeContextAlert(); const alert = orchestrator.consumeContextAlert();
expect(alert?.level).toBe('checkpoint'); expect(alert?.level).toBe('checkpoint');
@@ -967,8 +972,13 @@ describe('AgentOrchestrator', () => {
const stored = memoryStore.read('session/checkpoints/ws/context-test'); const stored = memoryStore.read('session/checkpoints/ws/context-test');
expect(stored.length).toBeGreaterThan(0); expect(stored.length).toBeGreaterThan(0);
expect(sessionCheckpoint).toHaveBeenCalledWith(expect.objectContaining({
session_id: 'ws:context-test',
}));
} finally {
initAuditLogger(previousAuditLogger as unknown as AuditLogger);
rmSync(tempDir, { recursive: true, force: true }); rmSync(tempDir, { recursive: true, force: true });
}
}); });
it('auto-compacts proactively at critical threshold and emits alert', async () => { it('auto-compacts proactively at critical threshold and emits alert', async () => {
@@ -1006,6 +1016,11 @@ describe('AgentOrchestrator', () => {
deleteConfig: vi.fn(), deleteConfig: vi.fn(),
}; };
const sessionAutoCompact = vi.fn();
const sessionCompact = vi.fn();
const previousAuditLogger = auditLogger;
initAuditLogger({ sessionAutoCompact, sessionCompact } as unknown as AuditLogger);
const orchestrator = new AgentOrchestrator({ const orchestrator = new AgentOrchestrator({
modelRouter: compactRouter, modelRouter: compactRouter,
systemPrompt: 'You are helpful.', systemPrompt: 'You are helpful.',
@@ -1036,10 +1051,18 @@ describe('AgentOrchestrator', () => {
}, },
}); });
try {
await orchestrator.process('continue'); await orchestrator.process('continue');
const alert = orchestrator.consumeContextAlert(); const alert = orchestrator.consumeContextAlert();
expect(alert?.actions.autoCompacted).toBe(true); expect(alert?.actions.autoCompacted).toBe(true);
expect(history.length).toBeLessThan(6); expect(history.length).toBeLessThan(6);
expect(sessionAutoCompact).toHaveBeenCalledWith(expect.objectContaining({
session_id: 'ws:auto-compact',
compacted: true,
}));
} finally {
initAuditLogger(previousAuditLogger as unknown as AuditLogger);
}
}); });
}); });
}); });
+17
View File
@@ -573,6 +573,7 @@ export class AgentOrchestrator {
} }
if (level === 'critical') { if (level === 'critical') {
const beforeAuto = budget;
try { try {
const result = await this.compact(); const result = await this.compact();
autoCompacted = Boolean(result && result.compactedCount > 0); autoCompacted = Boolean(result && result.compactedCount > 0);
@@ -580,6 +581,16 @@ export class AgentOrchestrator {
console.warn('[Flynn:compact] Proactive auto-compaction failed:', error); console.warn('[Flynn:compact] Proactive auto-compaction failed:', error);
} }
budget = this.getContextBudget(); budget = this.getContextBudget();
if (this._session) {
auditLogger?.sessionAutoCompact({
session_id: this._session.id,
usage_pct_before: beforeAuto.usagePct,
usage_pct_after: budget.usagePct,
compacted: autoCompacted,
tokens_before: beforeAuto.estimatedTokens,
tokens_after: budget.estimatedTokens,
});
}
level = this._resolveContextAlertLevel(budget.usagePct); level = this._resolveContextAlertLevel(budget.usagePct);
} }
@@ -691,6 +702,12 @@ export class AgentOrchestrator {
const namespace = `${namespaceBase}/${this._sanitizeSessionId(this._session.id)}`; const namespace = `${namespaceBase}/${this._sanitizeSessionId(this._session.id)}`;
const block = `## ${new Date().toISOString()}\n\n${summary}\n\n`; const block = `## ${new Date().toISOString()}\n\n${summary}\n\n`;
this._memoryStore.write(namespace, block, 'append'); this._memoryStore.write(namespace, block, 'append');
auditLogger?.sessionCheckpoint({
session_id: this._session.id,
namespace,
chars_written: block.length,
usage_pct: this.getContextBudget().usagePct,
});
this._lastCheckpointAt = Date.now(); this._lastCheckpointAt = Date.now();
return { saved: true, namespace }; return { saved: true, namespace };
} catch (error) { } catch (error) {
+29 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { createElevateCommand, createModelCommand, createQueueCommand } from './index.js'; import { createContextCommand, createElevateCommand, createModelCommand, createQueueCommand } from './index.js';
describe('builtin /model command', () => { describe('builtin /model command', () => {
it('passes through the full argument string', async () => { it('passes through the full argument string', async () => {
@@ -111,3 +111,31 @@ describe('builtin /queue command', () => {
expect(result).toEqual({ handled: true, text: 'reset' }); expect(result).toEqual({ handled: true, text: 'reset' });
}); });
}); });
describe('builtin /context command', () => {
it('returns context usage text from services', async () => {
const cmd = createContextCommand();
const getContext = vi.fn(() => 'Context Usage\n\n75.0%');
const result = await cmd.execute([], {
channel: 'test',
senderId: 'user',
sessionId: 's1',
rawInput: '/context',
services: { getContext },
});
expect(getContext).toHaveBeenCalledOnce();
expect(result).toEqual({ handled: true, text: 'Context Usage\n\n75.0%' });
});
it('returns not-available when service is missing', async () => {
const cmd = createContextCommand();
const result = await cmd.execute([], {
channel: 'test',
senderId: 'user',
sessionId: 's1',
rawInput: '/context',
services: {},
});
expect(result).toEqual({ handled: true, text: 'Context command is not available in this session.' });
});
});
+17
View File
@@ -65,6 +65,22 @@ export function createUsageCommand(): CommandDefinition {
}; };
} }
export function createContextCommand(): CommandDefinition {
return {
name: 'context',
description: 'Show context window usage',
execute: async (_args, ctx) => {
if (!ctx.services?.getContext) {
return notAvailable('Context command');
}
return {
handled: true,
text: await ctx.services.getContext(),
};
},
};
}
export function createModelCommand(): CommandDefinition { export function createModelCommand(): CommandDefinition {
return { return {
name: 'model', name: 'model',
@@ -183,6 +199,7 @@ export function registerBuiltinCommands(registry: CommandRegistry): void {
registry.register(createHelpCommand(registry)); registry.register(createHelpCommand(registry));
registry.register(createStatusCommand()); registry.register(createStatusCommand());
registry.register(createUsageCommand()); registry.register(createUsageCommand());
registry.register(createContextCommand());
registry.register(createModelCommand()); registry.register(createModelCommand());
registry.register(createCompactCommand()); registry.register(createCompactCommand());
registry.register(createResetCommand()); registry.register(createResetCommand());
+1
View File
@@ -4,6 +4,7 @@ export {
createHelpCommand, createHelpCommand,
createStatusCommand, createStatusCommand,
createUsageCommand, createUsageCommand,
createContextCommand,
createModelCommand, createModelCommand,
createCompactCommand, createCompactCommand,
createResetCommand, createResetCommand,
+1
View File
@@ -21,6 +21,7 @@ export interface CommandDefinition {
export interface CommandServices { export interface CommandServices {
getStatus?: () => Promise<string> | string; getStatus?: () => Promise<string> | string;
getUsage?: () => Promise<string> | string; getUsage?: () => Promise<string> | string;
getContext?: () => Promise<string> | string;
getModel?: () => Promise<string> | string; getModel?: () => Promise<string> | string;
setModel?: (tier: string) => Promise<string> | string; setModel?: (tier: string) => Promise<string> | string;
compact?: () => Promise<string> | string; compact?: () => Promise<string> | string;
+19
View File
@@ -82,6 +82,25 @@ describe('createAgentHandlers command fast-path', () => {
expect((event.data as { content: string }).content).toContain('Token Usage'); expect((event.data as { content: string }).content).toContain('Token Usage');
}); });
it('handles /context command via fast-path', async () => {
const sent: OutboundMessage[] = [];
const send = vi.fn((msg: OutboundMessage) => sent.push(msg));
const req: GatewayRequest = {
id: 8,
method: 'agent.send',
params: { message: '/context', connectionId: 'conn-1' },
};
await handlers['agent.send'](req, send);
expect(mockAgent.process).not.toHaveBeenCalled();
expect(sent).toHaveLength(1);
const event = sent[0] as GatewayEvent;
expect(event.event).toBe('done');
expect((event.data as { content: string }).content).toContain('Context Usage');
expect((event.data as { content: string }).content).toContain('Compaction threshold');
});
it('handles metadata commands via fast-path', async () => { it('handles metadata commands via fast-path', async () => {
const sent: OutboundMessage[] = []; const sent: OutboundMessage[] = [];
const send = vi.fn((msg: OutboundMessage) => sent.push(msg)); const send = vi.fn((msg: OutboundMessage) => sent.push(msg));
+13
View File
@@ -126,6 +126,19 @@ export function createAgentHandlers(deps: AgentHandlerDeps) {
return lines.join('\n'); return lines.join('\n');
}, },
getContext: () => {
const budget = agent.getContextBudget();
const lines = [
'**Context Usage**',
'',
`Estimated: ${budget.estimatedTokens.toLocaleString()} / ${budget.contextWindow.toLocaleString()} tokens`,
`Remaining: ${budget.remainingTokens.toLocaleString()} tokens`,
`Usage: ${budget.usagePct.toFixed(1)}%`,
`Compaction threshold: ${budget.thresholdPct}% (${budget.thresholdTokens.toLocaleString()} tokens)`,
`Should compact: ${budget.shouldCompact ? 'yes' : 'no'}`,
];
return lines.join('\n');
},
getModel: () => `Current model tier: ${agent.getModelTier()}`, getModel: () => `Current model tier: ${agent.getModelTier()}`,
setModel: (input) => { setModel: (input) => {
const raw = input.trim(); const raw = input.trim();