agentToolUtils.ts
tools/AgentTool/agentToolUtils.ts
687
Lines
22739
Bytes
12
Exports
29
Imports
10
Keywords
What this is
This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.
Beginner explanation
This file is part of the tool layer, which means it describes actions the system can perform for the user or model.
How it is used
Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.
Expert explanation
Architecturally, this file intersects with tool-system, planner-verifier-agents. It contains 687 lines, 29 detected imports, and 12 detected exports.
Important relationships
Detected exports
ResolvedAgentToolsfilterToolsForAgentresolveAgentToolsagentToolResultSchemaAgentToolResultcountToolUsesfinalizeAgentToolgetLastToolUseNameemitTaskProgressclassifyHandoffIfNeededextractPartialResultrunAsyncAgentLifecycle
Keywords
tooltoolsmessageanalyticsmetadata_i_verified_this_is_not_code_or_filepathsagentmessagescontenttaskidagentutilsclassifierresult
Detected imports
bun:bundlezod/v4../../bootstrap/state.js../../constants/tools.js../../services/AgentSummary/agentSummary.js../../services/analytics/index.js../../services/api/dumpPrompts.js../../state/AppState.js../../Tool.js../../Tool.js../../tasks/LocalAgentTask/LocalAgentTask.js../../types/ids.js../../types/message.js../../utils/agentSwarmsEnabled.js../../utils/debug.js../../utils/envUtils.js../../utils/errors.js../../utils/forkedAgent.js../../utils/lazySchema.js../../utils/messages.js../../utils/permissions/PermissionMode.js../../utils/permissions/permissionRuleParser.js../../utils/permissions/yoloClassifier.js../../utils/task/sdkProgress.js../../utils/teammateContext.js../../utils/tokens.js../ExitPlanModeTool/constants.js./constants.js./loadAgentsDir.js
Source notes
This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.
Full source
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)
}
}