summaryrefslogtreecommitdiffhomepage
path: root/packages/session-orchestrator/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-22 00:36:52 +0900
committerAdam Malczewski <[email protected]>2026-06-22 00:36:52 +0900
commitc1bfd62b0c734484efcff09d6cd521acdbab2640 (patch)
treef9194ea3214d5f15be6fed659a230997a1309ab9 /packages/session-orchestrator/src
parent7ff9f94c41a9870e124a50133cd74b42295ab9ac (diff)
downloaddispatch-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.ts42
-rw-r--r--packages/session-orchestrator/src/index.ts5
-rw-r--r--packages/session-orchestrator/src/orchestrator.test.ts20
-rw-r--r--packages/session-orchestrator/src/orchestrator.ts185
-rw-r--r--packages/session-orchestrator/src/queue.test.ts5
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() {},
};
}