/** * Periodic background summarization for coordinator mode sub-agents. * * Forks the sub-agent's conversation every ~30s using runForkedAgent() * to generate a 1-2 sentence progress summary. The summary is stored * on AgentProgress for UI display. * * Cache sharing: uses the same CacheSafeParams as the parent agent * to share the prompt cache. Tools are kept in the request for cache * key matching but denied via canUseTool callback. */ import type { TaskContext } from '../../Task.js' import { updateAgentSummary } from '../../tasks/LocalAgentTask/LocalAgentTask.js' import { filterIncompleteToolCalls } from '../../tools/AgentTool/runAgent.js' import type { AgentId } from '../../types/ids.js' import { logForDebugging } from '../../utils/debug.js' import { type CacheSafeParams, runForkedAgent, } from '../../utils/forkedAgent.js' import { logError } from '../../utils/log.js' import { createUserMessage } from '../../utils/messages.js' import { getAgentTranscript } from '../../utils/sessionStorage.js' const SUMMARY_INTERVAL_MS = 30_000 function buildSummaryPrompt(previousSummary: string | null): string { const prevLine = previousSummary ? `\nPrevious: "${previousSummary}" — say something NEW.\n` : '' return `Describe your most recent action in 3-5 words using present tense (-ing). Name the file or function, not the branch. Do not use tools. ${prevLine} Good: "Reading runAgent.ts" Good: "Fixing null check in validate.ts" Good: "Running auth module tests" Good: "Adding retry logic to fetchUser" Bad (past tense): "Analyzed the branch diff" Bad (too vague): "Investigating the issue" Bad (too long): "Reviewing full branch diff and AgentTool.tsx integration" Bad (branch name): "Analyzed adam/background-summary branch diff"` } export function startAgentSummarization( taskId: string, agentId: AgentId, cacheSafeParams: CacheSafeParams, setAppState: TaskContext['setAppState'], ): { stop: () => void } { // Drop forkContextMessages from the closure — runSummary rebuilds it each // tick from getAgentTranscript(). Without this, the original fork messages // (passed from AgentTool.tsx) are pinned for the lifetime of the timer. const { forkContextMessages: _drop, ...baseParams } = cacheSafeParams let summaryAbortController: AbortController | null = null let timeoutId: ReturnType | null = null let stopped = false let previousSummary: string | null = null async function runSummary(): Promise { if (stopped) return logForDebugging(`[AgentSummary] Timer fired for agent ${agentId}`) try { // Read current messages from transcript const transcript = await getAgentTranscript(agentId) if (!transcript || transcript.messages.length < 3) { // Not enough context yet — finally block will schedule next attempt logForDebugging( `[AgentSummary] Skipping summary for ${taskId}: not enough messages (${transcript?.messages.length ?? 0})`, ) return } // Filter to clean message state const cleanMessages = filterIncompleteToolCalls(transcript.messages) // Build fork params with current messages const forkParams: CacheSafeParams = { ...baseParams, forkContextMessages: cleanMessages, } logForDebugging( `[AgentSummary] Forking for summary, ${cleanMessages.length} messages in context`, ) // Create abort controller for this summary summaryAbortController = new AbortController() // Deny tools via callback, NOT by passing tools:[] - that busts cache const canUseTool = async () => ({ behavior: 'deny' as const, message: 'No tools needed for summary', decisionReason: { type: 'other' as const, reason: 'summary only' }, }) // DO NOT set maxOutputTokens here. The fork piggybacks on the main // thread's prompt cache by sending identical cache-key params (system, // tools, model, messages prefix, thinking config). Setting maxOutputTokens // would clamp budget_tokens, creating a thinking config mismatch that // invalidates the cache. // // ContentReplacementState is cloned by default in createSubagentContext // from forkParams.toolUseContext (the subagent's LIVE state captured at // onCacheSafeParams time). No explicit override needed. const result = await runForkedAgent({ promptMessages: [ createUserMessage({ content: buildSummaryPrompt(previousSummary) }), ], cacheSafeParams: forkParams, canUseTool, querySource: 'agent_summary', forkLabel: 'agent_summary', overrides: { abortController: summaryAbortController }, skipTranscript: true, }) if (stopped) return // Extract summary text from result for (const msg of result.messages) { if (msg.type !== 'assistant') continue // Skip API error messages if (msg.isApiErrorMessage) { logForDebugging( `[AgentSummary] Skipping API error message for ${taskId}`, ) continue } const textBlock = msg.message.content.find(b => b.type === 'text') if (textBlock?.type === 'text' && textBlock.text.trim()) { const summaryText = textBlock.text.trim() logForDebugging( `[AgentSummary] Summary result for ${taskId}: ${summaryText}`, ) previousSummary = summaryText updateAgentSummary(taskId, summaryText, setAppState) break } } } catch (e) { if (!stopped && e instanceof Error) { logError(e) } } finally { summaryAbortController = null // Reset timer on completion (not initiation) to prevent overlapping summaries if (!stopped) { scheduleNext() } } } function scheduleNext(): void { if (stopped) return timeoutId = setTimeout(runSummary, SUMMARY_INTERVAL_MS) } function stop(): void { logForDebugging(`[AgentSummary] Stopping summarization for ${taskId}`) stopped = true if (timeoutId) { clearTimeout(timeoutId) timeoutId = null } if (summaryAbortController) { summaryAbortController.abort() summaryAbortController = null } } // Start the first timer scheduleNext() return { stop } }