Advertisement

← vibesdk

worker/agents/output-formats/streaming-formats/xml-stream.ts

19.1 KB Download file
/**
 * General Purpose XML Streaming Format
 * Robust streaming parser for any XML structure with LLM error resilience
 * Extends the proven streaming infrastructure from SCOF format
 */

import { CodeGenerationFormat, CodeGenerationStreamingState, ParsingState } from './base';
import { FileGenerationOutputType } from "../../schemas";

// XML parsing state
export interface XmlParsingState extends ParsingState {
    // Current XML parsing state
    xmlParserState: 'seeking_element' | 'in_element' | 'complete';
    
    // Stack for nested elements
    elementStack: XmlElement[];
    
    // Current element being processed
    currentElement: XmlElement | null;
    
    // Extracted elements by tag name
    extractedElements: Map<string, XmlElement[]>;
    
    // Buffer for potential XML tags that might be split across chunks
    potentialTagBuffer: string;
    
    // Configuration for which elements to extract
    targetElements: Set<string>;
    
    // Callbacks for streaming element content
    streamingElements: Set<string>;
    
    // Error handling
    hasParsingErrors: boolean;
    errorMessages: string[];
    
    // Raw XML buffer for fallback parsing
    rawXmlBuffer: string;
}

export interface XmlElement {
    tagName: string;
    attributes: Record<string, string>;
    content: string;
    isComplete: boolean;
    children: XmlElement[];
}

export interface XmlStreamingCallbacks {
    onElementStart?: (element: XmlElement) => void;
    onElementContent?: (tagName: string, content: string, isComplete: boolean) => void;
    onElementComplete?: (element: XmlElement) => void;
    onParsingError?: (error: string) => void;
}

export interface XmlParsingConfig {
    // Elements to extract (empty set means extract all)
    targetElements?: string[];
    
    // Elements to stream content in real-time
    streamingElements?: string[];
    
    // Whether to preserve whitespace
    preserveWhitespace?: boolean;
    
    // Maximum buffer size to prevent memory issues
    maxBufferSize?: number;
}

/**
 * XmlStreamFormat - General purpose XML streaming parser
 * Uses the same reliability patterns as SCOF format for robust chunk handling
 */
export class XmlStreamFormat extends CodeGenerationFormat {
    
    parseStreamingChunks(
        _chunk: string,
        state: CodeGenerationStreamingState,
        _onFileOpen: (filePath: string) => void,
        _onFileChunk: (filePath: string, chunk: string, format: 'full_content' | 'unified_diff') => void,
        _onFileClose: (filePath: string) => void
    ): CodeGenerationStreamingState {
        // This method is required by the abstract class but not used for XML parsing
        // The actual XML parsing is done via parseXmlStream method
        return state;
    }
    
    serialize(_files: FileGenerationOutputType[]): string {
        // Not directly applicable for XML format
        return '';
    }
    
    deserialize(_serialized: string): FileGenerationOutputType[] {
        // Not directly applicable for XML format
        return [];
    }
    
    formatInstructions(): string {
        return `
<XML OUTPUT FORMAT>
Structure your response using well-formed XML:

<element_name>
[Content here]
</element_name>

<another_element attribute="value">
[More content]
</another_element>

IMPORTANT:
- Use proper XML formatting with matching opening/closing tags
- Attributes should be quoted with double quotes
- Content can span multiple lines
- Nested elements are supported
- Self-closing tags: <element />
</XML OUTPUT FORMAT>
`;
    }

    /**
     * Parse XML stream with robust error handling and fallback
     */
    parseXmlStream(
        chunk: string,
        state: XmlParsingState | null,
        callbacks: XmlStreamingCallbacks,
        config: XmlParsingConfig = {}
    ): XmlParsingState {
        // Initialize state if null or corrupted
        let workingState: XmlParsingState;
        if (!state || !this.isValidXmlState(state)) {
            workingState = this.initializeXmlState(config);
        } else {
            workingState = state;
        }
        
        // Add chunk to both content buffer and raw XML buffer
        workingState.contentBuffer += chunk;
        workingState.rawXmlBuffer += chunk;
        
        // Process the accumulated content
        this.processXmlContent(workingState, callbacks);
        
        return workingState;
    }
    
