UNPKG

cmte

Version:

Design by Committee™ except it's just you and LLMs

351 lines (325 loc) 15.5 kB
/** * Template renderer with support for output references and file collections. */ import yaml from 'js-yaml'; import { BaseLLMClient } from '../llm/base-llm-client.js'; import { OutputReferenceResolver } from './output-reference-resolver.js'; import { FileCollectionManager } from '../file-collection-manager.js'; import { logger } from '../../utils/logger.js'; // Correct path for logger export class TemplateRenderer { /** * Creates a new TemplateRenderer instance * @param {Object} context - The context object containing variables * @param {string} basePath - Base path for file collections (used by FileCollectionManager) * @param {OutputReferenceResolver} outputReferenceResolver - The shared resolver instance * @param {FileCollectionManager} fileCollectionManager - The shared file collection manager instance */ constructor(context, basePath, outputReferenceResolver, fileCollectionManager) { if (!context) throw new Error('TemplateRenderer requires context.'); if (!basePath) throw new Error('TemplateRenderer requires basePath.'); if (!outputReferenceResolver) throw new Error('TemplateRenderer requires an OutputReferenceResolver instance.'); if (!fileCollectionManager) throw new Error('TemplateRenderer requires a FileCollectionManager instance.'); this.context = context || {}; // Ensure context is an object this.referenceResolver = outputReferenceResolver; this.fileCollectionManager = fileCollectionManager; this.basePath = basePath; // Keep basePath if needed for direct path resolution } /** * Renders a template with temporary iteration context (if resolver supports it) * @param {string} template - Template to render * @param {string} iterationKey - Iteration key * @returns {Promise<string>} Rendered template */ async renderWithIteration(template, iterationKey) { // This relies on the resolver having set/clear iteration context methods if (typeof this.referenceResolver.setIterationContext === 'function') { this.referenceResolver.setIterationContext(iterationKey); } else { console.warn('Attempted to use renderWithIteration, but resolver does not support setIterationContext'); } try { return await this.render(template); } finally { if (typeof this.referenceResolver.clearIterationContext === 'function') { this.referenceResolver.clearIterationContext(); } } } /** * Renders a template, resolving context variables, output references, and file group references. * Supports {{ variable }}, {{ set.task.output[...] }}, and {{ files.groupNameOrPath }} * @param {string} template - Template to render * @returns {Promise<string>} Rendered template */ async render(template) { const regex = /\{\{([^}]+)\}\}/g; let lastIndex = 0; const parts = []; let match; while ((match = regex.exec(template)) !== null) { parts.push(template.substring(lastIndex, match.index)); lastIndex = regex.lastIndex; const reference = match[1].trim(); let replacement = match[0]; // Default to original placeholder let resolved = false; // Flag to track if resolved try { // --- Resolution Order v5 (Context First) --- // 1. Is it a File Collection reference? if (reference.startsWith('files.')) { const collectionName = reference.substring('files.'.length); try { replacement = await this.fileCollectionManager.loadAndRenderFiles(collectionName); resolved = true; logger.debug(`Successfully resolved \'${reference}\' as file collection.`); } catch (collectionError) { logger.warn(`File collection resolution failed for \'${reference}\': ${collectionError.message}`); // Allow falling through to context/output check? No, file ref should be explicit. throw collectionError; } } // 2. Try resolving as a direct Context Variable FIRST else { let contextValue = undefined; let contextError = null; // Track context error separately try { contextValue = await this.resolveContextVariable(reference); // Check if successfully resolved (not undefined) if (contextValue !== undefined) { replacement = contextValue; resolved = true; logger.debug(`Successfully resolved '${reference}' as context variable.`); } else { // Variable exists in context but is undefined OR path wasn't found. // resolveContextVariable already logs warnings. // We don't log here to avoid duplicate messages. // Do not set resolved = true; allow fallback. } } catch (e) { // Log the error but don't re-throw immediately, allow fallback to output ref check logger.warn(`Context variable resolution failed for '${reference}': ${e.message}`); contextError = e; // Store the error } } // 3. If NOT resolved yet, check if it looks like and resolves as an Output Reference if (!resolved && this.isOutputReference(reference)) { try { replacement = await this.referenceResolver.resolveReference(reference); resolved = true; logger.debug(`Successfully resolved \'${reference}\' as output reference.`); } catch (outputResolveError) { // Log the error, but don't re-throw if context check already happened. // If we reach here, it means context failed *and* output ref failed. logger.warn(`Output reference resolution failed for \'${reference}\': ${outputResolveError.message}`); // Propagate the error *only if* context resolution didn't already fail (to avoid masking context error) // Actually, let the final error handling outside catch it. throw outputResolveError; // Throw the specific error that occurred } } // Handle cases where nothing resolved the reference if (!resolved) { // If we got here, neither file, context, nor output reference worked. logger.warn(`Reference \'${reference}\' could not be resolved. Rendering as empty string.`); // Render empty string for unresolved references, consistent with previous behavior replacement = ''; // No longer throwing an error here // throw new Error(`Reference ${reference} could not be resolved`); } else { // --- Stringification logic (only if resolved) --- // Handle null/undefined -> render as empty string if (replacement === null || replacement === undefined) { replacement = ''; } // Handle arrays -> join with comma (no space) else if (Array.isArray(replacement)) { replacement = replacement.join(','); } // Handle other non-strings -> stringify else if (typeof replacement !== 'string') { try { replacement = JSON.stringify(replacement, null, 2); } catch (stringifyError) { logger.error(`Failed to stringify resolved value for reference \'${reference}\': ${stringifyError.message}`); replacement = `[Error: Failed to stringify value]`; } } } } catch (error) { // Catch errors propagated from resolution steps logger.warn(`Error rendering reference \'${reference}\': ${error.message}`); replacement = `[Error: ${error.message}]`; // Render error message in place } parts.push(replacement); } parts.push(template.substring(lastIndex)); return parts.join(''); } /** * Resolves a file collection reference like files.collectionName * @param {string} collectionName - The name of the collection. * @returns {Promise<string>} Concatenated content of resolved files. * @deprecated This method might be redundant if fileCollectionManager.loadAndRenderFiles handles everything. */ async resolveFileCollectionReference(collectionName) { logger.debug(`Resolving file collection reference: 'files.${collectionName}'`); // Assuming loadAndRenderFiles exists on the manager and returns the final string content return await this.fileCollectionManager.loadAndRenderFiles(collectionName); } /** * Resolve a variable key against the context and workflow outputs. * Order: * 1. `this.` references (iteration, set outputs) * 2. Direct context lookup (e.g., `item.content`, `global_var`) * 3. Output references (e.g., `set_name.task_name.output_key`) * @param {string} key - The variable key (e.g., "item.content", "this.iteration.key"). * @param {object} context - The current execution context. * @returns {any} The resolved value, or undefined if not found. */ async resolveContextVariable(key) { // 1. Check for 'this' reference if (key.startsWith('this.')) { const subKey = key.substring(5); if (!this.context.this) { logger.warn(`Context has no 'this' object for key: ${key}`); return undefined; } // Resolve within context.this (e.g., this.iteration.key, this.set.outputs.task_name) let thisValue = this._getNestedProperty(this.context.this, subKey); if (thisValue !== undefined) { return thisValue; } // If not directly in context.this, maybe it's an output from the *current* set? // e.g. this.task_name (equivalent to this.set.outputs.task_name) const currentSetOutputs = this.context.this?.set?.outputs || {}; if (subKey in currentSetOutputs) { return currentSetOutputs[subKey]; } } // 2. Try resolving directly from the context object (handling dot notation) let directValue = undefined; try { // Avoid treating single words as nested paths if (!key.includes('.')) { if (this.context && key in this.context) { directValue = this.context[key]; } } else { // Handle potential dot notation const keys = key.split('.'); let current = this.context; let found = true; for (const k of keys) { // Prevent accessing prototype properties if (current && typeof current === 'object' && Object.hasOwnProperty.call(current, k)) { current = current[k]; } else { found = false; break; // Path doesn't exist } } if (found) { directValue = current; } } } catch (e) { logger.warn(`Potential error during direct context lookup for key "${key}": ${e.message}`); // Ignore errors, means it wasn't a direct path or value wasn't found } // If found directly, return it if (directValue !== undefined) { return directValue; } // --- Fallback Logic for item.PROPERTY -> item.value.PROPERTY --- // If direct resolution failed AND the key starts with "item." (but isn't just "item" or "item.key") // AND context.item.value exists, try resolving inside item.value if (key.startsWith('item.') && key !== 'item' && key !== 'item.key' && this.context?.item?.value) { const subKey = key.substring(5); // Get the property name after "item." logger.debug(`Direct context lookup failed for '${key}', attempting fallback lookup in item.value.${subKey}`); try { // Use the _getNestedProperty helper to safely access potentially nested properties let fallbackValue = this._getNestedProperty(this.context.item.value, subKey); if (fallbackValue !== undefined) { logger.debug(`Successfully resolved '${key}' via fallback to item.value.${subKey}`); return fallbackValue; } } catch (e) { logger.warn(`Error during fallback lookup for key "${key}" in item.value: ${e.message}`); // Ignore errors, means fallback path didn't exist } } // --- End Fallback Logic --- // 3. If not found directly or via fallback, check if it looks like and resolves as an output reference if (!key.startsWith('this.') && this.isOutputReference(key)) { try { const parts = key.split('.'); const setName = parts[0]; const taskName = parts[1]; const outputKey = parts.slice(2).join('.'); // Handle nested output keys if needed if (this.workflowOutputs[setName] && this.workflowOutputs[setName][taskName]) { if (!outputKey) { // Reference to the whole task output object return this.workflowOutputs[setName][taskName]; } else { // Reference to a specific key within the task output let outputValue = this._getNestedProperty(this.workflowOutputs[setName][taskName], outputKey); if (outputValue !== undefined) { return outputValue; } } } } catch (error) { logger.error(`Error resolving output reference {{${key}}}: ${error.message}`); return undefined; // Error during output resolution } } // 4. If none of the above worked, return undefined logger.warn(`Variable {{${key}}} could not be resolved from context or outputs.`); return undefined; } /** * Checks if a reference *looks like* an output reference that the OutputReferenceResolver might handle. * This is intentionally broader than the resolver's own regex to ensure potential matches are passed to it. * @param {string} reference - Reference to check * @returns {boolean} True if reference might be an output reference */ isOutputReference(reference) { const trimmedRef = reference.trim(); // Allow '[this]' explicitly if (trimmedRef === '[this]') { return true; } // Stricter check based on README examples (name.name.output or name.name[key].output) // 1. Must not contain ':' (avoids collection patterns) if (trimmedRef.includes(':')) { return false; } if (!trimmedRef.includes('.')) { return false; } const hasBracket = trimmedRef.includes('['); const parts = trimmedRef.split('.'); // Allow if it has brackets (e.g., set.task[key].output) OR // if it has multiple parts (e.g., set.task.output) return hasBracket || parts.length > 2 || (parts.length === 2 && parts[1].includes('output')); // Allow simple task.output } /** * Safely get a nested property from an object using dot notation. * @param {object} obj The object to query. * @param {string} path The dot-separated path string. * @returns {any} The value at the path, or undefined if not found. * @private // Indicate intention for internal use */ _getNestedProperty(obj, path) { if (!path) return undefined; const keys = path.split('.'); let current = obj; for (const key of keys) { // Prevent accessing prototype properties and check for null/undefined if (current === null || typeof current !== 'object' || !Object.hasOwnProperty.call(current, key)) { return undefined; // Path doesn't exist safely } current = current[key]; } // Check if the final value is undefined return current !== undefined ? current : undefined; } }