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",
"updated_at": "2026-02-16",
"updated_at": "2026-02-17",
"description": "Tracks the status of all Flynn plans and implementation phases",
"plans": {
"automation-daily-briefing-and-cron-backup-scheduling": {
@@ -3504,8 +3504,8 @@
"proactive-context-usage-and-compaction-signals": {
"status": "completed",
"date": "2026-02-16",
"updated": "2026-02-16",
"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.",
"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. Follow-up added `/context` command fast-path visibility and dedicated audit events for proactive checkpoint writes and proactive auto-compaction.",
"files_modified": [
"src/context/compaction.ts",
"src/backends/native/prompts.ts",
@@ -3525,13 +3525,19 @@
"src/daemon/services.ts",
"src/gateway/ui/pages/chat.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",
"README.md",
"docs/performance/TUNING.md",
"config/default.yaml",
"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": {
+12
View File
@@ -16,6 +16,8 @@ import type {
SessionMessageEvent,
SessionDeleteEvent,
SessionCompactEvent,
SessionCheckpointEvent,
SessionAutoCompactEvent,
UserActionEvent,
CronTriggerEvent,
WebhookReceiveEvent,
@@ -175,6 +177,16 @@ export class AuditLogger {
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 {
if (!this.shouldLog('sessions', 'info')) {return;}
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.execution_blocked' | 'skills.installer.command_result' | 'skills.registry_install'
// 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
| 'cron.trigger' | 'cron.sent' | 'cron.add' | 'cron.remove'
// Automation - Webhook
@@ -182,6 +182,22 @@ export interface SessionCompactEvent {
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 {
session_id: string;
channel: string;
+37 -14
View File
@@ -927,6 +927,10 @@ describe('AgentOrchestrator', () => {
deleteConfig: vi.fn(),
};
const sessionCheckpoint = vi.fn();
const previousAuditLogger = auditLogger;
initAuditLogger({ sessionCheckpoint } as unknown as AuditLogger);
const orchestrator = new AgentOrchestrator({
modelRouter: mockRouter,
systemPrompt: 'You are helpful.',
@@ -958,17 +962,23 @@ describe('AgentOrchestrator', () => {
},
});
await orchestrator.process('ping');
const alert = orchestrator.consumeContextAlert();
expect(alert?.level).toBe('checkpoint');
expect(alert?.actions.checkpointSaved).toBe(true);
expect(alert?.actions.checkpointNamespace).toContain('session/checkpoints');
expect(orchestrator.consumeContextAlert()).toBeUndefined();
try {
await orchestrator.process('ping');
const alert = orchestrator.consumeContextAlert();
expect(alert?.level).toBe('checkpoint');
expect(alert?.actions.checkpointSaved).toBe(true);
expect(alert?.actions.checkpointNamespace).toContain('session/checkpoints');
expect(orchestrator.consumeContextAlert()).toBeUndefined();
const stored = memoryStore.read('session/checkpoints/ws/context-test');
expect(stored.length).toBeGreaterThan(0);
rmSync(tempDir, { recursive: true, force: true });
const stored = memoryStore.read('session/checkpoints/ws/context-test');
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 });
}
});
it('auto-compacts proactively at critical threshold and emits alert', async () => {
@@ -1006,6 +1016,11 @@ describe('AgentOrchestrator', () => {
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({
modelRouter: compactRouter,
systemPrompt: 'You are helpful.',
@@ -1036,10 +1051,18 @@ describe('AgentOrchestrator', () => {
},
});
await orchestrator.process('continue');
const alert = orchestrator.consumeContextAlert();
expect(alert?.actions.autoCompacted).toBe(true);
expect(history.length).toBeLessThan(6);
try {
await orchestrator.process('continue');
const alert = orchestrator.consumeContextAlert();
expect(alert?.actions.autoCompacted).toBe(true);
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') {
const beforeAuto = budget;
try {
const result = await this.compact();
autoCompacted = Boolean(result && result.compactedCount > 0);
@@ -580,6 +581,16 @@ export class AgentOrchestrator {
console.warn('[Flynn:compact] Proactive auto-compaction failed:', error);
}
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);
}
@@ -691,6 +702,12 @@ export class AgentOrchestrator {
const namespace = `${namespaceBase}/${this._sanitizeSessionId(this._session.id)}`;
const block = `## ${new Date().toISOString()}\n\n${summary}\n\n`;
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();
return { saved: true, namespace };
} catch (error) {
+29 -1
View File
@@ -1,6 +1,6 @@
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', () => {
it('passes through the full argument string', async () => {
@@ -111,3 +111,31 @@ describe('builtin /queue command', () => {
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 {
return {
name: 'model',
@@ -183,6 +199,7 @@ export function registerBuiltinCommands(registry: CommandRegistry): void {
registry.register(createHelpCommand(registry));
registry.register(createStatusCommand());
registry.register(createUsageCommand());
registry.register(createContextCommand());
registry.register(createModelCommand());
registry.register(createCompactCommand());
registry.register(createResetCommand());
+1
View File
@@ -4,6 +4,7 @@ export {
createHelpCommand,
createStatusCommand,
createUsageCommand,
createContextCommand,
createModelCommand,
createCompactCommand,
createResetCommand,
+1
View File
@@ -21,6 +21,7 @@ export interface CommandDefinition {
export interface CommandServices {
getStatus?: () => Promise<string> | string;
getUsage?: () => Promise<string> | string;
getContext?: () => Promise<string> | string;
getModel?: () => Promise<string> | string;
setModel?: (tier: string) => 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');
});
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 () => {
const sent: OutboundMessage[] = [];
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');
},
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()}`,
setModel: (input) => {
const raw = input.trim();