crewai-ts
Version:
TypeScript port of crewAI for agent-based workflows
465 lines (464 loc) • 19 kB
JavaScript
/**
* Prompt template system implementation
* Optimized for efficient template rendering with token management
*/
import { tokenCounter } from './tokenCounter.js';
import { Cache } from './cache.js';
import { ValidationError } from './errors.js';
// Cache for compiled templates
const templateCache = new Cache({
maxSize: 100,
ttl: 3600000 // 1 hour
});
/**
* PromptTemplate class for efficient template management
* Optimized for:
* - Efficient rendering with caching
* - Token limit management
* - Flexible variable handling
*/
export class PromptTemplate {
template;
options;
truncationStrategy;
compiled = null;
constructor(template, options = {}, truncationStrategy) {
this.template = template;
this.options = {
cache: true,
strictVariables: false,
autoTruncate: true,
...options
};
this.truncationStrategy = truncationStrategy;
// Pre-compile template if caching is enabled
if (this.options.cache) {
this.getCompiledTemplate();
}
}
/**
* Render the template with variables
*/
async render(variables = {}) {
// Preprocess variables if function is provided
let processedVars = this.options.preprocess
? this.options.preprocess(variables)
: variables;
// Apply default values
if (this.options.defaults) {
processedVars = { ...this.options.defaults, ...processedVars };
}
// Apply parent template variables if extending
if (this.options.extends) {
const parentVars = this.options.extends.getDefaultVariables();
processedVars = { ...parentVars, ...processedVars };
}
// Automatically truncate long text variables if needed
if (this.options.autoTruncate && this.options.maxTokens && this.truncationStrategy) {
processedVars = await this.truncateVariables(processedVars);
}
// Get the compiled template function
const compiled = this.getCompiledTemplate();
// Render the template
let rendered = compiled(processedVars);
// Postprocess if function is provided
if (this.options.postprocess) {
rendered = this.options.postprocess(rendered);
}
// Handle parent template if extending
if (this.options.extends) {
rendered = await this.options.extends.render({
...processedVars,
content: rendered
});
}
// Check if the rendered template exceeds token limit
if (this.options.maxTokens) {
const { tokens } = tokenCounter.countTokens(rendered, this.options.model);
if (tokens > this.options.maxTokens) {
// If we can't truncate or have already tried, throw an error
if (!this.options.autoTruncate || !this.truncationStrategy) {
throw new ValidationError(`Rendered template exceeds token limit: ${tokens}/${this.options.maxTokens}`, { context: { template: this.template.substring(0, 100) + '...' } });
}
// Try emergency truncation of the final result
rendered = this.emergencyTruncate(rendered, this.options.maxTokens);
}
}
return rendered;
}
/**
* Get the default variables from the template
*/
getDefaultVariables() {
return this.options.defaults || {};
}
/**
* Set the truncation strategy
*/
setTruncationStrategy(strategy) {
this.truncationStrategy = strategy;
return this;
}
/**
* Get a compiled template function
* Performance-optimized with null safety
*/
getCompiledTemplate() {
// Return from instance if already compiled - performance optimization
if (this.compiled) {
return this.compiled;
}
// Check cache if enabled
const cacheKey = `${this.template}:${this.options.version || 'latest'}`;
if (this.options.cache) {
// Use synchronous get method to avoid Promise complications
try {
// Ensure we get a non-Promise synchronous result
const cached = templateCache.get(cacheKey);
if (cached && typeof cached === 'function') {
// Store only if it's actually a function, not a Promise
this.compiled = cached;
return cached;
}
}
catch (error) {
// Fallback to compiling if cache retrieval fails
}
}
// Compile the template - explicitly typed to ensure type safety
const compiledTemplate = this.compileTemplate(this.template);
this.compiled = compiledTemplate;
// Cache if enabled - use try/catch for async operations
if (this.options.cache && compiledTemplate) {
try {
templateCache.set(cacheKey, compiledTemplate);
}
catch (error) {
// Ignore cache set errors
}
}
return compiledTemplate;
}
/**
* Compile a template string into a template function
*/
compileTemplate(template) {
// Simple implementation using string replacement
// A more sophisticated implementation might use a proper template engine
// Find all variable placeholders in the format {{variableName}}
const variableRegex = /\{\{\s*([\w\.]+)\s*\}\}/g;
const variables = new Set();
let match;
// Collect all variable names with null safety
while ((match = variableRegex.exec(template)) !== null) {
if (match[1]) { // Ensure match[1] is defined
variables.add(match[1]);
}
}
// Create compiled template function
return (vars) => {
let result = template;
// Replace each variable placeholder with its value
for (const variable of variables) {
const parts = variable.split('.');
// Get nested property value
let value = vars;
for (const part of parts) {
if (value === undefined || value === null) {
break;
}
value = value[part];
}
// Handle missing variables
if (value === undefined || value === null) {
if (this.options.strictVariables) {
throw new ValidationError(`Missing variable: ${variable}`);
}
value = `{{${variable}}}`; // Keep placeholder for missing variables
}
// Convert non-string values to string
if (typeof value !== 'string') {
if (value === null || value === undefined) {
value = ''; // Handle null/undefined as empty strings
}
else if (typeof value === 'object') {
value = JSON.stringify(value, null, 2);
}
else {
value = String(value);
}
}
// Replace all occurrences
const regex = new RegExp(`\\{\\{\\s*${String(variable).replace('.', '\\.')}\\s*\\}\\}`, 'g');
result = result.replace(regex, value);
}
return result;
};
}
/**
* Truncate variables based on the truncation strategy
*/
async truncateVariables(variables) {
if (!this.truncationStrategy || !this.options.maxTokens) {
return variables;
}
// Get variable token counts
const tokenCounts = new Map();
let totalTokens = 0;
for (const [key, value] of Object.entries(variables)) {
if (typeof value === 'string' && value.length > 0) {
const tokenCount = tokenCounter.countTokens(value, this.options.model);
tokenCounts.set(key, tokenCount);
totalTokens += tokenCount.tokens;
}
}
// Estimate template overhead by rendering with empty strings
const emptyVars = {};
for (const key of Object.keys(variables)) {
if (typeof variables[key] === 'string') {
emptyVars[key] = '';
}
else {
emptyVars[key] = variables[key];
}
}
const compiled = this.getCompiledTemplate();
const emptyRendered = compiled(emptyVars);
const templateOverhead = tokenCounter.countTokens(emptyRendered, this.options.model).tokens;
// If already under max tokens with overhead, return original variables
if (totalTokens + templateOverhead <= this.options.maxTokens) {
return variables;
}
// Calculate available tokens for variables
const availableTokens = this.options.maxTokens - templateOverhead;
// Sort variables by priority
const prioritizedVars = Object.keys(variables)
.filter(key => typeof variables[key] === 'string' && variables[key].length > 0)
.sort((a, b) => {
const priorityA = this.truncationStrategy?.priorities[a] || 0;
const priorityB = this.truncationStrategy?.priorities[b] || 0;
return priorityB - priorityA; // Higher priority first
});
// Allocate tokens based on priority and minimum requirements
const allocations = new Map();
let remainingTokens = availableTokens;
// First pass: allocate minimum tokens
for (const key of prioritizedVars) {
const minTokens = this.truncationStrategy?.minimumTokens?.[key] || 0;
allocations.set(key, minTokens);
remainingTokens -= minTokens;
}
// Second pass: allocate remaining tokens by priority and proportion
if (remainingTokens > 0) {
// Sort by priority first then by proportion
const proportionVars = [...prioritizedVars].sort((a, b) => {
const priorityA = this.truncationStrategy?.priorities[a] || 0;
const priorityB = this.truncationStrategy?.priorities[b] || 0;
if (priorityA !== priorityB) {
return priorityB - priorityA; // Higher priority first
}
const proportionA = this.truncationStrategy?.proportions?.[a] || 0;
const proportionB = this.truncationStrategy?.proportions?.[b] || 0;
return proportionB - proportionA; // Higher proportion first
});
for (const key of proportionVars) {
if (remainingTokens <= 0)
break;
const proportion = this.truncationStrategy?.proportions?.[key] || 0;
const currentCount = tokenCounts.get(key)?.tokens || 0;
const currentAllocation = allocations.get(key) || 0;
// Calculate target allocation based on proportion
const targetAllocation = proportion > 0
? Math.floor(availableTokens * proportion)
: currentCount;
// Allocate up to target or remaining tokens
const additionalAllocation = Math.min(Math.max(0, targetAllocation - currentAllocation), remainingTokens, currentCount - currentAllocation);
allocations.set(key, currentAllocation + additionalAllocation);
remainingTokens -= additionalAllocation;
}
// Final pass: distribute any remaining tokens to highest priority vars
if (remainingTokens > 0) {
for (const key of prioritizedVars) {
if (remainingTokens <= 0)
break;
const currentCount = tokenCounts.get(key)?.tokens || 0;
const currentAllocation = allocations.get(key) || 0;
if (currentAllocation < currentCount) {
const additionalAllocation = Math.min(remainingTokens, currentCount - currentAllocation);
allocations.set(key, currentAllocation + additionalAllocation);
remainingTokens -= additionalAllocation;
}
}
}
}
// Create truncated variables
const truncatedVars = { ...variables };
for (const [key, allocation] of allocations.entries()) {
const currentCount = tokenCounts.get(key)?.tokens || 0;
if (allocation < currentCount) {
// Use custom truncate function if available
if (this.truncationStrategy?.customTruncate?.[key]) {
truncatedVars[key] = this.truncationStrategy.customTruncate[key](variables[key], allocation);
}
else {
// Default truncation: keep beginning and end, drop middle
truncatedVars[key] = this.truncateText(variables[key], allocation, this.options.model);
}
}
}
return truncatedVars;
}
/**
* Truncate text to fit within token limit
*/
truncateText(text, maxTokens, model) {
if (!text)
return text;
const { tokens } = tokenCounter.countTokens(text, model);
if (tokens <= maxTokens) {
return text;
}
// For very small allocations, just take the beginning
if (maxTokens < 10) {
let truncated = text.slice(0, maxTokens * 4); // Rough estimate: 4 chars per token
while (tokenCounter.countTokens(truncated, model).tokens > maxTokens) {
truncated = truncated.slice(0, Math.floor(truncated.length * 0.9));
}
return truncated + '...';
}
// For larger allocations, keep beginning and end, with middle truncated
const frontRatio = 0.7; // 70% from beginning
const backRatio = 0.3; // 30% from end
// Initial estimates
const frontTokens = Math.floor(maxTokens * frontRatio);
const backTokens = Math.floor(maxTokens * backRatio);
// Get the front chunk
let frontText = text.slice(0, Math.floor(text.length * frontRatio));
while (tokenCounter.countTokens(frontText, model).tokens > frontTokens) {
frontText = frontText.slice(0, Math.floor(frontText.length * 0.9));
}
// Get the back chunk
let backText = text.slice(Math.floor(text.length * (1 - backRatio)));
while (tokenCounter.countTokens(backText, model).tokens > backTokens) {
backText = backText.slice(Math.floor(backText.length * 0.1));
}
return frontText + ' [...] ' + backText;
}
/**
* Emergency truncation of rendered template
*/
emergencyTruncate(text, maxTokens) {
const { tokens } = tokenCounter.countTokens(text, this.options.model);
if (tokens <= maxTokens) {
return text;
}
// Keep 80% from beginning, 20% from end
const frontRatio = 0.8;
// Initial estimates
const frontTokens = Math.floor(maxTokens * frontRatio);
const backTokens = maxTokens - frontTokens - 1; // -1 for ellipsis
// Binary search for front text
let frontText = '';
let low = 0;
let high = text.length * frontRatio;
while (low < high) {
const mid = Math.floor((low + high) / 2);
const chunk = text.slice(0, mid);
const chunkTokens = tokenCounter.countTokens(chunk, this.options.model).tokens;
if (chunkTokens <= frontTokens) {
frontText = chunk;
low = mid + 1;
}
else {
high = mid - 1;
}
}
// Binary search for back text
let backText = '';
low = text.length * (1 - 0.2);
high = text.length;
while (low < high) {
const mid = Math.floor((low + high) / 2);
const chunk = text.slice(mid);
const chunkTokens = tokenCounter.countTokens(chunk, this.options.model).tokens;
if (chunkTokens <= backTokens) {
backText = chunk;
high = mid - 1;
}
else {
low = mid + 1;
}
}
return frontText + ' [...] ' + backText;
}
}
/**
* Create a template with predefined variables
*/
export function createTemplate(template, options = {}) {
return new PromptTemplate(template, options);
}
/**
* Predefined prompt templates
*/
export const templates = {
/**
* Standard agent system prompt template
*/
agentSystem: createTemplate('# Agent Information\n' +
'You are {{name}}, {{role}}.\n\n' +
'## Your Task\n' +
'{{task}}\n\n' +
'## Your Goal\n' +
'{{goal}}\n\n' +
'{{#if backgroundInfo}}' +
'## Background Information\n' +
'{{backgroundInfo}}\n\n' +
'{{/if}}' +
'## Guidelines\n' +
'- {{guidelines}}\n' +
'- Always maintain professionalism and accuracy in your responses.\n' +
'- If you do not know something or cannot provide accurate information, say so.\n', {
maxTokens: 1000,
defaults: {
guidelines: 'Focus on providing relevant, concise, and accurate information.'
}
}),
/**
* Human message template for conversational prompts
*/
humanMessage: createTemplate('{{content}}', { maxTokens: 8000 }),
/**
* Task execution template
*/
taskExecution: createTemplate('# Task: {{taskName}}\n\n' +
'## Objective\n' +
'{{taskDescription}}\n\n' +
'## Input Data\n' +
'```\n{{taskInput}}\n```\n\n' +
'{{#if taskContext}}' +
'## Context\n' +
'{{taskContext}}\n\n' +
'{{/if}}' +
'## Instructions\n' +
'Please complete this task according to the objective. ' +
'Provide your response in a clear and structured format.', {
maxTokens: 4000,
strictVariables: true
}),
/**
* Crew orchestration template
*/
crewOrchestration: createTemplate('# Crew Orchestration\n\n' +
'## Crew Objective\n' +
'{{crewObjective}}\n\n' +
'## Available Agents\n' +
'{{availableAgents}}\n\n' +
'## Current Task\n' +
'{{currentTask}}\n\n' +
'## Progress So Far\n' +
'{{progress}}\n\n' +
'## Instructions\n' +
'As the orchestrator, determine which agent should handle the current task ' +
'and provide a plan for completing the crew objective.', { maxTokens: 3000 })
};