diff options
| author | Adam Malczewski <[email protected]> | 2026-06-22 00:36:52 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-22 00:36:52 +0900 |
| commit | c1bfd62b0c734484efcff09d6cd521acdbab2640 (patch) | |
| tree | f9194ea3214d5f15be6fed659a230997a1309ab9 /packages/session-orchestrator/src | |
| parent | 7ff9f94c41a9870e124a50133cd74b42295ab9ac (diff) | |
| download | dispatch-c1bfd62b0c734484efcff09d6cd521acdbab2640.tar.gz dispatch-c1bfd62b0c734484efcff09d6cd521acdbab2640.zip | |
feat: conversation compacting (manual + automatic)
Implement roadmap item 10: conversation compaction to reclaim context
window without losing the thread.
Wire (0.11.0):
- Add CompactionResult type
- Add ConversationCompactedMessage WS event
Transport-contract (0.15.0):
- Add CompactResponse, CompactThresholdResponse, SetCompactThresholdRequest
- Add ConversationCompactedMessage to WsServerMessage union
- Re-export CompactionResult
Conversation-store:
- replaceHistory: delete all chunks, reset seq, append new messages
- getCompactThreshold / setCompactThreshold (per-conversation setting)
- compactThresholdKey added to keys.ts
Session-orchestrator:
- CompactionService interface + compactionHandle
- conversationCompacted hook descriptor
- createCompactionService: load history, split old/recent, call provider
to summarize, replaceHistory with [system: summary] + recent N
- Auto-trigger: resolveCompaction lazy dep, fires after turn settles
(checks threshold, non-blocking)
- Hook declared in manifest contributes.hooks + services
Transport-http:
- POST /conversations/:id/compact (manual trigger)
- GET /conversations/:id/compact-threshold (read setting)
- PUT /conversations/:id/compact-threshold (set setting)
Transport-ws:
- Subscribe to conversationCompacted hook
- Broadcast conversation.compacted WS message
CLI:
- dispatch compact <conversationId> command
FE handoff: frontend-compaction-handoff.md
Diffstat (limited to 'packages/session-orchestrator/src')
| -rw-r--r-- | packages/session-orchestrator/src/extension.ts | 42 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/index.ts | 5 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.test.ts | 20 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.ts | 185 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/queue.test.ts | 5 |
5 files changed, 256 insertions, 1 deletions
diff --git a/packages/session-orchestrator/src/extension.ts b/packages/session-orchestrator/src/extension.ts index cbb3def..ae90902 100644 --- a/packages/session-orchestrator/src/extension.ts +++ b/packages/session-orchestrator/src/extension.ts @@ -5,6 +5,8 @@ import { runTurn } from "@dispatch/kernel"; import { messageQueueHandle } from "@dispatch/message-queue"; import { cacheWarmHandle, + compactionHandle, + createCompactionService, createSessionOrchestrator, createWarmService, sessionOrchestratorHandle, @@ -21,13 +23,18 @@ export const manifest: Manifest = { dependsOn: ["conversation-store", "credential-store"], activation: "eager", contributes: { - services: ["session-orchestrator/orchestrator", "session-orchestrator/warm"], + services: [ + "session-orchestrator/orchestrator", + "session-orchestrator/warm", + "session-orchestrator/compaction", + ], hooks: [ "session-orchestrator/turn-started", "session-orchestrator/turn-settled", "session-orchestrator/warm-completed", "session-orchestrator/conversation-closed", "session-orchestrator/conversation-status-changed", + "session-orchestrator/conversation-compacted", ], }, }; @@ -60,6 +67,16 @@ export function activate(host: HostAPI): void { const loaded = host.getExtensions().some((m) => m.id === "message-queue"); return loaded ? host.getService(messageQueueHandle) : undefined; }, + resolveCompaction: () => { + // Lazily resolve the compaction service (registered below after + // the orchestrator). By the time this is called at runtime + // (after a turn settles), the service is registered. + try { + return host.getService(compactionHandle); + } catch { + return undefined; + } + }, }); host.provideService(sessionOrchestratorHandle, orchestrator); @@ -86,6 +103,29 @@ export function activate(host: HostAPI): void { ); host.provideService(cacheWarmHandle, warmService); + + const compactionService = createCompactionService( + { + conversationStore, + resolveProvider: () => selectFirstProvider(host.getProviders()), + resolveTools: () => [...host.getTools().values()], + resolveModel: (modelName: string) => { + const store = host.getService(credentialStoreHandle); + const r = store.resolve(modelName); + if (r === undefined) return undefined; + const provider = host.getProviders().get(r.providerId); + return provider ? { provider, model: r.model } : undefined; + }, + applyToolsFilter: (assembly) => host.applyFilters(toolsFilter, assembly), + runTurn, + logger: host.logger, + now: () => Date.now(), + emit: (hook, payload) => host.emit(hook, payload), + }, + activeConversations, + ); + + host.provideService(compactionHandle, compactionService); } export const extension: Extension = { diff --git a/packages/session-orchestrator/src/index.ts b/packages/session-orchestrator/src/index.ts index d2aacd9..fa8d9e9 100644 --- a/packages/session-orchestrator/src/index.ts +++ b/packages/session-orchestrator/src/index.ts @@ -1,12 +1,17 @@ export { extension, manifest } from "./extension.js"; export { + type CompactionService, type ConversationClosedPayload, + type ConversationCompactedPayload, type ConversationOpenedPayload, type ConversationStatusChangedPayload, cacheWarmHandle, + compactionHandle, conversationClosed, + conversationCompacted, conversationOpened, conversationStatusChanged, + createCompactionService, createSessionOrchestrator, createWarmService, type EnqueueInput, diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts index 53c1ce7..e657fe2 100644 --- a/packages/session-orchestrator/src/orchestrator.test.ts +++ b/packages/session-orchestrator/src/orchestrator.test.ts @@ -90,6 +90,11 @@ function createInMemoryStore(): ConversationStore & { return null; }, async setConversationStatus() {}, + async replaceHistory() {}, + async getCompactThreshold() { + return null; + }, + async setCompactThreshold() {}, }; } @@ -540,6 +545,11 @@ describe("turn-sealed event", () => { return null; }, async setConversationStatus() {}, + async replaceHistory() {}, + async getCompactThreshold() { + return null; + }, + async setCompactThreshold() {}, }; const { orchestrator } = createSessionOrchestrator({ @@ -606,6 +616,11 @@ describe("turn-sealed event", () => { return null; }, async setConversationStatus() {}, + async replaceHistory() {}, + async getCompactThreshold() { + return null; + }, + async setCompactThreshold() {}, }; const { orchestrator } = createSessionOrchestrator({ @@ -961,6 +976,11 @@ describe("turn metrics persistence", () => { return null; }, async setConversationStatus() {}, + async replaceHistory() {}, + async getCompactThreshold() { + return null; + }, + async setCompactThreshold() {}, }; const { orchestrator } = createSessionOrchestrator({ diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts index 4ce83ca..e2d126c 100644 --- a/packages/session-orchestrator/src/orchestrator.ts +++ b/packages/session-orchestrator/src/orchestrator.ts @@ -2,6 +2,7 @@ import type { ConversationStore } from "@dispatch/conversation-store"; import type { AgentEvent, ChatMessage, + CompactionResult, ConversationStatus, EventHookDescriptor, Logger, @@ -128,6 +129,21 @@ export const conversationStatusChanged: EventHookDescriptor<ConversationStatusCh "session-orchestrator/conversation-status-changed", ); +/** Payload for the conversationCompacted bus event. */ +export interface ConversationCompactedPayload { + readonly conversationId: string; + readonly messagesSummarized: number; + readonly messagesKept: number; +} + +/** + * Fired when a conversation's history has been compacted (old messages + * summarized, recent messages retained). Transport-ws subscribes and + * broadcasts a `conversation.compacted` WS message so the FE reloads history. + */ +export const conversationCompacted: EventHookDescriptor<ConversationCompactedPayload> = + defineEventHook<ConversationCompactedPayload>("session-orchestrator/conversation-compacted"); + /** Payload for the warmCompleted bus event. */ export interface WarmCompletedPayload { readonly conversationId: string; @@ -158,6 +174,26 @@ export const cacheWarmHandle: ServiceHandle<WarmService> = defineService<WarmSer "session-orchestrator/warm", ); +// --- Compaction service --- + +export interface CompactionService { + /** + * Compact a conversation: summarize old messages and replace history with + * the summary + the most recent `keepLastN` messages. Returns the result + * or an error object. No-ops if the conversation is too short (≤ keepLastN + * messages). When `auto` is true, checks the compact-threshold setting and + * only compacts if the last turn's input tokens exceeded it. + */ + readonly compact: ( + conversationId: string, + opts?: { readonly keepLastN?: number; readonly modelName?: string; readonly auto?: boolean }, + ) => Promise<CompactionResult | { readonly error: string }>; +} + +export const compactionHandle: ServiceHandle<CompactionService> = defineService<CompactionService>( + "session-orchestrator/compaction", +); + export interface SessionOrchestrator { startTurn(input: StartTurnInput): StartTurnResult; /** @@ -214,6 +250,12 @@ export interface SessionOrchestratorDeps { * stays reproducible from its inputs and tests use a fake queue. */ readonly resolveQueue?: () => MessageQueueService | undefined; + /** + * Lazily resolves the compaction service, or `undefined` when not loaded. + * Used for automatic compaction after a turn settles (if the compact + * threshold is exceeded). Lazy so activation order doesn't matter. + */ + readonly resolveCompaction?: () => CompactionService | undefined; /** Apply the per-turn tools filter chain. Injected for testability. */ readonly applyToolsFilter: (assembly: ToolAssembly) => Promise<ToolAssembly>; /** Base logger (auto-scoped to this extension); childed per turn for span capture. */ @@ -444,6 +486,13 @@ export function createSessionOrchestrator( status: "idle", }); void deps.conversationStore.setConversationStatus(conversationId, "idle"); + // Fire-and-forget auto-compaction: check threshold and + // compact if exceeded. Non-blocking — the next turn + // starts fresh either way. + const compaction = deps.resolveCompaction?.(); + if (compaction !== undefined) { + void compaction.compact(conversationId, { auto: true }).catch(() => {}); + } } }); } @@ -642,3 +691,139 @@ export function createWarmService( }, }; } + +const DEFAULT_KEEP_LAST_N = 10; + +const COMPACTION_SYSTEM_PROMPT = + "You are a conversation summarizer. Summarize the following conversation concord concisely but comprehensively. " + + "Focus on key decisions, context, file paths, and any unresolved questions. " + + "The summary must preserve enough detail for the conversation to continue with full context."; + +function formatMessagesForSummary(messages: readonly ChatMessage[]): string { + return messages + .map((msg) => { + const text = msg.chunks + .map((c) => { + if (c.type === "text") return c.text; + if (c.type === "tool-call") return `[tool: ${c.toolName}]`; + if (c.type === "tool-result") return `[tool result: ${c.content.slice(0, 200)}]`; + return ""; + }) + .join(""); + return `${msg.role}: ${text}`; + }) + .join("\n\n"); +} + +export function createCompactionService( + deps: SessionOrchestratorDeps & { + readonly emit: <TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) => void; + }, + activeConversations: ReadonlySet<string>, +): CompactionService { + return { + async compact(conversationId, opts) { + if (activeConversations.has(conversationId)) { + return { error: "conversation is generating" }; + } + + const history = await deps.conversationStore.load(conversationId); + const keepLastN = opts?.keepLastN ?? DEFAULT_KEEP_LAST_N; + + if (history.length <= keepLastN) { + return { error: "conversation too short to compact" }; + } + + // Auto mode: check threshold + if (opts?.auto === true) { + const threshold = await deps.conversationStore.getCompactThreshold(conversationId); + if (threshold === null || threshold <= 0) return { error: "auto-compact disabled" }; + const metrics = await deps.conversationStore.loadMetrics(conversationId); + const lastTurn = metrics[metrics.length - 1]; + if (lastTurn === undefined) return { error: "no metrics" }; + const lastInputTokens = lastTurn.usage.inputTokens + (lastTurn.usage.cacheReadTokens ?? 0); + if (lastInputTokens < threshold) return { error: "threshold not exceeded" }; + } + + // Split: old messages to summarize + recent messages to keep. + const toSummarize = history.slice(0, history.length - keepLastN); + const toKeep = history.slice(history.length - keepLastN); + + // Resolve provider + let provider: ProviderContract; + let modelOverride: string | undefined; + if (opts?.modelName !== undefined && deps.resolveModel !== undefined) { + const resolved = deps.resolveModel(opts.modelName); + if (resolved === undefined) return { error: `unknown model: ${opts.modelName}` }; + provider = resolved.provider; + modelOverride = resolved.model; + } else { + provider = deps.resolveProvider(); + } + + // Build the summarization request: system prompt + conversation text + instruction + const conversationText = formatMessagesForSummary(toSummarize); + const summaryRequest: ChatMessage = { + role: "user", + chunks: [ + { + type: "text", + text: `Please summarize the following conversation:\n\n${conversationText}`, + }, + ], + }; + + const providerOpts: ProviderStreamOptions = { + maxTokens: 2000, + ...(modelOverride !== undefined ? { model: modelOverride } : {}), + ...(deps.logger !== undefined + ? { logger: deps.logger.child({ conversationId, attrs: { compaction: true } }) } + : {}), + }; + + // Call the provider and accumulate the summary + let summary = ""; + for await (const event of provider.stream([summaryRequest], [], { + ...providerOpts, + systemPrompt: COMPACTION_SYSTEM_PROMPT, + })) { + if ((event as ProviderEvent).type === "text-delta") { + summary += (event as { delta: string }).delta; + } else if ((event as ProviderEvent).type === "error") { + return { error: (event as { message: string }).message }; + } + } + + if (summary.trim().length === 0) { + return { error: "model produced empty summary" }; + } + + // Replace history: [system: summary] + recent messages + const summaryMessage: ChatMessage = { + role: "system", + chunks: [ + { + type: "text", + text: `The following is a summary of the previous conversation:\n\n${summary}`, + }, + ], + }; + + await deps.conversationStore.replaceHistory(conversationId, [summaryMessage, ...toKeep]); + + const result: CompactionResult = { + summary, + messagesSummarized: toSummarize.length, + messagesKept: toKeep.length, + }; + + deps.emit(conversationCompacted, { + conversationId, + messagesSummarized: toSummarize.length, + messagesKept: toKeep.length, + }); + + return result; + }, + }; +} diff --git a/packages/session-orchestrator/src/queue.test.ts b/packages/session-orchestrator/src/queue.test.ts index 745ac27..ae24ddf 100644 --- a/packages/session-orchestrator/src/queue.test.ts +++ b/packages/session-orchestrator/src/queue.test.ts @@ -86,6 +86,11 @@ function createInMemoryStore(): ConversationStore & { return null; }, async setConversationStatus() {}, + async replaceHistory() {}, + async getCompactThreshold() { + return null; + }, + async setCompactThreshold() {}, }; } |
