import { existsSync, readFileSync } from 'fs'; import { dirname, isAbsolute, normalize, relative, resolve } from 'path'; import type { FrameworkConfig, ConfigStep, ConfigConditionalStep, ProviderPackConfig, ToolPoliciesConfig, ToolPolicyRule, MCPGlobalServerConfig, } from './types'; import { loadValidatedConfig } from './load'; import { mcpConfigWarnings, workflowDefaultAgentWarning, } from './validate'; import type { Intent } from '../intent/intent'; import type { AgentConfig } from '../agent/agent'; import type { PipelineConfigV2 } from '../pipeline/pipeline'; import type { PipelineStep } from '../pipeline/steps'; import type { Tool, ToolSchemaMetadata } from '../tool/tool'; import { loadPromptFile } from '../utils/prompt-loader'; import type { Workflow } from '../workflow/manager'; import type { ProviderConfig } from '../platform/provider'; import { Schema, ParseResult } from 'effect'; // ============================================================================= // Pipeline V2 Function Registry // ============================================================================= // Module-level registry for config-resolvable functions const functionRegistry = new Map unknown | Promise>(); /** * Register a function for use in config-defined pipelines. * Must be called before loading config that references this function. */ export function registerPipelineFunction( id: string, fn: (ctx: any) => unknown | Promise ): void { functionRegistry.set(id, fn); } /** * Clear all registered pipeline functions. */ export function clearPipelineFunctions(): void { functionRegistry.clear(); } /** * Load configuration from a file */ export function loadConfig(filePath: string): FrameworkConfig { return loadValidatedConfig(filePath); } /** * Validate config structure */ export function validateConfig(config: FrameworkConfig): void { if (Object.prototype.hasOwnProperty.call(config, 'pipelines')) { throw new Error( 'The legacy top-level "pipelines" configuration has been removed. Migrate it to "pipelinesV2" or define a native workflow.', ); } const hasDefaultSystemMessage = Boolean(config.defaultSystemMessage); const policies = getPolicyConfig(config); if (config.agentDirs !== undefined) { if (!Array.isArray(config.agentDirs) || config.agentDirs.some((dir) => typeof dir !== 'string')) { throw new Error('agentDirs must be an array of directory paths'); } } if (config.intents) { for (const intent of config.intents) { if (!intent.id) { throw new Error('Intent must have an id'); } if (!intent.utterances || intent.utterances.length === 0) { throw new Error(`Intent "${intent.id}" must have at least one utterance`); } if (!intent.action) { throw new Error(`Intent "${intent.id}" must have an action`); } if (!intent.action.type || !intent.action.target) { throw new Error(`Intent "${intent.id}" action must have type and target`); } } } if (config.agents) { for (const agent of config.agents) { if (!agent.id) { throw new Error('Agent must have an id'); } if (!agent.systemMessage && !hasDefaultSystemMessage) { throw new Error(`Agent "${agent.id}" must have a systemMessage or defaultSystemMessage must be configured`); } if (!agent.platform) { throw new Error(`Agent "${agent.id}" must have a platform`); } if (!agent.model) { throw new Error(`Agent "${agent.id}" must have a model`); } } } if (config.tools) { for (const tool of config.tools) { if (!tool.id) { throw new Error('Tool must have an id'); } if (!tool.name) { throw new Error(`Tool "${tool.id}" must have a name`); } if (!tool.description) { throw new Error(`Tool "${tool.id}" must have a description`); } const schemaMetadata = tool.schema?.metadata; if (tool.strict && !schemaMetadata) { throw new Error(`Tool "${tool.id}" requires schema metadata when strict mode is enabled`); } if (schemaMetadata) { validateSchemaMetadata(tool.id, schemaMetadata); } } } // Validate routing configuration if (config.routing) { // defaultAgent must be a string if present if (config.routing.defaultAgent !== undefined && typeof config.routing.defaultAgent !== 'string') { throw new Error('Routing defaultAgent must be a string'); } // rules must be an array if (!Array.isArray(config.routing.rules)) { throw new Error('Routing rules must be an array'); } // Validate each rule for (const rule of config.routing.rules) { if (!rule.id) { throw new Error('Routing rule must have an id'); } if (!rule.agent) { throw new Error(`Routing rule "${rule.id}" must have an agent`); } // Don't throw on unknown agent - just warn at runtime (per project decision) } } // Validate workflow configuration if (config.workflows) { for (const [workflowName, workflowConfig] of Object.entries(config.workflows)) { if (!workflowConfig.defaultAgent) { throw new Error(`Workflow "${workflowName}" must have a defaultAgent`); } if (!workflowConfig.agents || !Array.isArray(workflowConfig.agents)) { throw new Error(`Workflow "${workflowName}" must have an agents array`); } if (workflowConfig.agents.length === 0) { throw new Error(`Workflow "${workflowName}" must have at least one agent`); } const warning = workflowDefaultAgentWarning(workflowName, workflowConfig); if (warning) console.warn(warning); } } // Validate persistence configuration if (config.persistence) { const validAdapters = ['postgres', 'sqlite']; if (!validAdapters.includes(config.persistence.adapter)) { throw new Error( `Invalid persistence adapter "${config.persistence.adapter}". Valid adapters are: ${validAdapters.join(', ')}` ); } } if (config.mcpServers) { for (const warning of mcpConfigWarnings(config.mcpServers, config.agents)) { console.warn(warning); } } if (policies) { const toolIds = new Set((config.tools ?? []).map((tool) => tool.id)); const intentIds = new Set((config.intents ?? []).map((intent) => intent.id)); const agentIds = new Set((config.agents ?? []).map((agent) => agent.id)); validateToolPolicies(policies, toolIds, intentIds, agentIds); } } /** * Extract intents from config */ export function extractIntents(config: FrameworkConfig): Intent[] { return config.intents || []; } /** * Extract agents from config * @param config - Framework configuration * @param basePath - Optional base path for resolving relative prompt file paths (usually config file path) */ export function extractAgents(config: FrameworkConfig, basePath?: string): AgentConfig[] { const agents = config.agents || []; const defaultSystemMessage = config.defaultSystemMessage ? loadPromptFile(config.defaultSystemMessage, basePath, false) : undefined; // If basePath is provided, resolve prompt file paths // Paths are sandboxed to the config file's directory to prevent path traversal attacks if (basePath && agents.length > 0) { return agents.map(agent => ({ ...agent, systemMessage: typeof agent.systemMessage === 'string' ? loadPromptFile(agent.systemMessage, basePath, false) : agent.systemMessage ?? defaultSystemMessage ?? '', })); } return agents.map(agent => ({ ...agent, systemMessage: agent.systemMessage ?? defaultSystemMessage ?? '', })); } function isPathWithinSandbox(filePath: string, sandboxDir: string): boolean { const normalizedFilePath = normalize(resolve(filePath)); const normalizedSandbox = normalize(resolve(sandboxDir)); const relativePath = relative(normalizedSandbox, normalizedFilePath); if (!relativePath || relativePath === '.' || relativePath === './') { return true; } if (relativePath.startsWith('..') || isAbsolute(relativePath)) { return false; } return true; } /** * Validate that config-defined agents do not reference markdown files that also contain * YAML frontmatter, which would be ambiguous with standalone agent definition files. */ export function validateNoAmbiguousPromptFiles(configAgents: AgentConfig[], basePath?: string): void { for (const agent of configAgents) { const systemMessage = agent.systemMessage; if (typeof systemMessage !== 'string' || !systemMessage.toLowerCase().endsWith('.md')) { continue; } const sandboxDir = basePath ? dirname(basePath) : process.cwd(); let filePath: string; if (isAbsolute(systemMessage)) { throw new Error(`Absolute paths are not allowed for security reasons. Use a relative path instead. Attempted path: ${systemMessage}`); } if (basePath) { filePath = resolve(sandboxDir, systemMessage); } else { filePath = resolve(process.cwd(), systemMessage); } filePath = normalize(filePath); if (!isPathWithinSandbox(filePath, sandboxDir)) { throw new Error(`Path traversal detected. File path "${systemMessage}" resolves outside the allowed directory "${sandboxDir}"`); } if (!existsSync(filePath)) { continue; } const content = readFileSync(filePath, 'utf-8'); if (content.startsWith('---\n') || content.startsWith('---\r\n')) { throw new Error( `Agent "${agent.id}" references "${systemMessage}" as systemMessage, but that file contains YAML frontmatter. A .md file should be either a standalone agent definition (with frontmatter) or a plain prompt file (without frontmatter), not both.` ); } } } /** * Extract tools from config (without execute functions) * * Config-loaded tools only have metadata (JSON Schema) - no Effect Schema. * They become fully typed tools when execute functions are registered at runtime. */ export function extractTools(config: FrameworkConfig): Omit[] { return (config.tools || []).map(tool => { // Build the tool definition, handling optional schema const toolDef: Omit = { id: tool.id, name: tool.name, description: tool.description, strict: tool.strict, }; // Only include schema if metadata exists // Config tools only have metadata, not Effect Schema types if (tool.schema?.metadata) { (toolDef as any).schema = { metadata: tool.schema.metadata, }; } return toolDef; }) as Omit[]; } /** * Extract tool access policies from config. */ export function extractToolPolicies(config: FrameworkConfig): ToolPoliciesConfig | undefined { const policies = getPolicyConfig(config); if (!policies) { return undefined; } return { default: cloneToolPolicyRule(policies.default), intents: cloneScopedPolicyRules(policies.intents), agents: cloneScopedPolicyRules(policies.agents), overrides: policies.overrides?.map((override) => ({ ...cloneToolPolicyRule(override), id: override.id, override: true, target: { intentId: override.target.intentId, agentId: override.target.agentId, }, })), }; } function validateSchemaMetadata(toolId: string, metadata: ToolSchemaMetadata): void { if (metadata.type !== 'object') { throw new Error(`Tool "${toolId}" schema metadata must be type "object"`); } if (!metadata.properties || typeof metadata.properties !== 'object') { throw new Error(`Tool "${toolId}" schema metadata must include properties`); } } function getPolicyConfig(config: FrameworkConfig): ToolPoliciesConfig | undefined { if (config.policies && config.toolPolicies) { throw new Error('Config cannot define both "policies" and "toolPolicies". Use only one policy section'); } return config.policies ?? config.toolPolicies; } function validateToolPolicies( policies: ToolPoliciesConfig, toolIds: Set, intentIds: Set, agentIds: Set ): void { validatePolicyRule(policies.default, 'Default tool policy', toolIds); for (const [intentId, policy] of Object.entries(policies.intents ?? {})) { if (!intentIds.has(intentId)) { throw new Error(`Tool policy references unknown intent "${intentId}"`); } validatePolicyRule(policy, `Tool policy for intent "${intentId}"`, toolIds); } for (const [agentId, policy] of Object.entries(policies.agents ?? {})) { if (!agentIds.has(agentId)) { throw new Error(`Tool policy references unknown agent "${agentId}"`); } validatePolicyRule(policy, `Tool policy for agent "${agentId}"`, toolIds); } const seenOverrideIds = new Set(); for (const override of policies.overrides ?? []) { if (seenOverrideIds.has(override.id)) { throw new Error(`Duplicate tool policy override id "${override.id}"`); } seenOverrideIds.add(override.id); if (!override.target || (!override.target.intentId && !override.target.agentId)) { throw new Error(`Tool policy override "${override.id}" must declare a target scope (intentId and/or agentId)`); } if (override.target.intentId && !intentIds.has(override.target.intentId)) { throw new Error( `Tool policy override "${override.id}" references unknown intent "${override.target.intentId}"` ); } if (override.target.agentId && !agentIds.has(override.target.agentId)) { throw new Error( `Tool policy override "${override.id}" references unknown agent "${override.target.agentId}"` ); } validatePolicyRule(override, `Tool policy override "${override.id}"`, toolIds); } } function validatePolicyRule(rule: ToolPolicyRule | undefined, scope: string, toolIds: Set): void { if (!rule) { return; } validatePolicyToolReferences(rule.allow, `${scope} allow`, toolIds); validatePolicyToolReferences(rule.deny, `${scope} deny`, toolIds); validatePolicyToolReferences(rule.requireApproval, `${scope} requireApproval`, toolIds); const allowDenyConflicts = findConflicts(rule.allow, rule.deny); if (allowDenyConflicts.length > 0) { throw new Error( `${scope} has conflicting allow/deny declarations for tool(s): ${allowDenyConflicts.map((id) => `"${id}"`).join(', ')}` ); } const denyApprovalConflicts = findConflicts(rule.deny, rule.requireApproval); if (denyApprovalConflicts.length > 0) { throw new Error( `${scope} has conflicting deny/requireApproval declarations for tool(s): ${denyApprovalConflicts.map((id) => `"${id}"`).join(', ')}` ); } } function validatePolicyToolReferences(toolRefs: string[] | undefined, scope: string, toolIds: Set): void { if (!toolRefs || toolRefs.length === 0) { return; } const seen = new Set(); for (const toolId of toolRefs) { if (seen.has(toolId)) { throw new Error(`${scope} contains duplicate tool reference "${toolId}"`); } seen.add(toolId); if (!toolIds.has(toolId)) { throw new Error(`${scope} references unknown tool "${toolId}"`); } } } function findConflicts(first: string[] | undefined, second: string[] | undefined): string[] { if (!first || !second || first.length === 0 || second.length === 0) { return []; } const right = new Set(second); return [...new Set(first.filter((id) => right.has(id)))]; } function cloneToolPolicyRule(rule: ToolPolicyRule | undefined): ToolPolicyRule | undefined { if (!rule) { return undefined; } return { allow: rule.allow ? [...rule.allow] : undefined, deny: rule.deny ? [...rule.deny] : undefined, requireApproval: rule.requireApproval ? [...rule.requireApproval] : undefined, requiredCategories: rule.requiredCategories ? [...rule.requiredCategories] : undefined, conflictResolution: rule.conflictResolution, conditions: rule.conditions ? { role: Array.isArray(rule.conditions.role) ? [...rule.conditions.role] : rule.conditions.role, userId: Array.isArray(rule.conditions.userId) ? [...rule.conditions.userId] : rule.conditions.userId, metadata: rule.conditions.metadata ? { ...rule.conditions.metadata } : undefined, } : undefined, }; } function cloneScopedPolicyRules( scoped: Record | undefined ): Record | undefined { if (!scoped) { return undefined; } return Object.fromEntries( Object.entries(scoped).map(([key, rule]) => [key, cloneToolPolicyRule(rule) as ToolPolicyRule]) ); } /** * Extract workflows from config */ export function extractWorkflows(config: FrameworkConfig): Workflow[] { if (!config.workflows) return []; return Object.entries(config.workflows).map(([name, workflowConfig]) => ({ name, defaultAgent: workflowConfig.defaultAgent, agents: workflowConfig.agents, routing: workflowConfig.routing, })); } // ============================================================================= // Provider Extraction // ============================================================================= /** * Effect Schema for provider pack configuration validation. */ const ProviderPackConfigSchema = Schema.Struct({ id: Schema.String.pipe(Schema.minLength(1, { message: () => 'Provider id is required' })), package: Schema.optional(Schema.String), apiKeyEnvVar: Schema.optional(Schema.String), baseUrl: Schema.optional(Schema.String), headers: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.String })), modelDefaults: Schema.optional(Schema.Struct({ model: Schema.optional(Schema.String), temperature: Schema.optional(Schema.Number.pipe(Schema.between(0, 2))), maxTokens: Schema.optional(Schema.Number.pipe(Schema.positive())), })), }); const ProvidersConfigSchema = Schema.Array(ProviderPackConfigSchema); /** * Validate providers configuration with Effect Schema. * Returns empty array if providers is null/undefined. * Throws ParseError if validation fails. */ export function validateProvidersConfig(providers: unknown): ProviderPackConfig[] { if (providers === undefined || providers === null) { return []; } return Schema.decodeUnknownSync(ProvidersConfigSchema)(providers) as ProviderPackConfig[]; } /** * Extracted provider ready for runtime registration. */ export interface ExtractedProvider { /** Provider ID (e.g., 'openai', 'anthropic') */ id: string; /** Package name - either explicit or defaults to id for built-ins */ package: string; /** Runtime configuration for the provider */ config: ProviderConfig; } /** * Extract provider registrations from config. * Validates with Effect Schema, then converts ProviderPackConfig[] to ExtractedProvider[] for runtime use. */ export function extractProviders(config: FrameworkConfig): ExtractedProvider[] { const validated = validateProvidersConfig(config.providers); if (validated.length === 0) return []; return validated.map((pack) => ({ id: pack.id, package: pack.package ?? pack.id, config: { apiKeyEnvVar: pack.apiKeyEnvVar, baseUrl: pack.baseUrl, headers: pack.headers, modelDefaults: pack.modelDefaults, }, })); } // ============================================================================= // Pipeline V2 Extraction // ============================================================================= /** * Extract extended pipelines from config. */ export function extractPipelinesV2( config: FrameworkConfig ): PipelineConfigV2[] { if (!config.pipelinesV2) { return []; } return Object.entries(config.pipelinesV2).map(([id, pipelineConfig]) => ({ id, steps: extractPipelineSteps(pipelineConfig.steps), description: pipelineConfig.description, utterances: pipelineConfig.utterances, failFast: pipelineConfig.failFast ?? true, })); } /** * Convert config steps to PipelineStep types. */ function extractPipelineSteps(configSteps: ConfigStep[]): PipelineStep[] { return configSteps.map((step, index) => { switch (step.type) { case 'agent': return { type: 'agent', name: step.name, agentId: step.agentId, retry: step.retry, contextView: step.contextView, }; case 'function': { const fn = functionRegistry.get(step.functionId); if (!fn) { console.warn( `Function "${step.functionId}" not registered, step "${step.name}" will fail at runtime` ); } return { type: 'function', name: step.name, fn: fn ?? (() => { throw new Error(`Function "${step.functionId}" not registered`); }), retry: step.retry, contextView: step.contextView, }; } case 'conditional': return { type: 'conditional', name: step.name, condition: createConditionPredicate(step.condition), whenTrue: extractPipelineSteps(step.whenTrue), whenFalse: step.whenFalse ? extractPipelineSteps(step.whenFalse) : undefined, retry: step.retry, contextView: step.contextView, }; case 'pipeline': return { type: 'pipeline', name: step.name, pipelineId: step.pipelineId, retry: step.retry, contextView: step.contextView, }; default: throw new Error(`Unknown step type at index ${index}`); } }); } /** * Create condition predicate from config expression. */ function createConditionPredicate( condition: ConfigConditionalStep['condition'] ): (ctx: any) => boolean { return (ctx: any) => { // Navigate to field using dot notation const value = getNestedValue(ctx, condition.field); if (condition.exists !== undefined) { return condition.exists ? value !== undefined : value === undefined; } if (condition.equals !== undefined) { return value === condition.equals; } if (condition.notEquals !== undefined) { return value !== condition.notEquals; } return false; }; } function getNestedValue(obj: any, path: string): any { return path.split('.').reduce((acc, key) => acc?.[key], obj); } // ============================================================================= // MCP Server Extraction // ============================================================================= /** * Resolve environment variable patterns in a string value. * Replaces ${VAR_NAME} with process.env.VAR_NAME. * If the environment variable is not set, logs a warning and keeps the literal value. * * @param value - String potentially containing ${ENV_VAR} patterns * @returns String with environment variables resolved */ function resolveEnvVars(value: string): string { return value.replace(/\$\{([A-Z_][A-Z0-9_]*)\}/g, (match, varName) => { const envValue = process.env[varName]; if (envValue === undefined) { console.warn( `[Config] Environment variable "${varName}" is not set. Keeping literal value "${match}"` ); return match; } return envValue; }); } /** * Resolve environment variables in all string values of an object. */ function resolveEnvVarsInObject>(obj: T | undefined): T | undefined { if (!obj) return obj; const resolved: any = {}; for (const [key, value] of Object.entries(obj)) { if (typeof value === 'string') { resolved[key] = resolveEnvVars(value); } else { resolved[key] = value; } } return resolved; } /** * Extract MCP server configurations from framework config. * Resolves ${ENV_VAR} patterns in string values and returns array with server IDs. * * @param config - Framework configuration * @returns Array of MCP server configs with resolved env vars and id field */ export function extractMCPServers( config: FrameworkConfig ): Array { if (!config.mcpServers || Object.keys(config.mcpServers).length === 0) { return []; } return Object.entries(config.mcpServers).map(([id, serverConfig]) => { // Resolve env vars in string fields const resolvedEnv = resolveEnvVarsInObject(serverConfig.env); const resolvedHeaders = resolveEnvVarsInObject(serverConfig.headers); const resolvedUrl = serverConfig.url ? resolveEnvVars(serverConfig.url) : undefined; return { id, transport: serverConfig.transport, command: serverConfig.command, args: serverConfig.args, env: resolvedEnv, allowedCommands: serverConfig.allowedCommands, envAllowlist: serverConfig.envAllowlist, url: resolvedUrl, headers: resolvedHeaders, allowedHosts: serverConfig.allowedHosts, allowedSchemes: serverConfig.allowedSchemes, timeout: serverConfig.timeout ?? 30000, // default 30s enabled: serverConfig.enabled ?? true, // default enabled lazy: serverConfig.lazy ?? false, // default auto-start retry: serverConfig.retry, healthCheckIntervalMs: serverConfig.healthCheckIntervalMs, }; }); } // ============================================================================= // Observability Extraction // ============================================================================= /** * Extract observability configuration from config. * Reads from config.observability and applies environment variable overrides. * * Environment variables: * - FRED_OTEL_ENDPOINT: OTLP endpoint URL * - FRED_OTEL_HEADERS: JSON object of headers (e.g., '{"Authorization":"Bearer token"}') * - FRED_LOG_LEVEL: Minimum log level (trace|debug|info|warning|error|fatal) * - FRED_SAMPLE_RATE: Success sampling rate (0.0 to 1.0) * - FRED_SLOW_THRESHOLD_MS: Slow threshold in milliseconds * - FRED_DEBUG: Debug mode (true/false) * * @param config - Framework configuration * @returns Observability configuration with environment overrides applied */ export function extractObservability(config: FrameworkConfig): import('./types').ObservabilityConfig { const base = config.observability ?? {}; const parseSampleRateOverride = (value: string | undefined): number | undefined => { if (value === undefined) { return undefined; } const parsed = Number(value); if (!Number.isFinite(parsed)) { console.warn(`[Config] Invalid FRED_SAMPLE_RATE value "${value}". Using configured/default value.`); return undefined; } return Math.max(0, Math.min(1, parsed)); }; const parseSlowThresholdOverride = (value: string | undefined): number | undefined => { if (value === undefined) { return undefined; } const parsed = Number(value); if (!Number.isFinite(parsed) || parsed < 0) { console.warn( `[Config] Invalid FRED_SLOW_THRESHOLD_MS value "${value}". Using configured/default value.` ); return undefined; } return parsed; }; // Apply environment variable overrides for OTLP const otlpEndpoint = process.env.FRED_OTEL_ENDPOINT ?? base.otlp?.endpoint; const otlpHeadersJson = process.env.FRED_OTEL_HEADERS; const otlpHeaders = (() => { if (!otlpHeadersJson) { return base.otlp?.headers; } try { return { ...base.otlp?.headers, ...JSON.parse(otlpHeadersJson) }; } catch { console.warn('[Config] Invalid FRED_OTEL_HEADERS JSON. Using configured/default headers.'); return base.otlp?.headers; } })(); const logLevel = (process.env.FRED_LOG_LEVEL as any) ?? base.logLevel; // Apply environment variable overrides for sampling const successSampleRate = parseSampleRateOverride(process.env.FRED_SAMPLE_RATE) ?? base.sampling?.successSampleRate; const slowThresholdMs = parseSlowThresholdOverride(process.env.FRED_SLOW_THRESHOLD_MS) ?? base.sampling?.slowThresholdMs; const debugMode = process.env.FRED_DEBUG ? process.env.FRED_DEBUG === 'true' : base.sampling?.debugMode; return { otlp: otlpEndpoint ? { endpoint: otlpEndpoint, headers: otlpHeaders, } : undefined, logLevel, resource: base.resource, enableConsoleFallback: base.enableConsoleFallback, sampling: { successSampleRate, slowThresholdMs, debugMode, }, metrics: base.metrics, }; }