    /**
     * Finalize XML parsing and return all extracted elements
     */
    finalizeXmlParsing(state: XmlParsingState): Map<string, XmlElement[]> {
        // Try final parsing attempt on remaining buffer or incomplete elements
        if (state.contentBuffer.length > 0 || state.elementStack.length > 0) {
            this.attemptFallbackExtraction(state);
        }
        
        return state.extractedElements;
    }
    
    /**
     * Get specific element by tag name (returns first match)
     */
    getElement(state: XmlParsingState, tagName: string): XmlElement | null {
        const elements = state.extractedElements.get(tagName.toLowerCase());
        return elements && elements.length > 0 ? elements[0] : null;
    }
    
    /**
     * Get all elements by tag name
     */
    getElements(state: XmlParsingState, tagName: string): XmlElement[] {
        return state.extractedElements.get(tagName.toLowerCase()) || [];
    }
    
    private isValidXmlState(state: any): state is XmlParsingState {
        return state &&
               typeof state.xmlParserState === 'string' &&
               Array.isArray(state.elementStack) &&
               state.extractedElements instanceof Map &&
               state.targetElements instanceof Set &&
               typeof state.contentBuffer === 'string' &&
               Array.isArray(state.errorMessages);
    }
    
    public initializeXmlState(config: XmlParsingConfig): XmlParsingState {
        return {
            // Base parsing state (required by interface)
            currentMode: 'idle',
            currentFile: null,
            currentFileFormat: null,
            contentBuffer: '',
            eofMarker: null,
            insideEofBlock: false,
            openedFiles: new Set(),
            closedFiles: new Set(),
            partialLineBuffer: '',
            commandBuffer: '',
            parsingMultiLineCommand: false,
            potentialEofBuffer: '',
            tailBuffer: '',
            lastChunkEndedWithNewline: false,
            betweenFilesBuffer: '',
            extractedInstallCommands: [],
            
            // XML-specific state
            xmlParserState: 'seeking_element',
            elementStack: [],
            currentElement: null,
            extractedElements: new Map(),
            potentialTagBuffer: '',
            targetElements: new Set((config.targetElements || []).map(t => t.toLowerCase())),
            streamingElements: new Set((config.streamingElements || []).map(t => t.toLowerCase())),
            hasParsingErrors: false,
            errorMessages: [],
            rawXmlBuffer: ''
        };
    }
    
    private processXmlContent(state: XmlParsingState, callbacks: XmlStreamingCallbacks): void {
        let changed = true;
        let iterations = 0;
        const maxIterations = 1000; // Prevent infinite loops
        
        // Process buffer until no more changes or max iterations
        while (changed && state.xmlParserState !== 'complete' && iterations < maxIterations) {
            changed = false;
            iterations++;
            
            try {
                // Look for XML tags in the buffer
                const tagMatch = this.findNextXmlTag(state.contentBuffer);
                
                if (tagMatch) {
                    changed = this.processXmlTag(tagMatch, state, callbacks);
                } else {
                    // No complete tags found, handle partial content
                    this.handlePartialContent(state, callbacks);
                    break;
                }
            } catch (error) {
                this.handleParsingError(state, `XML processing error: ${error instanceof Error ? error.message : 'Unknown error'}`, callbacks);
                break;
            }
        }
        
        // Prevent buffer from growing too large
        const maxBufferSize = (state as { maxBufferSize?: number }).maxBufferSize ?? 10000;
        if (state.contentBuffer.length > maxBufferSize) {
            // Keep last portion in case we have partial tags
            const keepSize = Math.min(2000, state.contentBuffer.length);
            state.contentBuffer = state.contentBuffer.substring(state.contentBuffer.length - keepSize);
        }
    }
    
    private findNextXmlTag(buffer: string): { type: 'opening' | 'closing' | 'self-closing', tagName: string, attributes: Record<string, string>, fullMatch: string, index: number } | null {
        // Regex for XML tags (opening, closing, or self-closing)
        const xmlTagRegex = /<\/?([a-zA-Z_][a-zA-Z0-9_-]*)[^>]*\/?>/;
        const match = buffer.match(xmlTagRegex);
        
        if (!match) return null;
        
        const fullMatch = match[0];
        const tagName = match[1];
        const index = match.index!;
        
        // Determine tag type
        let type: 'opening' | 'closing' | 'self-closing';
        if (fullMatch.startsWith('</')) {
            type = 'closing';
        } else if (fullMatch.endsWith('/>')) {
            type = 'self-closing';
        } else {
            type = 'opening';
        }
        
        // Extract attributes (simple implementation)
        const attributes = this.extractAttributes(fullMatch);
        
        return { type, tagName, attributes, fullMatch, index };
    }
    
