import { assertValidProcessProductName, type ProcessTurnStartSelection, resolveProcessTurnStartSelection, } from "@leitwerk-dev/domain"; import type { AutomaticTurnDefinition, ExternalTurnDefinition, HumanTurnDefinition, LlmTurnDefinition, ProcessToolOutcomeSpec, TurnDefinition, } from "./define-process.js"; import { llmTurn } from "./define-process.js"; import { validateMappedTurn } from "./mapped-turn.js"; import { compileOutcomeParameters } from "./outcome-schema.js"; import { validatePiBuiltInToolArray } from "./pi-config.js"; import { REQUIRED_MARKDOWN_RESULT_TURN_RESULT } from "./tool-renderers.js"; import { type ProcessActionPreviewDefinition, type ProcessActionSchedulingDefinition, RESERVED_INTEGRATION_TOOL_NAMES, } from "./types.js"; /** @internal */ export function isLlmTurnDefinition( turnDef: TurnDefinition, ): turnDef is LlmTurnDefinition { return turnDef.kind === "llm"; } /** @internal */ export function isAutomaticTurnDefinition( turnDef: TurnDefinition, ): turnDef is AutomaticTurnDefinition { return turnDef.kind === "automatic"; } /** @internal */ export function isHumanTurnDefinition( turnDef: TurnDefinition, ): turnDef is HumanTurnDefinition { return turnDef.kind === "human"; } /** @internal */ export function isExternalTurnDefinition( turnDef: TurnDefinition, ): turnDef is ExternalTurnDefinition { return turnDef.kind === "external"; } /** @internal */ export function resolveLlmTurnStartSelection( turnDef: Pick, "branchType" | "startFrom">, ): ProcessTurnStartSelection { return resolveProcessTurnStartSelection(turnDef); } /** @internal */ export function resolveLlmTurnRestorePrimaryLeafAfterTurn( turnDef: Pick, "branchType" | "restorePrimaryLeafAfterTurn">, ): boolean { return turnDef.restorePrimaryLeafAfterTurn ?? turnDef.branchType !== "primary"; } export function validateProcessActionPreviewDefinition( preview: ProcessActionPreviewDefinition | undefined, context: string, ): string[] { if (!preview) { return []; } if (preview.kind === "trigger") { return preview.trigger.trim() ? [] : [`${context} must declare a non-empty trigger preview`]; } if (preview.kind === "terminal" || preview.turnId === null || preview.turnId.trim()) { return []; } return [`${context} must declare a non-empty fixed turn id when turnId is not null`]; } export function validateProcessActionSchedulingDefinition( scheduling: ProcessActionSchedulingDefinition | undefined, context: string, ): string[] { return validateProcessActionPreviewDefinition(scheduling?.preview, context); } function findDuplicateBy( items: readonly TItem[], selectKey: (item: TItem) => string, ): TItem | undefined { const ids = new Set(); for (const item of items) { const key = selectKey(item); if (ids.has(key)) { return item; } ids.add(key); } return undefined; } function validateProcessProductName(name: string): string[] { try { assertValidProcessProductName(name); return []; } catch (error) { return [error instanceof Error ? error.message : String(error)]; } } function validateOutcomeTurnResultContract( turnKindLabel: string, turnId: string, outcomes: Partial>> | undefined, turnEnd: unknown, ): string[] { const errors: string[] = []; const hasOutcomeTools = Object.keys(outcomes ?? {}).length > 0; const hasTurnEndResult = turnEnd !== undefined; if (!hasOutcomeTools && !hasTurnEndResult) { errors.push( `${turnKindLabel} turn '${turnId}' must declare at least one outcome tool or a turnEnd result`, ); } if (hasOutcomeTools && hasTurnEndResult) { errors.push(`${turnKindLabel} turn '${turnId}' cannot declare both outcomes and turnEnd`); } return errors; } function validateOutcomeToolParameters( turnKindLabel: string, turnId: string, outcomes: Partial>> | undefined, ): string[] { const errors: string[] = []; for (const [outcome, outcomeSpec] of Object.entries(outcomes ?? {}) as Array< [string, ProcessToolOutcomeSpec] >) { const outcomeContext = `${turnKindLabel} turn '${turnId}' outcome '${outcome}'`; if (outcomeSpec.publishedProduct) { errors.push(...validateProcessProductName(outcomeSpec.publishedProduct)); const parameterName = outcomeSpec.turnResultMarkdownParameter?.trim() ?? ""; if (!parameterName) { errors.push( `${outcomeContext} publishes product '${outcomeSpec.publishedProduct}' but does not declare a turn-result markdown parameter`, ); } else if (!Object.hasOwn(outcomeSpec.parameters, parameterName)) { errors.push( `${outcomeContext} publishes product '${outcomeSpec.publishedProduct}' from missing parameter '${parameterName}'`, ); } } for (const [paramName, paramSpec] of Object.entries(outcomeSpec.parameters)) { const context = `${outcomeContext} parameter '${paramName}'`; const before = errors.length; for (const [property, type, message] of [ ["minItems", "array", "uses minItems but is not an array"], ["minimum", "number", "uses minimum but is not a number"], ["items", "array", "declares array items but is not an array"], ] as const) { if (paramSpec[property] !== undefined && paramSpec.type !== type) errors.push(`${context} ${message}`); } if (paramSpec.type === "array") { if (!paramSpec.items) { errors.push(`${context} is an array and must declare items`); } else if ( !paramSpec.items.anyOf && !["string", "number", "boolean", "object", "array"].includes(paramSpec.items.type ?? "") ) { errors.push( `${context} declares unsupported array item type '${String(paramSpec.items.type)}'`, ); } } if (errors.length === before) { try { compileOutcomeParameters({ [paramName]: paramSpec }, outcomeSpec.strictSchema); } catch (error) { errors.push(`${context}: ${error instanceof Error ? error.message : String(error)}`); } } } } return errors; } function assertNoValidationErrors(errors: readonly string[]): void { if (errors.length > 0) { throw new Error(errors.join("; ")); } } function validateTurnStartSelection( turnId: string, startFrom: ProcessTurnStartSelection, context = "startFrom", ): string[] { const errors: string[] = []; if (startFrom.kind === "entry" && startFrom.entryId.trim() === "") { errors.push(`LLM turn '${turnId}' ${context} entry id must be non-empty`); } if (startFrom.kind === "product_ref") { errors.push(...validateProcessProductName(startFrom.productName)); } if ("fallback" in startFrom && startFrom.fallback) { errors.push(...validateTurnStartSelection(turnId, startFrom.fallback, `${context} fallback`)); } return errors; } /** @internal */ export function validateLlmTurnDefinition< TOutcome extends string = string, TParams = unknown, TState = unknown, >(turnId: string, turnDef: LlmTurnDefinition): string[] { const errors: string[] = []; const declaredOutcomes = turnDef.outcomes; const startFrom = resolveLlmTurnStartSelection(turnDef); if ((turnDef.completionMode ?? "turn_end") !== "turn_end") { errors.push(`LLM turn '${turnId}' must use completionMode 'turn_end'`); } const declaredTurnEnd = turnDef.turnEnd; errors.push( ...validateOutcomeTurnResultContract("LLM", turnId, declaredOutcomes, declaredTurnEnd), ); if (turnDef.branchType === "root_branch" && startFrom.kind === "current_leaf") { errors.push( `LLM turn '${turnId}' uses branchType 'root_branch' but starts from the current leaf`, ); } if (turnDef.branchType === "leaf_branch" && startFrom.kind === "session_root") { errors.push( `LLM turn '${turnId}' uses branchType 'leaf_branch' but starts from the session root`, ); } errors.push(...validateTurnStartSelection(turnId, startFrom)); if (turnDef.askQuestions !== undefined && turnDef.askQuestions !== true) { errors.push(`LLM turn '${turnId}' askQuestions must be true when declared`); } if (!Array.isArray(turnDef.availableTools)) { errors.push(`LLM turn '${turnId}' must declare availableTools as an array`); } else { errors.push( ...validatePiBuiltInToolArray(turnDef.availableTools, `LLM turn '${turnId}' availableTools`), ); } if (turnDef.integrationTools !== undefined) { if (!Array.isArray(turnDef.integrationTools)) { errors.push(`LLM turn '${turnId}' integrationTools must be an array`); } else { const seen = new Set(); const conflictingNames = new Set([ ...RESERVED_INTEGRATION_TOOL_NAMES, ...Object.keys(declaredOutcomes ?? {}), ]); for (const name of turnDef.integrationTools) { if (typeof name !== "string" || name.trim() === "") { errors.push(`LLM turn '${turnId}' integration tool names must be non-empty strings`); continue; } if (seen.has(name)) { errors.push(`LLM turn '${turnId}' declares duplicate integration tool '${name}'`); } if (conflictingNames.has(name)) { errors.push( `LLM turn '${turnId}' integration tool '${name}' conflicts with a built-in, framework, or outcome tool`, ); } seen.add(name); } } } if (turnDef.turnResultMarkdown?.mode === "tool_call") { if (turnDef.turnResultMarkdown.toolName.trim() === "") { errors.push( `LLM turn '${turnId}' tool-call turnResultMarkdown must use a non-empty toolName`, ); } if (turnDef.turnResultMarkdown.path.trim() === "") { errors.push(`LLM turn '${turnId}' tool-call turnResultMarkdown must use a non-empty path`); } } if (turnDef.turnResultMarkdown?.mode === "outcome_tool_argument") { const parameterName = turnDef.turnResultMarkdown.parameterName.trim(); const outcomeEntries = Object.entries(declaredOutcomes ?? {}) as Array< [string, ProcessToolOutcomeSpec] >; if (parameterName === "") { errors.push( `LLM turn '${turnId}' outcome-tool turnResultMarkdown must use a non-empty parameterName`, ); } if (outcomeEntries.length === 0) { errors.push( `LLM turn '${turnId}' outcome-tool turnResultMarkdown requires at least one outcome tool`, ); } for (const [outcomeId, spec] of outcomeEntries) { if (parameterName in spec.parameters) { errors.push( `LLM turn '${turnId}' outcome tool '${outcomeId}' cannot declare reserved markdown parameter '${parameterName}'`, ); } } } errors.push(...validateOutcomeToolParameters("LLM", turnId, declaredOutcomes)); errors.push(...validateMappedTurn(turnId, turnDef)); errors.push(...validateExternalActions("LLM", turnId, turnDef.externalActions)); if (Object.keys(turnDef.externalActions ?? {}).length && !turnDef.waitFor) errors.push(`LLM turn '${turnId}' external actions require .waitFor(...)`); return errors; } /** @internal */ export function validateAutomaticTurnDefinition< TOutcome extends string = string, TParams = unknown, TState = unknown, >(turnId: string, turnDef: AutomaticTurnDefinition): string[] { return [ ...validateOutcomeTurnResultContract("Automatic", turnId, turnDef.outcomes, turnDef.turnEnd), ...validateOutcomeToolParameters("Automatic", turnId, turnDef.outcomes), ...validateExternalActions("Automatic", turnId, turnDef.externalActions), ]; } /** Intrinsic checks shared by standalone turn validation and route compilation. @internal */ export function validateExternalActions( kind: string, turnId: string, actions: HumanTurnDefinition["externalActions"], ): string[] { const errors: string[] = []; for (const [externalActionId, externalAction] of Object.entries(actions ?? {})) { if (externalActionId.trim() === "") { errors.push( kind === "Human" ? `Human turn '${turnId}' declares an empty external action id` : `${kind} turn '${turnId}' contains an external action with an empty id`, ); } if (externalAction.id !== externalActionId) { errors.push( `${kind} turn '${turnId}' external action '${externalActionId}' has mismatched ${kind === "Human" ? "spec " : ""}id '${externalAction.id}'`, ); } if (externalAction.source.kind.trim() === "") { errors.push( `${kind} turn '${turnId}' external action '${externalActionId}' must declare a non-empty source kind`, ); } const targetCount = (externalAction.to !== undefined ? 1 : 0) + (externalAction.complete === true ? 1 : 0) + (externalAction.lifecycleStatus !== undefined ? 1 : 0); if (targetCount !== 1) { errors.push( `${kind} turn '${turnId}' external action '${externalActionId}' must declare exactly one target${kind === "Human" ? " via 'to', 'complete', or 'lifecycleStatus'" : ""}`, ); } if (externalAction.publishInput) { errors.push(...validateProcessProductName(externalAction.publishInput.productName)); if (externalAction.publishInput.inputField.trim() === "") { errors.push( `${kind} turn '${turnId}' external action '${externalActionId}' publishInput must declare a non-empty inputField`, ); } if (externalAction.complete === true || externalAction.lifecycleStatus !== undefined) { errors.push( `${kind} turn '${turnId}' external action '${externalActionId}' cannot publish input on a terminal route`, ); } } } return errors; } /** Intrinsic metadata checked before action derivation, which also validates routing. @internal */ export function validateHumanTurnDefinition( turnId: string, turnDef: HumanTurnDefinition, ): string[] { const errors: string[] = []; if (turnDef.reviewProduct) { errors.push(...validateProcessProductName(turnDef.reviewProduct)); } const actionEntries = Object.entries(turnDef.actions); if (actionEntries.length === 0) { errors.push(`Human turn '${turnId}' must declare at least one action`); } for (const [actionId, action] of actionEntries) { if (!action.acceptanceState) { errors.push(`Human turn '${turnId}' is missing an acceptance state for action '${actionId}'`); } errors.push( ...validateProcessActionPreviewDefinition( action.preview, `Human turn '${turnId}' action '${actionId}' preview`, ), ); } const externalTriggers = actionEntries.flatMap(([actionId, action]) => (action.externalTriggers ?? []).map((trigger) => ({ actionId, trigger })), ); if (findDuplicateBy(externalTriggers, ({ trigger }) => trigger.id) !== undefined) { errors.push(`Human turn '${turnId}' contains duplicate external trigger ids`); } if (findDuplicateBy(externalTriggers, ({ actionId }) => actionId) !== undefined) { errors.push(`Human turn '${turnId}' contains duplicate external trigger action ids`); } for (const { actionId, trigger } of externalTriggers) { if (trigger.id.trim() === "") { errors.push(`Human turn '${turnId}' contains an external trigger with an empty id`); } const context = `Human turn '${turnId}' external trigger '${trigger.id}'`; for (const [field, value] of Object.entries({ actionId, label: trigger.label, description: trigger.description, })) { if (value.trim() === "") errors.push(`${context} must declare a non-empty ${field}`); } } const duplicateNote = findDuplicateBy(turnDef.notesFields ?? [], (field) => field.id); if (duplicateNote !== undefined) { errors.push(`Human turn '${turnId}' contains duplicate notes field id '${duplicateNote.id}'`); } return errors; } /** @internal */ export function validateExternalTurnDefinition( turnId: string, turnDef: ExternalTurnDefinition, ): string[] { const errors: string[] = []; if (turnDef.transitions.length === 0) { errors.push(`External turn '${turnId}' must declare at least one source transition`); } for (const [index, transition] of turnDef.transitions.entries()) { if (transition.source.kind.trim() === "") { errors.push( `External turn '${turnId}' source transition ${index} must declare a non-empty kind`, ); } const targetCount = (transition.to !== undefined ? 1 : 0) + (transition.complete === true ? 1 : 0) + (transition.lifecycleStatus !== undefined ? 1 : 0); if (targetCount !== 1) { errors.push( `External turn '${turnId}' source transition ${index} must declare exactly one target`, ); } } return errors; } /** @internal */ export function validateTurnDefinition( turnId: string, turnDef: TurnDefinition, ): string[] { return isLlmTurnDefinition(turnDef) ? validateLlmTurnDefinition(turnId, turnDef) : isAutomaticTurnDefinition(turnDef) ? validateAutomaticTurnDefinition(turnId, turnDef) : isHumanTurnDefinition(turnDef) ? validateHumanTurnDefinition(turnId, turnDef) : validateExternalTurnDefinition(turnId, turnDef); } /** @internal */ export function assertValidLlmTurnDefinition< TOutcome extends string = string, TParams = unknown, TState = unknown, >(turnId: string, turnDef: LlmTurnDefinition): void { assertNoValidationErrors(validateLlmTurnDefinition(turnId, turnDef)); } /** @internal */ export function createRootBranchReviewTurn< TOutcome extends string, TParams = unknown, TState = unknown, >( def: Omit< LlmTurnDefinition, "kind" | "branchType" | "completionMode" | "startFrom" | "restorePrimaryLeafAfterTurn" > & Partial< Pick< LlmTurnDefinition, "completionMode" | "startFrom" | "restorePrimaryLeafAfterTurn" > >, ): LlmTurnDefinition { const { completionMode = "turn_end", startFrom = { kind: "session_root" }, restorePrimaryLeafAfterTurn = true, turnResultMarkdown = REQUIRED_MARKDOWN_RESULT_TURN_RESULT, ...rest } = def; return llmTurn({ completionMode, branchType: "root_branch", startFrom, restorePrimaryLeafAfterTurn, turnResultMarkdown, ...rest, }); }