import { existsSync, readFileSync } from "node:fs";
import { parseDocument } from "yaml";
import { parseAlwaysOnConfig } from "../../always-on/config/parseAlwaysOnConfig.js";
import { parseCronConfig } from "../../cron/config/parseCronConfig.js";
import { parseModelConfig } from "../../model/config/parseModelConfig.js";
import { isRecord } from "../../model/config/schema.js";
import { ModelConfigError } from "../../model/protocol/errors.js";
import { getPilotConfigFilePath, getPilotMemoryRootDir, resolvePilotHome } from "../paths.js";
import { sha256, stableStringify } from "./hash.js";
import { mergeConfigSources } from "./merge.js";
import { parseMemoryConfig } from "./parseMemoryConfig.js";
import { parseAdaptersConfig, parseGatewayConfig } from "./parseGatewayConfig.js";
import { parseToolsConfig } from "./parseToolsConfig.js";
import { parseRouterConfig } from "../../router/config/parseRouterConfig.js";
import { redactConfig } from "./redact.js";
import {
PilotConfigError,
type PilotAgentConfig,
type PilotAgentModelSelection,
type PilotConfigDiagnostic,
type PilotExtensionConfig,
type PilotConfigLoadOptions,
type PilotConfigSnapshot,
type PilotConfigSource,
type PilotRawConfig,
} from "./types.js";
const SUPPORTED_SCHEMA_VERSION = 1;
const ENV_CONFIG_OVERRIDES = [
["PILOT_AGENT_MODEL", ["agent", "model"]],
] as const;
export function loadPilotConfig(options: PilotConfigLoadOptions = {}): PilotConfigSnapshot {
const env = options.env ?? process.env;
const loadedAt = new Date();
const diagnostics: PilotConfigDiagnostic[] = [];
const sources: PilotConfigSource[] = [];
const pilotHome = resolvePilotHome(env);
if (env.PILOT_HOME) {
sources.push({
kind: "env",
phase: "bootstrap",
priority: 30,
loadedAt,
contentHash: sha256("PILOT_HOME=<redacted-path>"),
});
}
const defaultConfigPath = getPilotConfigFilePath(pilotHome);
const defaultConfig = readYamlSource(defaultConfigPath, "default", 10, loadedAt, diagnostics, sources);
const envConfig = readEnvOverrides(env);
if (envConfig) {
sources.push({
kind: "env",
phase: "merge",
priority: 30,
loadedAt,
contentHash: sha256(stableStringify(redactConfig(envConfig))),
});
}
const rawConfig = mergeConfigSources(defaultConfig, envConfig) as PilotRawConfig;
validateTopLevel(rawConfig, diagnostics);
const schemaVersion = parseSchemaVersion(rawConfig.schemaVersion, diagnostics);
if (rawConfig.agent === undefined) {
diagnostics.push({
code: "CONFIG_AGENT_MISSING",
severity: "fatal",
message: "Config must contain an agent section.",
path: "agent",
recoverable: false,
});
}
if (rawConfig.model === undefined) {
diagnostics.push({
code: "CONFIG_MODEL_MISSING",
severity: "fatal",
message: "Config must contain a model section.",
path: "model",
recoverable: false,
});
}
throwConfigErrorIfFatal(diagnostics);
const model = parseModel(rawConfig.model, env, diagnostics);
const agent = parseAgent(rawConfig.agent, model, diagnostics);
const extension = parseExtension(rawConfig.extension, diagnostics);
const memory = parseMemoryConfig(rawConfig.memory, diagnostics, getPilotMemoryRootDir(pilotHome), model);
const gateway = parseGatewayConfig(rawConfig.gateway, diagnostics);
const adapters = parseAdaptersConfig(rawConfig.adapters, diagnostics);
const router = parseRouterSection(rawConfig.router, model, diagnostics);
if (router?.scenarios?.default && agent.model.id !== router.scenarios.default.id) {
const previousId = router.scenarios.default.id;
router.scenarios.default = {
id: agent.model.id,
provider: agent.model.provider,
model: agent.model.model,
};
diagnostics.push({
code: "CONFIG_MODEL_CONFLICT",
severity: "warning",
message:
`agent.model (${agent.model.id}) and router.scenarios.default (${previousId}) ` +
`disagree. Using agent.model and overriding router.scenarios.default ` +
`at runtime. Update the yaml to silence this warning.`,
path: "agent.model",
recoverable: true,
});
}
const alwaysOn = parseAlwaysOnConfig(rawConfig.alwaysOn, diagnostics);
const cron = parseCronConfig(rawConfig.cron, diagnostics);
const tools = parseToolsConfig(rawConfig.tools, diagnostics);
throwConfigErrorIfFatal(diagnostics);
const redactedSnapshotConfig = redactConfig({
agent,
model,
extension,
memory,
gateway,
adapters,
router,
alwaysOn,
cron,
tools,
});
return deepFreeze({
version: options.version ?? 1,
schemaVersion,
loadedAt,
contentHash: sha256(stableStringify(redactedSnapshotConfig)),
sources,
diagnostics,
config: {
agent,
model,
extension,
...(memory ? { memory } : {}),
...(gateway ? { gateway } : {}),
...(adapters ? { adapters } : {}),
...(router ? { router } : {}),
...(alwaysOn ? { alwaysOn } : {}),
...(cron ? { cron } : {}),
...(tools ? { tools } : {}),
},
});
}
function readYamlSource(
path: string,
kind: "default" | "project",
priority: number,
loadedAt: Date,
diagnostics: PilotConfigDiagnostic[],
sources: PilotConfigSource[],
): Record<string, unknown> | undefined {
if (!existsSync(path)) {
return undefined;
}
let content: string;
try {
content = readFileSync(path, "utf8");
} catch (error) {
diagnostics.push({
code: "CONFIG_READ_FAILED",
severity: "fatal",
message: `Failed to read ${kind} config.`,
path,
source: { kind, path },
hint: error instanceof Error ? error.message : undefined,
recoverable: false,
});
return undefined;
}
sources.push({
kind,
priority,
loadedAt,
path,
contentHash: sha256(content),
});
try {
const document = parseDocument(content, { prettyErrors: false });
if (document.errors.length > 0) {
diagnostics.push({
code: "CONFIG_YAML_INVALID",
severity: "fatal",
message: `Failed to parse ${kind} config YAML.`,
path,
source: { kind, path },
hint: document.errors.map((yamlError) => yamlError.message).join("; "),
recoverable: false,
});
return undefined;
}
const parsed = document.toJSON();
if (parsed === null || parsed === undefined) {
return {};
}
if (!isRecord(parsed)) {
diagnostics.push({
code: "CONFIG_ROOT_INVALID",
severity: "fatal",
message: `${kind} config root must be an object.`,
path,
source: { kind, path },
recoverable: false,
});
return undefined;
}
return parsed;
} catch (error) {
diagnostics.push({
code: "CONFIG_YAML_INVALID",
severity: "fatal",
message: `Failed to parse ${kind} config YAML.`,
path,
source: { kind, path },
hint: error instanceof Error ? error.message : undefined,
recoverable: false,
});
return undefined;
}
}
function readEnvOverrides(env: Record<string, string | undefined>): Record<string, unknown> | undefined {
const output: Record<string, unknown> = {};
for (const [envName, path] of ENV_CONFIG_OVERRIDES) {
const value = env[envName];
if (!value) {
continue;
}
setPath(output, path, value);
}
return Object.keys(output).length > 0 ? output : undefined;
}
function setPath(target: Record<string, unknown>, path: readonly string[], value: unknown): void {
let cursor = target;
for (let index = 0; index < path.length - 1; index += 1) {
const key = path[index];
const next = cursor[key];
if (!isRecord(next)) {
cursor[key] = {};
}
cursor = cursor[key] as Record<string, unknown>;
}
cursor[path[path.length - 1]] = value;
}
function validateTopLevel(rawConfig: PilotRawConfig, diagnostics: PilotConfigDiagnostic[]): void {
if (!isRecord(rawConfig)) {
diagnostics.push({
code: "CONFIG_ROOT_INVALID",
severity: "fatal",
message: "Config root must be an object.",
recoverable: false,
});
return;
}
if ("pilot" in rawConfig) {
diagnostics.push({
code: "CONFIG_PILOT_SECTION_FORBIDDEN",
severity: "fatal",
message: "YAML config must not contain a pilot section. Use PILOT_HOME for PilotHome.",
path: "pilot",
recoverable: false,
});
}
const allowedKeys = new Set([
"schemaVersion",
"agent",
"model",
"extension",
"memory",
"gateway",
"adapters",
"router",
"alwaysOn",
"cron",
"tools",
"webui",
]);
for (const key of Object.keys(rawConfig)) {
if (!allowedKeys.has(key)) {
diagnostics.push({
code: "CONFIG_UNKNOWN_FIELD",
severity: "warning",
message: `Unknown top-level config field ${key}.`,
path: key,
recoverable: true,
});
}
}
}
function parseAgent(
rawAgent: unknown,
modelConfig: ReturnType<typeof parseModel>,
diagnostics: PilotConfigDiagnostic[],
): PilotAgentConfig {
if (!isRecord(rawAgent)) {
diagnostics.push({
code: "CONFIG_AGENT_INVALID",
severity: "fatal",
message: "Agent config must be an object.",
path: "agent",
recoverable: false,
});
throwConfigErrorIfFatal(diagnostics);
throw new Error("Unreachable after fatal agent config diagnostic.");
}
const model = parseAgentModelSelection(rawAgent.model, "agent.model", modelConfig, diagnostics);
const subagents = parseAgentSubagents(rawAgent.subagents, diagnostics);
const maxContextTokens = readOptionalPositiveInteger(rawAgent.maxContextTokens, "agent.maxContextTokens");
if (rawAgent.fallbackModel !== undefined) {
diagnostics.push({
code: "CONFIG_AGENT_FALLBACK_MODEL_DEPRECATED",
severity: "warning",
message:
"agent.fallbackModel has been removed. Move the value to router.fallback.default to keep fallback behaviour.",
path: "agent.fallbackModel",
recoverable: true,
});
}
throwConfigErrorIfFatal(diagnostics);
return {
model,
...(maxContextTokens !== undefined ? { maxContextTokens } : {}),
...(subagents ? { subagents } : {}),
};
}
function parseAgentSubagents(
value: unknown,
diagnostics: PilotConfigDiagnostic[],
): PilotAgentConfig["subagents"] | undefined {
if (value === undefined) {
return undefined;
}
if (!isRecord(value)) {
throw new PilotConfigError("CONFIG_AGENT_SUBAGENTS_INVALID", "agent.subagents must be an object.");
}
for (const key of Object.keys(value)) {
if (key !== "timeoutMs") {
diagnostics.push({
code: "CONFIG_AGENT_UNKNOWN_FIELD",
severity: "warning",
message: `Unknown agent.subagents field ${key}.`,
path: `agent.subagents.${key}`,
recoverable: true,
});
}
}
return {
timeoutMs: readOptionalPositiveInteger(value.timeoutMs, "agent.subagents.timeoutMs"),
};
}
function parseAgentModelSelection(
value: unknown,
path: string,
modelConfig: ReturnType<typeof parseModel>,
diagnostics: PilotConfigDiagnostic[],
): PilotAgentModelSelection {
if (typeof value !== "string" || value.length === 0) {
diagnostics.push({
code: "CONFIG_AGENT_MODEL_INVALID",
severity: "fatal",
message: `${path} must be a non-empty provider/model string.`,
path,
recoverable: false,
});
throwConfigErrorIfFatal(diagnostics);
throw new Error("Unreachable after fatal agent model diagnostic.");
}
const separatorIndex = value.indexOf("/");
const providerId = separatorIndex >= 0 ? value.slice(0, separatorIndex) : "";
const modelId = separatorIndex >= 0 ? value.slice(separatorIndex + 1) : "";
if (!providerId || !modelId) {
diagnostics.push({
code: "CONFIG_AGENT_MODEL_INVALID",
severity: "fatal",
message: `${path} must use provider/model format.`,
path,
recoverable: false,
});
throwConfigErrorIfFatal(diagnostics);
throw new Error("Unreachable after fatal agent model format diagnostic.");
}
const provider = modelConfig.providers[providerId];
if (!provider) {
diagnostics.push({
code: "CONFIG_AGENT_PROVIDER_NOT_FOUND",
severity: "fatal",
message: `${path} references unknown provider ${providerId}.`,
path,
recoverable: false,
});
throwConfigErrorIfFatal(diagnostics);
throw new Error("Unreachable after fatal agent provider diagnostic.");
}
if (!provider.models[modelId]) {
diagnostics.push({
code: "CONFIG_AGENT_MODEL_NOT_FOUND",
severity: "fatal",
message: `${path} references unknown model ${modelId} for provider ${providerId}.`,
path,
recoverable: false,
});
throwConfigErrorIfFatal(diagnostics);
throw new Error("Unreachable after fatal agent model lookup diagnostic.");
}
return {
id: value,
provider: providerId,
model: modelId,
};
}
function parseSchemaVersion(
value: unknown,
diagnostics: PilotConfigDiagnostic[],
): number {
if (value === undefined) {
diagnostics.push({
code: "CONFIG_SCHEMA_VERSION_MISSING",
severity: "warning",
message: "schemaVersion is missing; assuming schemaVersion 1.",
path: "schemaVersion",
recoverable: true,
});
return SUPPORTED_SCHEMA_VERSION;
}
if (value !== SUPPORTED_SCHEMA_VERSION) {
diagnostics.push({
code: "CONFIG_SCHEMA_VERSION_UNSUPPORTED",
severity: "fatal",
message: `Unsupported schemaVersion ${String(value)}.`,
path: "schemaVersion",
recoverable: false,
});
return SUPPORTED_SCHEMA_VERSION;
}
return SUPPORTED_SCHEMA_VERSION;
}
function parseModel(
rawModel: unknown,
env: Record<string, string | undefined>,
diagnostics: PilotConfigDiagnostic[],
) {
try {
return parseModelConfig(rawModel, { env });
} catch (error) {
if (error instanceof ModelConfigError) {
diagnostics.push({
code: `MODEL_${error.code.toUpperCase()}`,
severity: "fatal",
message: error.message,
path: "model",
hint: stringifyDetails(error.details),
recoverable: false,
});
throwConfigErrorIfFatal(diagnostics);
}
throw error;
}
}
function parseExtension(rawExtension: unknown, diagnostics: PilotConfigDiagnostic[]): PilotExtensionConfig {
const defaults: PilotExtensionConfig = {
builtinPluginsEnabled: {},
includeHookEvents: false,
};
if (rawExtension === undefined) {
return defaults;
}
if (!isRecord(rawExtension)) {
diagnostics.push({
code: "CONFIG_EXTENSION_INVALID",
severity: "fatal",
message: "extension config must be an object.",
path: "extension",
recoverable: false,
});
return defaults;
}
const extension = { ...defaults };
if (rawExtension.builtinPluginsEnabled !== undefined) {
if (isRecord(rawExtension.builtinPluginsEnabled)) {
extension.builtinPluginsEnabled = Object.fromEntries(
Object.entries(rawExtension.builtinPluginsEnabled).filter(
(entry): entry is [string, boolean] => typeof entry[1] === "boolean",
),
);
} else {
diagnostics.push({
code: "CONFIG_EXTENSION_BUILTIN_PLUGINS_INVALID",
severity: "fatal",
message: "extension.builtinPluginsEnabled must be an object of booleans.",
path: "extension.builtinPluginsEnabled",
recoverable: false,
});
}
}
if (rawExtension.includeHookEvents !== undefined) {
if (typeof rawExtension.includeHookEvents === "boolean") {
extension.includeHookEvents = rawExtension.includeHookEvents;
} else {
diagnostics.push({
code: "CONFIG_EXTENSION_INCLUDE_HOOK_EVENTS_INVALID",
severity: "fatal",
message: "extension.includeHookEvents must be a boolean.",
path: "extension.includeHookEvents",
recoverable: false,
});
}
}
for (const key of Object.keys(rawExtension)) {
if (key !== "builtinPluginsEnabled" && key !== "includeHookEvents") {
diagnostics.push({
code: "CONFIG_EXTENSION_UNKNOWN_FIELD",
severity: "warning",
message: `Unknown extension config field ${key}.`,
path: `extension.${key}`,
recoverable: true,
});
}
}
return extension;
}
function stringifyDetails(details: unknown): string | undefined {
if (details === undefined) {
return undefined;
}
return stableStringify(redactConfig(details));
}
function readOptionalPositiveInteger(value: unknown, path: string): number | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
throw new PilotConfigError("CONFIG_INVALID_VALUE", `${path} must be a positive integer.`);
}
return Math.floor(value);
}
function throwConfigErrorIfFatal(diagnostics: PilotConfigDiagnostic[]): void {
const fatalDiagnostics = diagnostics.filter((diagnostic) => diagnostic.severity === "fatal");
if (fatalDiagnostics.length > 0) {
throw new PilotConfigError(
fatalDiagnostics[0].code,
fatalDiagnostics[0].message,
diagnostics,
);
}
}
function parseRouterSection(
rawRouter: unknown,
modelConfig: ReturnType<typeof parseModelConfig>,
diagnostics: PilotConfigDiagnostic[],
) {
const result = parseRouterConfig(rawRouter, modelConfig);
for (const issue of result.diagnostics) {
diagnostics.push({
code: issue.code,
severity: issue.severity,
message: issue.message,
path: issue.path,
hint: issue.hint,
recoverable: issue.severity !== "fatal",
});
}
return result.config;
}
function deepFreeze<T>(value: T): T {
if (value && typeof value === "object") {
Object.freeze(value);
for (const entryValue of Object.values(value as Record<string, unknown>)) {
deepFreeze(entryValue);
}
}
return value;
}