diff --git a/docs/plans/state.json b/docs/plans/state.json index 5b65893..be6f909 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -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": { diff --git a/src/audit/logger.ts b/src/audit/logger.ts index 0ac9d0e..78e2840 100644 --- a/src/audit/logger.ts +++ b/src/audit/logger.ts @@ -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 }); } + sessionCheckpoint(event: SessionCheckpointEvent): void { + if (!this.shouldLog('sessions', 'debug')) {return;} + this.write({ level: 'debug', event_type: 'session.checkpoint', event: event as unknown as Record }); + } + + 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 }); + } + userAction(event: UserActionEvent): void { if (!this.shouldLog('sessions', 'info')) {return;} this.write({ level: 'info', event_type: 'user.action', event: event as unknown as Record }); diff --git a/src/audit/types.ts b/src/audit/types.ts index d74bae6..0ea55fc 100644 --- a/src/audit/types.ts +++ b/src/audit/types.ts @@ -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; diff --git a/src/backends/native/orchestrator.test.ts b/src/backends/native/orchestrator.test.ts index 285a7da..1c75513 100644 --- a/src/backends/native/orchestrator.test.ts +++ b/src/backends/native/orchestrator.test.ts @@ -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); + } }); }); }); diff --git a/src/backends/native/orchestrator.ts b/src/backends/native/orchestrator.ts index 3ca5b7f..8414da2 100644 --- a/src/backends/native/orchestrator.ts +++ b/src/backends/native/orchestrator.ts @@ -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) { diff --git a/src/commands/builtin/index.test.ts b/src/commands/builtin/index.test.ts index 69fac64..8e3d240 100644 --- a/src/commands/builtin/index.test.ts +++ b/src/commands/builtin/index.test.ts @@ -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.' }); + }); +}); diff --git a/src/commands/builtin/index.ts b/src/commands/builtin/index.ts index 3ec24bc..511e076 100644 --- a/src/commands/builtin/index.ts +++ b/src/commands/builtin/index.ts @@ -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()); diff --git a/src/commands/index.ts b/src/commands/index.ts index fda9d72..ac9979b 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -4,6 +4,7 @@ export { createHelpCommand, createStatusCommand, createUsageCommand, + createContextCommand, createModelCommand, createCompactCommand, createResetCommand, diff --git a/src/commands/types.ts b/src/commands/types.ts index c738a04..71f4287 100644 --- a/src/commands/types.ts +++ b/src/commands/types.ts @@ -21,6 +21,7 @@ export interface CommandDefinition { export interface CommandServices { getStatus?: () => Promise | string; getUsage?: () => Promise | string; + getContext?: () => Promise | string; getModel?: () => Promise | string; setModel?: (tier: string) => Promise | string; compact?: () => Promise | string; diff --git a/src/gateway/handlers/agent.test.ts b/src/gateway/handlers/agent.test.ts index 2ca0e47..4b21b63 100644 --- a/src/gateway/handlers/agent.test.ts +++ b/src/gateway/handlers/agent.test.ts @@ -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)); diff --git a/src/gateway/handlers/agent.ts b/src/gateway/handlers/agent.ts index 5354aa6..1de113f 100644 --- a/src/gateway/handlers/agent.ts +++ b/src/gateway/handlers/agent.ts @@ -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();