/** * ResponseNormalizer - Schema-driven response transformation engine * * Provides a generic, configurable approach to normalizing protocol-specific * responses into the universal ContentItem[] format used by the harness. * * ## Architecture * Uses declarative configuration (ResponseNormalizerConfig) to define: * - How to extract content from responses (contentPath) * - How to normalize individual items (itemNormalizer) * - How to detect errors (errorPath, errorExtractor) * - Whether to flatten nested arrays (flattenArrays) * * ## Key Features * - **Generic**: Works with any protocol response type (MCP, HTTP, OpenAI) * - **Declarative**: Configuration-driven, not code-driven * - **Flexible**: PathExtractor supports strings, arrays, functions * - **Extensible**: Custom itemNormalizer for protocol-specific logic * - **Consistent**: Unified error detection and content extraction * * ## Benefits * - Eliminates code duplication across adapters * - Makes transformation logic visible at a glance * - Easy to add new response formats without copying code * - Consistent handling of edge cases (empty arrays, null values) * - Type-safe with generic type parameter * * @example MCP Tool Response * ```typescript * const normalizer = new ResponseNormalizer({ * contentPath: "content" * }); * const result = normalizer.normalize({ content: [{ type: "text", text: "Hi" }] }); * // => { content: [...], output_text: "Hi", isError: false } * ``` * * @example HTTP JSON Response * ```typescript * const normalizer = new ResponseNormalizer({ * contentPath: "data", * errorExtractor: (data) => data.status >= 400, * itemNormalizer: (item) => ({ * type: "text", * text: JSON.stringify(item, null, 2), * mimeType: "application/json" * }) * }); * ``` * * @example OpenAI Choice Array * ```typescript * const normalizer = new ResponseNormalizer({ * contentPath: (data) => data.choices.map(c => c.message.content), * flattenArrays: true * }); * ``` * * @module @wavespec/kit/response/normalizer */ import type { ContentItem } from "@wavespec/types"; import type { ResponseNormalizerConfig, NormalizedResponse, PathExtractor, ContentBuilder, } from "./types.js"; import { computeOutputText } from "./output-text.js"; /** * Default item normalizer for common protocol content types * * Converts protocol-specific content items to universal ContentItem format. * Handles all common content types found in MCP, HTTP, OpenAI protocols. * * ## Supported Content Types * - **text**: `{ type: "text", text: string }` → text ContentItem * - **image**: `{ type: "image", data: string, mimeType: string }` → image ContentItem * - **audio**: `{ type: "audio", data: string, mimeType: string }` → resource ContentItem (normalized) * - **resource**: `{ type: "resource", data: string, mimeType: string }` → resource ContentItem * - **primitives**: string, number, boolean → text ContentItem (stringified) * - **objects**: {...} → text ContentItem (JSON stringified) * * ## Edge Cases * - Null/undefined items are filtered out (return null) * - Objects without type field are treated as text (JSON stringified) * - Audio content is normalized to resource type (harness doesn't have audio type) * - Both `data` and `blob` fields are supported for resources * * @param item - Protocol content item or primitive value * @returns ContentItem or null if item should be filtered out */ export function defaultItemNormalizer(item: unknown): ContentItem | null { // Null/undefined → filter out if (item == null) { return null; } // Primitive value - wrap in text content if (typeof item !== "object") { return { type: "text", text: String(item), }; } // Type guard to safely access properties const typedItem = item as { type?: string; text?: string; data?: string; blob?: string; mimeType?: string; }; const itemType = typedItem.type; // Text content if (itemType === "text" || "text" in item) { const result: ContentItem = { type: "text", text: typedItem.text, }; if (typedItem.mimeType) { result.mimeType = typedItem.mimeType; } return result; } // Image content if (itemType === "image") { return { type: "image", data: typedItem.data, mimeType: typedItem.mimeType, }; } // Audio content (normalize to resource) if (itemType === "audio") { return { type: "resource", data: typedItem.data, mimeType: typedItem.mimeType, }; } // Resource content if (itemType === "resource" || "data" in item || "blob" in item) { const resourceData = typedItem.data === undefined ? typedItem.blob : typedItem.data; return { type: "resource", data: resourceData, mimeType: typedItem.mimeType, }; } // Already a ContentItem → return as-is if (itemType === "text" || itemType === "image" || itemType === "resource") { return item as ContentItem; } // Object without recognized type → JSON stringify return { type: "text", text: JSON.stringify(item, null, 2), mimeType: "application/json", }; } /** * ResponseNormalizer Class * * Schema-driven response transformation engine that converts protocol-specific * responses into the universal ContentItem[] format. Eliminates boilerplate by * using declarative configuration instead of custom normalization code. * * ## Type Parameter * @template TProtocolResponse - The protocol's response type (e.g., CallToolResult, HttpResponse) * * ## Configuration * Pass a ResponseNormalizerConfig defining: * - `contentPath`: How to extract content items from response * - `itemNormalizer`: (Optional) How to normalize each item * - `flattenArrays`: (Optional) Whether to flatten nested arrays * - `errorPath`/`errorExtractor`: (Optional) How to detect errors * * ## Usage Pattern * 1. Create normalizer with config * 2. Call normalize(response) for each response * 3. Get back NormalizedResponse with content, output_text, isError * * @example Simple MCP tool response * ```typescript * const normalizer = new ResponseNormalizer({ * contentPath: "content" * }); * const result = normalizer.normalize({ content: [{ type: "text", text: "Hi" }] }); * ``` * * @example HTTP with error detection * ```typescript * const normalizer = new ResponseNormalizer({ * contentPath: "data", * errorExtractor: (data) => data.status >= 400 * }); * ``` * * @example OpenAI with custom extraction * ```typescript * const normalizer = new ResponseNormalizer({ * contentPath: (data) => data.choices.map(c => c.message), * flattenArrays: true * }); * ``` */ export class ResponseNormalizer { private readonly config: ResponseNormalizerConfig; private readonly itemNormalizer: ContentBuilder; /** * Create a new ResponseNormalizer * * @param config - Normalization schema configuration */ constructor(config: ResponseNormalizerConfig) { this.config = config; this.itemNormalizer = config.itemNormalizer ?? defaultItemNormalizer; } /** * Normalize a protocol response to universal format * * Extracts content, normalizes items, computes output_text, detects errors. * * @param response - Protocol-specific response * @returns NormalizedResponse with content, output_text, isError */ normalize(response: TProtocolResponse): NormalizedResponse { const content = this.extractContent(response); const isError = this.extractError(response); return { content, output_text: computeOutputText(content), isError, }; } /** * Extract and normalize content items from response * * 1. Extract raw content using contentPath * 2. Ensure it's an array * 3. Flatten nested arrays if configured * 4. Normalize each item using itemNormalizer * 5. Filter out null results * * @param response - Protocol-specific response * @returns Array of normalized ContentItems */ private extractContent(response: TProtocolResponse): ContentItem[] { const raw = this.extractByPath(response, this.config.contentPath); // Ensure we have an array let items: unknown[]; if (Array.isArray(raw)) { items = raw; } else if (raw == null) { items = []; } else { items = [raw]; } // Flatten nested arrays if requested if (this.config.flattenArrays) { items = this.flattenArray(items); } // Normalize each item and filter out nulls const normalized: ContentItem[] = []; for (const item of items) { const contentItem = this.itemNormalizer(item); if (contentItem) { normalized.push(contentItem); } } return normalized; } /** * Extract error indicator from response * * Uses errorExtractor if provided, otherwise errorPath. * Returns false if no error detection is configured. * * @param response - Protocol-specific response * @returns true if response represents an error */ private extractError(response: TProtocolResponse): boolean { if (this.config.errorExtractor) { return this.config.errorExtractor(response); } if (this.config.errorPath) { const errorValue = this.extractByPath(response, this.config.errorPath); return Boolean(errorValue); } return false; } /** * Extract value from nested object using path * * Supports three path formats: * - String: "data.items" → obj.data.items * - Array: ["data", "items"] → obj["data"]["items"] * - Function: (obj) => obj.data.items → custom extraction * * Handles null/undefined gracefully by returning undefined. * * @param data - Object to extract from * @param path - Path specification * @returns Extracted value or undefined */ private extractByPath( data: unknown, path: PathExtractor, ): unknown { if (typeof path === "function") { return path(data as TProtocolResponse); } const segments = Array.isArray(path) ? path : path.split("."); let current: unknown = data; for (const segment of segments) { if (current == null) { return; } // Type assertion: we're doing dynamic property access on unknown value current = (current as Record)[segment]; } return current; } /** * Recursively flatten nested arrays * * Converts [[item1], [item2]] to [item1, item2]. * Non-array items are preserved as-is. * * @param items - Array to flatten * @returns Flattened array */ private flattenArray(items: unknown[]): unknown[] { const result: unknown[] = []; for (const item of items) { if (Array.isArray(item)) { result.push(...this.flattenArray(item)); } else { result.push(item); } } return result; } }