    private extractAttributes(tagString: string): Record<string, string> {
        const attributes: Record<string, string> = {};
        
        // Simple attribute extraction (handles most cases)
        const attrRegex = /([a-zA-Z_][a-zA-Z0-9_-]*)\s*=\s*["']([^"']*)["']/g;
        let match;
        
        while ((match = attrRegex.exec(tagString)) !== null) {
            attributes[match[1]] = match[2];
        }
        
        return attributes;
    }
    
    private processXmlTag(
        tagMatch: { type: 'opening' | 'closing' | 'self-closing', tagName: string, attributes: Record<string, string>, fullMatch: string, index: number },
        state: XmlParsingState,
        callbacks: XmlStreamingCallbacks
    ): boolean {
        const { type, tagName, attributes, fullMatch, index } = tagMatch;
        
        // Extract content before this tag
        const contentBefore = state.contentBuffer.substring(0, index);
        if (contentBefore && state.currentElement) {
            this.addContentToCurrentElement(state, contentBefore, callbacks);
        }
        
        // Remove processed content from buffer
        state.contentBuffer = state.contentBuffer.substring(index + fullMatch.length);
        
        if (type === 'opening') {
            return this.handleOpeningTag(tagName, attributes, state, callbacks);
        } else if (type === 'closing') {
            return this.handleClosingTag(tagName, state, callbacks);
        } else if (type === 'self-closing') {
            return this.handleSelfClosingTag(tagName, attributes, state, callbacks);
        }
        
        return false;
    }
    
    private handleOpeningTag(
        tagName: string,
        attributes: Record<string, string>,
        state: XmlParsingState,
        callbacks: XmlStreamingCallbacks
    ): boolean {
        
        // Create new element
        const element: XmlElement = {
            tagName: tagName.toLowerCase(),
            attributes: attributes,
            content: '',
            isComplete: false,
            children: []
        };
        
        // Add to parent element if we have one
        if (state.currentElement) {
            state.currentElement.children.push(element);
        }
        
        // Push to stack and set as current
        state.elementStack.push(element);
        state.currentElement = element;
        
        // Call callback if configured
        if (callbacks.onElementStart) {
            callbacks.onElementStart(element);
        }
        
        return true;
    }
    
    private handleClosingTag(
        tagName: string,
        state: XmlParsingState,
        callbacks: XmlStreamingCallbacks
    ): boolean {
        const normalizedTagName = tagName.toLowerCase();
        
        if (!state.currentElement) {
            this.handleParsingError(state, `Unexpected closing tag: ${tagName}`, callbacks);
            return false;
        }
        
        // Verify tag matching (case insensitive)
        if (state.currentElement.tagName.toLowerCase() !== normalizedTagName) {
            this.handleParsingError(state, `Mismatched closing tag: expected ${state.currentElement.tagName}, got ${tagName}`, callbacks);
            return false;
        }
        
        // Mark element as complete
        state.currentElement.isComplete = true;
        
        // Mark streaming as complete (don't re-stream content, just mark complete)
        if (state.streamingElements.has(normalizedTagName) && callbacks.onElementContent) {
            callbacks.onElementContent(tagName, '', true);
        }
        
        // Store completed element
        this.storeElement(state, state.currentElement);
        
        // Call completion callback
        if (callbacks.onElementComplete) {
            callbacks.onElementComplete(state.currentElement);
        }
        
        // Pop from stack
        state.elementStack.pop();
        state.currentElement = state.elementStack.length > 0 ? state.elementStack[state.elementStack.length - 1] : null;
        
        return true;
    }
    
