feat(commands,audit): add /context command and proactive compaction audit events
This commit is contained in:
+10
-4
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -4,6 +4,7 @@ export {
|
||||
createHelpCommand,
|
||||
createStatusCommand,
|
||||
createUsageCommand,
|
||||
createContextCommand,
|
||||
createModelCommand,
|
||||
createCompactCommand,
|
||||
createResetCommand,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user