/ tools / AgentTool / agentToolUtils.ts
agentToolUtils.ts
  1  import { feature } from 'bun:bundle'
  2  import { z } from 'zod/v4'
  3  import { clearInvokedSkillsForAgent } from '../../bootstrap/state.js'
  4  import {
  5    ALL_AGENT_DISALLOWED_TOOLS,
  6    ASYNC_AGENT_ALLOWED_TOOLS,
  7    CUSTOM_AGENT_DISALLOWED_TOOLS,
  8    IN_PROCESS_TEAMMATE_ALLOWED_TOOLS,
  9  } from '../../constants/tools.js'
 10  import { startAgentSummarization } from '../../services/AgentSummary/agentSummary.js'
 11  import {
 12    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 13    logEvent,
 14  } from '../../services/analytics/index.js'
 15  import { clearDumpState } from '../../services/api/dumpPrompts.js'
 16  import type { AppState } from '../../state/AppState.js'
 17  import type {
 18    Tool,
 19    ToolPermissionContext,
 20    Tools,
 21    ToolUseContext,
 22  } from '../../Tool.js'
 23  import { toolMatchesName } from '../../Tool.js'
 24  import {
 25    completeAgentTask as completeAsyncAgent,
 26    createActivityDescriptionResolver,
 27    createProgressTracker,
 28    enqueueAgentNotification,
 29    failAgentTask as failAsyncAgent,
 30    getProgressUpdate,
 31    getTokenCountFromTracker,
 32    isLocalAgentTask,
 33    killAsyncAgent,
 34    type ProgressTracker,
 35    updateAgentProgress as updateAsyncAgentProgress,
 36    updateProgressFromMessage,
 37  } from '../../tasks/LocalAgentTask/LocalAgentTask.js'
 38  import { asAgentId } from '../../types/ids.js'
 39  import type { Message as MessageType } from '../../types/message.js'
 40  import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'
 41  import { logForDebugging } from '../../utils/debug.js'
 42  import { isInProtectedNamespace } from '../../utils/envUtils.js'
 43  import { AbortError, errorMessage } from '../../utils/errors.js'
 44  import type { CacheSafeParams } from '../../utils/forkedAgent.js'
 45  import { lazySchema } from '../../utils/lazySchema.js'
 46  import {
 47    extractTextContent,
 48    getLastAssistantMessage,
 49  } from '../../utils/messages.js'
 50  import type { PermissionMode } from '../../utils/permissions/PermissionMode.js'
 51  import { permissionRuleValueFromString } from '../../utils/permissions/permissionRuleParser.js'
 52  import {
 53    buildTranscriptForClassifier,
 54    classifyYoloAction,
 55  } from '../../utils/permissions/yoloClassifier.js'
 56  import { emitTaskProgress as emitTaskProgressEvent } from '../../utils/task/sdkProgress.js'
 57  import { isInProcessTeammate } from '../../utils/teammateContext.js'
 58  import { getTokenCountFromUsage } from '../../utils/tokens.js'
 59  import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../ExitPlanModeTool/constants.js'
 60  import { AGENT_TOOL_NAME, LEGACY_AGENT_TOOL_NAME } from './constants.js'
 61  import type { AgentDefinition } from './loadAgentsDir.js'
 62  export type ResolvedAgentTools = {
 63    hasWildcard: boolean
 64    validTools: string[]
 65    invalidTools: string[]
 66    resolvedTools: Tools
 67    allowedAgentTypes?: string[]
 68  }
 69  
 70  export function filterToolsForAgent({
 71    tools,
 72    isBuiltIn,
 73    isAsync = false,
 74    permissionMode,
 75  }: {
 76    tools: Tools
 77    isBuiltIn: boolean
 78    isAsync?: boolean
 79    permissionMode?: PermissionMode
 80  }): Tools {
 81    return tools.filter(tool => {
 82      // Allow MCP tools for all agents
 83      if (tool.name.startsWith('mcp__')) {
 84        return true
 85      }
 86      // Allow ExitPlanMode for agents in plan mode (e.g., in-process teammates)
 87      // This bypasses both the ALL_AGENT_DISALLOWED_TOOLS and async tool filters
 88      if (
 89        toolMatchesName(tool, EXIT_PLAN_MODE_V2_TOOL_NAME) &&
 90        permissionMode === 'plan'
 91      ) {
 92        return true
 93      }
 94      if (ALL_AGENT_DISALLOWED_TOOLS.has(tool.name)) {
 95        return false
 96      }
 97      if (!isBuiltIn && CUSTOM_AGENT_DISALLOWED_TOOLS.has(tool.name)) {
 98        return false
 99      }
100      if (isAsync && !ASYNC_AGENT_ALLOWED_TOOLS.has(tool.name)) {
101        if (isAgentSwarmsEnabled() && isInProcessTeammate()) {
102          // Allow AgentTool for in-process teammates to spawn sync subagents.
103          // Validation in AgentTool.call() prevents background agents and teammate spawning.
104          if (toolMatchesName(tool, AGENT_TOOL_NAME)) {
105            return true
106          }
107          // Allow task tools for in-process teammates to coordinate via shared task list
108          if (IN_PROCESS_TEAMMATE_ALLOWED_TOOLS.has(tool.name)) {
109            return true
110          }
111        }
112        return false
113      }
114      return true
115    })
116  }
117  
118  /**
119   * Resolves and validates agent tools against available tools
120   * Handles wildcard expansion and validation in one place
121   */
122  export function resolveAgentTools(
123    agentDefinition: Pick<
124      AgentDefinition,
125      'tools' | 'disallowedTools' | 'source' | 'permissionMode'
126    >,
127    availableTools: Tools,
128    isAsync = false,
129    isMainThread = false,
130  ): ResolvedAgentTools {
131    const {
132      tools: agentTools,
133      disallowedTools,
134      source,
135      permissionMode,
136    } = agentDefinition
137    // When isMainThread is true, skip filterToolsForAgent entirely — the main
138    // thread's tool pool is already properly assembled by useMergedTools(), so
139    // the sub-agent disallow lists shouldn't apply.
140    const filteredAvailableTools = isMainThread
141      ? availableTools
142      : filterToolsForAgent({
143          tools: availableTools,
144          isBuiltIn: source === 'built-in',
145          isAsync,
146          permissionMode,
147        })
148  
149    // Create a set of disallowed tool names for quick lookup
150    const disallowedToolSet = new Set(
151      disallowedTools?.map(toolSpec => {
152        const { toolName } = permissionRuleValueFromString(toolSpec)
153        return toolName
154      }) ?? [],
155    )
156  
157    // Filter available tools based on disallowed list
158    const allowedAvailableTools = filteredAvailableTools.filter(
159      tool => !disallowedToolSet.has(tool.name),
160    )
161  
162    // If tools is undefined or ['*'], allow all tools (after filtering disallowed)
163    const hasWildcard =
164      agentTools === undefined ||
165      (agentTools.length === 1 && agentTools[0] === '*')
166    if (hasWildcard) {
167      return {
168        hasWildcard: true,
169        validTools: [],
170        invalidTools: [],
171        resolvedTools: allowedAvailableTools,
172      }
173    }
174  
175    const availableToolMap = new Map<string, Tool>()
176    for (const tool of allowedAvailableTools) {
177      availableToolMap.set(tool.name, tool)
178    }
179  
180    const validTools: string[] = []
181    const invalidTools: string[] = []
182    const resolved: Tool[] = []
183    const resolvedToolsSet = new Set<Tool>()
184    let allowedAgentTypes: string[] | undefined
185  
186    for (const toolSpec of agentTools) {
187      // Parse the tool spec to extract the base tool name and any permission pattern
188      const { toolName, ruleContent } = permissionRuleValueFromString(toolSpec)
189  
190      // Special case: Agent tool carries allowedAgentTypes metadata in its spec
191      if (toolName === AGENT_TOOL_NAME) {
192        if (ruleContent) {
193          // Parse comma-separated agent types: "worker, researcher" → ["worker", "researcher"]
194          allowedAgentTypes = ruleContent.split(',').map(s => s.trim())
195        }
196        // For sub-agents, Agent is excluded by filterToolsForAgent — mark the spec
197        // valid for allowedAgentTypes tracking but skip tool resolution.
198        if (!isMainThread) {
199          validTools.push(toolSpec)
200          continue
201        }
202        // For main thread, filtering was skipped so Agent is in availableToolMap —
203        // fall through to normal resolution below.
204      }
205  
206      const tool = availableToolMap.get(toolName)
207      if (tool) {
208        validTools.push(toolSpec)
209        if (!resolvedToolsSet.has(tool)) {
210          resolved.push(tool)
211          resolvedToolsSet.add(tool)
212        }
213      } else {
214        invalidTools.push(toolSpec)
215      }
216    }
217  
218    return {
219      hasWildcard: false,
220      validTools,
221      invalidTools,
222      resolvedTools: resolved,
223      allowedAgentTypes,
224    }
225  }
226  
227  export const agentToolResultSchema = lazySchema(() =>
228    z.object({
229      agentId: z.string(),
230      // Optional: older persisted sessions won't have this (resume replays
231      // results verbatim without re-validation). Used to gate the sync
232      // result trailer — one-shot built-ins skip the SendMessage hint.
233      agentType: z.string().optional(),
234      content: z.array(z.object({ type: z.literal('text'), text: z.string() })),
235      totalToolUseCount: z.number(),
236      totalDurationMs: z.number(),
237      totalTokens: z.number(),
238      usage: z.object({
239        input_tokens: z.number(),
240        output_tokens: z.number(),
241        cache_creation_input_tokens: z.number().nullable(),
242        cache_read_input_tokens: z.number().nullable(),
243        server_tool_use: z
244          .object({
245            web_search_requests: z.number(),
246            web_fetch_requests: z.number(),
247          })
248          .nullable(),
249        service_tier: z.enum(['standard', 'priority', 'batch']).nullable(),
250        cache_creation: z
251          .object({
252            ephemeral_1h_input_tokens: z.number(),
253            ephemeral_5m_input_tokens: z.number(),
254          })
255          .nullable(),
256      }),
257    }),
258  )
259  
260  export type AgentToolResult = z.input<ReturnType<typeof agentToolResultSchema>>
261  
262  export function countToolUses(messages: MessageType[]): number {
263    let count = 0
264    for (const m of messages) {
265      if (m.type === 'assistant') {
266        for (const block of m.message.content) {
267          if (block.type === 'tool_use') {
268            count++
269          }
270        }
271      }
272    }
273    return count
274  }
275  
276  export function finalizeAgentTool(
277    agentMessages: MessageType[],
278    agentId: string,
279    metadata: {
280      prompt: string
281      resolvedAgentModel: string
282      isBuiltInAgent: boolean
283      startTime: number
284      agentType: string
285      isAsync: boolean
286    },
287  ): AgentToolResult {
288    const {
289      prompt,
290      resolvedAgentModel,
291      isBuiltInAgent,
292      startTime,
293      agentType,
294      isAsync,
295    } = metadata
296  
297    const lastAssistantMessage = getLastAssistantMessage(agentMessages)
298    if (lastAssistantMessage === undefined) {
299      throw new Error('No assistant messages found')
300    }
301    // Extract text content from the agent's response. If the final assistant
302    // message is a pure tool_use block (loop exited mid-turn), fall back to
303    // the most recent assistant message that has text content.
304    let content = lastAssistantMessage.message.content.filter(
305      _ => _.type === 'text',
306    )
307    if (content.length === 0) {
308      for (let i = agentMessages.length - 1; i >= 0; i--) {
309        const m = agentMessages[i]!
310        if (m.type !== 'assistant') continue
311        const textBlocks = m.message.content.filter(_ => _.type === 'text')
312        if (textBlocks.length > 0) {
313          content = textBlocks
314          break
315        }
316      }
317    }
318  
319    const totalTokens = getTokenCountFromUsage(lastAssistantMessage.message.usage)
320    const totalToolUseCount = countToolUses(agentMessages)
321  
322    logEvent('tengu_agent_tool_completed', {
323      agent_type:
324        agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
325      model:
326        resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
327      prompt_char_count: prompt.length,
328      response_char_count: content.length,
329      assistant_message_count: agentMessages.length,
330      total_tool_uses: totalToolUseCount,
331      duration_ms: Date.now() - startTime,
332      total_tokens: totalTokens,
333      is_built_in_agent: isBuiltInAgent,
334      is_async: isAsync,
335    })
336  
337    // Signal to inference that this subagent's cache chain can be evicted.
338    const lastRequestId = lastAssistantMessage.requestId
339    if (lastRequestId) {
340      logEvent('tengu_cache_eviction_hint', {
341        scope:
342          'subagent_end' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
343        last_request_id:
344          lastRequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
345      })
346    }
347  
348    return {
349      agentId,
350      agentType,
351      content,
352      totalDurationMs: Date.now() - startTime,
353      totalTokens,
354      totalToolUseCount,
355      usage: lastAssistantMessage.message.usage,
356    }
357  }
358  
359  /**
360   * Returns the name of the last tool_use block in an assistant message,
361   * or undefined if the message is not an assistant message with tool_use.
362   */
363  export function getLastToolUseName(message: MessageType): string | undefined {
364    if (message.type !== 'assistant') return undefined
365    const block = message.message.content.findLast(b => b.type === 'tool_use')
366    return block?.type === 'tool_use' ? block.name : undefined
367  }
368  
369  export function emitTaskProgress(
370    tracker: ProgressTracker,
371    taskId: string,
372    toolUseId: string | undefined,
373    description: string,
374    startTime: number,
375    lastToolName: string,
376  ): void {
377    const progress = getProgressUpdate(tracker)
378    emitTaskProgressEvent({
379      taskId,
380      toolUseId,
381      description: progress.lastActivity?.activityDescription ?? description,
382      startTime,
383      totalTokens: progress.tokenCount,
384      toolUses: progress.toolUseCount,
385      lastToolName,
386    })
387  }
388  
389  export async function classifyHandoffIfNeeded({
390    agentMessages,
391    tools,
392    toolPermissionContext,
393    abortSignal,
394    subagentType,
395    totalToolUseCount,
396  }: {
397    agentMessages: MessageType[]
398    tools: Tools
399    toolPermissionContext: AppState['toolPermissionContext']
400    abortSignal: AbortSignal
401    subagentType: string
402    totalToolUseCount: number
403  }): Promise<string | null> {
404    if (feature('TRANSCRIPT_CLASSIFIER')) {
405      if (toolPermissionContext.mode !== 'auto') return null
406  
407      const agentTranscript = buildTranscriptForClassifier(agentMessages, tools)
408      if (!agentTranscript) return null
409  
410      const classifierResult = await classifyYoloAction(
411        agentMessages,
412        {
413          role: 'user',
414          content: [
415            {
416              type: 'text',
417              text: "Sub-agent has finished and is handing back control to the main agent. Review the sub-agent's work based on the block rules and let the main agent know if any file is dangerous (the main agent will see the reason).",
418            },
419          ],
420        },
421        tools,
422        toolPermissionContext as ToolPermissionContext,
423        abortSignal,
424      )
425  
426      const handoffDecision = classifierResult.unavailable
427        ? 'unavailable'
428        : classifierResult.shouldBlock
429          ? 'blocked'
430          : 'allowed'
431      logEvent('tengu_auto_mode_decision', {
432        decision:
433          handoffDecision as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
434        toolName:
435          // Use legacy name for analytics continuity across the Task→Agent rename
436          LEGACY_AGENT_TOOL_NAME as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
437        inProtectedNamespace: isInProtectedNamespace(),
438        classifierModel:
439          classifierResult.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
440        agentType:
441          subagentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
442        toolUseCount: totalToolUseCount,
443        isHandoff: true,
444        // For handoff, the relevant agent completion is the subagent's final
445        // assistant message — the last thing the classifier transcript shows
446        // before the handoff review prompt.
447        agentMsgId: getLastAssistantMessage(agentMessages)?.message
448          .id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
449        classifierStage:
450          classifierResult.stage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
451        classifierStage1RequestId:
452          classifierResult.stage1RequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
453        classifierStage1MsgId:
454          classifierResult.stage1MsgId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
455        classifierStage2RequestId:
456          classifierResult.stage2RequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
457        classifierStage2MsgId:
458          classifierResult.stage2MsgId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
459      })
460  
461      if (classifierResult.shouldBlock) {
462        // When classifier is unavailable, still propagate the sub-agent's
463        // results but with a warning so the parent agent can verify the work.
464        if (classifierResult.unavailable) {
465          logForDebugging(
466            'Handoff classifier unavailable, allowing sub-agent output with warning',
467            { level: 'warn' },
468          )
469          return `Note: The safety classifier was unavailable when reviewing this sub-agent's work. Please carefully verify the sub-agent's actions and output before acting on them.`
470        }
471  
472        logForDebugging(
473          `Handoff classifier flagged sub-agent output: ${classifierResult.reason}`,
474          { level: 'warn' },
475        )
476        return `SECURITY WARNING: This sub-agent performed actions that may violate security policy. Reason: ${classifierResult.reason}. Review the sub-agent's actions carefully before acting on its output.`
477      }
478    }
479  
480    return null
481  }
482  
483  /**
484   * Extract a partial result string from an agent's accumulated messages.
485   * Used when an async agent is killed to preserve what it accomplished.
486   * Returns undefined if no text content is found.
487   */
488  export function extractPartialResult(
489    messages: MessageType[],
490  ): string | undefined {
491    for (let i = messages.length - 1; i >= 0; i--) {
492      const m = messages[i]!
493      if (m.type !== 'assistant') continue
494      const text = extractTextContent(m.message.content, '\n')
495      if (text) {
496        return text
497      }
498    }
499    return undefined
500  }
501  
502  type SetAppState = (f: (prev: AppState) => AppState) => void
503  
504  /**
505   * Drives a background agent from spawn to terminal notification.
506   * Shared between AgentTool's async-from-start path and resumeAgentBackground.
507   */
508  export async function runAsyncAgentLifecycle({
509    taskId,
510    abortController,
511    makeStream,
512    metadata,
513    description,
514    toolUseContext,
515    rootSetAppState,
516    agentIdForCleanup,
517    enableSummarization,
518    getWorktreeResult,
519  }: {
520    taskId: string
521    abortController: AbortController
522    makeStream: (
523      onCacheSafeParams: ((p: CacheSafeParams) => void) | undefined,
524    ) => AsyncGenerator<MessageType, void>
525    metadata: Parameters<typeof finalizeAgentTool>[2]
526    description: string
527    toolUseContext: ToolUseContext
528    rootSetAppState: SetAppState
529    agentIdForCleanup: string
530    enableSummarization: boolean
531    getWorktreeResult: () => Promise<{
532      worktreePath?: string
533      worktreeBranch?: string
534    }>
535  }): Promise<void> {
536    let stopSummarization: (() => void) | undefined
537    const agentMessages: MessageType[] = []
538    try {
539      const tracker = createProgressTracker()
540      const resolveActivity = createActivityDescriptionResolver(
541        toolUseContext.options.tools,
542      )
543      const onCacheSafeParams = enableSummarization
544        ? (params: CacheSafeParams) => {
545            const { stop } = startAgentSummarization(
546              taskId,
547              asAgentId(taskId),
548              params,
549              rootSetAppState,
550            )
551            stopSummarization = stop
552          }
553        : undefined
554      for await (const message of makeStream(onCacheSafeParams)) {
555        agentMessages.push(message)
556        // Append immediately when UI holds the task (retain). Bootstrap reads
557        // disk in parallel and UUID-merges the prefix — disk-write-before-yield
558        // means live is always a suffix of disk, so merge is order-correct.
559        rootSetAppState(prev => {
560          const t = prev.tasks[taskId]
561          if (!isLocalAgentTask(t) || !t.retain) return prev
562          const base = t.messages ?? []
563          return {
564            ...prev,
565            tasks: {
566              ...prev.tasks,
567              [taskId]: { ...t, messages: [...base, message] },
568            },
569          }
570        })
571        updateProgressFromMessage(
572          tracker,
573          message,
574          resolveActivity,
575          toolUseContext.options.tools,
576        )
577        updateAsyncAgentProgress(
578          taskId,
579          getProgressUpdate(tracker),
580          rootSetAppState,
581        )
582        const lastToolName = getLastToolUseName(message)
583        if (lastToolName) {
584          emitTaskProgress(
585            tracker,
586            taskId,
587            toolUseContext.toolUseId,
588            description,
589            metadata.startTime,
590            lastToolName,
591          )
592        }
593      }
594  
595      stopSummarization?.()
596  
597      const agentResult = finalizeAgentTool(agentMessages, taskId, metadata)
598  
599      // Mark task completed FIRST so TaskOutput(block=true) unblocks
600      // immediately. classifyHandoffIfNeeded (API call) and getWorktreeResult
601      // (git exec) are notification embellishments that can hang — they must
602      // not gate the status transition (gh-20236).
603      completeAsyncAgent(agentResult, rootSetAppState)
604  
605      let finalMessage = extractTextContent(agentResult.content, '\n')
606  
607      if (feature('TRANSCRIPT_CLASSIFIER')) {
608        const handoffWarning = await classifyHandoffIfNeeded({
609          agentMessages,
610          tools: toolUseContext.options.tools,
611          toolPermissionContext:
612            toolUseContext.getAppState().toolPermissionContext,
613          abortSignal: abortController.signal,
614          subagentType: metadata.agentType,
615          totalToolUseCount: agentResult.totalToolUseCount,
616        })
617        if (handoffWarning) {
618          finalMessage = `${handoffWarning}\n\n${finalMessage}`
619        }
620      }
621  
622      const worktreeResult = await getWorktreeResult()
623  
624      enqueueAgentNotification({
625        taskId,
626        description,
627        status: 'completed',
628        setAppState: rootSetAppState,
629        finalMessage,
630        usage: {
631          totalTokens: getTokenCountFromTracker(tracker),
632          toolUses: agentResult.totalToolUseCount,
633          durationMs: agentResult.totalDurationMs,
634        },
635        toolUseId: toolUseContext.toolUseId,
636        ...worktreeResult,
637      })
638    } catch (error) {
639      stopSummarization?.()
640      if (error instanceof AbortError) {
641        // killAsyncAgent is a no-op if TaskStop already set status='killed' —
642        // but only this catch handler has agentMessages, so the notification
643        // must fire unconditionally. Transition status BEFORE worktree cleanup
644        // so TaskOutput unblocks even if git hangs (gh-20236).
645        killAsyncAgent(taskId, rootSetAppState)
646        logEvent('tengu_agent_tool_terminated', {
647          agent_type:
648            metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
649          model:
650            metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
651          duration_ms: Date.now() - metadata.startTime,
652          is_async: true,
653          is_built_in_agent: metadata.isBuiltInAgent,
654          reason:
655            'user_kill_async' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
656        })
657        const worktreeResult = await getWorktreeResult()
658        const partialResult = extractPartialResult(agentMessages)
659        enqueueAgentNotification({
660          taskId,
661          description,
662          status: 'killed',
663          setAppState: rootSetAppState,
664          toolUseId: toolUseContext.toolUseId,
665          finalMessage: partialResult,
666          ...worktreeResult,
667        })
668        return
669      }
670      const msg = errorMessage(error)
671      failAsyncAgent(taskId, msg, rootSetAppState)
672      const worktreeResult = await getWorktreeResult()
673      enqueueAgentNotification({
674        taskId,
675        description,
676        status: 'failed',
677        error: msg,
678        setAppState: rootSetAppState,
679        toolUseId: toolUseContext.toolUseId,
680        ...worktreeResult,
681      })
682    } finally {
683      clearInvokedSkillsForAgent(agentIdForCleanup)
684      clearDumpState(agentIdForCleanup)
685    }
686  }