context-builder.js
1 /** 2 * Context Builder Utility 3 * 4 * Builds enriched agent context with task history for learning. 5 * Agents learn from past successes/failures to improve future performance. 6 */ 7 8 import { getAll } from '../../utils/db.js'; 9 import { loadContextWithMetadata } from './context-loader.js'; 10 11 // In-memory cache for task history (30 minute TTL) 12 const taskHistoryCache = new Map(); 13 const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes 14 15 /** 16 * Build enriched agent context with task history 17 * 18 * Adds recent task history to base context for learning: 19 * - Recent successful tasks (patterns that work) 20 * - Recent failures (mistakes to avoid) 21 * - Related tasks (same file/error type) 22 * 23 * @param {string} agentName - Agent name 24 * @param {string[]} contextFiles - Base context files to load 25 * @param {Object} [currentTask] - Current task being processed (for related history) 26 * @returns {Promise<Object>} - Enriched context object 27 * 28 * @example 29 * const context = await buildAgentContext('developer', ['base.md', 'developer.md'], task); 30 * // context.fullContext includes base + history 31 * // context.baseContext is original context 32 * // context.historyContext is task history 33 */ 34 export async function buildAgentContext(agentName, contextFiles, currentTask = null) { 35 // Load base context 36 const baseContext = await loadContextWithMetadata(contextFiles); 37 38 // Check if history is enabled 39 const enableHistory = process.env.AGENT_ENABLE_TASK_HISTORY !== 'false'; 40 if (!enableHistory) { 41 return { 42 fullContext: baseContext.context, 43 baseContext: baseContext.context, 44 historyContext: null, 45 historyTokens: 0, 46 totalTokens: estimateTokens(baseContext.context), 47 metadata: baseContext, 48 }; 49 } 50 51 // Get recent task history 52 const recentSuccesses = await getRecentCompletedTasks(agentName, 10); 53 const recentFailures = await getRecentFailedTasks(agentName, 5); 54 const relatedTasks = currentTask ? await getRelatedTasks(agentName, currentTask, 5) : []; 55 56 // Format history sections 57 const historyContext = formatTaskHistory(recentSuccesses, recentFailures, relatedTasks); 58 59 // Combine base + history 60 const fullContext = `${baseContext.context}\n\n${historyContext}`; 61 62 return { 63 fullContext, 64 baseContext: baseContext.context, 65 historyContext, 66 historyTokens: estimateTokens(historyContext), 67 totalTokens: estimateTokens(fullContext), 68 metadata: { 69 ...baseContext, 70 historyStats: { 71 recentSuccesses: recentSuccesses.length, 72 recentFailures: recentFailures.length, 73 relatedTasks: relatedTasks.length, 74 }, 75 }, 76 }; 77 } 78 79 /** 80 * Get recent completed tasks for an agent 81 * 82 * @param {string} agentName - Agent name 83 * @param {number} limit - Maximum tasks to retrieve 84 * @returns {Promise<Array<Object>>} - Recent successful tasks 85 */ 86 async function getRecentCompletedTasks(agentName, limit) { 87 const cacheKey = `${agentName}:successes:${limit}`; 88 const cached = getCached(cacheKey); 89 if (cached) return cached; 90 91 const tasks = await getAll( 92 `SELECT 93 t.id, 94 t.task_type, 95 t.created_at, 96 t.completed_at, 97 t.context_json, 98 t.result_json, 99 o.duration_ms, 100 o.result_json as outcome_result 101 FROM tel.agent_tasks t 102 LEFT JOIN tel.agent_outcomes o ON o.task_id = t.id AND o.outcome = 'success' 103 WHERE t.assigned_to = $1 104 AND t.status = 'completed' 105 AND t.completed_at > NOW() - INTERVAL '7 days' 106 ORDER BY t.completed_at DESC 107 LIMIT $2`, 108 [agentName, limit] 109 ); 110 111 // Parse JSON fields 112 const parsed = tasks.map(t => ({ 113 ...t, 114 context_json: t.context_json ? tryParseJSON(t.context_json) : null, 115 result_json: t.result_json ? tryParseJSON(t.result_json) : null, 116 outcome_result: t.outcome_result ? tryParseJSON(t.outcome_result) : null, 117 })); 118 119 setCached(cacheKey, parsed); 120 return parsed; 121 } 122 123 /** 124 * Get recent failed tasks for an agent 125 * 126 * @param {string} agentName - Agent name 127 * @param {number} limit - Maximum tasks to retrieve 128 * @returns {Promise<Array<Object>>} - Recent failed tasks 129 */ 130 async function getRecentFailedTasks(agentName, limit) { 131 const cacheKey = `${agentName}:failures:${limit}`; 132 const cached = getCached(cacheKey); 133 if (cached) return cached; 134 135 const tasks = await getAll( 136 `SELECT 137 t.id, 138 t.task_type, 139 t.created_at, 140 t.completed_at, 141 t.error_message, 142 t.context_json, 143 o.context_json as outcome_context, 144 o.duration_ms 145 FROM tel.agent_tasks t 146 LEFT JOIN tel.agent_outcomes o ON o.task_id = t.id AND o.outcome = 'failure' 147 WHERE t.assigned_to = $1 148 AND t.status = 'failed' 149 AND t.completed_at > NOW() - INTERVAL '7 days' 150 ORDER BY t.completed_at DESC 151 LIMIT $2`, 152 [agentName, limit] 153 ); 154 155 // Parse JSON fields 156 const parsed = tasks.map(t => ({ 157 ...t, 158 context_json: t.context_json ? tryParseJSON(t.context_json) : null, 159 outcome_context: t.outcome_context ? tryParseJSON(t.outcome_context) : null, 160 })); 161 162 setCached(cacheKey, parsed); 163 return parsed; 164 } 165 166 /** 167 * Get related tasks (same file or error type) 168 * 169 * @param {string} agentName - Agent name 170 * @param {Object} currentTask - Current task 171 * @param {number} limit - Maximum tasks to retrieve 172 * @returns {Promise<Array<Object>>} - Related tasks 173 */ 174 async function getRelatedTasks(agentName, currentTask, limit) { 175 if (!currentTask || !currentTask.context_json) return []; 176 177 const context = currentTask.context_json; 178 const filePath = context.file_path || context.file || extractFilePathFromContext(context); 179 const errorType = context.error_type; 180 181 if (!filePath && !errorType) return []; 182 183 // Cache key based on file + error type 184 const cacheKey = `${agentName}:related:${filePath || 'none'}:${errorType || 'none'}:${limit}`; 185 const cached = getCached(cacheKey); 186 if (cached) return cached; 187 188 // Build WHERE clause with positional params 189 const conditions = []; 190 const params = [agentName]; 191 let paramIdx = 2; 192 193 if (filePath) { 194 conditions.push(`(t.context_json::text LIKE $${paramIdx} OR t.result_json::text LIKE $${paramIdx + 1})`); 195 params.push(`%${filePath}%`, `%${filePath}%`); 196 paramIdx += 2; 197 } 198 199 if (errorType) { 200 conditions.push(`t.context_json::text LIKE $${paramIdx}`); 201 params.push(`%${errorType}%`); 202 paramIdx++; 203 } 204 205 params.push(limit); 206 207 const whereClause = conditions.join(' OR '); 208 209 const tasks = await getAll( 210 `SELECT 211 t.id, 212 t.task_type, 213 t.status, 214 t.created_at, 215 t.completed_at, 216 t.context_json, 217 t.result_json, 218 o.outcome, 219 o.duration_ms 220 FROM tel.agent_tasks t 221 LEFT JOIN tel.agent_outcomes o ON o.task_id = t.id 222 WHERE t.assigned_to = $1 223 AND (${whereClause}) 224 AND t.status IN ('completed', 'failed') 225 AND t.completed_at > NOW() - INTERVAL '30 days' 226 ORDER BY t.completed_at DESC 227 LIMIT $${paramIdx}`, 228 params 229 ); 230 231 // Parse JSON fields 232 const parsed = tasks.map(t => ({ 233 ...t, 234 context_json: t.context_json ? tryParseJSON(t.context_json) : null, 235 result_json: t.result_json ? tryParseJSON(t.result_json) : null, 236 })); 237 238 setCached(cacheKey, parsed); 239 return parsed; 240 } 241 242 /** 243 * Format task history into context string 244 * 245 * @param {Array<Object>} successes - Recent successful tasks 246 * @param {Array<Object>} failures - Recent failed tasks 247 * @param {Array<Object>} related - Related tasks 248 * @returns {string} - Formatted history context 249 */ 250 function formatTaskHistory(successes, failures, related) { 251 const sections = []; 252 253 sections.push('## Task History (Learning Context)\n'); 254 sections.push( 255 'The following is a summary of your recent task performance to help you learn from past experiences.\n' 256 ); 257 258 // Recent successes 259 if (successes.length > 0) { 260 sections.push('\n### Recent Successful Approaches (Last 7 Days)\n'); 261 sections.push('Learn from these successful patterns:\n'); 262 263 for (const task of successes.slice(0, 5)) { 264 const summary = formatSuccessfulTask(task); 265 if (summary) sections.push(summary); 266 } 267 } 268 269 // Recent failures 270 if (failures.length > 0) { 271 sections.push('\n### Past Failures to Avoid (Last 7 Days)\n'); 272 sections.push('Learn from these mistakes:\n'); 273 274 for (const task of failures.slice(0, 3)) { 275 const summary = formatFailedTask(task); 276 if (summary) sections.push(summary); 277 } 278 } 279 280 // Related tasks 281 if (related.length > 0) { 282 sections.push('\n### Related Tasks (Similar Context)\n'); 283 sections.push('Tasks involving similar files or error types:\n'); 284 285 for (const task of related.slice(0, 3)) { 286 const summary = formatRelatedTask(task); 287 if (summary) sections.push(summary); 288 } 289 } 290 291 if (successes.length === 0 && failures.length === 0 && related.length === 0) { 292 sections.push( 293 '\n*No historical task data available yet. As you complete tasks, this section will provide learning insights.*\n' 294 ); 295 } 296 297 return sections.join('\n'); 298 } 299 300 /** 301 * Format successful task into summary 302 * 303 * @param {Object} task - Successful task 304 * @returns {string|null} - Formatted summary or null 305 */ 306 function formatSuccessfulTask(task) { 307 const result = task.result_json || task.outcome_result; 308 if (!result) return null; 309 310 const parts = []; 311 parts.push(`- **${task.task_type}** (Task #${task.id})`); 312 313 // Extract key details 314 if (result.files_changed) { 315 parts.push(` - Files: ${result.files_changed.slice(0, 2).join(', ')}`); 316 } else if (result.file_path) { 317 parts.push(` - File: ${result.file_path}`); 318 } 319 320 if (result.approach || result.action_taken) { 321 parts.push(` - Approach: ${result.approach || result.action_taken}`); 322 } 323 324 if (task.duration_ms) { 325 parts.push(` - Duration: ${Math.round(task.duration_ms / 1000)}s`); 326 } 327 328 return parts.join('\n'); 329 } 330 331 /** 332 * Format failed task into summary 333 * 334 * @param {Object} task - Failed task 335 * @returns {string|null} - Formatted summary or null 336 */ 337 function formatFailedTask(task) { 338 if (!task.error_message) return null; 339 340 const parts = []; 341 parts.push(`- **${task.task_type}** (Task #${task.id})`); 342 343 // Normalize error message 344 const errorPreview = normalizeErrorMessage(task.error_message); 345 parts.push(` - Error: ${errorPreview}`); 346 347 // Extract context 348 const context = task.context_json || task.outcome_context; 349 if (context && context.error_type) { 350 parts.push(` - Error Type: ${context.error_type}`); 351 } 352 353 if (context && (context.file_path || context.file)) { 354 parts.push(` - File: ${context.file_path || context.file}`); 355 } 356 357 return parts.join('\n'); 358 } 359 360 /** 361 * Format related task into summary 362 * 363 * @param {Object} task - Related task 364 * @returns {string|null} - Formatted summary or null 365 */ 366 function extractRelatedInsight(outcome, context, result) { 367 if (outcome === 'completed' && result?.approach) { 368 return ` - What worked: ${result.approach.substring(0, 100)}`; 369 } 370 if (outcome === 'failed' && context?.error) { 371 return ` - What failed: ${normalizeErrorMessage(context.error)}`; 372 } 373 return null; 374 } 375 376 function formatRelatedTask(task) { 377 const parts = []; 378 const outcome = task.outcome || task.status; 379 const icon = outcome === 'success' || outcome === 'completed' ? '✓' : '✗'; 380 381 parts.push(`- ${icon} **${task.task_type}** (Task #${task.id}) - ${outcome}`); 382 383 // Extract file 384 const context = task.context_json; 385 const result = task.result_json; 386 const file = context?.file_path || context?.file || result?.file_path; 387 388 if (file) { 389 parts.push(` - File: ${file}`); 390 } 391 392 // Extract key insight 393 const insight = extractRelatedInsight(outcome, context, result); 394 if (insight) { 395 parts.push(insight); 396 } 397 398 return parts.join('\n'); 399 } 400 401 /** 402 * Extract file path from task context 403 * 404 * @param {Object} context - Task context 405 * @returns {string|null} - File path or null 406 */ 407 function extractFilePathFromContext(context) { 408 if (!context) return null; 409 410 // Try various field names 411 const candidates = [ 412 context.file_path, 413 context.file, 414 context.filePath, 415 context.affected_file, 416 context.files_changed?.[0], 417 ]; 418 419 for (const candidate of candidates) { 420 if (candidate && typeof candidate === 'string') { 421 return candidate; 422 } 423 } 424 425 // Try to extract from error message or stack trace 426 if (context.error_message) { 427 const match = context.error_message.match(/\/[^\s]+\.js/); 428 if (match) return match[0]; 429 } 430 431 if (context.stack_trace) { 432 const match = context.stack_trace.match(/\/[^\s]+\.js/); 433 if (match) return match[0]; 434 } 435 436 return null; 437 } 438 439 /** 440 * Normalize error message for display 441 * 442 * @param {string} error - Error message 443 * @returns {string} - Normalized error (max 100 chars) 444 */ 445 function normalizeErrorMessage(error) { 446 if (!error) return 'Unknown error'; 447 448 return ( 449 error 450 // Remove file paths 451 .replace(/\/[^\s]+\.js:\d+:\d+/g, '[file:line]') 452 // Remove absolute paths 453 .replace(/\/home\/[^\s]+/g, '[path]') 454 // Truncate 455 .substring(0, 100) 456 ); 457 } 458 459 /** 460 * Estimate token count (rough approximation: 1 token ≈ 4 chars) 461 * 462 * @param {string} text - Text to estimate 463 * @returns {number} - Estimated token count 464 */ 465 function estimateTokens(text) { 466 if (!text) return 0; 467 return Math.ceil(text.length / 4); 468 } 469 470 /** 471 * Try to parse JSON, return null on failure 472 * 473 * @param {string} json - JSON string 474 * @returns {Object|null} - Parsed object or null 475 */ 476 function tryParseJSON(json) { 477 try { 478 return JSON.parse(json); 479 } catch { 480 return null; 481 } 482 } 483 484 /** 485 * Get cached value 486 * 487 * @param {string} key - Cache key 488 * @returns {any|null} - Cached value or null 489 */ 490 function getCached(key) { 491 const cached = taskHistoryCache.get(key); 492 if (!cached) return null; 493 494 const { data, timestamp } = cached; 495 if (Date.now() - timestamp > CACHE_TTL_MS) { 496 taskHistoryCache.delete(key); 497 return null; 498 } 499 500 return data; 501 } 502 503 /** 504 * Set cached value 505 * 506 * @param {string} key - Cache key 507 * @param {any} data - Data to cache 508 */ 509 function setCached(key, data) { 510 taskHistoryCache.set(key, { data, timestamp: Date.now() }); 511 } 512 513 /** 514 * Clear task history cache (for testing) 515 */ 516 export function clearCache() { 517 taskHistoryCache.clear(); 518 }