chore: initialize recovered claude workspace
This commit is contained in:
686
src/tools/AgentTool/agentToolUtils.ts
Normal file
686
src/tools/AgentTool/agentToolUtils.ts
Normal file
@@ -0,0 +1,686 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { z } from 'zod/v4'
|
||||
import { clearInvokedSkillsForAgent } from '../../bootstrap/state.js'
|
||||
import {
|
||||
ALL_AGENT_DISALLOWED_TOOLS,
|
||||
ASYNC_AGENT_ALLOWED_TOOLS,
|
||||
CUSTOM_AGENT_DISALLOWED_TOOLS,
|
||||
IN_PROCESS_TEAMMATE_ALLOWED_TOOLS,
|
||||
} from '../../constants/tools.js'
|
||||
import { startAgentSummarization } from '../../services/AgentSummary/agentSummary.js'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../../services/analytics/index.js'
|
||||
import { clearDumpState } from '../../services/api/dumpPrompts.js'
|
||||
import type { AppState } from '../../state/AppState.js'
|
||||
import type {
|
||||
Tool,
|
||||
ToolPermissionContext,
|
||||
Tools,
|
||||
ToolUseContext,
|
||||
} from '../../Tool.js'
|
||||
import { toolMatchesName } from '../../Tool.js'
|
||||
import {
|
||||
completeAgentTask as completeAsyncAgent,
|
||||
createActivityDescriptionResolver,
|
||||
createProgressTracker,
|
||||
enqueueAgentNotification,
|
||||
failAgentTask as failAsyncAgent,
|
||||
getProgressUpdate,
|
||||
getTokenCountFromTracker,
|
||||
isLocalAgentTask,
|
||||
killAsyncAgent,
|
||||
type ProgressTracker,
|
||||
updateAgentProgress as updateAsyncAgentProgress,
|
||||
updateProgressFromMessage,
|
||||
} from '../../tasks/LocalAgentTask/LocalAgentTask.js'
|
||||
import { asAgentId } from '../../types/ids.js'
|
||||
import type { Message as MessageType } from '../../types/message.js'
|
||||
import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { isInProtectedNamespace } from '../../utils/envUtils.js'
|
||||
import { AbortError, errorMessage } from '../../utils/errors.js'
|
||||
import type { CacheSafeParams } from '../../utils/forkedAgent.js'
|
||||
import { lazySchema } from '../../utils/lazySchema.js'
|
||||
import {
|
||||
extractTextContent,
|
||||
getLastAssistantMessage,
|
||||
} from '../../utils/messages.js'
|
||||
import type { PermissionMode } from '../../utils/permissions/PermissionMode.js'
|
||||
import { permissionRuleValueFromString } from '../../utils/permissions/permissionRuleParser.js'
|
||||
import {
|
||||
buildTranscriptForClassifier,
|
||||
classifyYoloAction,
|
||||
} from '../../utils/permissions/yoloClassifier.js'
|
||||
import { emitTaskProgress as emitTaskProgressEvent } from '../../utils/task/sdkProgress.js'
|
||||
import { isInProcessTeammate } from '../../utils/teammateContext.js'
|
||||
import { getTokenCountFromUsage } from '../../utils/tokens.js'
|
||||
import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../ExitPlanModeTool/constants.js'
|
||||
import { AGENT_TOOL_NAME, LEGACY_AGENT_TOOL_NAME } from './constants.js'
|
||||
import type { AgentDefinition } from './loadAgentsDir.js'
|
||||
export type ResolvedAgentTools = {
|
||||
hasWildcard: boolean
|
||||
validTools: string[]
|
||||
invalidTools: string[]
|
||||
resolvedTools: Tools
|
||||
allowedAgentTypes?: string[]
|
||||
}
|
||||
|
||||
export function filterToolsForAgent({
|
||||
tools,
|
||||
isBuiltIn,
|
||||
isAsync = false,
|
||||
permissionMode,
|
||||
}: {
|
||||
tools: Tools
|
||||
isBuiltIn: boolean
|
||||
isAsync?: boolean
|
||||
permissionMode?: PermissionMode
|
||||
}): Tools {
|
||||
return tools.filter(tool => {
|
||||
// Allow MCP tools for all agents
|
||||
if (tool.name.startsWith('mcp__')) {
|
||||
return true
|
||||
}
|
||||
// Allow ExitPlanMode for agents in plan mode (e.g., in-process teammates)
|
||||
// This bypasses both the ALL_AGENT_DISALLOWED_TOOLS and async tool filters
|
||||
if (
|
||||
toolMatchesName(tool, EXIT_PLAN_MODE_V2_TOOL_NAME) &&
|
||||
permissionMode === 'plan'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (ALL_AGENT_DISALLOWED_TOOLS.has(tool.name)) {
|
||||
return false
|
||||
}
|
||||
if (!isBuiltIn && CUSTOM_AGENT_DISALLOWED_TOOLS.has(tool.name)) {
|
||||
return false
|
||||
}
|
||||
if (isAsync && !ASYNC_AGENT_ALLOWED_TOOLS.has(tool.name)) {
|
||||
if (isAgentSwarmsEnabled() && isInProcessTeammate()) {
|
||||
// Allow AgentTool for in-process teammates to spawn sync subagents.
|
||||
// Validation in AgentTool.call() prevents background agents and teammate spawning.
|
||||
if (toolMatchesName(tool, AGENT_TOOL_NAME)) {
|
||||
return true
|
||||
}
|
||||
// Allow task tools for in-process teammates to coordinate via shared task list
|
||||
if (IN_PROCESS_TEAMMATE_ALLOWED_TOOLS.has(tool.name)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves and validates agent tools against available tools
|
||||
* Handles wildcard expansion and validation in one place
|
||||
*/
|
||||
export function resolveAgentTools(
|
||||
agentDefinition: Pick<
|
||||
AgentDefinition,
|
||||
'tools' | 'disallowedTools' | 'source' | 'permissionMode'
|
||||
>,
|
||||
availableTools: Tools,
|
||||
isAsync = false,
|
||||
isMainThread = false,
|
||||
): ResolvedAgentTools {
|
||||
const {
|
||||
tools: agentTools,
|
||||
disallowedTools,
|
||||
source,
|
||||
permissionMode,
|
||||
} = agentDefinition
|
||||
// When isMainThread is true, skip filterToolsForAgent entirely — the main
|
||||
// thread's tool pool is already properly assembled by useMergedTools(), so
|
||||
// the sub-agent disallow lists shouldn't apply.
|
||||
const filteredAvailableTools = isMainThread
|
||||
? availableTools
|
||||
: filterToolsForAgent({
|
||||
tools: availableTools,
|
||||
isBuiltIn: source === 'built-in',
|
||||
isAsync,
|
||||
permissionMode,
|
||||
})
|
||||
|
||||
// Create a set of disallowed tool names for quick lookup
|
||||
const disallowedToolSet = new Set(
|
||||
disallowedTools?.map(toolSpec => {
|
||||
const { toolName } = permissionRuleValueFromString(toolSpec)
|
||||
return toolName
|
||||
}) ?? [],
|
||||
)
|
||||
|
||||
// Filter available tools based on disallowed list
|
||||
const allowedAvailableTools = filteredAvailableTools.filter(
|
||||
tool => !disallowedToolSet.has(tool.name),
|
||||
)
|
||||
|
||||
// If tools is undefined or ['*'], allow all tools (after filtering disallowed)
|
||||
const hasWildcard =
|
||||
agentTools === undefined ||
|
||||
(agentTools.length === 1 && agentTools[0] === '*')
|
||||
if (hasWildcard) {
|
||||
return {
|
||||
hasWildcard: true,
|
||||
validTools: [],
|
||||
invalidTools: [],
|
||||
resolvedTools: allowedAvailableTools,
|
||||
}
|
||||
}
|
||||
|
||||
const availableToolMap = new Map<string, Tool>()
|
||||
for (const tool of allowedAvailableTools) {
|
||||
availableToolMap.set(tool.name, tool)
|
||||
}
|
||||
|
||||
const validTools: string[] = []
|
||||
const invalidTools: string[] = []
|
||||
const resolved: Tool[] = []
|
||||
const resolvedToolsSet = new Set<Tool>()
|
||||
let allowedAgentTypes: string[] | undefined
|
||||
|
||||
for (const toolSpec of agentTools) {
|
||||
// Parse the tool spec to extract the base tool name and any permission pattern
|
||||
const { toolName, ruleContent } = permissionRuleValueFromString(toolSpec)
|
||||
|
||||
// Special case: Agent tool carries allowedAgentTypes metadata in its spec
|
||||
if (toolName === AGENT_TOOL_NAME) {
|
||||
if (ruleContent) {
|
||||
// Parse comma-separated agent types: "worker, researcher" → ["worker", "researcher"]
|
||||
allowedAgentTypes = ruleContent.split(',').map(s => s.trim())
|
||||
}
|
||||
// For sub-agents, Agent is excluded by filterToolsForAgent — mark the spec
|
||||
// valid for allowedAgentTypes tracking but skip tool resolution.
|
||||
if (!isMainThread) {
|
||||
validTools.push(toolSpec)
|
||||
continue
|
||||
}
|
||||
// For main thread, filtering was skipped so Agent is in availableToolMap —
|
||||
// fall through to normal resolution below.
|
||||
}
|
||||
|
||||
const tool = availableToolMap.get(toolName)
|
||||
if (tool) {
|
||||
validTools.push(toolSpec)
|
||||
if (!resolvedToolsSet.has(tool)) {
|
||||
resolved.push(tool)
|
||||
resolvedToolsSet.add(tool)
|
||||
}
|
||||
} else {
|
||||
invalidTools.push(toolSpec)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasWildcard: false,
|
||||
validTools,
|
||||
invalidTools,
|
||||
resolvedTools: resolved,
|
||||
allowedAgentTypes,
|
||||
}
|
||||
}
|
||||
|
||||
export const agentToolResultSchema = lazySchema(() =>
|
||||
z.object({
|
||||
agentId: z.string(),
|
||||
// Optional: older persisted sessions won't have this (resume replays
|
||||
// results verbatim without re-validation). Used to gate the sync
|
||||
// result trailer — one-shot built-ins skip the SendMessage hint.
|
||||
agentType: z.string().optional(),
|
||||
content: z.array(z.object({ type: z.literal('text'), text: z.string() })),
|
||||
totalToolUseCount: z.number(),
|
||||
totalDurationMs: z.number(),
|
||||
totalTokens: z.number(),
|
||||
usage: z.object({
|
||||
input_tokens: z.number(),
|
||||
output_tokens: z.number(),
|
||||
cache_creation_input_tokens: z.number().nullable(),
|
||||
cache_read_input_tokens: z.number().nullable(),
|
||||
server_tool_use: z
|
||||
.object({
|
||||
web_search_requests: z.number(),
|
||||
web_fetch_requests: z.number(),
|
||||
})
|
||||
.nullable(),
|
||||
service_tier: z.enum(['standard', 'priority', 'batch']).nullable(),
|
||||
cache_creation: z
|
||||
.object({
|
||||
ephemeral_1h_input_tokens: z.number(),
|
||||
ephemeral_5m_input_tokens: z.number(),
|
||||
})
|
||||
.nullable(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
export type AgentToolResult = z.input<ReturnType<typeof agentToolResultSchema>>
|
||||
|
||||
export function countToolUses(messages: MessageType[]): number {
|
||||
let count = 0
|
||||
for (const m of messages) {
|
||||
if (m.type === 'assistant') {
|
||||
for (const block of m.message.content) {
|
||||
if (block.type === 'tool_use') {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
export function finalizeAgentTool(
|
||||
agentMessages: MessageType[],
|
||||
agentId: string,
|
||||
metadata: {
|
||||
prompt: string
|
||||
resolvedAgentModel: string
|
||||
isBuiltInAgent: boolean
|
||||
startTime: number
|
||||
agentType: string
|
||||
isAsync: boolean
|
||||
},
|
||||
): AgentToolResult {
|
||||
const {
|
||||
prompt,
|
||||
resolvedAgentModel,
|
||||
isBuiltInAgent,
|
||||
startTime,
|
||||
agentType,
|
||||
isAsync,
|
||||
} = metadata
|
||||
|
||||
const lastAssistantMessage = getLastAssistantMessage(agentMessages)
|
||||
if (lastAssistantMessage === undefined) {
|
||||
throw new Error('No assistant messages found')
|
||||
}
|
||||
// Extract text content from the agent's response. If the final assistant
|
||||
// message is a pure tool_use block (loop exited mid-turn), fall back to
|
||||
// the most recent assistant message that has text content.
|
||||
let content = lastAssistantMessage.message.content.filter(
|
||||
_ => _.type === 'text',
|
||||
)
|
||||
if (content.length === 0) {
|
||||
for (let i = agentMessages.length - 1; i >= 0; i--) {
|
||||
const m = agentMessages[i]!
|
||||
if (m.type !== 'assistant') continue
|
||||
const textBlocks = m.message.content.filter(_ => _.type === 'text')
|
||||
if (textBlocks.length > 0) {
|
||||
content = textBlocks
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalTokens = getTokenCountFromUsage(lastAssistantMessage.message.usage)
|
||||
const totalToolUseCount = countToolUses(agentMessages)
|
||||
|
||||
logEvent('tengu_agent_tool_completed', {
|
||||
agent_type:
|
||||
agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
model:
|
||||
resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
prompt_char_count: prompt.length,
|
||||
response_char_count: content.length,
|
||||
assistant_message_count: agentMessages.length,
|
||||
total_tool_uses: totalToolUseCount,
|
||||
duration_ms: Date.now() - startTime,
|
||||
total_tokens: totalTokens,
|
||||
is_built_in_agent: isBuiltInAgent,
|
||||
is_async: isAsync,
|
||||
})
|
||||
|
||||
// Signal to inference that this subagent's cache chain can be evicted.
|
||||
const lastRequestId = lastAssistantMessage.requestId
|
||||
if (lastRequestId) {
|
||||
logEvent('tengu_cache_eviction_hint', {
|
||||
scope:
|
||||
'subagent_end' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
last_request_id:
|
||||
lastRequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
agentId,
|
||||
agentType,
|
||||
content,
|
||||
totalDurationMs: Date.now() - startTime,
|
||||
totalTokens,
|
||||
totalToolUseCount,
|
||||
usage: lastAssistantMessage.message.usage,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name of the last tool_use block in an assistant message,
|
||||
* or undefined if the message is not an assistant message with tool_use.
|
||||
*/
|
||||
export function getLastToolUseName(message: MessageType): string | undefined {
|
||||
if (message.type !== 'assistant') return undefined
|
||||
const block = message.message.content.findLast(b => b.type === 'tool_use')
|
||||
return block?.type === 'tool_use' ? block.name : undefined
|
||||
}
|
||||
|
||||
export function emitTaskProgress(
|
||||
tracker: ProgressTracker,
|
||||
taskId: string,
|
||||
toolUseId: string | undefined,
|
||||
description: string,
|
||||
startTime: number,
|
||||
lastToolName: string,
|
||||
): void {
|
||||
const progress = getProgressUpdate(tracker)
|
||||
emitTaskProgressEvent({
|
||||
taskId,
|
||||
toolUseId,
|
||||
description: progress.lastActivity?.activityDescription ?? description,
|
||||
startTime,
|
||||
totalTokens: progress.tokenCount,
|
||||
toolUses: progress.toolUseCount,
|
||||
lastToolName,
|
||||
})
|
||||
}
|
||||
|
||||
export async function classifyHandoffIfNeeded({
|
||||
agentMessages,
|
||||
tools,
|
||||
toolPermissionContext,
|
||||
abortSignal,
|
||||
subagentType,
|
||||
totalToolUseCount,
|
||||
}: {
|
||||
agentMessages: MessageType[]
|
||||
tools: Tools
|
||||
toolPermissionContext: AppState['toolPermissionContext']
|
||||
abortSignal: AbortSignal
|
||||
subagentType: string
|
||||
totalToolUseCount: number
|
||||
}): Promise<string | null> {
|
||||
if (feature('TRANSCRIPT_CLASSIFIER')) {
|
||||
if (toolPermissionContext.mode !== 'auto') return null
|
||||
|
||||
const agentTranscript = buildTranscriptForClassifier(agentMessages, tools)
|
||||
if (!agentTranscript) return null
|
||||
|
||||
const classifierResult = await classifyYoloAction(
|
||||
agentMessages,
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
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).",
|
||||
},
|
||||
],
|
||||
},
|
||||
tools,
|
||||
toolPermissionContext as ToolPermissionContext,
|
||||
abortSignal,
|
||||
)
|
||||
|
||||
const handoffDecision = classifierResult.unavailable
|
||||
? 'unavailable'
|
||||
: classifierResult.shouldBlock
|
||||
? 'blocked'
|
||||
: 'allowed'
|
||||
logEvent('tengu_auto_mode_decision', {
|
||||
decision:
|
||||
handoffDecision as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
toolName:
|
||||
// Use legacy name for analytics continuity across the Task→Agent rename
|
||||
LEGACY_AGENT_TOOL_NAME as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
inProtectedNamespace: isInProtectedNamespace(),
|
||||
classifierModel:
|
||||
classifierResult.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
agentType:
|
||||
subagentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
toolUseCount: totalToolUseCount,
|
||||
isHandoff: true,
|
||||
// For handoff, the relevant agent completion is the subagent's final
|
||||
// assistant message — the last thing the classifier transcript shows
|
||||
// before the handoff review prompt.
|
||||
agentMsgId: getLastAssistantMessage(agentMessages)?.message
|
||||
.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
classifierStage:
|
||||
classifierResult.stage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
classifierStage1RequestId:
|
||||
classifierResult.stage1RequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
classifierStage1MsgId:
|
||||
classifierResult.stage1MsgId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
classifierStage2RequestId:
|
||||
classifierResult.stage2RequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
classifierStage2MsgId:
|
||||
classifierResult.stage2MsgId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
|
||||
if (classifierResult.shouldBlock) {
|
||||
// When classifier is unavailable, still propagate the sub-agent's
|
||||
// results but with a warning so the parent agent can verify the work.
|
||||
if (classifierResult.unavailable) {
|
||||
logForDebugging(
|
||||
'Handoff classifier unavailable, allowing sub-agent output with warning',
|
||||
{ level: 'warn' },
|
||||
)
|
||||
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.`
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`Handoff classifier flagged sub-agent output: ${classifierResult.reason}`,
|
||||
{ level: 'warn' },
|
||||
)
|
||||
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.`
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a partial result string from an agent's accumulated messages.
|
||||
* Used when an async agent is killed to preserve what it accomplished.
|
||||
* Returns undefined if no text content is found.
|
||||
*/
|
||||
export function extractPartialResult(
|
||||
messages: MessageType[],
|
||||
): string | undefined {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!
|
||||
if (m.type !== 'assistant') continue
|
||||
const text = extractTextContent(m.message.content, '\n')
|
||||
if (text) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
type SetAppState = (f: (prev: AppState) => AppState) => void
|
||||
|
||||
/**
|
||||
* Drives a background agent from spawn to terminal notification.
|
||||
* Shared between AgentTool's async-from-start path and resumeAgentBackground.
|
||||
*/
|
||||
export async function runAsyncAgentLifecycle({
|
||||
taskId,
|
||||
abortController,
|
||||
makeStream,
|
||||
metadata,
|
||||
description,
|
||||
toolUseContext,
|
||||
rootSetAppState,
|
||||
agentIdForCleanup,
|
||||
enableSummarization,
|
||||
getWorktreeResult,
|
||||
}: {
|
||||
taskId: string
|
||||
abortController: AbortController
|
||||
makeStream: (
|
||||
onCacheSafeParams: ((p: CacheSafeParams) => void) | undefined,
|
||||
) => AsyncGenerator<MessageType, void>
|
||||
metadata: Parameters<typeof finalizeAgentTool>[2]
|
||||
description: string
|
||||
toolUseContext: ToolUseContext
|
||||
rootSetAppState: SetAppState
|
||||
agentIdForCleanup: string
|
||||
enableSummarization: boolean
|
||||
getWorktreeResult: () => Promise<{
|
||||
worktreePath?: string
|
||||
worktreeBranch?: string
|
||||
}>
|
||||
}): Promise<void> {
|
||||
let stopSummarization: (() => void) | undefined
|
||||
const agentMessages: MessageType[] = []
|
||||
try {
|
||||
const tracker = createProgressTracker()
|
||||
const resolveActivity = createActivityDescriptionResolver(
|
||||
toolUseContext.options.tools,
|
||||
)
|
||||
const onCacheSafeParams = enableSummarization
|
||||
? (params: CacheSafeParams) => {
|
||||
const { stop } = startAgentSummarization(
|
||||
taskId,
|
||||
asAgentId(taskId),
|
||||
params,
|
||||
rootSetAppState,
|
||||
)
|
||||
stopSummarization = stop
|
||||
}
|
||||
: undefined
|
||||
for await (const message of makeStream(onCacheSafeParams)) {
|
||||
agentMessages.push(message)
|
||||
// Append immediately when UI holds the task (retain). Bootstrap reads
|
||||
// disk in parallel and UUID-merges the prefix — disk-write-before-yield
|
||||
// means live is always a suffix of disk, so merge is order-correct.
|
||||
rootSetAppState(prev => {
|
||||
const t = prev.tasks[taskId]
|
||||
if (!isLocalAgentTask(t) || !t.retain) return prev
|
||||
const base = t.messages ?? []
|
||||
return {
|
||||
...prev,
|
||||
tasks: {
|
||||
...prev.tasks,
|
||||
[taskId]: { ...t, messages: [...base, message] },
|
||||
},
|
||||
}
|
||||
})
|
||||
updateProgressFromMessage(
|
||||
tracker,
|
||||
message,
|
||||
resolveActivity,
|
||||
toolUseContext.options.tools,
|
||||
)
|
||||
updateAsyncAgentProgress(
|
||||
taskId,
|
||||
getProgressUpdate(tracker),
|
||||
rootSetAppState,
|
||||
)
|
||||
const lastToolName = getLastToolUseName(message)
|
||||
if (lastToolName) {
|
||||
emitTaskProgress(
|
||||
tracker,
|
||||
taskId,
|
||||
toolUseContext.toolUseId,
|
||||
description,
|
||||
metadata.startTime,
|
||||
lastToolName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
stopSummarization?.()
|
||||
|
||||
const agentResult = finalizeAgentTool(agentMessages, taskId, metadata)
|
||||
|
||||
// Mark task completed FIRST so TaskOutput(block=true) unblocks
|
||||
// immediately. classifyHandoffIfNeeded (API call) and getWorktreeResult
|
||||
// (git exec) are notification embellishments that can hang — they must
|
||||
// not gate the status transition (gh-20236).
|
||||
completeAsyncAgent(agentResult, rootSetAppState)
|
||||
|
||||
let finalMessage = extractTextContent(agentResult.content, '\n')
|
||||
|
||||
if (feature('TRANSCRIPT_CLASSIFIER')) {
|
||||
const handoffWarning = await classifyHandoffIfNeeded({
|
||||
agentMessages,
|
||||
tools: toolUseContext.options.tools,
|
||||
toolPermissionContext:
|
||||
toolUseContext.getAppState().toolPermissionContext,
|
||||
abortSignal: abortController.signal,
|
||||
subagentType: metadata.agentType,
|
||||
totalToolUseCount: agentResult.totalToolUseCount,
|
||||
})
|
||||
if (handoffWarning) {
|
||||
finalMessage = `${handoffWarning}\n\n${finalMessage}`
|
||||
}
|
||||
}
|
||||
|
||||
const worktreeResult = await getWorktreeResult()
|
||||
|
||||
enqueueAgentNotification({
|
||||
taskId,
|
||||
description,
|
||||
status: 'completed',
|
||||
setAppState: rootSetAppState,
|
||||
finalMessage,
|
||||
usage: {
|
||||
totalTokens: getTokenCountFromTracker(tracker),
|
||||
toolUses: agentResult.totalToolUseCount,
|
||||
durationMs: agentResult.totalDurationMs,
|
||||
},
|
||||
toolUseId: toolUseContext.toolUseId,
|
||||
...worktreeResult,
|
||||
})
|
||||
} catch (error) {
|
||||
stopSummarization?.()
|
||||
if (error instanceof AbortError) {
|
||||
// killAsyncAgent is a no-op if TaskStop already set status='killed' —
|
||||
// but only this catch handler has agentMessages, so the notification
|
||||
// must fire unconditionally. Transition status BEFORE worktree cleanup
|
||||
// so TaskOutput unblocks even if git hangs (gh-20236).
|
||||
killAsyncAgent(taskId, rootSetAppState)
|
||||
logEvent('tengu_agent_tool_terminated', {
|
||||
agent_type:
|
||||
metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
model:
|
||||
metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
duration_ms: Date.now() - metadata.startTime,
|
||||
is_async: true,
|
||||
is_built_in_agent: metadata.isBuiltInAgent,
|
||||
reason:
|
||||
'user_kill_async' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
const worktreeResult = await getWorktreeResult()
|
||||
const partialResult = extractPartialResult(agentMessages)
|
||||
enqueueAgentNotification({
|
||||
taskId,
|
||||
description,
|
||||
status: 'killed',
|
||||
setAppState: rootSetAppState,
|
||||
toolUseId: toolUseContext.toolUseId,
|
||||
finalMessage: partialResult,
|
||||
...worktreeResult,
|
||||
})
|
||||
return
|
||||
}
|
||||
const msg = errorMessage(error)
|
||||
failAsyncAgent(taskId, msg, rootSetAppState)
|
||||
const worktreeResult = await getWorktreeResult()
|
||||
enqueueAgentNotification({
|
||||
taskId,
|
||||
description,
|
||||
status: 'failed',
|
||||
error: msg,
|
||||
setAppState: rootSetAppState,
|
||||
toolUseId: toolUseContext.toolUseId,
|
||||
...worktreeResult,
|
||||
})
|
||||
} finally {
|
||||
clearInvokedSkillsForAgent(agentIdForCleanup)
|
||||
clearDumpState(agentIdForCleanup)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user