/ src / agents / utils / context-builder.js
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  }