/** * Per-connection spectral SDK lifecycle. * * One `AgentBridge` instance per active WebSocket connection. Wraps: * - `createAgentSession` (in-memory session manager — we own persistence in * SQLite; spectral doesn't need to write its own JSONL files). * - `subscribe` listener that translates spectral `AgentSessionEvent`s into our * own `ServerEvent` wire format and pushes them through a caller-supplied * sink. * - `prompt(text)` to send user input. * - `dispose()` for clean teardown on WS close. * * Event mapping (agent → wire): * - `message_start` (assistant) → emit our own `message_start` with a * fresh UUID `messageId`. spectral's AssistantMessage has no stable id field, so * we mint one per turn and use it for all subsequent deltas/tool events * until `message_end`. * - `message_update` w/ inner text_delta / thinking_delta → wire `text_delta` * or `thinking_delta` carrying the `delta` and our `messageId`. * - `tool_execution_start` → wire `tool_call`. * - `tool_execution_end` → wire `tool_result`. * - `message_end` (assistant) → wire `message_end` and persist the * final assembled text + JSONL of every WIRE event we emitted for this * message into SQLite. Content is taken from the final * `AssistantMessage.content` (concatenating `text` blocks); we fall back * to the `text_delta` accumulator when the provider didn't populate * final blocks (e.g. deepseek via openai-compatible). * - `agent_end` → wire `agent_end`. * * Persistence shape: * `events_jsonl` is the newline-delimited JSON of the wire-format * `ServerEvent`s we emitted for this message — NOT raw spectral * `AgentSessionEvent`s. This guarantees the client's `parseWireEvents` * reducer can rehydrate the turn after a refresh using the exact same * reducer it uses for the live broadcast. * * Errors thrown synchronously from `session.prompt()` (e.g. no model * configured) are caught by the caller in `routes.ts` and re-emitted as * `{type:"error"}`. * * History rehydration: * On first attach to a previously-created session (e.g. after a server * restart), the SessionStreamManager passes the full SQLite transcript * to the AgentBridge via `AgentBridgeOptions.history`. Before * `createAgentSession` is called, each message is appended to the * in-memory SessionManager so the LLM sees the full conversation * context from the very first prompt. Multi-turn conversations within * a single spectral session also work normally (the same AgentSession * instance is reused across `prompt()` calls). */ import { type AgentSessionEvent, type CompactionResult } from "../sdk/coding-agent/index.js"; import { type AgentSettings } from "./handlers/agent-settings.js"; import { fetchAllowedModels as defaultFetchAllowedModels } from "../relay/models-fetch.js"; import type { SessionMemorySnapshot } from "./storage.js"; import type { ImageAttachment, ServerEvent, WireMessage } from "./wire.js"; import type { DevProcessRegistry } from "./dev-process-registry.js"; export { bareModelId, calculateCredits, getModelCost, lookupPricing, } from "./agent-bridge/pricing.js"; export { SPECTRAL_PROXY_OPENAI, SPECTRAL_PROXY_USER_MODEL, inferSyntheticContextWindow, inferSyntheticOpenAICompat, inferSyntheticThinkingLevelMap, } from "./agent-bridge/synthetic-model.js"; export { parseWireToolEvents, sanitizeRehydratedBlock, toRestoredMemorySnapshot, } from "./agent-bridge/rehydrate.js"; export { createHeadlessUIContext } from "./agent-bridge/headless-ui.js"; interface AgentBridgeCompactionOptions { customInstructions?: string; phaseBoundary?: boolean; keepRecentTokens?: number; memoryHookMode?: "inline" | "skip"; } /** Optional override for fetchAllowedModels (tests inject a mock). */ export type FetchAllowedModelsFn = typeof defaultFetchAllowedModels; export interface AgentBridgeOptions { /** Stable local session identity forwarded to Responses/cache-aware providers. */ sessionId?: string; /** Session-level primary agent override; undefined means inherit settings. */ activePrimaryAgent?: string | null; /** Genuine upstream Responses response id restored from durable session metadata. */ nativeResponseId?: string; /** Working directory spectral will operate in (used by built-in tools). */ cwd: string; /** spectral config / agent dir. Defaults to ~/.spectral/agent (spectral default). */ agentDir?: string; /** * Backend base URL (no trailing slash). All inference is proxied through * `${backendUrl}/v1` — spectral NEVER reads `~/.spectral/agent/auth.json` and NEVER * holds raw provider API keys; the backend authenticates the machine via * `machineJwt` and uses its own centralized provider credentials. */ backendUrl: string; /** * Bearer token used to authenticate with the backend's `/v1/*` endpoints * AND with the GraphQL `availableAgentModels` query. Issued by * `ensureMachineRegistered` and persisted in `machine.json`. */ machineJwt: string; teamId?: string; /** Sink for outbound wire events. Synchronous; the bridge does not buffer. */ emit: (event: ServerEvent) => void; /** * Called when an assistant message completes, after `message_end` is * emitted. The bridge passes the accumulated text content and the JSONL * of every agent events captured for this message. Caller is expected to * persist this to SQLite. */ onAssistantMessageComplete: (msg: { messageId: string; content: string; eventsJsonl: string; /** Present only for native Responses API messages; never a local wire id. */ nativeResponseId?: string; }) => void; /** * Called when the bridge decides to skip persisting an empty intermediate * message. Caller should clean up any stub row inserted at message_start. */ onAssistantMessageSkipped?: (messageId: string) => void; /** * Called when spectral or an extension throws asynchronously (e.g. from * `prompt`). Caller can choose how to surface to the client. The bridge * also emits `{type:"error"}` on the wire as a fallback. */ onError?: (err: Error) => void; /** * Optional session history to rehydrate into spectral's in-memory session * before the first prompt. Populated from SQLite by the session-stream * manager when attaching to a previously-created session (so the LLM * sees the full conversation transcript from the beginning). Omitted for * brand-new sessions (no history) and for tests that don't need it. */ history?: WireMessage[]; /** * Optional persisted observational-memory snapshot restored after message * history rehydration so the next prompt still sees compacted memory after * reconnect/server restart. */ memorySnapshot?: SessionMemorySnapshot | null; /** * Test seam — override the model whitelist fetch. Production code never * sets this; tests inject a stub returning a synthetic list so `start()` * can run without a live backend. */ fetchAllowedModels?: FetchAllowedModelsFn; /** * Agent-behavior toggles (primary-agent injection, subagent tool). * When omitted, settings are loaded from `settings.json` at `start()` * time. Changes take effect on the next session/bridge — a running * session is not hot-reloaded. */ agentSettings?: AgentSettings; /** Machine-level registry for primary-agent background bash jobs. */ devProcessRegistry?: DevProcessRegistry; /** * Called when cloud-agent discovery reports the cached machine JWT was * rejected by the backend (HTTP 401/403 or a GraphQL auth error). The * caller (SessionStreamManager / serve.ts) should force a machine * re-registration (`ensureMachineRegistered({ forceRefresh: true })`) so * the next turn sees fresh cloud agents instead of looping on the same * dead token. Invoked at most once per `start()` call. * * If the callback returns a Promise, `start()` awaits it so cloud agents * are populated before the session accepts prompts — preventing a race * where the first subagent tool call sees an empty agent list. */ onCloudAgentsAuthRejected?: (reason: string) => void | Promise; } export declare class AgentBridge { private session?; private suppressNextAgentEndForGeneration; private turnGeneration; private sessionManager?; private unsubscribe?; private pending?; private disposed; private opts; /** * spectral's model registry. Built lazily in `start()` so we can resolve a * `modelId` (envelope-supplied or SQLite-persisted) to a concrete `Model` * via `registry.getAll().find(m => m.id === modelId)` before invoking * `session.setModel()`. Phase 3 (Available Models whitelist). */ private modelRegistry?; /** * Raw allowed models list from the backend, preserved in sortOrder. * Used by `getFirstAvailableModelId()` to return the backend-curated * top pick when no explicit model selection is made. */ private allowedModels?; /** * Last `modelId` we successfully applied via `session.setModel()`, or * `undefined` if we never applied one (spectral falls back to its own settings * file in that case, matching pre-Phase-3 behaviour). Tracked so repeated * envelopes carrying the same modelId don't churn spectral's internal state. */ private lastAppliedModelId?; private promptActivityTimer?; private promptReject?; /** Per-turn tool execution backstop timer (see TOOL_EXECUTION_TIMEOUT_MS). */ private toolExecutionTimer?; /** * Number of in-flight `subagent` tool calls. While >0 the parent prompt * inactivity watchdog is paused: the parent is legitimately waiting on the * child agent, and the child carries its own independent deadline * (SPECTRAL_SUBAGENT_TIMEOUT_MS). Pausing (rather than just bumping on * progress events) prevents a silent subagent phase — a long LLM thinking * stretch or a quiet build/test — from tripping the parent watchdog and * aborting the whole prompt mid-delegation. */ private activeSubagents; /** Current model's credit rates (from availableAgentModels), used for token_usage. */ private activeCreditRates; private memoryPhase; private promptMutationPhase; /** * Wall-clock ms of the most recent `auto_retry_start` / `auto_retry_end` * seen by the bridge. spectral's retry backoff/attempt cycles emit no * meaningful progress events, so without this record the inactivity * watchdog would abort a session that is merely waiting out a retry. * Consumed by `isSessionAutoRetrying()` alongside spectral's own * `session.isRetrying` flag. */ private lastAutoRetryActivity; /** * Studio project id resolved from `.aexol/aexol.jsonc` in the session cwd. * Sent as the `X-Project-Id` header on every proxied LLM call so the * backend can attribute agent-session credits to the bound project. */ private boundProjectId; private bindingWatcher?; private bindingWatcherTimer?; /** * Per-toolCallId trailing-edge throttle for `subagent_progress`. Keyed by * the parent subagent tool call id so parallel/chain delegations coalesce * independently. The pending event is the latest accumulated snapshot; the * timer flushes it once the minimum interval has elapsed. */ private subagentProgressThrottle; /** * toolCallId → startedAt captured at `tool_execution_start` so the * terminal `tool_result` can carry the real start time (durations * end-to-end). Entries with no end event are bounded: more than 2000 * orphans means ids are never resolving, so the oldest are cleared. */ private toolStartedAt; constructor(opts: AgentBridgeOptions); /** * Resolve credit rates for a subagent result. Subagents may run a * different model than the parent — see pricing.resolveCreditRatesForModel * for the model-matching rule. */ private resolveSubagentCreditRates; /** * Create the spectral session, wire up subscription, and return. * Throws on creation failure (caller should surface to client). */ start(): Promise; private proxyProviderForAllowedModel; /** * Re-read `.aexol/aexol.jsonc` from the session cwd and update the bound * studio project id. When the binding changes, re-registers synthetic * providers so the `X-Project-Id` header on subsequent LLM calls reflects * the new project. Safe to call repeatedly; no-op when unchanged. */ private refreshProjectBinding; /** * Public entry point for runtime binding changes (e.g. `/agent` bind/unbind). * Re-reads the binding file and updates provider headers in place. */ reloadProjectBinding(): Promise; /** * Hot-reload extension settings for this running session. Delegates to * `AgentSession.reload()`, which re-reads settings.json, rebuilds the * resource loader/extension runtime (tool registry + `ExtensionRunner`) * and re-emits `session_start`. * Callers MUST only invoke this while no turn is in flight: the reload * swaps the tool registry / extension runner, so a tool already * dispatched by the running turn can resolve against the torn-down * runner. (`ExtensionRunner.invalidate()` on its own is only called from * `AgentSession.dispose()`, not from `reload()`.) * No-op before `start()` resolves — there is no session yet, and it reads * fresh settings when it is created. */ reloadExtensions(): Promise; /** * Watch the `.aexol/aexol.jsonc` binding file for external changes so the * X-Project-Id header on synthetic providers stays in sync without a manual * reload. Debounced because `fs.watch` can fire multiple times for a single * save. Errors are swallowed — a broken watcher must never break the agent. */ private setupBindingWatcher; private teardownBindingWatcher; private refreshAllowedModels; /** * Register one synthetic provider per upstream API shape. All non-built-in * models (OpenAI, OpenRouter, DeepSeek, Google, Cerebras, etc.) go to * `${backendUrl}/v1/chat/completions` (OpenAI-compatible API); built-in * UserModel entries route through the same endpoint under a dedicated * provider. * * spectral will send `Authorization: Bearer ${apiKey}` (because `authHeader: true`) * which carries the machine JWT — the only credential the backend trusts. * * The `id` we register is the raw `modelId` (e.g. `gpt-4o-mini`), * which is exactly what the backend expects in `body.model`. */ /** * Determine the input modalities for a model. * * When the admin explicitly marks a model as the default vision model * (isVisionDefault = true), we always include "image" in the input array * regardless of what the backend's capabilities-derived `supportsImages` * flag reports. Custom/built-in models may lack proper modality metadata, * but the admin knows this model can handle images. * * If `supportsImages` is explicitly false, the vision extension will use * the configured default/fallback vision model before the main model call. */ private modelInput; private registerSyntheticProviders; /** * Apply a sticky model selection to the underlying spectral session, if it * differs from what was last applied. No-ops when: * - `modelId` is null/undefined (caller passed nothing to apply) * - the same modelId was already applied to this session * - the registry can't resolve the modelId (emits an `error` wire event * so the client surfaces the failure, but does NOT throw — the caller * is expected to drop the prompt) * * Returns true when the requested model is now in effect (either because * we just applied it or because it was already applied). Returns false * on resolution failure so the caller can skip `prompt()` and avoid * driving spectral against the wrong model. * * Phase 3 (Available Models whitelist). */ /** * Return the modelId of the first available model from the backend * whitelist (preserving the backend's sortOrder, which is the same * ordering the frontend uses in its model picker). Returns `undefined` * when no models are available (e.g. backend unreachable at startup). * * Used by `SessionStreamManager.prompt()` as a defense-in-depth * default when neither the envelope nor SQLite supply a modelId. */ getFirstAvailableModelId(): string | undefined; /** * Return current session context usage from spectral's built-in estimator. * Used after compaction and session start to push updated context-window * stats to the frontend without waiting for the next assistant turn. */ getContextUsage(): { tokens: number | null; contextWindow: number; percent: number | null; } | undefined; /** True while spectral is streaming a response. */ isStreaming(): boolean; /** True while spectral is inside an auto-retry backoff/attempt. */ isRetrying(): boolean; /** True while spectral is doing any agent work (streaming or retrying). */ isBusy(): boolean; getSessionBranch(): Array<{ type: string; id: string; timestamp?: string; message?: unknown; content?: unknown; customType?: string; summary?: unknown; fromId?: string; data?: unknown; details?: unknown; firstKeptEntryId?: string; }>; getMemoryActivity(): { phase: "idle" | "observing" | "compacting" | "reflecting" | "pruning"; inFlight: { observer: boolean; compaction: boolean; reflection: boolean; pruner: boolean; }; }; private updateMemoryPhase; setModel(modelId: string | null | undefined): Promise; /** * Map a frontend reasoning-effort string to spectral's ThinkingLevel. * Frontend sends: xhigh | high | medium | low | minimal | none | undefined * spectral expects: "high" | "medium" | "low" | "minimal" | "off" * * Mapping: * xhigh → high (spectral doesn't have xhigh, default to max) * high → high * medium → medium * low → low * minimal → minimal * none → off * undefined → no-op (spectral keeps whatever it has currently) */ private mapReasoningEffortToThinkingLevel; /** * Set the reasoning/thinking effort level for the next prompt. * Pass `undefined` to leave spectral's current level unchanged. * * The caller (SessionStreamManager) is responsible for persisting the * value to SQLite; this method only applies it to spectral's in-memory session. */ setReasoningEffort(effort: string | undefined): void; /** * Update the session-level primary-agent override WITHOUT restarting the * bridge. The `before_agent_start` hook reads `this.opts.activePrimaryAgent` * on every turn, so mutating it here takes effect on the next user message. * * Pass `null` to clear the override (fall back to settings-level default); * pass `undefined` to disable the session override entirely (also falls * back to settings). The caller (SessionStreamManager / dispatcher) is * responsible for persisting the value to SQLite. */ setActivePrimaryAgent(agent: string | null | undefined): void; /** * Forward a user message to ext. Resolves when the full turn ends. * The caller is responsible for persisting the user message to SQLite * BEFORE invoking this — we don't do it here because spectral's `prompt` may * fail and we still want the user message recorded. * * When `images` is non-empty, each base64-encoded attachment is converted * to a spectral `ImageContent` block and passed as `options.images` to * `session.prompt()`. Image-capable models receive the images natively; * models without image support are handled by the spectral-vision-fallback * extension, which replaces images with text descriptions before the * provider request. */ prompt(text: string, images?: ImageAttachment[]): Promise; /** * Manually compact the session context via spectral's built-in compaction. * The DCP path generates a summary of older conversation history, preserving the * most recent ~20K tokens verbatim. Compaction events are forwarded to * the wire through `handleEvent()`. * * `customInstructions` forwards guidance to compaction hooks so memory * observations can stay relevant to the current task. */ private withPromptTimeout; /** * Whether `ev` represents real agent progress and should reset the * inactivity watchdog. `tool_execution_update` IS included: live stdout/ * stderr churn proves the tool is actively producing output, so a * long-running but legitimately-streaming command must not trip the * watchdog. The separate per-tool TOOL_EXECUTION_TIMEOUT_MS backstop still * catches a tool that runs forever. High-frequency churn events that can * stream indefinitely without progress remain excluded: * - `compaction_delta` * - `token_usage` (wire-only; not a session event, excluded for clarity) * * Meaningful events map the task's allow-list (message_start, text/thinking * deltas via message_update, message_end, agent_end, tool_execution_start, * tool_execution_end → tool_result / subagent_end). */ private isMeaningfulPromptActivity; /** * Reset the inactivity watchdog. Called from `handleEvent` ONLY for * meaningful progress events (see `isMeaningfulPromptActivity`), so a * long-running prompt that is actively streaming text / calling tools / * streaming tool output is never killed by the inactivity timeout. */ private bumpPromptActivity; /** Arm a fresh inactivity watchdog timeout (used by bump/resume paths). */ private schedulePromptActivityTimeout; /** * Whether the session is currently inside spectral's auto-retry machinery. * Two signals, checked in order: * 1. spectral's `isRetrying` flag — true only during the backoff sleep * window, not across the whole backoff/retry cycle; * 2. a recent `auto_retry_start`/`auto_retry_end` bridge sighting — * `lastAutoRetryActivity` covers the gaps between attempts where the * flag has flipped false (belt-and-braces for the windows between one * retry cycle ending and the next attempt emitting its first event). */ private isSessionAutoRetrying; /** * Fire the inactivity watchdog. A session mid auto-retry is NOT stuck — * spectral is deliberately sleeping between provider attempts, which emits * no meaningful progress events. Never abort a retrying session for * inactivity: re-arm a fresh window instead of rejecting the prompt. The * watchdog stays fully armed for non-retrying silence, and * `clearPromptTimers()` still runs at turn end, so this pause cannot leak. */ private firePromptInactivityTimeout; /** * Pause the parent inactivity watchdog while a subagent tool is running. * Idempotent per active subagent: the watchdog stays cleared until the last * in-flight subagent finishes. */ private pausePromptActivityForSubagent; /** Re-arm the inactivity watchdog once no subagents remain in flight. */ private resumePromptActivityAfterSubagent; private clearPromptTimers; /** * Start the per-turn tool execution backstop. Only the first * `tool_execution_start` of a turn arms it; nested tool starts reuse the * running timer. Cleared on `tool_execution_end` / `agent_end` and on * teardown via `clearPromptTimers`. */ private startToolExecutionTimer; private clearToolExecutionTimer; compact(options?: string | AgentBridgeCompactionOptions): Promise; dispose(): void; /** * Emit a wire event AND record it in the pending message's audit log so * the persisted JSONL exactly matches the live broadcast. Use this in * place of `this.opts.emit` for any event that should appear in * `events_jsonl` for the current assistant message. */ private emitAndBuffer; /** * Live-only `subagent_progress` relay with a per-toolCallId trailing-edge * throttle. The bridge receives `tool_execution_update` on every subagent * token/thinking delta, each carrying the full accumulated snapshot. We * coalesce those into at most one frame per * `SUBAGENT_PROGRESS_MIN_INTERVAL_MS`, always keeping (and eventually * flushing) the latest snapshot so the frontend's delta extraction still * sees the complete transcript. */ private emitSubagentProgressThrottled; /** * Flush any pending throttled `subagent_progress` frame immediately (used at * subagent completion and prompt teardown so the latest state is not lost), * then drop the throttle state for that tool call. */ private flushSubagentProgress; /** Flush and clear every pending throttled subagent progress frame. */ private flushAllSubagentProgress; /** * Finalize the current `pending` assistant message: persist its assembled * content + wire events (including any tool_call / tool_result events that * arrived after `message_end`) to SQLite via `onAssistantMessageComplete`. * * Idempotent: marks the pending as `finalized` on first call; subsequent * calls are no-ops (guards against double-persisting when both * `message_start` and `agent_end` would otherwise finalize the same pending). * * Skips empty framing-only messages (messages with no text, thinking, or * tool events that contribute nothing the client can render). */ private finalizePendingMessage; /** * Subscriber callback. Public so tests can drive event flow without * spinning up a real spectral session — production code never calls this * directly; spectral's `subscribe()` does, via the closure registered in * `start()`. */ handleEvent(ev: AgentSessionEvent): void; } //# sourceMappingURL=agent-bridge.d.ts.map