summaryrefslogtreecommitdiffhomepage
path: root/packages/session-orchestrator/src/orchestrator.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/session-orchestrator/src/orchestrator.ts')
-rw-r--r--packages/session-orchestrator/src/orchestrator.ts2640
1 files changed, 1607 insertions, 1033 deletions
diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts
index 4aa77f7..a2e141a 100644
--- a/packages/session-orchestrator/src/orchestrator.ts
+++ b/packages/session-orchestrator/src/orchestrator.ts
@@ -1,78 +1,170 @@
import type { ConversationStore } from "@dispatch/conversation-store";
import type {
- AgentEvent,
- ChatMessage,
- CompactionResult,
- ConversationStatus,
- EventHookDescriptor,
- Logger,
- ModelInfo,
- ProviderContract,
- ProviderEvent,
- ProviderStreamOptions,
- ReasoningEffort,
- RetryStrategy,
- RunTurnInput,
- RunTurnResult,
- ToolContract,
- ToolDispatchPolicy,
- UsageEvent,
+ AgentEvent,
+ ChatMessage,
+ CompactionResult,
+ ConversationStatus,
+ EventHookDescriptor,
+ ImageInput,
+ Logger,
+ ModelInfo,
+ ProviderContract,
+ ProviderEvent,
+ ProviderStreamOptions,
+ ReasoningEffort,
+ RetryStrategy,
+ RunTurnInput,
+ RunTurnResult,
+ ToolContract,
+ ToolDispatchPolicy,
+ UsageEvent,
} from "@dispatch/kernel";
import { defineEventHook, defineService, type ServiceHandle } from "@dispatch/kernel";
import type { MessageQueueService, QueuedMessage } from "@dispatch/message-queue";
+import type { ConcurrencyLimiter } from "@dispatch/provider-concurrency";
+import { wrapProviderWithConcurrency } from "@dispatch/provider-concurrency";
import type { SystemPromptService } from "@dispatch/system-prompt";
import { createMetricsAccumulator } from "./metrics.js";
import {
- buildUserMessage,
- defaultDispatchPolicy,
- delayFor,
- generateTurnId,
- resolveModelName,
- resolveReasoningEffort,
+ buildUserMessage,
+ defaultDispatchPolicy,
+ delayFor,
+ generateTurnId,
+ type MemorySample,
+ memoryDelta,
+ memorySampleAttributes,
+ resolveModelName,
+ resolveReasoningEffort,
} from "./pure.js";
import type { ToolAssembly } from "./tools-filter.js";
+// --- Vision handoff (lazy, optional) ---
+
+/**
+ * Minimal contract the vision-handoff service satisfies. Defined here (not
+ * imported from the vision-handoff package) so the orchestrator has NO
+ * compile-time dependency on it — the service is resolved lazily at runtime
+ * (like the message-queue / system-prompt services), and the feature degrades
+ * off cleanly when the extension isn't loaded (images pass through unchanged,
+ * which is correct for vision-capable models and a no-op for text-only turns).
+ *
+ * `prepareForProvider` transforms a message list for the provider: if the
+ * active model is vision-capable, messages pass through unchanged; otherwise
+ * image chunks are replaced with numbered placeholders (telling the model to
+ * call `consult_vision`) and the images are registered for tool access.
+ */
+export interface VisionHandoffService {
+ /**
+ * Store images to tmp files and return compact URLs. Each input image's data
+ * URL is saved to a tmp file and replaced with a compact HTTP path so the
+ * persisted conversation store holds a tiny string, not megabytes of base64.
+ * When `saveImageToTmp` is not configured, data URLs pass through unchanged.
+ */
+ readonly storeImages: (
+ conversationId: string,
+ images: readonly ImageInput[],
+ ) => Promise<readonly ImageInput[]>;
+
+ /** Delete all tmp images for a conversation (on close). Best-effort. */
+ readonly purgeConversationImages: (conversationId: string) => Promise<void>;
+
+ readonly prepareForProvider: (
+ messages: readonly ChatMessage[],
+ currentModelName: string | undefined,
+ opts?: {
+ readonly conversationId?: string;
+ readonly imageLimit?: number;
+ readonly signal?: AbortSignal;
+ readonly logger?: Logger;
+ },
+ ) => Promise<readonly ChatMessage[]>;
+}
+
+/**
+ * Local handle for the vision-handoff service, keyed by the same ID the
+ * vision-handoff extension registers under (`"vision-handoff/service"`). Defined
+ * locally (not imported) so the orchestrator has no compile-time dependency on
+ * the vision-handoff package — the service is resolved lazily at runtime, and
+ * the feature degrades off cleanly when the extension isn't loaded.
+ */
+export const visionHandoffLocalHandle: ServiceHandle<VisionHandoffService> =
+ defineService<VisionHandoffService>("vision-handoff/service");
+
// --- Broadcast hub types ---
export interface StartTurnInput {
- readonly conversationId: string;
- readonly text: string;
- readonly modelName?: string;
- readonly cwd?: string;
- /**
- * The computer to execute this turn's tools on (SSH config alias). Mirrors
- * `cwd`: an explicit per-turn override resolved via `getEffectiveComputer`.
- * Omitted/`undefined` = use the persisted per-conversation / workspace
- * default (LOCAL when none set). The orchestrator never interprets it — it
- * forwards the alias string verbatim (like cwd forwards a path).
- */
- readonly computerId?: string;
- readonly reasoningEffort?: ReasoningEffort;
- /**
- * The workspace this conversation belongs to. Defaults to `"default"` when
- * omitted. On the first turn for a new conversation, the workspaceId is
- * persisted (the workspace is auto-created if missing) so subsequent turns
- * resolve the effective cwd from the workspace's `defaultCwd`.
- */
- readonly workspaceId?: string;
+ readonly conversationId: string;
+ readonly text: string;
+ /**
+ * Images attached to this turn (e.g. user-pasted screenshots). Each is
+ * appended as an `image` chunk on the persisted user message. For a
+ * vision-capable model the images pass through to the provider natively; for
+ * a non-vision model the vision handoff transcribes them to text first.
+ * Optional — omit for a text-only turn.
+ */
+ readonly images?: readonly ImageInput[];
+ readonly modelName?: string;
+ readonly cwd?: string;
+ /**
+ * The computer to execute this turn's tools on (SSH config alias). Mirrors
+ * `cwd`: an explicit per-turn override resolved via `getEffectiveComputer`.
+ * Omitted/`undefined` = use the persisted per-conversation / workspace
+ * default (LOCAL when none set). The orchestrator never interprets it — it
+ * forwards the alias string verbatim (like cwd forwards a path).
+ */
+ readonly computerId?: string;
+ readonly reasoningEffort?: ReasoningEffort;
+ /**
+ * The workspace this conversation belongs to. Defaults to `"default"` when
+ * omitted. On the first turn for a new conversation, the workspaceId is
+ * persisted (the workspace is auto-created if missing) so subsequent turns
+ * resolve the effective cwd from the workspace's `defaultCwd`.
+ */
+ readonly workspaceId?: string;
+ /**
+ * An EXPLICIT system-prompt override. When provided (a string, including the
+ * empty string), it is sent to the provider AS-IS and the system-prompt
+ * SERVICE is bypassed entirely (no template construction, no
+ * persist/reuse). This is how a caller that owns its own prompt (e.g. the
+ * heartbeat extension) drives a turn without the templated workspace prompt.
+ * Omitted/`undefined` = the existing behavior (construct/reuse via the
+ * system-prompt service when loaded).
+ */
+ readonly systemPrompt?: string;
+ /**
+ * A human-readable title for the conversation tab. When provided, it is
+ * persisted via `setConversationTitle` AFTER the new-conversation workspace
+ * setup resolves (so the `meta === null` newness detection still fires and
+ * `ensureWorkspace` / `setWorkspaceId` / first-turn system-prompt
+ * construction are NOT skipped) and BEFORE the first message append (so the
+ * append's auto-title does not overwrite it). Omit to keep the auto-derived
+ * title. The caller is responsible for trimming/validation.
+ */
+ readonly title?: string;
}
export type StartTurnResult =
- | { readonly started: true; readonly turnId: string }
- | { readonly started: false; readonly reason: "already-active" };
+ | { readonly started: true; readonly turnId: string }
+ | { readonly started: false; readonly reason: "already-active" };
/** Input to `SessionOrchestrator.enqueue` — the single entry transports call. */
export interface EnqueueInput {
- readonly conversationId: string;
- readonly text: string;
- /** Workspace to stamp on a new conversation. Defaults to `"default"`. */
- readonly workspaceId?: string;
- /**
- * Per-turn computer override (SSH alias), threaded to `startTurn` when the
- * conversation is idle (the message starts a turn). Additive optional —
- * mirrors `workspaceId` on this type (enqueue does not carry `cwd`).
- */
- readonly computerId?: string;
+ readonly conversationId: string;
+ readonly text: string;
+ /**
+ * Images attached (the steering / opening message analog of
+ * `StartTurnInput.images`). Threaded to `startTurn` when the conversation is
+ * idle (the message starts a turn). Additive optional.
+ */
+ readonly images?: readonly ImageInput[];
+ /** Workspace to stamp on a new conversation. Defaults to `"default"`. */
+ readonly workspaceId?: string;
+ /**
+ * Per-turn computer override (SSH alias), threaded to `startTurn` when the
+ * conversation is idle (the message starts a turn). Additive optional —
+ * mirrors `workspaceId` on this type (enqueue does not carry `cwd`).
+ */
+ readonly computerId?: string;
}
/**
@@ -84,41 +176,55 @@ export interface EnqueueInput {
* is dropped, see `enqueue` docs).
*/
export interface EnqueueResult {
- readonly startedTurn: boolean;
- readonly queue: readonly QueuedMessage[];
+ readonly startedTurn: boolean;
+ readonly queue: readonly QueuedMessage[];
+}
+
+/**
+ * Result of `SessionOrchestrator.cancelQueuedMessage`. `cancelled` is true when
+ * a message with the given id was found in the conversation's queue and removed
+ * (it will never run — never delivered as steering, never carried into a new
+ * turn). `cancelled` is false when the message was not in the queue (already
+ * drained/delivered, never existed, unknown conversation) OR when the
+ * message-queue extension isn't loaded (degraded — feature off). `queue` is the
+ * post-cancel snapshot (empty when no queue extension is loaded).
+ */
+export interface CancelQueuedMessageResult {
+ readonly cancelled: boolean;
+ readonly queue: readonly QueuedMessage[];
}
export type TurnEventListener = (event: AgentEvent) => void;
interface ActiveTurn {
- buffer: AgentEvent[];
- turnId: string;
- /** Aborts this turn's kernel runTurn (closeConversation). */
- controller: AbortController;
+ buffer: AgentEvent[];
+ turnId: string;
+ /** Aborts this turn's kernel runTurn (closeConversation). */
+ controller: AbortController;
}
// --- Lifecycle event hooks ---
/** Context carried on turn-lifecycle events, enough to replicate the turn's request prefix. */
export interface TurnLifecyclePayload {
- readonly conversationId: string;
- readonly cwd?: string;
- /** The computer this turn executes on (SSH alias), mirroring `cwd`. */
- readonly computerId?: string;
- readonly modelName?: string;
+ readonly conversationId: string;
+ readonly cwd?: string;
+ /** The computer this turn executes on (SSH alias), mirroring `cwd`. */
+ readonly computerId?: string;
+ readonly modelName?: string;
}
/** Fired when a turn STARTS driving a conversation (consumers cancel warming timers). */
export const turnStarted: EventHookDescriptor<TurnLifecyclePayload> =
- defineEventHook<TurnLifecyclePayload>("session-orchestrator/turn-started");
+ defineEventHook<TurnLifecyclePayload>("session-orchestrator/turn-started");
/** Fired when a turn SETTLES (sealed) for a conversation (consumers arm warming timers). */
export const turnSettled: EventHookDescriptor<TurnLifecyclePayload> =
- defineEventHook<TurnLifecyclePayload>("session-orchestrator/turn-settled");
+ defineEventHook<TurnLifecyclePayload>("session-orchestrator/turn-settled");
/** Payload for the conversationClosed bus event. */
export interface ConversationClosedPayload {
- readonly conversationId: string;
+ readonly conversationId: string;
}
/**
@@ -127,17 +233,17 @@ export interface ConversationClosedPayload {
* disables its schedule). Emitted by `SessionOrchestrator.closeConversation`.
*/
export const conversationClosed: EventHookDescriptor<ConversationClosedPayload> =
- defineEventHook<ConversationClosedPayload>("session-orchestrator/conversation-closed");
+ defineEventHook<ConversationClosedPayload>("session-orchestrator/conversation-closed");
/** Payload for the conversationOpened bus event. */
export interface ConversationOpenedPayload {
- readonly conversationId: string;
- /**
- * The conversation's actual persisted workspace id (resolved from the
- * store, not the per-turn start option), so a frontend can open/focus the
- * tab in the correct workspace. Falls back to `"default"`.
- */
- readonly workspaceId: string;
+ readonly conversationId: string;
+ /**
+ * The conversation's actual persisted workspace id (resolved from the
+ * store, not the per-turn start option), so a frontend can open/focus the
+ * tab in the correct workspace. Falls back to `"default"`.
+ */
+ readonly workspaceId: string;
}
/**
@@ -147,18 +253,18 @@ export interface ConversationOpenedPayload {
* open/focus a tab — the backend just signals.
*/
export const conversationOpened: EventHookDescriptor<ConversationOpenedPayload> =
- defineEventHook<ConversationOpenedPayload>("session-orchestrator/conversation-opened");
+ defineEventHook<ConversationOpenedPayload>("session-orchestrator/conversation-opened");
/** Payload for the conversationStatusChanged bus event. */
export interface ConversationStatusChangedPayload {
- readonly conversationId: string;
- readonly status: ConversationStatus;
- /**
- * The conversation's actual persisted workspace id (resolved from the
- * store, not the per-turn start option), so a frontend can sync the tab
- * in the correct workspace. Falls back to `"default"`.
- */
- readonly workspaceId: string;
+ readonly conversationId: string;
+ readonly status: ConversationStatus;
+ /**
+ * The conversation's actual persisted workspace id (resolved from the
+ * store, not the per-turn start option), so a frontend can sync the tab
+ * in the correct workspace. Falls back to `"default"`.
+ */
+ readonly workspaceId: string;
}
/**
@@ -167,16 +273,16 @@ export interface ConversationStatusChangedPayload {
* message to all connected frontend clients so tabs sync across devices.
*/
export const conversationStatusChanged: EventHookDescriptor<ConversationStatusChangedPayload> =
- defineEventHook<ConversationStatusChangedPayload>(
- "session-orchestrator/conversation-status-changed",
- );
+ defineEventHook<ConversationStatusChangedPayload>(
+ "session-orchestrator/conversation-status-changed",
+ );
/** Payload for the conversationCompacted bus event. */
export interface ConversationCompactedPayload {
- readonly conversationId: string;
- readonly newConversationId: string;
- readonly messagesSummarized: number;
- readonly messagesKept: number;
+ readonly conversationId: string;
+ readonly newConversationId: string;
+ readonly messagesSummarized: number;
+ readonly messagesKept: number;
}
/**
@@ -185,163 +291,217 @@ export interface ConversationCompactedPayload {
* broadcasts a `conversation.compacted` WS message so the FE reloads history.
*/
export const conversationCompacted: EventHookDescriptor<ConversationCompactedPayload> =
- defineEventHook<ConversationCompactedPayload>("session-orchestrator/conversation-compacted");
+ defineEventHook<ConversationCompactedPayload>("session-orchestrator/conversation-compacted");
/** Payload for the warmCompleted bus event. */
export interface WarmCompletedPayload {
- readonly conversationId: string;
- readonly usage: WarmResult;
+ readonly conversationId: string;
+ readonly usage: WarmResult;
}
/** Fired when a warm probe succeeds (both automatic and manual paths). */
export const warmCompleted: EventHookDescriptor<WarmCompletedPayload> =
- defineEventHook<WarmCompletedPayload>("session-orchestrator/warm-completed");
+ defineEventHook<WarmCompletedPayload>("session-orchestrator/warm-completed");
// --- Warm service ---
export interface WarmResult {
- readonly inputTokens: number;
- readonly outputTokens: number;
- readonly cacheReadTokens: number;
- readonly cacheWriteTokens: number;
+ readonly inputTokens: number;
+ readonly outputTokens: number;
+ readonly cacheReadTokens: number;
+ readonly cacheWriteTokens: number;
}
export interface WarmService {
- readonly warm: (
- conversationId: string,
- opts?: { readonly cwd?: string; readonly modelName?: string },
- ) => Promise<WarmResult | { readonly error: string }>;
+ readonly warm: (
+ conversationId: string,
+ opts?: { readonly cwd?: string; readonly modelName?: string },
+ ) => Promise<WarmResult | { readonly error: string }>;
}
export const cacheWarmHandle: ServiceHandle<WarmService> = defineService<WarmService>(
- "session-orchestrator/warm",
+ "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 }>;
+ /**
+ * 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",
+ "session-orchestrator/compaction",
);
export interface SessionOrchestrator {
- startTurn(input: StartTurnInput): StartTurnResult;
- /**
- * The single entry transports call to deliver a user message. Owns the
- * idle→startTurn vs active→queue decision (no separate `isActive` race —
- * `startTurn`'s single-flight guard is authoritative). When the conversation
- * is idle, starts a turn (the message is the opening prompt). When active,
- * enqueues onto the steering queue (if the message-queue extension is
- * loaded); with no queue extension loaded the message is dropped and the
- * returned snapshot is empty (degraded — feature off).
- */
- enqueue(input: EnqueueInput): EnqueueResult;
- subscribe(conversationId: string, listener: TurnEventListener): () => void;
- isActive(conversationId: string): boolean;
- /**
- * Explicitly close a conversation (the user closed its tab — distinct from a
- * socket disconnect, which never touches the turn): aborts any in-flight turn
- * (the kernel finishes with `finishReason: "aborted"`, partial messages are
- * persisted and the turn seals normally) and emits the `conversationClosed`
- * hook so per-conversation background work (cache-warming) stops.
- * Idempotent — closing an idle/unknown conversation just emits the hook.
- */
- closeConversation(conversationId: string): { readonly abortedTurn: boolean };
- /**
- * Stop an in-flight generation WITHOUT closing the conversation. Aborts
- * the turn's AbortController — the kernel finishes with
- * `finishReason: "aborted"`, partial messages are persisted, and the turn
- * seals normally (status transitions active → idle via the normal settle
- * path). Idempotent — stopping an idle/unknown conversation is a no-op.
- */
- stopTurn(conversationId: string): { readonly abortedTurn: boolean };
- handleMessage(input: {
- conversationId: string;
- text: string;
- onEvent: (event: AgentEvent) => void;
- modelName?: string;
- cwd?: string;
- computerId?: string;
- reasoningEffort?: ReasoningEffort;
- workspaceId?: string;
- }): Promise<void>;
+ startTurn(input: StartTurnInput): StartTurnResult;
+ /**
+ * The single entry transports call to deliver a user message. Owns the
+ * idle→startTurn vs active→queue decision (no separate `isActive` race —
+ * `startTurn`'s single-flight guard is authoritative). When the conversation
+ * is idle, starts a turn (the message is the opening prompt). When active,
+ * enqueues onto the steering queue (if the message-queue extension is
+ * loaded); with no queue extension loaded the message is dropped and the
+ * returned snapshot is empty (degraded — feature off).
+ */
+ enqueue(input: EnqueueInput): EnqueueResult;
+ /**
+ * Cancel (remove) a SINGLE queued message by id so it never runs. The single
+ * entry transports call to cancel a queued steering message. Resolves the
+ * message-queue service lazily (same as `enqueue`); when the extension isn't
+ * loaded the call degrades to `{ cancelled: false, queue: [] }`. Idempotent —
+ * cancelling a message that is no longer queued (already drained/delivered)
+ * returns `{ cancelled: false, ... }` without error.
+ */
+ cancelQueuedMessage(input: {
+ readonly conversationId: string;
+ readonly messageId: string;
+ }): CancelQueuedMessageResult;
+ subscribe(conversationId: string, listener: TurnEventListener): () => void;
+ isActive(conversationId: string): boolean;
+ /**
+ * The number of conversations currently driving a turn (in the
+ * `activeConversations` set). Used by host-bin's periodic memory telemetry
+ * to tag each RSS sample with the active-conversation count, so growth can
+ * be attributed to the streaming/turn path vs an idle baseline.
+ */
+ getActiveConversationCount(): number;
+ /**
+ * Explicitly close a conversation (the user closed its tab — distinct from a
+ * socket disconnect, which never touches the turn): aborts any in-flight turn
+ * (the kernel finishes with `finishReason: "aborted"`, partial messages are
+ * persisted and the turn seals normally) and emits the `conversationClosed`
+ * hook so per-conversation background work (cache-warming) stops.
+ * Idempotent — closing an idle/unknown conversation just emits the hook.
+ */
+ closeConversation(conversationId: string): { readonly abortedTurn: boolean };
+ /**
+ * Stop an in-flight generation WITHOUT closing the conversation. Aborts
+ * the turn's AbortController — the kernel finishes with
+ * `finishReason: "aborted"`, partial messages are persisted, and the turn
+ * seals normally (status transitions active → idle via the normal settle
+ * path). Idempotent — stopping an idle/unknown conversation is a no-op.
+ */
+ stopTurn(conversationId: string): { readonly abortedTurn: boolean };
+ handleMessage(input: {
+ conversationId: string;
+ text: string;
+ onEvent: (event: AgentEvent) => void;
+ modelName?: string;
+ cwd?: string;
+ computerId?: string;
+ reasoningEffort?: ReasoningEffort;
+ workspaceId?: string;
+ /** Explicit system-prompt override — see {@link StartTurnInput.systemPrompt}. */
+ systemPrompt?: string;
+ /** Images attached to this turn — see {@link StartTurnInput.images}. */
+ images?: readonly ImageInput[];
+ /** Conversation tab title — see {@link StartTurnInput.title}. */
+ title?: string;
+ }): Promise<void>;
}
export const sessionOrchestratorHandle = defineService<SessionOrchestrator>(
- "session-orchestrator/orchestrator",
+ "session-orchestrator/orchestrator",
);
export interface SessionOrchestratorDeps {
- readonly conversationStore: ConversationStore;
- readonly resolveProvider: () => ProviderContract;
- readonly resolveTools: () => readonly ToolContract[];
- readonly resolveDispatch?: () => ToolDispatchPolicy;
- readonly resolveModel?: (
- modelName: string,
- ) => { provider: ProviderContract; model: string } | undefined;
- /**
- * Resolve full `ModelInfo` (including `contextWindow`) for a model name.
- * Used by the compaction service to calculate the auto-compact threshold
- * as a percentage of the context window.
- */
- readonly resolveModelInfo?: (modelName: string) => Promise<ModelInfo | undefined>;
- readonly runTurn: (input: RunTurnInput) => Promise<RunTurnResult>;
- /**
- * Lazily resolves the message-queue service (the steering queue), or
- * `undefined` when the message-queue extension isn't loaded (the feature
- * degrades off: no `drainSteering`, no post-seal carry, `enqueue` drops
- * messages when active). host-bin wires this via `host.getService`; the
- * orchestrator calls it per-turn / per-enqueue so activation order with the
- * message-queue extension doesn't matter. Injected (not ambient) so a turn
- * 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;
- /**
- * Lazily resolves the system-prompt service, or `undefined` when the
- * system-prompt extension isn't loaded. Used to construct the per-
- * conversation system prompt once (first turn) and reuse it (cache-safe) on
- * subsequent turns, and to reconstruct it on compaction. Lazy so activation
- * order doesn't matter.
- */
- readonly resolveSystemPrompt?: () => SystemPromptService | 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. */
- readonly logger?: Logger;
- /** Injected monotonic-ish clock (ms) forwarded to RunTurnInput for timing events. */
- readonly now?: () => number;
- /** Emit a lifecycle event hook to subscribers. Injected from host. */
- readonly emit?: <TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) => void;
+ readonly conversationStore: ConversationStore;
+ readonly resolveProvider: () => ProviderContract;
+ readonly resolveTools: () => readonly ToolContract[];
+ readonly resolveDispatch?: () => ToolDispatchPolicy;
+ readonly resolveModel?: (
+ modelName: string,
+ ) => { provider: ProviderContract; model: string } | undefined;
+ /**
+ * Resolve full `ModelInfo` (including `contextWindow`) for a model name.
+ * Used by the compaction service to calculate the auto-compact threshold
+ * as a percentage of the context window.
+ */
+ readonly resolveModelInfo?: (modelName: string) => Promise<ModelInfo | undefined>;
+ readonly runTurn: (input: RunTurnInput) => Promise<RunTurnResult>;
+ /**
+ * Lazily resolves the message-queue service (the steering queue), or
+ * `undefined` when the message-queue extension isn't loaded (the feature
+ * degrades off: no `drainSteering`, no post-seal carry, `enqueue` drops
+ * messages when active). host-bin wires this via `host.getService`; the
+ * orchestrator calls it per-turn / per-enqueue so activation order with the
+ * message-queue extension doesn't matter. Injected (not ambient) so a turn
+ * 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;
+ /**
+ * Lazily resolves the system-prompt service, or `undefined` when the
+ * system-prompt extension isn't loaded. Used to construct the per-
+ * conversation system prompt once (first turn) and reuse it (cache-safe) on
+ * subsequent turns, and to reconstruct it on compaction. Lazy so activation
+ * order doesn't matter.
+ */
+ readonly resolveSystemPrompt?: () => SystemPromptService | undefined;
+ /**
+ * Lazily resolves the concurrency limiter, or `undefined` when the
+ * provider-concurrency extension isn't loaded (no concurrency limiting —
+ * feature degrades off). When present, each resolved provider is wrapped so
+ * that a concurrency slot is acquired before the stream starts and released
+ * when the stream completes. Lazy so activation order doesn't matter.
+ */
+ readonly resolveConcurrencyLimiter?: () => ConcurrencyLimiter | undefined;
+ /**
+ * Lazily resolves the vision-handoff service, or `undefined` when the
+ * vision-handoff extension isn't loaded. Used to transcribe image chunks to
+ * text for non-vision models before they reach the provider (so a text-only
+ * model can still reason about pasted/code images). When `undefined`, images
+ * pass through unchanged (correct for vision-capable models; a text-only model
+ * would then receive image content its API may reject — the feature degrades
+ * off cleanly for text-only turns since there are no images). Lazy so
+ * activation order doesn't matter; called per-turn.
+ */
+ readonly resolveVisionHandoff?: () => VisionHandoffService | 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. */
+ readonly logger?: Logger;
+ /** Injected monotonic-ish clock (ms) forwarded to RunTurnInput for timing events. */
+ readonly now?: () => number;
+ /**
+ * Optional process.memoryUsage() sampler, injected for testability. When
+ * present, the orchestrator captures a sample immediately before and after
+ * each turn's stream completes (`deps.runTurn`) and logs the per-turn delta
+ * tagged with conversationId + turnId — correlating RSS growth with the
+ * streaming path (the prime leak suspect). When absent (undefined), no
+ * per-turn memory telemetry is emitted (feature degrades off cleanly).
+ * Pure decision logic stays unchanged; this is additive observability.
+ */
+ readonly sampleMemory?: () => MemorySample;
+ /** Emit a lifecycle event hook to subscribers. Injected from host. */
+ readonly emit?: <TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) => void;
}
/** Deps for the warm service — emit is REQUIRED so warmCompleted is never silently dropped. */
export type WarmServiceDeps = SessionOrchestratorDeps & {
- readonly emit: <TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) => void;
+ readonly emit: <TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) => void;
};
export interface SessionOrchestratorBundle {
- readonly orchestrator: SessionOrchestrator;
- /** The shared active-conversations set, for use by createWarmService. */
- readonly activeConversations: ReadonlySet<string>;
+ readonly orchestrator: SessionOrchestrator;
+ /** The shared active-conversations set, for use by createWarmService. */
+ readonly activeConversations: ReadonlySet<string>;
}
/**
@@ -354,848 +514,1262 @@ export interface SessionOrchestratorBundle {
* turn `aborted`). The kernel imports no timer; this is the shell-provided I/O.
*/
export function createRetryStrategy(): RetryStrategy {
- const sleep = (ms: number, signal: AbortSignal): Promise<void> => {
- return new Promise((resolve, reject) => {
- if (signal.aborted) {
- reject(new Error("aborted"));
- return;
- }
- const timer = setTimeout(() => {
- signal.removeEventListener("abort", onAbort);
- resolve();
- }, ms);
- const onAbort = () => {
- clearTimeout(timer);
- reject(new Error("aborted"));
- };
- signal.addEventListener("abort", onAbort, { once: true });
- });
- };
- return { delayFor, sleep };
+ const sleep = (ms: number, signal: AbortSignal): Promise<void> => {
+ return new Promise((resolve, reject) => {
+ if (signal.aborted) {
+ reject(new Error("aborted"));
+ return;
+ }
+ const timer = setTimeout(() => {
+ signal.removeEventListener("abort", onAbort);
+ resolve();
+ }, ms);
+ const onAbort = () => {
+ clearTimeout(timer);
+ reject(new Error("aborted"));
+ };
+ signal.addEventListener("abort", onAbort, { once: true });
+ });
+ };
+ return { delayFor, sleep };
}
export function createSessionOrchestrator(
- deps: SessionOrchestratorDeps,
+ deps: SessionOrchestratorDeps,
): SessionOrchestratorBundle {
- const activeConversations = new Set<string>();
- const subscribers = new Map<string, Set<TurnEventListener>>();
- const activeTurns = new Map<string, ActiveTurn>();
- // One stateless retry strategy shared by every turn (delayFor is pure; sleep
- // is a stateless setTimeout closure). Wired into each RunTurnInput.retry.
- const retryStrategy = createRetryStrategy();
-
- function emitToHub(conversationId: string, event: AgentEvent): void {
- const turn = activeTurns.get(conversationId);
- if (turn !== undefined) {
- turn.buffer.push(event);
- }
- const listeners = subscribers.get(conversationId);
- if (listeners !== undefined) {
- for (const listener of listeners) {
- listener(event);
- }
- }
- }
-
- /**
- * Post-seal carry: if a steering queue is available and non-empty, drain it,
- * combine, and start a NEW detached turn whose opening `user-message` carries
- * the combined text (no `steering` event — that's only for mid-turn drain).
- * Returns true iff a new turn was started. Called from `runTurnDetached`'s
- * finally AFTER `activeTurns.delete` (so the new turn's single-flight guard
- * passes) and BEFORE `activeConversations.delete` (skipped when carried, since
- * the new turn re-adds it). May chain — the new turn's own finally re-checks.
- */
- function tryCarryQueue(conversationId: string): boolean {
- const queue = deps.resolveQueue?.();
- if (queue === undefined) return false;
- if (queue.getQueue(conversationId).length === 0) return false;
- const drained = queue.drain(conversationId);
- const combined = drained.map((q) => q.text).join("\n\n");
- const result = orchestrator.startTurn({ conversationId, text: combined });
- return result.started;
- }
-
- function runTurnDetached(
- conversationId: string,
- text: string,
- modelName: string | undefined,
- cwd: string | undefined,
- computerId: string | undefined,
- reasoningEffortOverride: ReasoningEffort | undefined,
- workspaceId: string,
- ): void {
- const turnId = generateTurnId();
- const controller = new AbortController();
- activeTurns.set(conversationId, { buffer: [], turnId, controller });
- activeConversations.add(conversationId);
-
- emitToHub(conversationId, { type: "user-message", conversationId, turnId, text });
-
- // For a NEW conversation the workspace MUST be assigned (persisted)
- // BEFORE getEffectiveCwd runs, so the effective cwd resolves against
- // the intended workspace's defaultCwd rather than the stale "default"
- // workspace returned by getWorkspaceId for a not-yet-persisted
- // conversation. Detect newness via getConversationMeta === null
- // (equivalent to history.length === 0 in practice). Existing
- // conversations keep their assigned workspace — never overwritten.
- // The newness flag is also reused to decide whether to construct
- // (first turn) or get (subsequent turn) the system prompt — see the
- // providerOpts assembly below.
- const workspaceSetupPromise = (async (): Promise<boolean> => {
- const meta = await deps.conversationStore.getConversationMeta(conversationId);
- if (meta === null) {
- await deps.conversationStore.ensureWorkspace(workspaceId);
- await deps.conversationStore.setWorkspaceId(conversationId, workspaceId);
- return true;
- }
- return false;
- })();
-
- // ALWAYS resolve the effective cwd through getEffectiveCwd, passing the
- // per-turn cwd as the overrideCwd when present. A relative per-turn cwd
- // (e.g. "arch-rewrite") must be resolved against the workspace's
- // defaultCwd via the same workspace-relative algorithm the persisted cwd
- // uses — NOT used raw (which would resolve against process.cwd() and
- // break). When cwd is undefined, getEffectiveCwd reads the persisted cwd.
- // Chained after workspaceSetupPromise so the workspace is assigned
- // first for new conversations (the timing invariant this enforces).
- const effectiveCwdPromise = workspaceSetupPromise.then(() =>
- deps.conversationStore.getEffectiveCwd(conversationId, cwd).then((c) => c ?? undefined),
- );
-
- // Resolve the effective computer the SAME way cwd resolves — pass the
- // per-turn computerId as the overrideAlias. When computerId is
- // undefined, getEffectiveComputer reads the persisted per-conversation
- // computerId → workspace defaultComputerId → null (LOCAL). Chained
- // after workspaceSetupPromise (same timing invariant as cwd). The
- // orchestrator never interprets the alias — it forwards the string
- // verbatim (like cwd forwards a path). Mirrors effectiveCwdPromise.
- const effectiveComputerIdPromise = workspaceSetupPromise.then(() =>
- deps.conversationStore
- .getEffectiveComputer(conversationId, computerId)
- .then((c) => c ?? undefined),
- );
-
- const storedEffortPromise = deps.conversationStore.getReasoningEffort(conversationId);
- // Resolve the persisted model (if any) in parallel with the other
- // per-conversation reads. The effective model name is
- // per-turn override → persisted → (undefined → default provider), the
- // same resolution chain as `resolveReasoningEffort`.
- const storedModelPromise = deps.conversationStore.getModel(conversationId);
-
- const payloadPromise = Promise.all([
- effectiveCwdPromise,
- effectiveComputerIdPromise,
- storedEffortPromise,
- storedModelPromise,
- ]).then(([effectiveCwd, effectiveComputerId, _storedEffort, storedModel]) => {
- const effectiveModelName = resolveModelName(modelName, storedModel);
- return {
- conversationId,
- ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}),
- ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
- ...(effectiveModelName !== undefined ? { modelName: effectiveModelName } : {}),
- };
- });
-
- payloadPromise.then((payload) => {
- deps.emit?.(turnStarted, payload);
- // Resolve the persisted workspace id (not the per-turn start option)
- // before emitting so the broadcast carries the correct workspace.
- void deps.conversationStore.getWorkspaceId(conversationId).then((workspaceId) => {
- deps.emit?.(conversationStatusChanged, {
- conversationId,
- status: "active",
- workspaceId,
- });
- });
- void deps.conversationStore.setConversationStatus(conversationId, "active");
- });
-
- void (async () => {
- let sealed = false;
- try {
- const [effectiveCwd, effectiveComputerId, storedEffort, isNewConversation, storedModel] =
- await Promise.all([
- effectiveCwdPromise,
- effectiveComputerIdPromise,
- storedEffortPromise,
- workspaceSetupPromise,
- storedModelPromise,
- ]);
-
- if (cwd !== undefined) {
- await deps.conversationStore.setCwd(conversationId, cwd);
- }
-
- // Persist the per-turn computer override, mirroring the cwd
- // persistence above. Only stamped when a computerId was actually
- // provided — NOT when it resolved to undefined (LOCAL) via the
- // workspace default. Idempotent when the value is unchanged.
- if (computerId !== undefined) {
- await deps.conversationStore.setComputerId(conversationId, computerId);
- }
-
- const resolvedEffort = resolveReasoningEffort(reasoningEffortOverride, storedEffort);
- // Effective model name: per-turn override → persisted → undefined
- // (→ default provider). Resolved here so every downstream consumer
- // (resolveModel, system prompt, payload) sees the same model as if
- // the caller had passed it explicitly.
- const effectiveModelName = resolveModelName(modelName, storedModel);
-
- const history = await deps.conversationStore.load(conversationId);
- const userMsg = buildUserMessage(text);
-
- // Workspace assignment for new conversations happens BEFORE
- // effective-cwd resolution (see workspaceSetupPromise above) so
- // getEffectiveCwd resolves against the intended workspace, not
- // the stale "default". The history-load + append flow below is
- // otherwise unchanged.
-
- let provider: ProviderContract;
- let modelOverride: string | undefined;
-
- if (effectiveModelName !== undefined && deps.resolveModel !== undefined) {
- const resolved = deps.resolveModel(effectiveModelName);
- if (resolved === undefined) {
- emitToHub(conversationId, {
- type: "error",
- conversationId,
- turnId,
- message: `unknown model: ${effectiveModelName}`,
- });
- return;
- }
- provider = resolved.provider;
- modelOverride = resolved.model;
- // Persist the resolved model so it sticks for future turns
- // and browser sessions (per-conversation model persistence).
- // Only stamped when a model was actually used — NOT on the
- // default-provider fallthrough (nothing to persist). Idempotent
- // when the value is unchanged (re-stamps the same persisted
- // model). The early `return` above means an unknown model is
- // never persisted.
- await deps.conversationStore.setModel(conversationId, effectiveModelName);
- } else {
- provider = deps.resolveProvider();
- }
-
- const baseTools = deps.resolveTools();
- const assembled = await deps.applyToolsFilter({
- tools: baseTools,
- conversationId,
- ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}),
- ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
- });
- const dispatch = deps.resolveDispatch?.() ?? defaultDispatchPolicy();
- const turnLogger = deps.logger?.child({ conversationId, turnId });
- const metrics = createMetricsAccumulator();
-
- const emitAndAccumulate = (event: AgentEvent): void => {
- metrics.ingest(event);
- emitToHub(conversationId, event);
- };
-
- // Resolve the system prompt for this turn (cache-safe). On the
- // FIRST turn of a new conversation, construct it once (resolves all
- // template variables + persists the result). On subsequent turns,
- // reuse the persisted prompt via `getWithMeta` — but ONLY when the
- // stored cwd matches the current effective cwd. If the cwd changed
- // since the prompt was constructed (or no prompt was ever stored),
- // reconstruct against the new cwd so the prompt is never stale.
- // This preserves the cache-safe design (construct once per cwd,
- // reuse on subsequent turns with the same cwd) while fixing the bug
- // where a cwd change left the prompt stale. When the system-prompt
- // service isn't loaded, no system prompt is sent (current behavior
- // preserved).
- const systemPromptService = deps.resolveSystemPrompt?.();
- let systemPrompt: string | undefined;
- if (systemPromptService !== undefined) {
- if (isNewConversation) {
- systemPrompt = await systemPromptService.construct(
- conversationId,
- effectiveCwd ?? process.cwd(),
- {
- ...(effectiveModelName !== undefined ? { model: effectiveModelName } : {}),
- ...(workspaceId !== undefined ? { workspaceId } : {}),
- ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
- },
- );
- } else {
- const meta = await systemPromptService.getWithMeta(conversationId);
- const currentCwd = effectiveCwd ?? process.cwd();
- const currentComputerId = effectiveComputerId ?? null;
- // Invalidate when cwd OR computerId changed (switching computers
- // must rebuild the prompt against the remote OS/hostname).
- if (
- meta.prompt !== null &&
- meta.cwd === currentCwd &&
- meta.computerId === currentComputerId
- ) {
- systemPrompt = meta.prompt;
- } else {
- systemPrompt = await systemPromptService.construct(conversationId, currentCwd, {
- ...(effectiveModelName !== undefined ? { model: effectiveModelName } : {}),
- ...(workspaceId !== undefined ? { workspaceId } : {}),
- ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
- });
- }
- }
- }
-
- const providerOpts: ProviderStreamOptions = {
- reasoningEffort: resolvedEffort,
- ...(modelOverride !== undefined ? { model: modelOverride } : {}),
- ...(systemPrompt !== undefined ? { systemPrompt } : {}),
- };
-
- // Resolve the steering queue once for this turn. When present, wire
- // `drainSteering`: the kernel calls it at the tool-result boundary and
- // appends whatever it returns as user-role messages alongside the tool
- // results (mid-turn steering). The wrapper emits a `steering` AgentEvent
- // into the hub (buffered for late-join like `user-message`) so a
- // frontend can place a user bubble in the transcript live; the kernel
- // only appends the returned messages — it does NOT emit the event.
- const queue = deps.resolveQueue?.();
- const drainSteering =
- queue === undefined
- ? undefined
- : (): readonly ChatMessage[] => {
- const queued = queue.drain(conversationId);
- if (queued.length === 0) return [];
- const steerText = queued.map((q) => q.text).join("\n\n");
- emitToHub(conversationId, {
- type: "steering",
- conversationId,
- turnId,
- text: steerText,
- });
- return [{ role: "user", chunks: [{ type: "text", text: steerText }] }];
- };
-
- const opts: RunTurnInput = {
- provider,
- messages: [...history, userMsg],
- tools: assembled.tools,
- dispatch,
- emit: emitAndAccumulate,
- conversationId,
- turnId,
- signal: controller.signal,
- providerOpts,
- retry: retryStrategy,
- ...(turnLogger !== undefined ? { logger: turnLogger } : {}),
- ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}),
- ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
- ...(deps.now !== undefined ? { now: deps.now } : {}),
- ...(drainSteering !== undefined ? { drainSteering } : {}),
- };
-
- // Persist the user message at turn start so it has a seq
- // number before the first step generates. This enables the
- // FE to syncTail during generation (CR-6).
- await deps.conversationStore.append(conversationId, [userMsg]);
-
- let stepsPersisted = false;
- const result = await deps.runTurn({
- ...opts,
- // Incremental persistence: persist each step's messages
- // as they are finalized. Seq numbers are assigned during
- // generation, so the FE can GET /conversations/:id?sinceSeq=N
- // mid-turn and pick up committed chunks (CR-6).
- onStepComplete: async (stepMessages) => {
- await deps.conversationStore.append(conversationId, stepMessages);
- stepsPersisted = true;
- },
- });
-
- // Fallback: if onStepComplete was never called (e.g., a fake
- // runTurn in tests), persist all result messages as a batch.
- if (!stepsPersisted && result.messages.length > 0) {
- await deps.conversationStore.append(conversationId, result.messages);
- }
-
- const turnMetrics = metrics.build(turnId);
- await deps.conversationStore.appendMetrics(conversationId, turnMetrics);
-
- emitToHub(conversationId, { type: "turn-sealed", conversationId, turnId });
- sealed = true;
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err);
- emitToHub(conversationId, {
- type: "error",
- conversationId,
- turnId,
- message,
- });
- } finally {
- activeTurns.delete(conversationId);
- // Post-seal carry: if the turn sealed with a non-empty steering queue
- // (no tool call fired → drainSteering never drained it), start a NEW
- // detached turn whose opening user-message carries the combined text.
- // The new turn re-adds to activeTurns + activeConversations, so skip
- // the activeConversations.delete when carried. May chain (user keeps
- // steering) — each carried turn's own finally re-checks the queue.
- const carried = sealed && tryCarryQueue(conversationId);
- if (!carried) {
- activeConversations.delete(conversationId);
- }
- void payloadPromise.then((payload) => {
- deps.emit?.(turnSettled, payload);
- if (!carried) {
- // Resolve the persisted workspace id before emitting so the
- // broadcast carries the correct workspace.
- void deps.conversationStore.getWorkspaceId(conversationId).then((workspaceId) => {
- deps.emit?.(conversationStatusChanged, {
- conversationId,
- status: "idle",
- workspaceId,
- });
- });
- 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,
- ...(payload.modelName !== undefined ? { modelName: payload.modelName } : {}),
- })
- .catch(() => {});
- }
- }
- });
- }
- })();
- }
-
- const orchestrator: SessionOrchestrator = {
- startTurn({ conversationId, text, modelName, cwd, computerId, reasoningEffort, workspaceId }) {
- if (activeTurns.has(conversationId)) {
- return { started: false, reason: "already-active" };
- }
- runTurnDetached(
- conversationId,
- text,
- modelName,
- cwd,
- computerId,
- reasoningEffort,
- workspaceId ?? "default",
- );
- const turn = activeTurns.get(conversationId);
- const turnId = turn !== undefined ? turn.turnId : "";
- return { started: true, turnId };
- },
-
- enqueue({ conversationId, text, workspaceId, computerId }) {
- const result = orchestrator.startTurn({
- conversationId,
- text,
- ...(workspaceId !== undefined ? { workspaceId } : {}),
- ...(computerId !== undefined ? { computerId } : {}),
- });
- if (result.started) {
- return { startedTurn: true, queue: [] };
- }
- // Already active → enqueue onto the steering queue. When the
- // message-queue extension isn't loaded this degrades: the message is
- // dropped and the snapshot is empty (feature off).
- const queue = deps.resolveQueue?.();
- const snapshot = queue !== undefined ? queue.enqueue(conversationId, text) : [];
- return { startedTurn: false, queue: snapshot };
- },
-
- subscribe(conversationId, listener) {
- let listeners = subscribers.get(conversationId);
- if (listeners === undefined) {
- listeners = new Set();
- subscribers.set(conversationId, listeners);
- }
- const turn = activeTurns.get(conversationId);
- if (turn !== undefined) {
- const snapshot = [...turn.buffer];
- listeners.add(listener);
- for (const event of snapshot) {
- listener(event);
- }
- } else {
- listeners.add(listener);
- }
- return () => {
- const set = subscribers.get(conversationId);
- if (set !== undefined) {
- set.delete(listener);
- if (set.size === 0) {
- subscribers.delete(conversationId);
- }
- }
- };
- },
-
- isActive(conversationId) {
- return activeTurns.has(conversationId);
- },
-
- closeConversation(conversationId) {
- const turn = activeTurns.get(conversationId);
- const abortedTurn = turn !== undefined;
- if (turn !== undefined) {
- turn.controller.abort();
- }
- deps.emit?.(conversationClosed, { conversationId });
- // Resolve the persisted workspace id before emitting so the
- // broadcast carries the correct workspace. The hook is
- // fire-and-forget; closeConversation stays synchronous (returns
- // immediately) while the status-changed emit resolves async.
- void deps.conversationStore.getWorkspaceId(conversationId).then((workspaceId) => {
- deps.emit?.(conversationStatusChanged, {
- conversationId,
- status: "closed",
- workspaceId,
- });
- });
- void deps.conversationStore.setConversationStatus(conversationId, "closed");
- return { abortedTurn };
- },
-
- stopTurn(conversationId) {
- const turn = activeTurns.get(conversationId);
- const abortedTurn = turn !== undefined;
- if (turn !== undefined) {
- turn.controller.abort();
- }
- return { abortedTurn };
- },
-
- async handleMessage({
- conversationId,
- text,
- onEvent,
- modelName,
- cwd,
- computerId,
- reasoningEffort,
- workspaceId,
- }) {
- const turnInput: StartTurnInput = {
- conversationId,
- text,
- ...(modelName !== undefined ? { modelName } : {}),
- ...(cwd !== undefined ? { cwd } : {}),
- ...(computerId !== undefined ? { computerId } : {}),
- ...(reasoningEffort !== undefined ? { reasoningEffort } : {}),
- ...(workspaceId !== undefined ? { workspaceId } : {}),
- };
- const result = orchestrator.startTurn(turnInput);
- if (!result.started) {
- const errorTurnId = generateTurnId();
- onEvent({
- type: "error",
- conversationId,
- turnId: errorTurnId,
- message: "turn already active for this conversation",
- });
- return;
- }
-
- await new Promise<void>((resolve) => {
- const unsubscribe = orchestrator.subscribe(conversationId, (event) => {
- onEvent(event);
- if (event.type === "turn-sealed" || event.type === "error") {
- unsubscribe();
- resolve();
- }
- });
- });
- },
- };
-
- return { orchestrator, activeConversations };
+ const activeConversations = new Set<string>();
+ const subscribers = new Map<string, Set<TurnEventListener>>();
+ const activeTurns = new Map<string, ActiveTurn>();
+ // One stateless retry strategy shared by every turn (delayFor is pure; sleep
+ // is a stateless setTimeout closure). Wired into each RunTurnInput.retry.
+ const retryStrategy = createRetryStrategy();
+
+ function emitToHub(conversationId: string, event: AgentEvent): void {
+ const turn = activeTurns.get(conversationId);
+ if (turn !== undefined) {
+ turn.buffer.push(event);
+ }
+ const listeners = subscribers.get(conversationId);
+ if (listeners !== undefined) {
+ for (const listener of listeners) {
+ listener(event);
+ }
+ }
+ }
+
+ /**
+ * Post-seal carry: if a steering queue is available and non-empty, drain it,
+ * combine, and start a NEW detached turn whose opening `user-message` carries
+ * the combined text (no `steering` event — that's only for mid-turn drain).
+ * Returns true iff a new turn was started. Called from `runTurnDetached`'s
+ * finally AFTER `activeTurns.delete` (so the new turn's single-flight guard
+ * passes) and BEFORE `activeConversations.delete` (skipped when carried, since
+ * the new turn re-adds it). May chain — the new turn's own finally re-checks.
+ */
+ function tryCarryQueue(conversationId: string): boolean {
+ const queue = deps.resolveQueue?.();
+ if (queue === undefined) return false;
+ if (queue.getQueue(conversationId).length === 0) return false;
+ const drained = queue.drain(conversationId);
+ const combined = drained.map((q) => q.text).join("\n\n");
+ const result = orchestrator.startTurn({ conversationId, text: combined });
+ return result.started;
+ }
+
+ function runTurnDetached(
+ conversationId: string,
+ text: string,
+ modelName: string | undefined,
+ cwd: string | undefined,
+ computerId: string | undefined,
+ reasoningEffortOverride: ReasoningEffort | undefined,
+ workspaceId: string,
+ systemPromptOverride: string | undefined,
+ images: readonly ImageInput[] | undefined,
+ title: string | undefined,
+ ): void {
+ const turnId = generateTurnId();
+ const promptStartedAt = deps.now?.() ?? Date.now();
+ const controller = new AbortController();
+ activeTurns.set(conversationId, { buffer: [], turnId, controller });
+ activeConversations.add(conversationId);
+
+ emitToHub(conversationId, { type: "user-message", conversationId, turnId, text });
+
+ // For a NEW conversation the workspace MUST be assigned (persisted)
+ // BEFORE getEffectiveCwd runs, so the effective cwd resolves against
+ // the intended workspace's defaultCwd rather than the stale "default"
+ // workspace returned by getWorkspaceId for a not-yet-persisted
+ // conversation. Detect newness via getConversationMeta === null
+ // (equivalent to history.length === 0 in practice). Existing
+ // conversations keep their assigned workspace — never overwritten.
+ // The newness flag is also reused to decide whether to construct
+ // (first turn) or get (subsequent turn) the system prompt — see the
+ // providerOpts assembly below.
+ //
+ // An explicit `title` (e.g. the CLI `--title` flag) is persisted HERE,
+ // AFTER the workspace setup resolves — deliberately NOT before the turn.
+ // Setting it earlier (e.g. in the HTTP route) would pre-create the meta
+ // row, make `meta !== null`, and fool this newness check into skipping
+ // `ensureWorkspace` / `setWorkspaceId` / first-turn system-prompt
+ // construction. By deferring it to here, the title lands after the
+ // workspace is assigned but BEFORE the first message append (so the
+ // append's auto-title sees a non-"Untitled" title and preserves it).
+ const workspaceSetupPromise = (async (): Promise<boolean> => {
+ const meta = await deps.conversationStore.getConversationMeta(conversationId);
+ if (meta === null) {
+ await deps.conversationStore.ensureWorkspace(workspaceId);
+ await deps.conversationStore.setWorkspaceId(conversationId, workspaceId);
+ }
+ if (title !== undefined) {
+ // Best-effort: a title-set failure must NOT break the turn (the
+ // workspace setup above already succeeded). Log and continue — the
+ // append's auto-derived title applies instead.
+ try {
+ await deps.conversationStore.setConversationTitle(conversationId, title);
+ } catch (err) {
+ deps.logger?.child({ conversationId }).warn("orchestrator: title set failure", {
+ error: err instanceof Error ? err.message : String(err),
+ });
+ }
+ }
+ return meta === null;
+ })();
+
+ // ALWAYS resolve the effective cwd through getEffectiveCwd, passing the
+ // per-turn cwd as the overrideCwd when present. A relative per-turn cwd
+ // (e.g. "arch-rewrite") must be resolved against the workspace's
+ // defaultCwd via the same workspace-relative algorithm the persisted cwd
+ // uses — NOT used raw (which would resolve against process.cwd() and
+ // break). When cwd is undefined, getEffectiveCwd reads the persisted cwd.
+ // Chained after workspaceSetupPromise so the workspace is assigned
+ // first for new conversations (the timing invariant this enforces).
+ const effectiveCwdPromise = workspaceSetupPromise.then(() =>
+ deps.conversationStore.getEffectiveCwd(conversationId, cwd).then((c) => c ?? undefined),
+ );
+
+ // Resolve the effective computer the SAME way cwd resolves — pass the
+ // per-turn computerId as the overrideAlias. When computerId is
+ // undefined, getEffectiveComputer reads the persisted per-conversation
+ // computerId → workspace defaultComputerId → null (LOCAL). Chained
+ // after workspaceSetupPromise (same timing invariant as cwd). The
+ // orchestrator never interprets the alias — it forwards the string
+ // verbatim (like cwd forwards a path). Mirrors effectiveCwdPromise.
+ const effectiveComputerIdPromise = workspaceSetupPromise.then(() =>
+ deps.conversationStore
+ .getEffectiveComputer(conversationId, computerId)
+ .then((c) => c ?? undefined),
+ );
+
+ const storedEffortPromise = deps.conversationStore.getReasoningEffort(conversationId);
+ // Resolve the persisted model (if any) in parallel with the other
+ // per-conversation reads. The effective model name is
+ // per-turn override → persisted → (undefined → default provider), the
+ // same resolution chain as `resolveReasoningEffort`.
+ const storedModelPromise = deps.conversationStore.getModel(conversationId);
+
+ const payloadPromise = Promise.all([
+ effectiveCwdPromise,
+ effectiveComputerIdPromise,
+ storedEffortPromise,
+ storedModelPromise,
+ ]).then(([effectiveCwd, effectiveComputerId, _storedEffort, storedModel]) => {
+ const effectiveModelName = resolveModelName(modelName, storedModel);
+ return {
+ conversationId,
+ ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}),
+ ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
+ ...(effectiveModelName !== undefined ? { modelName: effectiveModelName } : {}),
+ };
+ });
+
+ payloadPromise.then((payload) => {
+ deps.emit?.(turnStarted, payload);
+ // Resolve the persisted workspace id (not the per-turn start option)
+ // before emitting so the broadcast carries the correct workspace.
+ void deps.conversationStore.getWorkspaceId(conversationId).then((workspaceId) => {
+ deps.emit?.(conversationStatusChanged, {
+ conversationId,
+ status: "active",
+ workspaceId,
+ });
+ });
+ void deps.conversationStore.setConversationStatus(conversationId, "active");
+ });
+
+ void (async () => {
+ let sealed = false;
+ try {
+ const [effectiveCwd, effectiveComputerId, storedEffort, isNewConversation, storedModel] =
+ await Promise.all([
+ effectiveCwdPromise,
+ effectiveComputerIdPromise,
+ storedEffortPromise,
+ workspaceSetupPromise,
+ storedModelPromise,
+ ]);
+
+ if (cwd !== undefined) {
+ await deps.conversationStore.setCwd(conversationId, cwd);
+ }
+
+ // Persist the per-turn computer override, mirroring the cwd
+ // persistence above. Only stamped when a computerId was actually
+ // provided — NOT when it resolved to undefined (LOCAL) via the
+ // workspace default. Idempotent when the value is unchanged.
+ if (computerId !== undefined) {
+ await deps.conversationStore.setComputerId(conversationId, computerId);
+ }
+
+ const resolvedEffort = resolveReasoningEffort(reasoningEffortOverride, storedEffort);
+ // Effective model name: per-turn override → persisted → undefined
+ // (→ default provider). Resolved here so every downstream consumer
+ // (resolveModel, system prompt, payload) sees the same model as if
+ // the caller had passed it explicitly.
+ const effectiveModelName = resolveModelName(modelName, storedModel);
+
+ const history = await deps.conversationStore.load(conversationId);
+
+ // Store images to tmp files (compact URLs) BEFORE building the user
+ // message so the persisted chunks hold tiny URL references, not
+ // megabytes of base64 data URLs. When the vision-handoff service isn't
+ // loaded, images pass through unchanged (backward compatible).
+ const visionHandoffForStore = deps.resolveVisionHandoff?.();
+ const storedImages =
+ visionHandoffForStore !== undefined && images !== undefined
+ ? await visionHandoffForStore.storeImages(conversationId, images)
+ : images;
+
+ const userMsg = buildUserMessage(text, storedImages);
+
+ // Workspace assignment for new conversations happens BEFORE
+ // effective-cwd resolution (see workspaceSetupPromise above) so
+ // getEffectiveCwd resolves against the intended workspace, not
+ // the stale "default". The history-load + append flow below is
+ // otherwise unchanged.
+
+ let provider: ProviderContract;
+ let modelOverride: string | undefined;
+
+ if (effectiveModelName !== undefined && deps.resolveModel !== undefined) {
+ const resolved = deps.resolveModel(effectiveModelName);
+ if (resolved === undefined) {
+ emitToHub(conversationId, {
+ type: "error",
+ conversationId,
+ turnId,
+ message: `unknown model: ${effectiveModelName}`,
+ });
+ return;
+ }
+ provider = resolved.provider;
+ modelOverride = resolved.model;
+ // Persist the resolved model so it sticks for future turns
+ // and browser sessions (per-conversation model persistence).
+ // Only stamped when a model was actually used — NOT on the
+ // default-provider fallthrough (nothing to persist). Idempotent
+ // when the value is unchanged (re-stamps the same persisted
+ // model). The early `return` above means an unknown model is
+ // never persisted.
+ await deps.conversationStore.setModel(conversationId, effectiveModelName);
+ } else {
+ provider = deps.resolveProvider();
+ }
+
+ // Wrap the resolved provider with concurrency limiting when the
+ // provider-concurrency extension is loaded. The slot is acquired
+ // before the stream starts (before the HTTP request) and released
+ // when the stream completes (after all tokens are generated). The
+ // promptStartedAt (turn start time) is used for oldest-agent-first
+ // scheduling when multiple agents are queued.
+ //
+ // Status lifecycle with concurrency: "active" is emitted early (in
+ // payloadPromise.then, before this code runs). If acquire() blocks,
+ // onQueued emits "queued" (broadcast-only — persisted status stays
+ // "active"). When the slot is granted, onAcquired emits "active"
+ // again, transitioning "queued" → "active" so the FE switches from
+ // the loading ring back to dots. A request that gets a slot
+ // immediately never emits "queued" — onAcquired fires right after
+ // the early "active", which is a harmless no-op re-broadcast.
+ const limiter = deps.resolveConcurrencyLimiter?.();
+ if (limiter !== undefined) {
+ const emitStatus = (status: "queued" | "active"): void => {
+ void deps.conversationStore.getWorkspaceId(conversationId).then((workspaceId) => {
+ deps.emit?.(conversationStatusChanged, {
+ conversationId,
+ status,
+ workspaceId,
+ });
+ });
+ };
+ provider = wrapProviderWithConcurrency(
+ provider,
+ limiter,
+ conversationId,
+ workspaceId,
+ promptStartedAt,
+ () => emitStatus("queued"),
+ () => emitStatus("active"),
+ );
+ }
+
+ const baseTools = deps.resolveTools();
+ const assembled = await deps.applyToolsFilter({
+ tools: baseTools,
+ conversationId,
+ ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}),
+ ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
+ // Thread the turn's abort signal into the filter chain so a filter
+ // awaiting slow I/O (the MCP tools filter connecting to MCP servers)
+ // can be interrupted by POST /conversations/:id/stop instead of
+ // blocking the turn until its own timeout fires.
+ signal: controller.signal,
+ });
+ const dispatch = deps.resolveDispatch?.() ?? defaultDispatchPolicy();
+ const turnLogger = deps.logger?.child({ conversationId, turnId });
+ const metrics = createMetricsAccumulator();
+
+ const emitAndAccumulate = (event: AgentEvent): void => {
+ metrics.ingest(event);
+ emitToHub(conversationId, event);
+ };
+
+ // Resolve the system prompt for this turn (cache-safe). On the
+ // FIRST turn of a new conversation, construct it once (resolves all
+ // template variables + persists the result). On subsequent turns,
+ // reuse the persisted prompt via `getWithMeta` — but ONLY when the
+ // stored cwd matches the current effective cwd. If the cwd changed
+ // since the prompt was constructed (or no prompt was ever stored),
+ // reconstruct against the new cwd so the prompt is never stale.
+ // This preserves the cache-safe design (construct once per cwd,
+ // reuse on subsequent turns with the same cwd) while fixing the bug
+ // where a cwd change left the prompt stale. When the system-prompt
+ // service isn't loaded, no system prompt is sent (current behavior
+ // preserved).
+ //
+ // EXPLICIT OVERRIDE: when `systemPromptOverride` is provided (a
+ // string, incl. empty), it is sent to the provider AS-IS and the
+ // system-prompt service is bypassed entirely (no construct/reuse).
+ // This lets a caller that owns its own prompt (e.g. the heartbeat
+ // extension) drive a turn without the templated workspace prompt.
+ let systemPrompt: string | undefined;
+ if (systemPromptOverride !== undefined) {
+ systemPrompt = systemPromptOverride;
+ } else {
+ const systemPromptService = deps.resolveSystemPrompt?.();
+ if (systemPromptService !== undefined) {
+ if (isNewConversation) {
+ systemPrompt = await systemPromptService.construct(
+ conversationId,
+ effectiveCwd ?? process.cwd(),
+ {
+ ...(effectiveModelName !== undefined ? { model: effectiveModelName } : {}),
+ ...(workspaceId !== undefined ? { workspaceId } : {}),
+ ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
+ },
+ );
+ } else {
+ const meta = await systemPromptService.getWithMeta(conversationId);
+ const currentCwd = effectiveCwd ?? process.cwd();
+ const currentComputerId = effectiveComputerId ?? null;
+ // Invalidate when cwd OR computerId changed (switching computers
+ // must rebuild the prompt against the remote OS/hostname).
+ if (
+ meta.prompt !== null &&
+ meta.cwd === currentCwd &&
+ meta.computerId === currentComputerId
+ ) {
+ systemPrompt = meta.prompt;
+ } else {
+ systemPrompt = await systemPromptService.construct(conversationId, currentCwd, {
+ ...(effectiveModelName !== undefined ? { model: effectiveModelName } : {}),
+ ...(workspaceId !== undefined ? { workspaceId } : {}),
+ ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
+ });
+ }
+ }
+ }
+ }
+
+ const providerOpts: ProviderStreamOptions = {
+ reasoningEffort: resolvedEffort,
+ ...(modelOverride !== undefined ? { model: modelOverride } : {}),
+ ...(systemPrompt !== undefined ? { systemPrompt } : {}),
+ };
+
+ // Resolve the steering queue once for this turn. When present, wire
+ // `drainSteering`: the kernel calls it at the tool-result boundary and
+ // appends whatever it returns as user-role messages alongside the tool
+ // results (mid-turn steering). The wrapper emits a `steering` AgentEvent
+ // into the hub (buffered for late-join like `user-message`) so a
+ // frontend can place a user bubble in the transcript live; the kernel
+ // only appends the returned messages — it does NOT emit the event.
+ const queue = deps.resolveQueue?.();
+ const drainSteering =
+ queue === undefined
+ ? undefined
+ : async (): Promise<readonly ChatMessage[]> => {
+ const queued = queue.drain(conversationId);
+ if (queued.length === 0) return [];
+ const steerText = queued.map((q) => q.text).join("\n\n");
+ const steeringMessage: ChatMessage = {
+ role: "user",
+ chunks: [{ type: "text", text: steerText }],
+ };
+ // Persist the injected steering message to the store as part
+ // of the SAME critical section as the injection, so it is
+ // never lost. Without this, the message would live only in
+ // the kernel's in-memory messages array (never persisted),
+ // so a user could never see it — and in-flight compaction
+ // (which loads the store) would scrub it. A fire-and-forget
+ // append would race with the next step's `onStepComplete`
+ // append and collide on the store's seq counter, so we
+ // `await` it (the kernel awaits drainSteering). Errors
+ // propagate (a DB failure ends the turn, matching
+ // `onStepComplete`'s behavior).
+ await deps.conversationStore.append(conversationId, [steeringMessage]);
+ emitToHub(conversationId, {
+ type: "steering",
+ conversationId,
+ turnId,
+ text: steerText,
+ });
+ return [steeringMessage];
+ };
+
+ // Vision handoff: transform the message list for the provider. When the
+ // active model is vision-capable, images pass through natively (no-op).
+ // When it is NOT vision-capable, image chunks are transcribed to text
+ // descriptions via a vision-capable model — so a text-only model can
+ // still reason about images. The PERSISTED user message keeps the
+ // original image chunks (appended below); only the provider's view is
+ // transcribed. When the vision-handoff service isn't loaded, images pass
+ // through unchanged (correct for vision models; text-only models would
+ // then receive image content their API may reject — degrades off cleanly
+ // for text-only turns with no images).
+ const visionHandoff = deps.resolveVisionHandoff?.();
+ let providerMessages: readonly ChatMessage[] = [...history, userMsg];
+ if (visionHandoff !== undefined) {
+ const visionSettings = await deps.conversationStore.getVisionSettings();
+ providerMessages = await visionHandoff.prepareForProvider(
+ providerMessages,
+ effectiveModelName,
+ {
+ conversationId,
+ imageLimit: visionSettings.imageLimit,
+ signal: controller.signal,
+ ...(turnLogger !== undefined ? { logger: turnLogger } : {}),
+ },
+ );
+ }
+
+ const opts: RunTurnInput = {
+ provider,
+ messages: providerMessages,
+ tools: assembled.tools,
+ dispatch,
+ emit: emitAndAccumulate,
+ conversationId,
+ turnId,
+ signal: controller.signal,
+ providerOpts,
+ retry: retryStrategy,
+ ...(turnLogger !== undefined ? { logger: turnLogger } : {}),
+ ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}),
+ ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
+ ...(drainSteering !== undefined ? { drainSteering } : {}),
+ // In-flight compaction: at every tool-result boundary the kernel
+ // calls this with the step's usage + the running messages. When the
+ // context size exceeds the compact-percent threshold (percent of the
+ // model's context window), the old history is summarized and
+ // replaced with [summary, ...recent] — mid-turn, without stopping —
+ // so a long-running turn (e.g. left overnight) does not run out of
+ // context. This is DISTINCT from the post-seal auto-compact below:
+ // that one runs AFTER the turn ends (preparing the next turn) and
+ // refuses while the conversation is active; this one runs DURING the
+ // turn (saving the running turn) and uses the live step usage (not
+ // persisted metrics, which are only written at turn end). When the
+ // threshold is not exceeded, compaction is disabled (percent 0), or
+ // the model's context window is unknown, it returns void and the
+ // kernel keeps its history unchanged (a strict no-op).
+ onStepBoundary: async ({ stepUsage, messages }) => {
+ const stored = await deps.conversationStore.getCompactPercent(conversationId);
+ const percent = stored ?? DEFAULT_COMPACT_PERCENT;
+ if (percent <= 0) return; // auto-compact disabled
+ // contextSize mirrors the persisted definition: this step's
+ // inputTokens + outputTokens (the prompt the NEXT step would
+ // inherit, grown by this step's output).
+ const contextSize = stepUsage.inputTokens + stepUsage.outputTokens;
+ if (effectiveModelName === undefined || deps.resolveModelInfo === undefined) return;
+ const info = await deps.resolveModelInfo(effectiveModelName);
+ if (info?.contextWindow === undefined) return;
+ const threshold = Math.floor(info.contextWindow * (percent / 100));
+ if (contextSize < threshold) return; // threshold not exceeded
+
+ const keepLastN = DEFAULT_KEEP_LAST_N;
+ turnLogger?.info("compaction:in-flight", {
+ conversationId,
+ turnId,
+ contextSize,
+ threshold,
+ percent,
+ });
+ const outcome = await performCompaction(
+ {
+ conversationStore: deps.conversationStore,
+ resolveProvider: deps.resolveProvider,
+ ...(deps.resolveModel !== undefined ? { resolveModel: deps.resolveModel } : {}),
+ ...(deps.resolveSystemPrompt !== undefined
+ ? { resolveSystemPrompt: deps.resolveSystemPrompt }
+ : {}),
+ ...(deps.resolveConcurrencyLimiter !== undefined
+ ? { resolveConcurrencyLimiter: deps.resolveConcurrencyLimiter }
+ : {}),
+ ...(deps.logger !== undefined ? { logger: deps.logger } : {}),
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
+ // emit is required by performCompaction; fall back to a no-op
+ // when the orchestrator was constructed without one (tests).
+ emit: deps.emit ?? noopEmit,
+ },
+ conversationId,
+ // Pass the kernel's LIVE messages array (not a store reload):
+ // it includes mid-turn steering messages (now persisted by
+ // drainSteering) and is the authoritative prompt state. Using it
+ // for the split keeps the store write and the kernel's
+ // replacement aligned (same recent slice) — no DB↔LLM divergence.
+ { keepLastN, modelName: effectiveModelName, messages },
+ );
+ if ("error" in outcome) {
+ turnLogger?.warn("compaction:in-flight:skipped", {
+ conversationId,
+ turnId,
+ error: outcome.error,
+ });
+ return; // too short / empty summary / unknown model → no replacement
+ }
+ // Return the compacted history the kernel should adopt. This is
+ // EXACTLY what performCompaction wrote to the store
+ // ([summary, ...recent-from-live]), so the kernel's working
+ // history and the store stay byte-aligned.
+ return outcome.compactedMessages;
+ },
+ };
+
+ // Persist the user message at turn start so it has a seq
+ // number before the first step generates. This enables the
+ // FE to syncTail during generation (CR-6).
+ await deps.conversationStore.append(conversationId, [userMsg]);
+
+ // Per-turn memory telemetry: capture a sample immediately BEFORE the
+ // stream starts. Paired with the post-stream sample below, this
+ // measures the streaming path's memory footprint per turn (the prime
+ // leak suspect — AI-SDK streaming buffers + per-turn message arrays).
+ // Tagged with conversationId + turnId via the turnLogger's correlation
+ // context. Additive observability only — does not alter the stream.
+ const sampleMem = deps.sampleMemory;
+ const memBefore = sampleMem?.();
+ if (memBefore !== undefined) {
+ turnLogger?.debug("memory:turn:before", memorySampleAttributes(memBefore));
+ }
+
+ let stepsPersisted = false;
+ const result = await deps.runTurn({
+ ...opts,
+ // Incremental persistence: persist each step's messages
+ // as they are finalized. Seq numbers are assigned during
+ // generation, so the FE can GET /conversations/:id?sinceSeq=N
+ // mid-turn and pick up committed chunks (CR-6).
+ onStepComplete: async (stepMessages) => {
+ await deps.conversationStore.append(conversationId, stepMessages);
+ stepsPersisted = true;
+ },
+ });
+
+ // Per-turn memory telemetry: capture a sample immediately AFTER the
+ // stream completes and log the per-turn delta vs `memBefore`. A
+ // positive rss delta on a sealed turn flags memory retained by the
+ // streaming path (the leak we are localizing). No I/O beyond the
+ // injected sampler; pure delta computation via memoryDelta(). The
+ // delta attributes carry a `delta` prefix so the absolute "after"
+ // values and the per-turn delta coexist without key collision.
+ if (memBefore !== undefined) {
+ const memAfter = sampleMem?.();
+ if (memAfter !== undefined) {
+ turnLogger?.info("memory:turn:after", {
+ ...memorySampleAttributes(memAfter),
+ ...memorySampleAttributes(memoryDelta(memBefore, memAfter), "delta"),
+ });
+ }
+ }
+
+ // Fallback: if onStepComplete was never called (e.g., a fake
+ // runTurn in tests), persist all result messages as a batch.
+ if (!stepsPersisted && result.messages.length > 0) {
+ await deps.conversationStore.append(conversationId, result.messages);
+ }
+
+ const turnMetrics = metrics.build(turnId);
+ await deps.conversationStore.appendMetrics(conversationId, turnMetrics);
+
+ emitToHub(conversationId, { type: "turn-sealed", conversationId, turnId });
+ sealed = true;
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ emitToHub(conversationId, {
+ type: "error",
+ conversationId,
+ turnId,
+ message,
+ });
+ } finally {
+ activeTurns.delete(conversationId);
+ // Post-seal carry: if the turn sealed with a non-empty steering queue
+ // (no tool call fired → drainSteering never drained it), start a NEW
+ // detached turn whose opening user-message carries the combined text.
+ // The new turn re-adds to activeTurns + activeConversations, so skip
+ // the activeConversations.delete when carried. May chain (user keeps
+ // steering) — each carried turn's own finally re-checks the queue.
+ const carried = sealed && tryCarryQueue(conversationId);
+ if (!carried) {
+ activeConversations.delete(conversationId);
+ }
+ void payloadPromise.then((payload) => {
+ deps.emit?.(turnSettled, payload);
+ if (!carried) {
+ // Resolve the persisted workspace id before emitting so the
+ // broadcast carries the correct workspace.
+ void deps.conversationStore.getWorkspaceId(conversationId).then((workspaceId) => {
+ deps.emit?.(conversationStatusChanged, {
+ conversationId,
+ status: "idle",
+ workspaceId,
+ });
+ });
+ 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,
+ ...(payload.modelName !== undefined ? { modelName: payload.modelName } : {}),
+ })
+ .catch(() => {});
+ }
+ }
+ });
+ }
+ })();
+ }
+
+ const orchestrator: SessionOrchestrator = {
+ startTurn({
+ conversationId,
+ text,
+ modelName,
+ cwd,
+ computerId,
+ reasoningEffort,
+ workspaceId,
+ systemPrompt,
+ images,
+ title,
+ }) {
+ if (activeTurns.has(conversationId)) {
+ return { started: false, reason: "already-active" };
+ }
+ runTurnDetached(
+ conversationId,
+ text,
+ modelName,
+ cwd,
+ computerId,
+ reasoningEffort,
+ workspaceId ?? "default",
+ systemPrompt,
+ images,
+ title,
+ );
+ const turn = activeTurns.get(conversationId);
+ const turnId = turn !== undefined ? turn.turnId : "";
+ return { started: true, turnId };
+ },
+
+ enqueue({ conversationId, text, workspaceId, computerId, images }) {
+ const result = orchestrator.startTurn({
+ conversationId,
+ text,
+ ...(workspaceId !== undefined ? { workspaceId } : {}),
+ ...(computerId !== undefined ? { computerId } : {}),
+ ...(images !== undefined ? { images } : {}),
+ });
+ if (result.started) {
+ return { startedTurn: true, queue: [] };
+ }
+ // Already active → enqueue onto the steering queue. When the
+ // message-queue extension isn't loaded this degrades: the message is
+ // dropped and the snapshot is empty (feature off).
+ const queue = deps.resolveQueue?.();
+ const snapshot = queue !== undefined ? queue.enqueue(conversationId, text) : [];
+ return { startedTurn: false, queue: snapshot };
+ },
+
+ cancelQueuedMessage({ conversationId, messageId }) {
+ // When the message-queue extension isn't loaded this degrades: nothing to
+ // cancel, empty snapshot (feature off). Mirrors `enqueue`'s degraded path.
+ const queue = deps.resolveQueue?.();
+ if (queue === undefined) {
+ return { cancelled: false, queue: [] };
+ }
+ const beforeLen = queue.getQueue(conversationId).length;
+ const snapshot = queue.cancel(conversationId, messageId);
+ const cancelled = snapshot.length < beforeLen;
+ return { cancelled, queue: snapshot };
+ },
+
+ subscribe(conversationId, listener) {
+ let listeners = subscribers.get(conversationId);
+ if (listeners === undefined) {
+ listeners = new Set();
+ subscribers.set(conversationId, listeners);
+ }
+ const turn = activeTurns.get(conversationId);
+ if (turn !== undefined) {
+ const snapshot = [...turn.buffer];
+ listeners.add(listener);
+ for (const event of snapshot) {
+ listener(event);
+ }
+ } else {
+ listeners.add(listener);
+ }
+ return () => {
+ const set = subscribers.get(conversationId);
+ if (set !== undefined) {
+ set.delete(listener);
+ if (set.size === 0) {
+ subscribers.delete(conversationId);
+ }
+ }
+ };
+ },
+
+ isActive(conversationId) {
+ return activeTurns.has(conversationId);
+ },
+
+ getActiveConversationCount() {
+ return activeConversations.size;
+ },
+
+ closeConversation(conversationId) {
+ const turn = activeTurns.get(conversationId);
+ const abortedTurn = turn !== undefined;
+ if (turn !== undefined) {
+ turn.controller.abort();
+ }
+ deps.emit?.(conversationClosed, { conversationId });
+ // Resolve the persisted workspace id before emitting so the
+ // broadcast carries the correct workspace. The hook is
+ // fire-and-forget; closeConversation stays synchronous (returns
+ // immediately) while the status-changed emit resolves async.
+ void deps.conversationStore.getWorkspaceId(conversationId).then((workspaceId) => {
+ deps.emit?.(conversationStatusChanged, {
+ conversationId,
+ status: "closed",
+ workspaceId,
+ });
+ });
+ void deps.conversationStore.setConversationStatus(conversationId, "closed");
+ // Purge tmp images for this conversation (best-effort, fire-and-forget).
+ const vh = deps.resolveVisionHandoff?.();
+ if (vh !== undefined) void vh.purgeConversationImages(conversationId);
+ return { abortedTurn };
+ },
+
+ stopTurn(conversationId) {
+ const turn = activeTurns.get(conversationId);
+ const abortedTurn = turn !== undefined;
+ if (turn !== undefined) {
+ turn.controller.abort();
+ }
+ return { abortedTurn };
+ },
+
+ async handleMessage({
+ conversationId,
+ text,
+ onEvent,
+ modelName,
+ cwd,
+ computerId,
+ reasoningEffort,
+ workspaceId,
+ systemPrompt,
+ images,
+ title,
+ }) {
+ const turnInput: StartTurnInput = {
+ conversationId,
+ text,
+ ...(modelName !== undefined ? { modelName } : {}),
+ ...(cwd !== undefined ? { cwd } : {}),
+ ...(computerId !== undefined ? { computerId } : {}),
+ ...(reasoningEffort !== undefined ? { reasoningEffort } : {}),
+ ...(workspaceId !== undefined ? { workspaceId } : {}),
+ ...(systemPrompt !== undefined ? { systemPrompt } : {}),
+ ...(images !== undefined ? { images } : {}),
+ ...(title !== undefined ? { title } : {}),
+ };
+ const result = orchestrator.startTurn(turnInput);
+ if (!result.started) {
+ const errorTurnId = generateTurnId();
+ onEvent({
+ type: "error",
+ conversationId,
+ turnId: errorTurnId,
+ message: "turn already active for this conversation",
+ });
+ return;
+ }
+
+ await new Promise<void>((resolve) => {
+ const unsubscribe = orchestrator.subscribe(conversationId, (event) => {
+ onEvent(event);
+ if (event.type === "turn-sealed" || event.type === "error") {
+ unsubscribe();
+ resolve();
+ }
+ });
+ });
+ },
+ };
+
+ return { orchestrator, activeConversations };
}
export function createWarmService(
- deps: WarmServiceDeps,
- activeConversations: ReadonlySet<string>,
+ deps: WarmServiceDeps,
+ activeConversations: ReadonlySet<string>,
): WarmService {
- return {
- async warm(conversationId, opts) {
- if (activeConversations.has(conversationId)) {
- return { error: "conversation is generating" };
- }
-
- const history = await deps.conversationStore.load(conversationId);
- if (history.length === 0) {
- return { error: "no history" };
- }
-
- let provider: ProviderContract;
- let modelOverride: string | undefined;
-
- // Resolve the model the SAME way the real turn does: per-turn override
- // → persisted per-conversation model → default provider. A mismatch here
- // silently busts the prompt cache (the model block of the prompt prefix
- // diverges from the real turn's). Warm is a probe — it does NOT persist
- // (no setModel), it only reads so it sends the same model the next real
- // turn will. See notes/observability-design.md §3.1.
- const storedModel = await deps.conversationStore.getModel(conversationId);
- const effectiveModelName = resolveModelName(opts?.modelName, storedModel);
-
- if (effectiveModelName !== undefined && deps.resolveModel !== undefined) {
- const resolved = deps.resolveModel(effectiveModelName);
- if (resolved === undefined) {
- return { error: `unknown model: ${effectiveModelName}` };
- }
- provider = resolved.provider;
- modelOverride = resolved.model;
- } else {
- provider = deps.resolveProvider();
- }
-
- const baseTools = deps.resolveTools();
- // Resolve cwd the SAME way handleMessage does — pass opts.cwd as the overrideCwd
- // The tools filter is cwd-sensitive (e.g. skill discovery rewrites the
- // `load_skill` description per-cwd). If the warm assembles tools under a
- // different cwd than the real turn, the tools block — the FIRST bytes of
- // the prompt-cache prefix — diverges and the cache misses entirely (0%).
- // A manual reheat sends no cwd, so without this fallback it would warm the
- // wrong prefix. See notes/observability-design.md §3.1.
- const cwd =
- (await deps.conversationStore.getEffectiveCwd(conversationId, opts?.cwd)) ?? undefined;
- const assembled = await deps.applyToolsFilter({
- tools: baseTools,
- conversationId,
- ...(cwd !== undefined ? { cwd } : {}),
- });
-
- // Resolve reasoning effort the SAME way the real turn does (stored → "high";
- // no per-turn override on warm). A mismatch here silently busts the prompt cache.
- const storedEffort = await deps.conversationStore.getReasoningEffort(conversationId);
- const resolvedEffort = resolveReasoningEffort(undefined, storedEffort);
-
- const probeMsg: ChatMessage = {
- role: "user",
- chunks: [{ type: "text", text: "reply with just a ." }],
- };
- const messages = [...history, probeMsg];
-
- // Capture the warm send as a `provider.request` span, flagged `warm: true`
- // so it can be diffed against the corresponding real turn's request (the
- // prompt-cache 0%-hit debugging workflow — see notes/observability-design.md
- // §3.1). Without this the warm body is invisible and the cache bust is
- // undebuggable. The child-bound `warm` attribute flows into the span the
- // provider opens (kernel logger merges child attrs into span attributes).
- const warmLogger = deps.logger?.child({ conversationId, attrs: { warm: true } });
- const providerOpts: ProviderStreamOptions = {
- maxTokens: 1,
- reasoningEffort: resolvedEffort,
- ...(modelOverride !== undefined ? { model: modelOverride } : {}),
- ...(warmLogger !== undefined ? { logger: warmLogger } : {}),
- };
-
- let inputTokens = 0;
- let outputTokens = 0;
- let cacheReadTokens = 0;
- let cacheWriteTokens = 0;
-
- for await (const event of provider.stream(messages, assembled.tools, providerOpts)) {
- if ((event as ProviderEvent).type === "usage") {
- const usageEvent = event as UsageEvent;
- inputTokens = usageEvent.usage.inputTokens;
- outputTokens = usageEvent.usage.outputTokens;
- cacheReadTokens = usageEvent.usage.cacheReadTokens ?? 0;
- cacheWriteTokens = usageEvent.usage.cacheWriteTokens ?? 0;
- }
- }
-
- const result: WarmResult = { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens };
- deps.emit(warmCompleted, { conversationId, usage: result });
- return result;
- },
- };
+ return {
+ async warm(conversationId, opts) {
+ if (activeConversations.has(conversationId)) {
+ return { error: "conversation is generating" };
+ }
+
+ const history = await deps.conversationStore.load(conversationId);
+ if (history.length === 0) {
+ return { error: "no history" };
+ }
+
+ let provider: ProviderContract;
+ let modelOverride: string | undefined;
+
+ // Resolve the model the SAME way the real turn does: per-turn override
+ // → persisted per-conversation model → default provider. A mismatch here
+ // silently busts the prompt cache (the model block of the prompt prefix
+ // diverges from the real turn's). Warm is a probe — it does NOT persist
+ // (no setModel), it only reads so it sends the same model the next real
+ // turn will. See notes/observability-design.md §3.1.
+ const storedModel = await deps.conversationStore.getModel(conversationId);
+ const effectiveModelName = resolveModelName(opts?.modelName, storedModel);
+
+ if (effectiveModelName !== undefined && deps.resolveModel !== undefined) {
+ const resolved = deps.resolveModel(effectiveModelName);
+ if (resolved === undefined) {
+ return { error: `unknown model: ${effectiveModelName}` };
+ }
+ provider = resolved.provider;
+ modelOverride = resolved.model;
+ } else {
+ provider = deps.resolveProvider();
+ }
+
+ // Wrap with concurrency limiting (same as the main turn path).
+ const warmLimiter = deps.resolveConcurrencyLimiter?.();
+ if (warmLimiter !== undefined) {
+ const warmWorkspaceId = await deps.conversationStore.getWorkspaceId(conversationId);
+ provider = wrapProviderWithConcurrency(
+ provider,
+ warmLimiter,
+ conversationId,
+ warmWorkspaceId,
+ deps.now?.() ?? Date.now(),
+ );
+ }
+
+ const baseTools = deps.resolveTools();
+ // Resolve cwd the SAME way handleMessage does — pass opts.cwd as the overrideCwd
+ // The tools filter is cwd-sensitive (e.g. skill discovery rewrites the
+ // `load_skill` description per-cwd). If the warm assembles tools under a
+ // different cwd than the real turn, the tools block — the FIRST bytes of
+ // the prompt-cache prefix — diverges and the cache misses entirely (0%).
+ // A manual reheat sends no cwd, so without this fallback it would warm the
+ // wrong prefix. See notes/observability-design.md §3.1.
+ const cwd =
+ (await deps.conversationStore.getEffectiveCwd(conversationId, opts?.cwd)) ?? undefined;
+ const assembled = await deps.applyToolsFilter({
+ tools: baseTools,
+ conversationId,
+ ...(cwd !== undefined ? { cwd } : {}),
+ });
+
+ // Resolve reasoning effort the SAME way the real turn does (stored → "high";
+ // no per-turn override on warm). A mismatch here silently busts the prompt cache.
+ const storedEffort = await deps.conversationStore.getReasoningEffort(conversationId);
+ const resolvedEffort = resolveReasoningEffort(undefined, storedEffort);
+
+ const probeMsg: ChatMessage = {
+ role: "user",
+ chunks: [{ type: "text", text: "reply with just a ." }],
+ };
+ const messages = [...history, probeMsg];
+
+ // Capture the warm send as a `provider.request` span, flagged `warm: true`
+ // so it can be diffed against the corresponding real turn's request (the
+ // prompt-cache 0%-hit debugging workflow — see notes/observability-design.md
+ // §3.1). Without this the warm body is invisible and the cache bust is
+ // undebuggable. The child-bound `warm` attribute flows into the span the
+ // provider opens (kernel logger merges child attrs into span attributes).
+ const warmLogger = deps.logger?.child({ conversationId, attrs: { warm: true } });
+ const providerOpts: ProviderStreamOptions = {
+ maxTokens: 1,
+ reasoningEffort: resolvedEffort,
+ ...(modelOverride !== undefined ? { model: modelOverride } : {}),
+ ...(warmLogger !== undefined ? { logger: warmLogger } : {}),
+ };
+
+ let inputTokens = 0;
+ let outputTokens = 0;
+ let cacheReadTokens = 0;
+ let cacheWriteTokens = 0;
+
+ for await (const event of provider.stream(messages, assembled.tools, providerOpts)) {
+ if ((event as ProviderEvent).type === "usage") {
+ const usageEvent = event as UsageEvent;
+ inputTokens = usageEvent.usage.inputTokens;
+ outputTokens = usageEvent.usage.outputTokens;
+ cacheReadTokens = usageEvent.usage.cacheReadTokens ?? 0;
+ cacheWriteTokens = usageEvent.usage.cacheWriteTokens ?? 0;
+ }
+ }
+
+ const result: WarmResult = { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens };
+ deps.emit(warmCompleted, { conversationId, usage: result });
+ return result;
+ },
+ };
}
const DEFAULT_KEEP_LAST_N = 10;
const DEFAULT_COMPACT_PERCENT = 85;
+/**
+ * No-op emit used as a fallback when the orchestrator is constructed without an
+ * `emit` (some tests). `performCompaction` requires a non-optional `emit` (it
+ * emits `conversationCompacted`); the in-flight path degrades to emitting
+ * nothing rather than skipping compaction entirely. Generic-typed so it
+ * satisfies `PerformCompactionDeps["emit"]` for any hook payload type.
+ */
+const noopEmit: <TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) => void =
+ () => {};
+
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.";
+ "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");
+ 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");
+}
+
+/**
+ * Deps for {@link performCompaction} — the subset of `SessionOrchestratorDeps`
+ * needed to summarize old history, fork it to an archive, and replace it with
+ * a summary + recent messages. Structural so both the compaction SERVICE
+ * (`compact`, manual + post-seal auto) and the IN-FLIGHT compaction path (the
+ * turn loop's `onStepBoundary`) can call the same shared core without duplicating
+ * the summarization/fork/replace/emit logic. The active-conversation guard and
+ * the threshold check are the CALLERS' policy (they differ between the two
+ * paths) and are NOT performed here.
+ */
+interface PerformCompactionDeps {
+ readonly conversationStore: ConversationStore;
+ readonly resolveProvider: () => ProviderContract;
+ readonly resolveModel?: (
+ modelName: string,
+ ) => { provider: ProviderContract; model: string } | undefined;
+ readonly resolveSystemPrompt?: () => SystemPromptService | undefined;
+ readonly resolveConcurrencyLimiter?: () => ConcurrencyLimiter | undefined;
+ readonly logger?: Logger;
+ readonly now?: () => number;
+ readonly emit: <TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) => void;
+}
+
+/** Result of a successful {@link performCompaction}. */
+interface PerformCompactionResult {
+ readonly summary: string;
+ readonly newConversationId: string;
+ readonly messagesSummarized: number;
+ readonly messagesKept: number;
+ /**
+ * The system-role summary message that heads the compacted history
+ * (`[summaryMessage, ...recentKept]`). Returned so the in-flight caller can
+ * build the kernel's replacement history with the SAME summary object.
+ */
+ readonly summaryMessage: ChatMessage;
+ /**
+ * The full compacted history `[summaryMessage, ...recentKept]` exactly as
+ * written to the store. The in-flight caller returns this to the kernel so
+ * the kernel's working history and the store stay byte-aligned (the same
+ * `recentKept` slice — taken from the caller-supplied live `messages` — is
+ * used for BOTH the store write and this return value).
+ */
+ readonly compactedMessages: readonly ChatMessage[];
+}
+
+/**
+ * The shared compaction core: summarize the oldest `history.length -
+ * keepLastN` messages via a provider stream, fork the full pre-compaction
+ * history to an archive (non-destructive), and replace the live history with
+ * `[summaryMessage, ...recentKept]`. Emits `conversationCompacted`. Returns the
+ * result (incl. the `summaryMessage` + the `compactedMessages`) or an error.
+ *
+ * History source: when `opts.messages` is provided (the in-flight path), it is
+ * used as the authoritative history — this is the kernel's LIVE messages array,
+ * which includes mid-turn steering messages (and the vision-transformed
+ * provider view) that a store reload could miss (the steering persist may not
+ * have completed, or — before this fix — was never done at all). Using the live
+ * array keeps the store write and the kernel's replacement aligned (same
+ * `recentKept` slice), avoiding the DB↔LLM structural divergence where
+ * independent slices dropped different messages. When `opts.messages` is
+ * omitted (the post-seal/manual `compact()` path — the turn has ended, so the
+ * store is stable), the history is loaded from the store.
+ *
+ * Performs NO active-conversation guard and NO threshold check — those are the
+ * callers' policy. No-ops (returns an error) when the conversation is too
+ * short to compact (≤ keepLastN messages) or the summary is empty.
+ */
+async function performCompaction(
+ deps: PerformCompactionDeps,
+ conversationId: string,
+ opts: {
+ readonly keepLastN?: number;
+ readonly modelName?: string;
+ /** The kernel's live messages array (in-flight path). Omit to load the store (post-seal/manual). */
+ readonly messages?: readonly ChatMessage[];
+ },
+): Promise<PerformCompactionResult | { readonly error: string }> {
+ // Use the caller-supplied live messages (in-flight) or load the store
+ // (post-seal/manual — the store is stable once the turn has ended).
+ const history = opts.messages ?? (await deps.conversationStore.load(conversationId));
+ const keepLastN = opts?.keepLastN ?? DEFAULT_KEEP_LAST_N;
+
+ if (history.length <= keepLastN) {
+ return { error: "conversation too short to compact" };
+ }
+
+ // 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();
+ }
+
+ // Wrap with concurrency limiting (same as the main turn path).
+ const compactionLimiter = deps.resolveConcurrencyLimiter?.();
+ if (compactionLimiter !== undefined) {
+ const compactionWorkspaceId = await deps.conversationStore.getWorkspaceId(conversationId);
+ provider = wrapProviderWithConcurrency(
+ provider,
+ compactionLimiter,
+ conversationId,
+ compactionWorkspaceId,
+ deps.now?.() ?? Date.now(),
+ );
+ }
+
+ // 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 } }) }
+ : {}),
+ };
+
+ // Reconstruct the system prompt on compaction (fresh variable
+ // resolution — files/cwd/time may have changed since construction).
+ // The construct call also persists the result for future turns. When
+ // the system-prompt service is unavailable, fall back to the
+ // compaction-only system prompt (current behavior, no regression).
+ const systemPromptService = deps.resolveSystemPrompt?.();
+ let compactionSystemPrompt: string;
+ if (systemPromptService !== undefined) {
+ const cwd = (await deps.conversationStore.getEffectiveCwd(conversationId)) ?? process.cwd();
+ const workspaceId = await deps.conversationStore.getWorkspaceId(conversationId);
+ const computerId = await deps.conversationStore.getEffectiveComputer(conversationId);
+ const constructed = await systemPromptService.construct(conversationId, cwd, {
+ ...(opts?.modelName !== undefined ? { model: opts.modelName } : {}),
+ workspaceId,
+ ...(computerId !== null ? { computerId } : {}),
+ });
+ compactionSystemPrompt = `${constructed}\n\n${COMPACTION_SYSTEM_PROMPT}`;
+ } else {
+ compactionSystemPrompt = COMPACTION_SYSTEM_PROMPT;
+ }
+
+ // Call the provider and accumulate the summary
+ let summary = "";
+ for await (const event of provider.stream([summaryRequest], [], {
+ ...providerOpts,
+ systemPrompt: compactionSystemPrompt,
+ })) {
+ 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" };
+ }
+
+ // Non-destructive: fork the full pre-compaction history to a new
+ // archive conversation. The original conversation keeps its ID
+ // (so messaging between agents still works) and gets the compacted
+ // content. The archive inherits the original's compactedFrom,
+ // creating a chain: A → Y → X → ...
+ const archiveId = crypto.randomUUID();
+ await deps.conversationStore.forkHistory(conversationId, archiveId);
+
+ // Replace history: [system: summary] + the recent kept messages. `toKeep`
+ // is sliced from the caller-supplied live `messages` (in-flight) — the SAME
+ // slice returned below as `compactedMessages` — so the store and the kernel's
+ // working history stay byte-aligned (same messages kept/dropped).
+ const summaryMessage: ChatMessage = {
+ role: "system",
+ chunks: [
+ {
+ type: "text",
+ text: `The following is a summary of the previous conversation:\n\n${summary}`,
+ },
+ ],
+ };
+
+ const compactedMessages: readonly ChatMessage[] = [summaryMessage, ...toKeep];
+ await deps.conversationStore.replaceHistory(conversationId, compactedMessages);
+ await deps.conversationStore.setCompactedFrom(conversationId, archiveId);
+
+ deps.emit(conversationCompacted, {
+ conversationId,
+ newConversationId: archiveId,
+ messagesSummarized: toSummarize.length,
+ messagesKept: toKeep.length,
+ });
+
+ return {
+ summary,
+ newConversationId: archiveId,
+ messagesSummarized: toSummarize.length,
+ messagesKept: toKeep.length,
+ summaryMessage,
+ compactedMessages,
+ };
}
export function createCompactionService(
- deps: SessionOrchestratorDeps & {
- readonly emit: <TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) => void;
- },
- activeConversations: ReadonlySet<string>,
+ 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 if contextSize exceeds percent of contextWindow.
- if (opts?.auto === true) {
- const stored = await deps.conversationStore.getCompactPercent(conversationId);
- const percent = stored ?? DEFAULT_COMPACT_PERCENT;
- if (percent <= 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 contextSize = lastTurn.contextSize;
- if (contextSize === undefined) return { error: "no context size" };
-
- // Resolve the model's context window.
- const modelName = opts.modelName;
- if (modelName === undefined || deps.resolveModelInfo === undefined) {
- return { error: "cannot resolve model info" };
- }
- const info = await deps.resolveModelInfo(modelName);
- if (info?.contextWindow === undefined) {
- return { error: "model context window unknown" };
- }
- const threshold = Math.floor(info.contextWindow * (percent / 100));
- if (contextSize < 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 } }) }
- : {}),
- };
-
- // Reconstruct the system prompt on compaction (fresh variable
- // resolution — files/cwd/time may have changed since construction).
- // The construct call also persists the result for future turns. When
- // the system-prompt service is unavailable, fall back to the
- // compaction-only system prompt (current behavior, no regression).
- const systemPromptService = deps.resolveSystemPrompt?.();
- let compactionSystemPrompt: string;
- if (systemPromptService !== undefined) {
- const cwd = (await deps.conversationStore.getEffectiveCwd(conversationId)) ?? process.cwd();
- const workspaceId = await deps.conversationStore.getWorkspaceId(conversationId);
- const computerId = await deps.conversationStore.getEffectiveComputer(conversationId);
- const constructed = await systemPromptService.construct(conversationId, cwd, {
- ...(opts?.modelName !== undefined ? { model: opts.modelName } : {}),
- workspaceId,
- ...(computerId !== null ? { computerId } : {}),
- });
- compactionSystemPrompt = `${constructed}\n\n${COMPACTION_SYSTEM_PROMPT}`;
- } else {
- compactionSystemPrompt = COMPACTION_SYSTEM_PROMPT;
- }
-
- // Call the provider and accumulate the summary
- let summary = "";
- for await (const event of provider.stream([summaryRequest], [], {
- ...providerOpts,
- systemPrompt: compactionSystemPrompt,
- })) {
- 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" };
- }
-
- // Non-destructive: fork the full pre-compaction history to a new
- // archive conversation. The original conversation keeps its ID
- // (so messaging between agents still works) and gets the compacted
- // content. The archive inherits the original's compactedFrom,
- // creating a chain: A → Y → X → ...
- const archiveId = crypto.randomUUID();
- await deps.conversationStore.forkHistory(conversationId, archiveId);
-
- // 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]);
- await deps.conversationStore.setCompactedFrom(conversationId, archiveId);
-
- const result: CompactionResult = {
- summary,
- newConversationId: archiveId,
- messagesSummarized: toSummarize.length,
- messagesKept: toKeep.length,
- };
-
- deps.emit(conversationCompacted, {
- conversationId,
- newConversationId: archiveId,
- messagesSummarized: toSummarize.length,
- messagesKept: toKeep.length,
- });
-
- return result;
- },
- };
+ return {
+ async compact(conversationId, opts) {
+ if (activeConversations.has(conversationId)) {
+ return { error: "conversation is generating" };
+ }
+
+ // Auto mode: check if contextSize exceeds percent of contextWindow.
+ // The threshold check is the caller's policy (uses persisted turn
+ // metrics) and is NOT performed by the shared `performCompaction` core.
+ if (opts?.auto === true) {
+ const stored = await deps.conversationStore.getCompactPercent(conversationId);
+ const percent = stored ?? DEFAULT_COMPACT_PERCENT;
+ if (percent <= 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 contextSize = lastTurn.contextSize;
+ if (contextSize === undefined) return { error: "no context size" };
+
+ // Resolve the model's context window.
+ const modelName = opts.modelName;
+ if (modelName === undefined || deps.resolveModelInfo === undefined) {
+ return { error: "cannot resolve model info" };
+ }
+ const info = await deps.resolveModelInfo(modelName);
+ if (info?.contextWindow === undefined) {
+ return { error: "model context window unknown" };
+ }
+ const threshold = Math.floor(info.contextWindow * (percent / 100));
+ if (contextSize < threshold) return { error: "threshold not exceeded" };
+ }
+
+ // Shared summarize + fork + replace + emit core (no active guard, no
+ // threshold — those are this caller's policy above). The length check
+ // ("conversation too short to compact") lives inside the core.
+ const outcome = await performCompaction(deps, conversationId, {
+ ...(opts?.keepLastN !== undefined ? { keepLastN: opts.keepLastN } : {}),
+ ...(opts?.modelName !== undefined ? { modelName: opts.modelName } : {}),
+ });
+ if ("error" in outcome) return { error: outcome.error };
+
+ const { summary, newConversationId, messagesSummarized, messagesKept } = outcome;
+ const result: CompactionResult = {
+ summary,
+ newConversationId,
+ messagesSummarized,
+ messagesKept,
+ };
+ return result;
+ },
+ };
}