perf(coding-agent): deferred heavy module imports to startup paths

- Lazy-loaded OTEL SDK, HTML export, TTSR, and autoresearch modules.
- Made resolveMemoryBackend async to import backends on demand.
- Replaced backend resolution with direct settings reads for rekey checks.
This commit is contained in:
can1357
2026-06-06 23:25:18 +02:00
parent 5ec6c0e7a7
commit f552ce4e6d
16 changed files with 95 additions and 70 deletions
@@ -771,7 +771,6 @@ function resolveCanonicalIdForModel(
return { id: claudeFamilyAlias, source: claudeFamilyAlias === model.id ? "bundled" : "heuristic" };
}
const heuristicCandidates = getHeuristicCanonicalCandidates(model.id, referenceData.officialIds);
const officialMatches = new Set<string>();
for (const candidate of heuristicCandidates) {
+2 -2
View File
@@ -40,7 +40,6 @@ import {
resolveActiveProjectRegistryPath,
} from "./discovery/helpers";
import { injectOmpExtensionCliRoots } from "./discovery/omp-extension-roots";
import { exportFromFile } from "./export/html";
import { ExtensionRunner } from "./extensibility/extensions/runner";
import type { ExtensionUIContext } from "./extensibility/extensions/types";
import { scheduleMarketplaceAutoUpdate } from "./extensibility/plugins/marketplace-auto-update";
@@ -784,6 +783,7 @@ export async function runRootCommand(
let result: string;
try {
const outputPath = parsedArgs.messages.length > 0 ? parsedArgs.messages[0] : undefined;
const { exportFromFile } = await import("./export/html");
result = await exportFromFile(parsedArgs.export, outputPath);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Failed to export session";
@@ -977,7 +977,7 @@ export async function runRootCommand(
// Both are no-ops when OTEL_EXPORTER_OTLP_ENDPOINT is unset. An empty config
// is enough to enable telemetry — content capture is governed by the
// standard OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT env var.
initTelemetryExport();
await initTelemetryExport();
if (isTelemetryExportEnabled()) {
sessionOptions.telemetry = {};
}
@@ -1,4 +1,16 @@
export * from "../mnemopi";
export type {
MnemopiBackendConfig,
MnemopiLlmMode,
MnemopiProviderOptions,
MnemopiScoping,
} from "../mnemopi/config";
export type {
MnemopiMemoryEditOperation,
MnemopiMemoryEditOptions,
MnemopiMemoryEditResult,
MnemopiSessionState,
MnemopiSessionStateOptions,
} from "../mnemopi/state";
export * from "./local-backend";
export * from "./off-backend";
export * from "./resolve";
@@ -1,6 +1,4 @@
import type { Settings } from "../config/settings";
import { hindsightBackend } from "../hindsight";
import { mnemopiBackend } from "../mnemopi";
import { localBackend } from "./local-backend";
import { offBackend } from "./off-backend";
import type { MemoryBackend } from "./types";
@@ -18,10 +16,10 @@ import type { MemoryBackend } from "./types";
* `memories.enabled` remains accepted only as a legacy migration input. Once
* a config is loaded, `memory.backend` is the sole runtime selector.
*/
export function resolveMemoryBackend(settings: Settings): MemoryBackend {
export async function resolveMemoryBackend(settings: Settings): Promise<MemoryBackend> {
const id = settings.get("memory.backend");
if (id === "hindsight") return hindsightBackend;
if (id === "mnemopi") return mnemopiBackend;
if (id === "hindsight") return (await import("../hindsight/backend")).hindsightBackend;
if (id === "mnemopi") return (await import("../mnemopi/backend")).mnemopiBackend;
if (id === "local") return localBackend;
return offBackend;
}
@@ -1,7 +1,7 @@
/**
* Memory backend abstraction.
*
* Backends are mutually exclusive — `resolveMemoryBackend(settings)` returns
* Backends are mutually exclusive — `await resolveMemoryBackend(settings)` resolves
* exactly one. Implementations MUST be self-contained: they own the per-session
* state they create in `start()` and tear it down on `clear()`.
*/
@@ -13,7 +13,6 @@ import { Loader, Markdown, padding, Spacer, Text, visibleWidth } from "@oh-my-pi
import { formatDuration, Snowflake } from "@oh-my-pi/pi-utils";
import { $ } from "bun";
import { shouldEnableAppendOnlyContext } from "../../config/append-only-context-mode";
import { loadCustomShare } from "../../export/custom-share";
import type { CompactOptions } from "../../extensibility/extensions/types";
import {
diffMentalModelContent,
@@ -131,6 +130,7 @@ export class CommandController {
}
try {
const { loadCustomShare } = await import("../../export/custom-share");
const customShare = await loadCustomShare();
if (customShare) {
const loader = new BorderedLoader(this.ctx.ui, theme, "Sharing...");
@@ -465,7 +465,7 @@ export class CommandController {
const argumentText = text.slice(7).trim();
const action = argumentText.split(/\s+/, 1)[0]?.toLowerCase() || "view";
const agentDir = this.ctx.settings.getAgentDir();
const backend = resolveMemoryBackend(this.ctx.settings);
const backend = await resolveMemoryBackend(this.ctx.settings);
if (action === "view") {
const payload = await backend.buildDeveloperInstructions(agentDir, this.ctx.settings, this.ctx.session);
@@ -7,7 +7,6 @@ import { getAgentDbPath, getProjectDir, normalizePathForComparison } from "@oh-m
import { getRoleInfo } from "../../config/model-registry";
import { formatModelSelectorValue } from "../../config/model-resolver";
import { settings } from "../../config/settings";
import { DebugSelectorComponent } from "../../debug";
import { disableProvider, enableProvider } from "../../discovery";
import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
import {
@@ -1080,7 +1079,8 @@ export class SelectorController {
});
}
showDebugSelector(): void {
async showDebugSelector(): Promise<void> {
const { DebugSelectorComponent } = await import("../../debug");
this.showSelector(done => {
const selector = new DebugSelectorComponent(this.ctx, done);
return { component: selector, focus: selector };
@@ -2844,8 +2844,8 @@ export class InteractiveMode implements InteractiveModeContext {
}
}
showDebugSelector(): void {
this.#selectorController.showDebugSelector();
async showDebugSelector(): Promise<void> {
await this.#selectorController.showDebugSelector();
}
showSessionObserver(): void {
+1 -1
View File
@@ -269,7 +269,7 @@ export interface InteractiveModeContext {
handleSessionDeleteCommand(): Promise<void>;
showOAuthSelector(mode: "login" | "logout", providerId?: string): Promise<void>;
showHookConfirm(title: string, message: string): Promise<boolean>;
showDebugSelector(): void;
showDebugSelector(): Promise<void>;
showSessionObserver(): void;
resetObserverRegistry(): void;
+20 -22
View File
@@ -36,7 +36,6 @@ import {
} from "@oh-my-pi/pi-utils";
import chalk from "chalk";
import { type AsyncJob, AsyncJobManager, isBackgroundJobSupportEnabled } from "./async";
import { createAutoresearchExtension } from "./autoresearch";
import { loadCapability } from "./capability";
import { type Rule, ruleCapability, setActiveRules } from "./capability/rule";
import { bucketRules } from "./capability/rule-buckets";
@@ -57,7 +56,6 @@ import { resolveConfigValue } from "./config/resolve-config-value";
import { initializeWithSettings } from "./discovery";
import { disposeAllKernelSessions, disposeKernelSessionsByOwner } from "./eval/py/executor";
import { defaultEvalSessionId } from "./eval/session-id";
import { TtsrManager } from "./export/ttsr";
import {
type CustomCommandsLoadResult,
type LoadedCustomCommand,
@@ -90,7 +88,7 @@ import { LocalProtocolHandler, type LocalProtocolOptions } from "./internal-urls
import { LSP_STARTUP_EVENT_CHANNEL, type LspStartupEvent } from "./lsp/startup-events";
import { discoverAndLoadMCPTools, MCPManager, type MCPToolsLoadResult } from "./mcp";
import { resolveMemoryBackend } from "./memory-backend";
import { getMnemopiSessionState, type MnemopiSessionState } from "./mnemopi/state";
import type { MnemopiSessionState } from "./mnemopi/state";
import asyncResultTemplate from "./prompts/tools/async-result.md" with { type: "text" };
import { AgentRegistry, MAIN_AGENT_ID } from "./registry/agent-registry";
import {
@@ -1150,6 +1148,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// Discover rules and bucket them in one pass to avoid repeated scans over large rule sets.
const { ttsrManager, rulebookRules, alwaysApplyRules } = await logger.time("discoverTtsrRules", async () => {
const { TtsrManager } = await import("./export/ttsr");
const ttsrSettings = settings.getGroup("ttsr");
const ttsrManager = new TtsrManager(ttsrSettings);
const rulesResult =
@@ -1295,7 +1294,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
session ? session.trackEvalExecution(execution, abortController) : execution,
getSessionId: () => sessionManager.getSessionId?.() ?? null,
getHindsightSessionState: () => session?.getHindsightSessionState(),
getMnemopiSessionState: () => getMnemopiSessionState(session),
getMnemopiSessionState: () => session?.getMnemopiSessionState(),
getAgentId: () => resolvedAgentId,
getToolByName: name => session?.getToolByName(name),
agentRegistry,
@@ -1472,7 +1471,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
}
const inlineExtensions: ExtensionFactory[] = options.extensions ? [...options.extensions] : [];
inlineExtensions.push(createAutoresearchExtension);
inlineExtensions.push((await import("./autoresearch")).createAutoresearchExtension);
if (customTools.length > 0) {
inlineExtensions.push(createCustomToolsExtension(customTools));
}
@@ -1607,9 +1606,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// `ExtensionToolWrapper` installed below is the only place the per-tool approval gate runs.
// A conditional runner means the approval system silently disappears for users with no
// extensions, contradicting non-yolo `tools.approvalMode` settings without feedback.
// (Today `createAutoresearchExtension` is unconditionally pushed below, so this scenario
// is unreachable; the unconditional construction makes that invariant explicit instead of
// implicit, so a future change to make autoresearch optional cannot silently re-open the hole.)
// (The builtin autoresearch extension is unconditionally loaded above, so this scenario
// is unreachable; unconditional runner construction keeps that invariant explicit and
// prevents future optional extensions from silently re-opening the hole.)
const extensionRunner: ExtensionRunner = new ExtensionRunner(
extensionsResult.extensions,
extensionsResult.runtime,
@@ -1749,7 +1748,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const promptTools = buildSystemPromptToolMetadata(tools, {
search_tool_bm25: { description: renderSearchToolBm25Description(discoverableToolsForDesc) },
});
const memoryBackend = resolveMemoryBackend(settings);
const memoryBackend = await resolveMemoryBackend(settings);
const memoryInstructions = await memoryBackend.buildDeveloperInstructions(agentDir, settings, session);
// Build combined append prompt: memory instructions + MCP server instructions
@@ -2267,19 +2266,18 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
}
}
logger.time("startMemoryStartupTask", () =>
Promise.resolve(
resolveMemoryBackend(settings).start({
session,
settings,
modelRegistry,
agentDir,
taskDepth,
parentHindsightSessionState: options.parentHindsightSessionState,
parentMnemopiSessionState: options.parentMnemopiSessionState,
}),
),
);
logger.time("startMemoryStartupTask", async () => {
const memoryBackend = await resolveMemoryBackend(settings);
await memoryBackend.start({
session,
settings,
modelRegistry,
agentDir,
taskDepth,
parentHindsightSessionState: options.parentHindsightSessionState,
parentMnemopiSessionState: options.parentMnemopiSessionState,
});
});
// Wire MCP manager callbacks to session for reactive tool updates.
// Skip when reusing a parent's manager — the parent owns the callbacks.
@@ -128,7 +128,6 @@ import {
} from "../eval/py/executor";
import { defaultEvalSessionId } from "../eval/session-id";
import { type BashResult, executeBash as executeBashCommand } from "../exec/bash-executor";
import { exportSessionToHtml } from "../export/html";
import type { TtsrManager, TtsrMatchContext } from "../export/ttsr";
import type { LoadedCustomCommand } from "../extensibility/custom-commands";
import type { CustomTool, CustomToolContext } from "../extensibility/custom-tools/types";
@@ -2967,14 +2966,14 @@ export class AgentSession {
}
#rekeyHindsightMemoryForCurrentSessionId(): void {
if (resolveMemoryBackend(this.settings).id !== "hindsight") return;
if (this.settings.get("memory.backend") !== "hindsight") return;
const sid = this.agent.sessionId;
if (!sid) return;
this.getHindsightSessionState()?.setSessionId(sid);
}
#rekeyMnemopiMemoryForCurrentSessionId(): void {
if (resolveMemoryBackend(this.settings).id !== "mnemopi") return;
if (this.settings.get("memory.backend") !== "mnemopi") return;
const sid = this.agent.sessionId;
if (!sid) return;
this.getMnemopiSessionState()?.setSessionId(sid);
@@ -2982,14 +2981,14 @@ export class AgentSession {
/** New session file: reset auto-recall / retain-threshold counters for the new transcript. */
#resetHindsightConversationTrackingIfHindsight(): void {
if (resolveMemoryBackend(this.settings).id !== "hindsight") return;
if (this.settings.get("memory.backend") !== "hindsight") return;
const state = this.getHindsightSessionState();
if (!state || state.aliasOf) return;
state.resetConversationTracking();
}
#resetMnemopiConversationTrackingIfMnemopi(): void {
if (resolveMemoryBackend(this.settings).id !== "mnemopi") return;
if (this.settings.get("memory.backend") !== "mnemopi") return;
const state = this.getMnemopiSessionState();
if (!state || state.aliasOf) return;
state.resetConversationTracking();
@@ -3670,7 +3669,7 @@ export class AgentSession {
}
async #buildSystemPromptForAgentStart(promptText: string): Promise<string[]> {
const backend = resolveMemoryBackend(this.settings);
const backend = await resolveMemoryBackend(this.settings);
if (!backend.beforeAgentStartPrompt) return this.#baseSystemPrompt;
try {
@@ -6096,7 +6095,7 @@ export class AgentSession {
messagesToSummarize: AgentMessage[];
turnPrefixMessages: AgentMessage[];
}): Promise<string | undefined> {
const backend = resolveMemoryBackend(this.settings);
const backend = await resolveMemoryBackend(this.settings);
if (!backend.preCompactionContext) return undefined;
const messages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
try {
@@ -9588,6 +9587,7 @@ export class AgentSession {
*/
async exportToHtml(outputPath?: string): Promise<string> {
const themeName = getCurrentThemeName();
const { exportSessionToHtml } = await import("../export/html");
return exportSessionToHtml(this.sessionManager, this.state, { outputPath, themeName });
}
@@ -934,7 +934,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
allowArgs: true,
handle: async (command, runtime) => {
const verb = (command.args.trim().split(/\s+/)[0] ?? "").toLowerCase() || "view";
const backend = resolveMemoryBackend(runtime.settings);
const backend = await resolveMemoryBackend(runtime.settings);
switch (verb) {
case "view": {
const payload = await backend.buildDeveloperInstructions(
+25 -7
View File
@@ -23,11 +23,7 @@
* `sdk-trace-base@2.7` exports cleanly on Bun.
*/
import { logger, postmortem } from "@oh-my-pi/pi-utils";
import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
import type * as TraceNode from "@opentelemetry/sdk-trace-node";
/**
* Periodic flush interval. A long-lived `omp` process (the ACP server is
@@ -36,7 +32,8 @@ import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
*/
const FLUSH_INTERVAL_MS = 30_000;
let provider: NodeTracerProvider | undefined;
let provider: TraceNode.NodeTracerProvider | undefined;
let initPromise: Promise<void> | undefined;
/**
* Whether {@link initTelemetryExport} registered a real provider. The CLI uses
@@ -53,8 +50,10 @@ export function isTelemetryExportEnabled(): boolean {
* the OTEL kill-switches are engaged), so it is safe to call unconditionally at
* startup.
*/
export function initTelemetryExport(): void {
export async function initTelemetryExport(): Promise<void> {
if (provider) return;
if (initPromise) return initPromise;
// The OTEL env contract parses booleans and enum lists case-insensitively, so
// OTEL_SDK_DISABLED=TRUE and OTEL_TRACES_EXPORTER=None must also disable export.
if (process.env.OTEL_SDK_DISABLED?.trim().toLowerCase() === "true") return;
@@ -77,6 +76,25 @@ export function initTelemetryExport(): void {
return;
}
initPromise = registerProvider();
return initPromise;
}
async function registerProvider(): Promise<void> {
const [
{ AsyncLocalStorageContextManager },
{ OTLPTraceExporter },
{ resourceFromAttributes },
{ BatchSpanProcessor },
{ NodeTracerProvider },
] = await Promise.all([
import("@opentelemetry/context-async-hooks"),
import("@opentelemetry/exporter-trace-otlp-proto"),
import("@opentelemetry/resources"),
import("@opentelemetry/sdk-trace-base"),
import("@opentelemetry/sdk-trace-node"),
]);
// The exporter reads endpoint/headers/timeout from OTEL_EXPORTER_OTLP_* itself,
// so there is nothing to thread through here.
const exporter = new OTLPTraceExporter();
@@ -11,10 +11,10 @@ describe("resolveMemoryBackend", () => {
resetSettingsForTest();
});
it("returns the hindsight backend when memory.backend is hindsight, regardless of legacy memories.enabled", () => {
it("returns the hindsight backend when memory.backend is hindsight, regardless of legacy memories.enabled", async () => {
const a = Settings.isolated({ "memory.backend": "hindsight", "memories.enabled": false });
const b = Settings.isolated({ "memory.backend": "hindsight", "memories.enabled": true });
expect(resolveMemoryBackend(a).id).toBe("hindsight");
expect(resolveMemoryBackend(b).id).toBe("hindsight");
expect((await resolveMemoryBackend(a)).id).toBe("hindsight");
expect((await resolveMemoryBackend(b)).id).toBe("hindsight");
});
});
@@ -35,7 +35,7 @@ const server = Bun.serve({
process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = `http://localhost:${server.port}/v1/traces`;
process.env.OTEL_SERVICE_NAME = "oh-my-pi-export-probe";
initTelemetryExport();
await initTelemetryExport();
if (!isTelemetryExportEnabled()) {
console.error("PROBE: provider did not register");
await server.stop(true);
@@ -33,45 +33,45 @@ afterEach(() => {
});
describe("initTelemetryExport gating", () => {
it("stays disabled when no OTLP endpoint is configured", () => {
initTelemetryExport();
it("stays disabled when no OTLP endpoint is configured", async () => {
await initTelemetryExport();
expect(isTelemetryExportEnabled()).toBe(false);
});
it("stays disabled when OTEL_SDK_DISABLED=true even with an endpoint", () => {
it("stays disabled when OTEL_SDK_DISABLED=true even with an endpoint", async () => {
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "http://localhost:4318";
process.env.OTEL_SDK_DISABLED = "true";
initTelemetryExport();
await initTelemetryExport();
expect(isTelemetryExportEnabled()).toBe(false);
});
it("stays disabled when OTEL_TRACES_EXPORTER=none even with an endpoint", () => {
it("stays disabled when OTEL_TRACES_EXPORTER=none even with an endpoint", async () => {
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "http://localhost:4318";
process.env.OTEL_TRACES_EXPORTER = "none";
initTelemetryExport();
await initTelemetryExport();
expect(isTelemetryExportEnabled()).toBe(false);
});
it("declines unsupported OTLP protocols instead of misrouting spans", () => {
it("declines unsupported OTLP protocols instead of misrouting spans", async () => {
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "http://localhost:4317";
process.env.OTEL_EXPORTER_OTLP_PROTOCOL = "grpc";
initTelemetryExport();
await initTelemetryExport();
expect(isTelemetryExportEnabled()).toBe(false);
process.env.OTEL_EXPORTER_OTLP_TRACES_PROTOCOL = "http/json";
initTelemetryExport();
await initTelemetryExport();
expect(isTelemetryExportEnabled()).toBe(false);
});
it("honors the kill-switches case-insensitively per the OTEL env contract", () => {
it("honors the kill-switches case-insensitively per the OTEL env contract", async () => {
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "http://localhost:4318";
process.env.OTEL_SDK_DISABLED = "TRUE";
initTelemetryExport();
await initTelemetryExport();
expect(isTelemetryExportEnabled()).toBe(false);
delete process.env.OTEL_SDK_DISABLED;
process.env.OTEL_TRACES_EXPORTER = "otlp,None";
initTelemetryExport();
await initTelemetryExport();
expect(isTelemetryExportEnabled()).toBe(false);
});
});