blob: 66b5640c9814e83d1707bef3bd1213b3682f0b8b [file]
import { randomUUID } from 'node:crypto';
import type { SessionChangedReason, SessionEvent } from '@maka/core';
import type { LlmCallRecord, ToolInvocationRecord } from '@maka/core/usage-stats/types';
import {
AiSdkBackend,
buildDefaultContextBudgetPolicy,
buildLlmHistorySummarizer,
buildProviderOptions,
createProviderRequestCaptureRecorder,
getAIModel,
loadHistoryCompactBlocksFromArtifacts,
loadSynthesisCacheBlocksFromArtifacts,
persistSynthesisCacheBlocksToArtifacts,
recordLlmCall,
recordToolInvocation,
renderPlanExecutionPrompt,
renderInterruptedPlanContext,
renderPlanModePrompt,
resolveSelectedModelContextWindow,
} from '@maka/runtime';
import type {
BackendFactory,
GoalTurnOutcome,
HostCapabilities,
SessionActivityLease,
SessionActivityRegistry,
SessionManager,
ToolArtifactRecorderInput,
ToolResultArchiveReaderInput,
ToolResultArchiveRecorderInput,
buildPricingLookup,
} from '@maka/runtime';
import type { createSqliteModelCallLedger } from '@maka/storage';
import {
type ArtifactStore,
createAttachmentByteReader,
openRuntimeEventPersistence,
persistProviderRequestCaptureArtifact,
type TelemetryRepo,
} from '@maka/storage';
import { WEB_SEARCH_TOOL_NAME } from './web-search/agent-tool.js';
import { errorCode, errorMessage, errorReason } from './chat-readiness.js';
import type { assembleDesktopTools } from './tool-assembly.js';
import type { ToolArtifactPersistence } from './tool-artifact-persistence.js';
import type { createMainGoalWiring } from './goal-wiring.js';
import type { createSubscriptionModelFetch } from './subscription-model-fetch.js';
import type { createSystemPromptMainService } from './system-prompt-main.js';
import { startDesktopSessionTurn, type SessionGoalBoundary } from './session-turn-stream.js';
import {
resolveDesktopBackendToolSurface,
type DesktopBackendToolSurfaceDeps,
} from './desktop-backend-tool-surface.js';
type AssembledTools = ReturnType<typeof assembleDesktopTools>;
type SystemPromptMainService = ReturnType<typeof createSystemPromptMainService>;
type SubscriptionModelFetchBuilder = ReturnType<typeof createSubscriptionModelFetch>;
type GoalWiring = ReturnType<typeof createMainGoalWiring>;
type ModelCallLedger = ReturnType<typeof createSqliteModelCallLedger>;
type PricingLookup = ReturnType<typeof buildPricingLookup>;
type RuntimeCommitStore = Awaited<ReturnType<typeof openRuntimeEventPersistence>>['runtimeCommitStore'];
const SKILL_CATALOG_TRACE_DECISION_LIMIT = 100;
export interface AiSdkBackendFactoryDeps extends DesktopBackendToolSurfaceDeps {
buildSubscriptionModelFetch: SubscriptionModelFetchBuilder;
systemPromptService: SystemPromptMainService;
telemetryRepo: TelemetryRepo;
modelCallLedger: ModelCallLedger;
ensureUsageReady: () => Promise<void>;
artifactStore: ArtifactStore;
desktopSessionSkillHosts: Map<string, HostCapabilities>;
sandboxDiagnosticsProvider: AssembledTools['sandboxDiagnosticsProvider'];
persistToolArtifacts: ToolArtifactPersistence['persistToolArtifacts'];
persistArchivedToolResult: ToolArtifactPersistence['persistArchivedToolResult'];
readArchivedToolResult: ToolArtifactPersistence['readArchivedToolResult'];
runtimeCommitStore: RuntimeCommitStore;
safeSendToRenderer: (channel: string, ...args: unknown[]) => void;
emitSessionsChanged: (reason: SessionChangedReason, sessionId?: string) => void;
getRuntime: () => SessionManager;
getLookupPricing: () => PricingLookup;
}
/**
* Build the real `ai-sdk` backend factory (arch R5). Pure move of main.ts's
* `backends.register('ai-sdk', async (ctx) => …)` closure. Two module-scoped
* seams that resolve AFTER the registration point are injected as accessors:
* `getRuntime` (the SessionManager is constructed after registration) and
* `getLookupPricing` (a mutable pricing lookup reassigned by usage IPC + startup;
* read live per `recordLlmCall`, snapshotted once for the `lookupPricing` field —
* matching the original module-`let` closure semantics exactly).
*/
export function createAiSdkBackendFactory(deps: AiSdkBackendFactoryDeps): BackendFactory {
const {
buildSubscriptionModelFetch,
systemPromptService,
telemetryRepo,
modelCallLedger,
ensureUsageReady,
artifactStore,
desktopSessionSkillHosts,
sandboxDiagnosticsProvider,
persistToolArtifacts,
persistArchivedToolResult,
readArchivedToolResult,
runtimeCommitStore,
safeSendToRenderer,
emitSessionsChanged,
getRuntime,
getLookupPricing,
} = deps;
return async (ctx) => {
await ensureUsageReady();
const toolSurface = await resolveDesktopBackendToolSurface(deps, ctx);
const {
connection,
apiKey,
model,
supportsVision,
collaborationMode,
planState,
activeExecution,
interruptedExecution,
selectedTools,
toolAvailability: backendToolAvailability,
skillHost: backendSkillHost,
admitsAgentChildren,
} = toolSurface;
const modelFetch = buildSubscriptionModelFetch(connection, ctx.sessionId, model);
const memoryPromptSnapshot = await systemPromptService.buildLocalMemoryPromptFragment(ctx.sessionId);
// Legacy child-run backends share the parent sessionId; linked child
// sessions have their own id. Both receive a narrower tool surface without
// the Desktop Skill tool, so only a session's full backend owns this entry.
if (!ctx.tools) desktopSessionSkillHosts.set(ctx.sessionId, backendSkillHost);
const effectivePermissionMode = collaborationMode === 'plan' ? 'explore' : ctx.header.permissionMode;
const sandboxDiagnosticsSnapshot = await sandboxDiagnosticsProvider.resolve({
mode: effectivePermissionMode,
cwd: ctx.header.cwd,
});
return new AiSdkBackend({
sessionId: ctx.sessionId,
header: { ...ctx.header, model, permissionMode: effectivePermissionMode },
appendMessage: ctx.appendMessage ?? ((message) => ctx.store.appendMessage(ctx.sessionId, message)),
readExecutionBoundary: () => ctx.store.readExecutionBoundary!(ctx.sessionId),
createSandboxBoundaryRequest: (request) =>
ctx.store.createSandboxBoundaryRequest!(request),
settleSandboxBoundaryRequest: (request) =>
ctx.store.settleSandboxBoundaryRequest!(request),
connection,
apiKey: apiKey ?? '',
modelId: model,
modelFactory: (input) => getAIModel({ ...input, fetch: modelFetch }),
tools: selectedTools,
sandboxDiagnosticsSnapshot,
planTraceContext: {
mode: collaborationMode,
storeVersion: planState.storeVersion,
...(activeExecution
? {
planId: activeExecution.planId,
proposalId: activeExecution.proposalId,
executionId: activeExecution.executionId,
}
: {}),
},
toolAvailability: backendToolAvailability,
...(admitsAgentChildren
? {
spawnChildAgent: (input) => getRuntime().spawnChildAgent(ctx.sessionId, input),
spawnChildSession: (input) => {
const observation = createLinkedChildEventProjection({
lifecycle: 'created',
safeSendToRenderer,
emitSessionsChanged,
onReady: input.onReady,
onEvent: input.onEvent,
});
return getRuntime().spawnChildSession(ctx.sessionId, {
spawnedBy: {
parentRunId: input.parentRunId,
parentTurnId: input.parentTurnId,
toolCallId: input.toolCallId,
},
agentProfile: input.agentProfile,
prompt: input.prompt,
...(input.swarm ? { swarm: input.swarm } : {}),
abortSignal: input.abortSignal,
onReady: observation.onReady,
onEvent: observation.onEvent,
});
},
prepareChildAgentResume: (sourceRunId) =>
getRuntime().prepareChildAgentResume(ctx.sessionId, sourceRunId),
resumeChildAgent: (input) => {
const observation = createLinkedChildEventProjection({
lifecycle: 'continued',
safeSendToRenderer,
emitSessionsChanged,
onReady: input.onReady,
onEvent: input.onEvent,
});
return getRuntime().resumeChildAgent(ctx.sessionId, {
...input,
onReady: observation.onReady,
onEvent: observation.onEvent,
});
},
retryChildAgent: (input) => {
const observation = createLinkedChildEventProjection({
lifecycle: 'continued',
safeSendToRenderer,
emitSessionsChanged,
onReady: input.onReady,
onEvent: input.onEvent,
});
return getRuntime().retryChildAgent(ctx.sessionId, {
...input,
onReady: observation.onReady,
onEvent: observation.onEvent,
});
},
listChildAgents: () => getRuntime().listChildAgents(ctx.sessionId),
readChildAgentOutput: (input) =>
getRuntime().readChildAgentOutput(ctx.sessionId, input),
}
: {}),
providerOptions: buildProviderOptions(connection, model, ctx.header.thinkingLevel),
contextBudget: buildDefaultContextBudgetPolicy(connection, {
name: 'desktop-default-history-budget',
modelId: model,
}),
systemPrompt: async ({ cwd, emitSkillCatalogTrace }) => {
const base = await systemPromptService.buildBackendSystemPrompt(
ctx.header,
cwd,
{
memoryFragment: memoryPromptSnapshot,
childInstruction: ctx.systemPrompt,
skillBudget: { contextWindow: resolveSelectedModelContextWindow(connection, model) },
host: backendSkillHost,
},
);
const skillReport = systemPromptService.getLastSkillSelectionReport(cwd);
if (skillReport) {
emitSkillCatalogTrace?.('Skill catalog selection completed', {
policyVersion: skillReport.policyVersion,
budgetChars: skillReport.budgetChars,
usedChars: skillReport.usedChars,
totalCount: skillReport.totalCount,
eligibleCount: skillReport.eligibleCount,
advertisedCount: skillReport.advertisedCount,
omittedCount: skillReport.omittedCount,
decisionCount: skillReport.decisions.length,
decisionsTruncated:
skillReport.decisions.length > SKILL_CATALOG_TRACE_DECISION_LIMIT,
decisions: skillReport.decisions
.slice(0, SKILL_CATALOG_TRACE_DECISION_LIMIT)
.map((decision) => ({
skillRef: decision.ref,
reason: decision.reason,
...(decision.rank !== undefined ? { rank: decision.rank } : {}),
})),
});
}
return collaborationMode === 'plan' ? `${base}\n\n${renderPlanModePrompt()}` : base;
},
turnTailPrompt: async ({ cwd, sessionId }) => {
const base = await systemPromptService.buildTurnTailPrompt(cwd, sessionId);
const execution = activeExecution ?? (
collaborationMode === 'plan' ? interruptedExecution : undefined
);
if (!execution) return base;
const proposal = planState.proposals.find(
(candidate) => candidate.proposalId === execution.proposalId,
);
if (!proposal) return base;
const planContext = activeExecution
? renderPlanExecutionPrompt({ proposal, execution: activeExecution })
: renderInterruptedPlanContext({ proposal, execution });
return `${base}\n\n${planContext}`;
},
shellRunContextSummary: ctx.shellRunContextSummary,
lookupPricing: getLookupPricing(),
recordLlmCall: (event: LlmCallRecord) => recordLlmCall({ repo: telemetryRepo, lookupPricing: getLookupPricing() }, event),
// One canonical record, one commit point (#1679): the AgentRun stream is
// the only durable authority, and the ledger is a projection written only
// after the authority holds the record. A failed projection marks the run
// so the Usage read path re-derives it from the stream. Settlement runs
// after the provider call completed and billed, so neither step may fail
// the turn — the seam swallows what is thrown here.
recordModelCallAttempt: async (attempt) => {
await ctx.recordModelCallAttempt?.(attempt);
// Marked before the projection, so a crash between the two still
// leaves a run the repair path can find.
await modelCallLedger
.markRunPendingReprojection(attempt.sessionId, attempt.runId)
.catch(() => undefined);
await modelCallLedger.record(attempt);
await modelCallLedger
.clearPendingReprojection(attempt.sessionId, attempt.runId)
.catch(() => undefined);
},
recordToolInvocation: (event: ToolInvocationRecord) =>
recordToolInvocation(
{ repo: telemetryRepo },
// PR-AGENT-WEB-SEARCH-TOOL-0: scrub the query out of the
// telemetry record. The agent passes the raw user query as
// the tool argument; persisting it in `argsSummary` would
// leak user-derived content into the usage log.
event.toolName === WEB_SEARCH_TOOL_NAME
? { ...event, argsSummary: undefined }
: event,
),
recordToolArtifacts: (event: ToolArtifactRecorderInput) => persistToolArtifacts(ctx.header.cwd, event),
archiveToolResult: (event: ToolResultArchiveRecorderInput) => persistArchivedToolResult(event),
readToolResultArchive: (event: ToolResultArchiveReaderInput) => readArchivedToolResult(event),
readAttachmentBytes: createAttachmentByteReader({ artifactStore, sessionId: ctx.sessionId }),
...(runtimeCommitStore
? { runtimeCommitSink: runtimeCommitStore }
: {}),
supportsVision,
loadHistoryCompact: (event) => loadHistoryCompactBlocksFromArtifacts(artifactStore, event),
loadHistoryCompactCheckpoint: ctx.loadHistoryCompactCheckpoint,
summarizeHistoryCompact: buildLlmHistorySummarizer({
// Reuse the same connection/model the session already drives, so the
// summary stays consistent with the model that will consume it.
resolveModel: () =>
getAIModel({ connection, apiKey: apiKey ?? '', modelId: model, fetch: modelFetch }),
providerOptions: buildProviderOptions(connection, model, ctx.header.thinkingLevel),
}),
loadSynthesisCache: (event) => loadSynthesisCacheBlocksFromArtifacts(artifactStore, event),
writeSynthesisCache: (event) => persistSynthesisCacheBlocksToArtifacts(artifactStore, event, {
onArtifactCreated: (artifact) => {
safeSendToRenderer('artifacts:changed', {
reason: 'created',
artifactId: artifact.id,
sessionId: artifact.sessionId,
ts: Date.now(),
});
},
}),
recordRunTrace: ctx.recordRunTrace,
...(ctx.recordProviderRequestCapture
? {
recordProviderRequestCapture: createProviderRequestCaptureRecorder({
persistArtifact: async (capture) => {
const artifact = await persistProviderRequestCaptureArtifact(artifactStore, {
sessionId: ctx.sessionId,
turnId: capture.turnId,
captureId: capture.captureId,
step: capture.step,
serializedRequest: capture.serializedRequest,
now: Date.now(),
});
return { artifactId: artifact.id };
},
recordLedger: ctx.recordProviderRequestCapture,
}),
recordProviderRequestAttempt: ctx.recordProviderRequestAttempt,
}
: {}),
recordHistoryCompactCheckpoint: ctx.recordHistoryCompactCheckpoint,
loadTurnRuntimeEvents: ctx.loadTurnRuntimeEvents,
allowMidTurnHistoryCompaction: ctx.allowMidTurnHistoryCompaction,
recordActiveFullCompactBlock: ctx.recordActiveFullCompactBlock,
recordSemanticCompactBlock: ctx.recordSemanticCompactBlock,
newId: randomUUID,
now: Date.now,
});
};
}
interface LinkedChildReady {
childSessionId?: string;
turnId: string;
runId?: string;
agentId: string;
agentName: string;
}
/**
* Bridge linked-child events onto the child Session's normal Desktop channel
* while the parent tool call remains the stream consumer.
* Direct user follow-ups already use createSessionStreamer; this closes the
* nested spawn/resume/retry observation gap without inventing a subagent-only
* event protocol.
*/
export function createLinkedChildEventProjection<
Ready extends LinkedChildReady = LinkedChildReady,
>(input: {
lifecycle: 'created' | 'continued';
safeSendToRenderer: (channel: string, ...args: unknown[]) => void;
emitSessionsChanged: (reason: SessionChangedReason, sessionId?: string) => void;
onReady?: (ready: Ready) => void | Promise<void>;
onEvent?: (event: SessionEvent) => void;
}): {
onReady(ready: Ready): Promise<void>;
onEvent(event: SessionEvent): void;
} {
let childSessionId: string | undefined;
let messageAppendBroadcasted = false;
return {
async onReady(ready) {
childSessionId = ready.childSessionId;
if (childSessionId) {
input.emitSessionsChanged(
input.lifecycle === 'created' ? 'created' : 'status-change',
childSessionId,
);
input.emitSessionsChanged('turn-status-change', childSessionId);
}
await input.onReady?.(ready);
},
onEvent(event) {
if (childSessionId) {
input.safeSendToRenderer(`sessions:event:${childSessionId}`, event);
if (!messageAppendBroadcasted) {
input.emitSessionsChanged('message-appended', childSessionId);
messageAppendBroadcasted = true;
}
if (isStatusChangingSessionEvent(event)) {
input.emitSessionsChanged('status-change', childSessionId);
}
if (isTurnStatusChangingSessionEvent(event)) {
input.emitSessionsChanged('turn-status-change', childSessionId);
}
}
input.onEvent?.(event);
},
};
}
interface StreamEventsOptions {
turnId: string;
goalBoundary: SessionGoalBoundary;
activity?: SessionActivityLease;
observeEvent?: (event: SessionEvent) => void;
}
interface StreamEventsResult {
turnId: string;
ok: boolean;
error?: string;
outcome: GoalTurnOutcome;
}
export type StreamEvents = (
sessionId: string,
iterator: AsyncIterable<SessionEvent>,
options: StreamEventsOptions,
) => Promise<StreamEventsResult>;
export interface SessionStreamerDeps {
sessionActivities: SessionActivityRegistry;
goalWiring: GoalWiring;
computerUseOverlay: AssembledTools['computerUseOverlay'];
computerUseTools: AssembledTools['computerUseTools'];
safeSendToRenderer: (channel: string, ...args: unknown[]) => void;
emitSessionsChanged: (reason: SessionChangedReason, sessionId?: string) => void;
interruptActivePlanExecution?: (sessionId: string, reason: string) => Promise<unknown>;
}
function isStatusChangingSessionEvent(event: SessionEvent): boolean {
return event.type === 'sandbox_boundary_request' ||
event.type === 'sandbox_boundary_decision_ack' ||
event.type === 'complete' ||
event.type === 'abort' ||
event.type === 'error';
}
function isTurnStatusChangingSessionEvent(event: SessionEvent): boolean {
return event.type === 'complete' || event.type === 'abort' || event.type === 'error';
}
/**
* Session event fan-out plumbing (arch R5). Pure move of main.ts's `streamEvents`
* plus its two event-classifier helpers. Returns the `streamEvents` function that
* every turn-driving call site in main.ts drives; behavior is identical to the
* in-main.ts original.
*/
export function createSessionStreamer(deps: SessionStreamerDeps): StreamEvents {
const {
sessionActivities,
goalWiring,
computerUseOverlay,
computerUseTools,
safeSendToRenderer,
emitSessionsChanged,
interruptActivePlanExecution,
} = deps;
return function streamEvents(
sessionId: string,
iterator: AsyncIterable<SessionEvent>,
options: StreamEventsOptions,
): Promise<StreamEventsResult> {
let userAppendBroadcasted = false;
const turnId = options.turnId;
const started = startDesktopSessionTurn({
sessionId,
events: iterator,
turnId,
goalBoundary: options.goalBoundary,
activities: sessionActivities,
...(options.activity ? { activity: options.activity } : {}),
beginObservedTurn: (externalSessionId, externalTurnId) =>
goalWiring.coordinator.beginObservedTurn(externalSessionId, externalTurnId),
onEvent: (event) => {
if (!userAppendBroadcasted) {
emitSessionsChanged('message-appended', sessionId);
userAppendBroadcasted = true;
}
safeSendToRenderer(`sessions:event:${sessionId}`, event);
if (isStatusChangingSessionEvent(event)) {
emitSessionsChanged('status-change', sessionId);
}
if (isTurnStatusChangingSessionEvent(event)) {
emitSessionsChanged('turn-status-change', sessionId);
computerUseOverlay.clearForSession(sessionId);
computerUseTools.clearSession(sessionId);
}
options.observeEvent?.(event);
},
onStreamError: (error) => {
const event = {
type: 'error',
id: randomUUID(),
turnId,
ts: Date.now(),
recoverable: false,
code: errorCode(error),
reason: errorReason(error),
message: errorMessage(error),
} satisfies SessionEvent;
safeSendToRenderer(`sessions:event:${sessionId}`, event);
emitSessionsChanged('status-change', sessionId);
emitSessionsChanged('turn-status-change', sessionId);
computerUseOverlay.clearForSession(sessionId);
computerUseTools.clearSession(sessionId);
},
onDrained: async (outcome) => {
emitSessionsChanged('message-appended', sessionId);
if (
interruptActivePlanExecution &&
(outcome.kind === 'aborted' || outcome.kind === 'errored')
) {
await interruptActivePlanExecution(
sessionId,
outcome.kind === 'aborted' ? 'turn_aborted' : `turn_error:${outcome.reason}`,
).catch(() => undefined);
}
},
});
if (started.kind === 'unavailable') throw new Error(started.reason);
return started.completion.then((outcome) => {
const failureReason = outcome.kind === 'errored' || outcome.kind === 'suspended'
? outcome.reason
: undefined;
return {
turnId,
ok: outcome.kind === 'completed',
...(failureReason ? { error: failureReason } : {}),
outcome,
};
});
};
}