/** * Tool Description Banner — embeds live task status into tool descriptions. * * When the orchestrating LLM re-fetches the tool list (via * notifications/tools/list_changed), the description for respond-task, * message-task, and cancel-task includes a compact status footer showing * running/completed/question-pending tasks. * * Hard cap: 500 chars max for the banner to stay within tool description limits. */ import type { TaskState } from '../task/task-state.js'; import { TaskStatus, isTerminalStatus } from '../task/task-state.js'; const BANNER_MAX_CHARS = 500; const RECENTLY_TERMINAL_WINDOW_MS = 5 * 60 * 1000; // 5 minutes function truncateBanner(text: string): string { if (text.length <= BANNER_MAX_CHARS) return text; return text.slice(0, BANNER_MAX_CHARS - 3) + '...'; } /** * Build a compact status banner for message-task and cancel-task descriptions. * Shows: running count, recently completed tasks, tasks needing answers. * Returns '' when nothing to report. */ export function buildStatusBanner(allTasks: TaskState[]): string { const now = Date.now(); const running: string[] = []; const needsAnswer: string[] = []; const recentlyDone: { id: string; status: string; agoMs: number }[] = []; for (const task of allTasks) { if (task.status === TaskStatus.WAITING_ANSWER) { needsAnswer.push(task.id); } else if (task.status === TaskStatus.RUNNING || task.status === TaskStatus.PENDING) { running.push(task.id); } else if (isTerminalStatus(task.status)) { const updatedMs = new Date(task.updatedAt).getTime(); const agoMs = now - updatedMs; if (agoMs <= RECENTLY_TERMINAL_WINDOW_MS) { recentlyDone.push({ id: task.id, status: task.status, agoMs }); } } } if (running.length === 0 && needsAnswer.length === 0 && recentlyDone.length === 0) { return ''; } const parts: string[] = ['---']; // Summary line const summaryParts: string[] = []; if (running.length > 0) summaryParts.push(`${running.length} running`); if (needsAnswer.length > 0) summaryParts.push(`${needsAnswer.length} needs answer`); if (recentlyDone.length > 0) summaryParts.push(`${recentlyDone.length} recently finished`); parts.push(`AGENT STATUS: ${summaryParts.join(' | ')}`); // Tasks needing answers for (const id of needsAnswer) { parts.push(`- ${id} [waiting_answer] — use respond-task`); } // Recently terminal tasks (most recent first, limit 3) const sorted = recentlyDone.sort((a, b) => a.agoMs - b.agoMs).slice(0, 3); for (const t of sorted) { const ago = t.agoMs < 60_000 ? `${Math.round(t.agoMs / 1000)}s ago` : `${Math.round(t.agoMs / 60_000)}min ago`; parts.push(`- ${t.id} [${t.status}] (${ago})`); } parts.push('Read task:///all for full details.'); return truncateBanner(parts.join('\n')); } /** * Build a status banner specifically for respond-task. * Focuses on tasks with pending questions, showing question text and answer format. * Returns '' when no questions are pending. */ export function buildRespondBanner(allTasks: TaskState[]): string { const tasksWithQuestions = allTasks .filter(t => t.status === TaskStatus.WAITING_ANSWER && t.pendingQuestions.length > 0); if (tasksWithQuestions.length === 0) return ''; const parts: string[] = ['---']; parts.push(`ACTION REQUIRED — ${tasksWithQuestions.length} task(s) waiting for your answer:`); for (const task of tasksWithQuestions) { const pq = task.pendingQuestions[0]!; if (pq.type === 'user_input' && pq.questions.length > 0) { const firstQ = pq.questions[0]!; let line = `- ${task.id}: "${firstQ.text}"`; if (firstQ.options && firstQ.options.length > 0) { const choiceStr = firstQ.options.map((c, i) => `${i + 1}) ${String(c)}`).join(' '); line += ` Options: ${choiceStr}`; } parts.push(line); } else { parts.push(`- ${task.id}: [${pq.type}]`); } } parts.push('Use respond-task { "task_id": "", ... }'); return truncateBanner(parts.join('\n')); }