cmte
Version:
Design by Committee™ except it's just you and LLMs
351 lines (325 loc) • 15.5 kB
JavaScript
/**
* 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;
}
}