    private handleSelfClosingTag(
        tagName: string,
        attributes: Record<string, string>,
        state: XmlParsingState,
        callbacks: XmlStreamingCallbacks
    ): boolean {
        // Create complete element
        const element: XmlElement = {
            tagName: tagName.toLowerCase(),
            attributes: attributes,
            content: '',
            isComplete: true,
            children: []
        };
        
        // Add to parent if we have one
        if (state.currentElement) {
            state.currentElement.children.push(element);
        }
        
        // Store element
        this.storeElement(state, element);
        
        // Call callbacks
        if (callbacks.onElementStart) {
            callbacks.onElementStart(element);
        }
        if (callbacks.onElementComplete) {
            callbacks.onElementComplete(element);
        }
        
        return true;
    }
    
    private addContentToCurrentElement(
        state: XmlParsingState,
        content: string,
        callbacks: XmlStreamingCallbacks
    ): void {
        if (!state.currentElement || !content) return;
        
        // Add content to current element
        state.currentElement.content += content;
        
        // Stream content if configured
        const normalizedTagName = state.currentElement.tagName.toLowerCase();
        if (state.streamingElements.has(normalizedTagName) && callbacks.onElementContent) {
            callbacks.onElementContent(state.currentElement.tagName, content, false);
        }
    }
    
    private handlePartialContent(state: XmlParsingState, callbacks: XmlStreamingCallbacks): void {
        // Stream content immediately for responsive feedback
        if (state.currentElement && state.contentBuffer.length > 0) {
            // Check if buffer might end with partial tag
            const hasPartialTag = state.contentBuffer.includes('<');
            
            if (!hasPartialTag) {
                // Safe to process all content
                this.addContentToCurrentElement(state, state.contentBuffer, callbacks);
                state.contentBuffer = '';
            } else {
                // Process content before the last '<' character
                const lastTagIndex = state.contentBuffer.lastIndexOf('<');
                if (lastTagIndex > 0) {
                    const safeContent = state.contentBuffer.substring(0, lastTagIndex);
                    this.addContentToCurrentElement(state, safeContent, callbacks);
                    state.contentBuffer = state.contentBuffer.substring(lastTagIndex);
                }
            }
        }
    }
    
    private storeElement(state: XmlParsingState, element: XmlElement): void {
        const normalizedTagName = element.tagName.toLowerCase();
        
        // Store if it's a target element or if no targets specified
        if (state.targetElements.size === 0 || state.targetElements.has(normalizedTagName)) {
            if (!state.extractedElements.has(normalizedTagName)) {
                state.extractedElements.set(normalizedTagName, []);
            }
            state.extractedElements.get(normalizedTagName)!.push(element);
        }
    }
    
    private handleParsingError(
        state: XmlParsingState,
        error: string,
        callbacks: XmlStreamingCallbacks
    ): void {
        state.hasParsingErrors = true;
        state.errorMessages.push(error);
        
        if (callbacks.onParsingError) {
            callbacks.onParsingError(error);
        }
        
        // Try fallback extraction
        this.attemptFallbackExtraction(state);
    }
    
    private attemptFallbackExtraction(state: XmlParsingState): void {
        try {
            // Try to extract elements using lenient regex
            for (const targetElement of state.targetElements) {
                if (!state.extractedElements.has(targetElement)) {
                    const regex = new RegExp(`<${targetElement}[^>]*>(.*?)(?:<\\/${targetElement}>|$)`, 'is');
                    const match = state.rawXmlBuffer.match(regex);
                    
                    if (match) {
                        const element: XmlElement = {
                            tagName: targetElement,
                            attributes: {},
                            content: match[1].trim(),
                            isComplete: true,
                            children: []
                        };
                        
                        state.extractedElements.set(targetElement, [element]);
                    }
                }
            }
        } catch (error) {
            console.warn('Fallback XML extraction failed:', error);
        }
    }
}

/**
 * Utility function to create XML stream parser
 */
export function createXmlStreamParser(_config?: XmlParsingConfig): XmlStreamFormat {
    return new XmlStreamFormat();
}

/**
 * Utility function to parse complete XML string
 */
export function parseXmlString(xmlString: string, config?: XmlParsingConfig): Map<string, XmlElement[]> {
    const parser = new XmlStreamFormat();
    const state = parser.initializeXmlState(config || {});
    
    parser.parseXmlStream(xmlString, state, {}, config);
    return parser.finalizeXmlParsing(state);
}