/ src / services / AgentSummary / agentSummary.ts
agentSummary.ts
  1  /**
  2   * Periodic background summarization for coordinator mode sub-agents.
  3   *
  4   * Forks the sub-agent's conversation every ~30s using runForkedAgent()
  5   * to generate a 1-2 sentence progress summary. The summary is stored
  6   * on AgentProgress for UI display.
  7   *
  8   * Cache sharing: uses the same CacheSafeParams as the parent agent
  9   * to share the prompt cache. Tools are kept in the request for cache
 10   * key matching but denied via canUseTool callback.
 11   */
 12  
 13  import type { TaskContext } from '../../Task.js'
 14  import { updateAgentSummary } from '../../tasks/LocalAgentTask/LocalAgentTask.js'
 15  import { filterIncompleteToolCalls } from '../../tools/AgentTool/runAgent.js'
 16  import type { AgentId } from '../../types/ids.js'
 17  import { logForDebugging } from '../../utils/debug.js'
 18  import {
 19    type CacheSafeParams,
 20    runForkedAgent,
 21  } from '../../utils/forkedAgent.js'
 22  import { logError } from '../../utils/log.js'
 23  import { createUserMessage } from '../../utils/messages.js'
 24  import { getAgentTranscript } from '../../utils/sessionStorage.js'
 25  
 26  const SUMMARY_INTERVAL_MS = 30_000
 27  
 28  function buildSummaryPrompt(previousSummary: string | null): string {
 29    const prevLine = previousSummary
 30      ? `\nPrevious: "${previousSummary}" — say something NEW.\n`
 31      : ''
 32  
 33    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.
 34  ${prevLine}
 35  Good: "Reading runAgent.ts"
 36  Good: "Fixing null check in validate.ts"
 37  Good: "Running auth module tests"
 38  Good: "Adding retry logic to fetchUser"
 39  
 40  Bad (past tense): "Analyzed the branch diff"
 41  Bad (too vague): "Investigating the issue"
 42  Bad (too long): "Reviewing full branch diff and AgentTool.tsx integration"
 43  Bad (branch name): "Analyzed adam/background-summary branch diff"`
 44  }
 45  
 46  export function startAgentSummarization(
 47    taskId: string,
 48    agentId: AgentId,
 49    cacheSafeParams: CacheSafeParams,
 50    setAppState: TaskContext['setAppState'],
 51  ): { stop: () => void } {
 52    // Drop forkContextMessages from the closure — runSummary rebuilds it each
 53    // tick from getAgentTranscript(). Without this, the original fork messages
 54    // (passed from AgentTool.tsx) are pinned for the lifetime of the timer.
 55    const { forkContextMessages: _drop, ...baseParams } = cacheSafeParams
 56    let summaryAbortController: AbortController | null = null
 57    let timeoutId: ReturnType<typeof setTimeout> | null = null
 58    let stopped = false
 59    let previousSummary: string | null = null
 60  
 61    async function runSummary(): Promise<void> {
 62      if (stopped) return
 63  
 64      logForDebugging(`[AgentSummary] Timer fired for agent ${agentId}`)
 65  
 66      try {
 67        // Read current messages from transcript
 68        const transcript = await getAgentTranscript(agentId)
 69        if (!transcript || transcript.messages.length < 3) {
 70          // Not enough context yet — finally block will schedule next attempt
 71          logForDebugging(
 72            `[AgentSummary] Skipping summary for ${taskId}: not enough messages (${transcript?.messages.length ?? 0})`,
 73          )
 74          return
 75        }
 76  
 77        // Filter to clean message state
 78        const cleanMessages = filterIncompleteToolCalls(transcript.messages)
 79  
 80        // Build fork params with current messages
 81        const forkParams: CacheSafeParams = {
 82          ...baseParams,
 83          forkContextMessages: cleanMessages,
 84        }
 85  
 86        logForDebugging(
 87          `[AgentSummary] Forking for summary, ${cleanMessages.length} messages in context`,
 88        )
 89  
 90        // Create abort controller for this summary
 91        summaryAbortController = new AbortController()
 92  
 93        // Deny tools via callback, NOT by passing tools:[] - that busts cache
 94        const canUseTool = async () => ({
 95          behavior: 'deny' as const,
 96          message: 'No tools needed for summary',
 97          decisionReason: { type: 'other' as const, reason: 'summary only' },
 98        })
 99  
100        // DO NOT set maxOutputTokens here. The fork piggybacks on the main
101        // thread's prompt cache by sending identical cache-key params (system,
102        // tools, model, messages prefix, thinking config). Setting maxOutputTokens
103        // would clamp budget_tokens, creating a thinking config mismatch that
104        // invalidates the cache.
105        //
106        // ContentReplacementState is cloned by default in createSubagentContext
107        // from forkParams.toolUseContext (the subagent's LIVE state captured at
108        // onCacheSafeParams time). No explicit override needed.
109        const result = await runForkedAgent({
110          promptMessages: [
111            createUserMessage({ content: buildSummaryPrompt(previousSummary) }),
112          ],
113          cacheSafeParams: forkParams,
114          canUseTool,
115          querySource: 'agent_summary',
116          forkLabel: 'agent_summary',
117          overrides: { abortController: summaryAbortController },
118          skipTranscript: true,
119        })
120  
121        if (stopped) return
122  
123        // Extract summary text from result
124        for (const msg of result.messages) {
125          if (msg.type !== 'assistant') continue
126          // Skip API error messages
127          if (msg.isApiErrorMessage) {
128            logForDebugging(
129              `[AgentSummary] Skipping API error message for ${taskId}`,
130            )
131            continue
132          }
133          const textBlock = msg.message.content.find(b => b.type === 'text')
134          if (textBlock?.type === 'text' && textBlock.text.trim()) {
135            const summaryText = textBlock.text.trim()
136            logForDebugging(
137              `[AgentSummary] Summary result for ${taskId}: ${summaryText}`,
138            )
139            previousSummary = summaryText
140            updateAgentSummary(taskId, summaryText, setAppState)
141            break
142          }
143        }
144      } catch (e) {
145        if (!stopped && e instanceof Error) {
146          logError(e)
147        }
148      } finally {
149        summaryAbortController = null
150        // Reset timer on completion (not initiation) to prevent overlapping summaries
151        if (!stopped) {
152          scheduleNext()
153        }
154      }
155    }
156  
157    function scheduleNext(): void {
158      if (stopped) return
159      timeoutId = setTimeout(runSummary, SUMMARY_INTERVAL_MS)
160    }
161  
162    function stop(): void {
163      logForDebugging(`[AgentSummary] Stopping summarization for ${taskId}`)
164      stopped = true
165      if (timeoutId) {
166        clearTimeout(timeoutId)
167        timeoutId = null
168      }
169      if (summaryAbortController) {
170        summaryAbortController.abort()
171        summaryAbortController = null
172      }
173    }
174  
175    // Start the first timer
176    scheduleNext()
177  
178    return { stop }
179  }