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",
|
"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": {
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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,17 +962,23 @@ describe('AgentOrchestrator', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await orchestrator.process('ping');
|
try {
|
||||||
const alert = orchestrator.consumeContextAlert();
|
await orchestrator.process('ping');
|
||||||
expect(alert?.level).toBe('checkpoint');
|
const alert = orchestrator.consumeContextAlert();
|
||||||
expect(alert?.actions.checkpointSaved).toBe(true);
|
expect(alert?.level).toBe('checkpoint');
|
||||||
expect(alert?.actions.checkpointNamespace).toContain('session/checkpoints');
|
expect(alert?.actions.checkpointSaved).toBe(true);
|
||||||
expect(orchestrator.consumeContextAlert()).toBeUndefined();
|
expect(alert?.actions.checkpointNamespace).toContain('session/checkpoints');
|
||||||
|
expect(orchestrator.consumeContextAlert()).toBeUndefined();
|
||||||
|
|
||||||
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({
|
||||||
rmSync(tempDir, { recursive: true, force: true });
|
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 () => {
|
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', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await orchestrator.process('continue');
|
try {
|
||||||
const alert = orchestrator.consumeContextAlert();
|
await orchestrator.process('continue');
|
||||||
expect(alert?.actions.autoCompacted).toBe(true);
|
const alert = orchestrator.consumeContextAlert();
|
||||||
expect(history.length).toBeLessThan(6);
|
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') {
|
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) {
|
||||||
|
|||||||
@@ -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.' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export {
|
|||||||
createHelpCommand,
|
createHelpCommand,
|
||||||
createStatusCommand,
|
createStatusCommand,
|
||||||
createUsageCommand,
|
createUsageCommand,
|
||||||
|
createContextCommand,
|
||||||
createModelCommand,
|
createModelCommand,
|
||||||
createCompactCommand,
|
createCompactCommand,
|
||||||
createResetCommand,
|
createResetCommand,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user