mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 14:25:51 +00:00
* feat: 第一版大重构 * fix: 修复类型问题 * chore: 更新版本到 1.3.2 * Add brave as alternative WebSearchTool * fix: 修正顺序 * fix: 修复对穷鬼模式的 auto dream 和 session memory 越过 * feat: 穷鬼模式去除 session-summary * feat: 创建 builtin-tools 包,搬运所有工具实现 将 src/tools/ 下的全部 60 个工具目录迁移至 packages/builtin-tools/src/tools/, 内部导入路径已更新为 src/ alias 模式。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: 更新 src/ 中所有工具引用至 builtin-tools 包,删除 src/tools/ - src/tools.ts 及 178 个 src/ 文件的 import 路径从 ./tools/ 改为 builtin-tools/tools/ - 删除 src/tools/ 整个目录(已迁移至 packages/builtin-tools/) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: 添加 builtin-tools 路径别名至 tsconfig,更新 bun.lock - tsconfig.json 新增 builtin-tools/* 和 builtin-tools 路径映射 - 新增 packages/builtin-tools/src 至 include Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: 为 builtin-tools、mcp-client、agent-tools 添加 @claude-code-best 作用域前缀 所有包名及 import 路径统一添加 @claude-code-best/ 前缀: - builtin-tools → @claude-code-best/builtin-tools - mcp-client → @claude-code-best/mcp-client - agent-tools → @claude-code-best/agent-tools Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: 修复 node 环境没有 bun 的问题 --------- Co-authored-by: Eric-Guo <eric.guocz@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1837 lines
73 KiB
TypeScript
1837 lines
73 KiB
TypeScript
import { feature } from 'bun:bundle'
|
||
import * as React from 'react'
|
||
import { buildTool, type ToolDef, toolMatchesName } from 'src/Tool.js'
|
||
import type {
|
||
AssistantMessage,
|
||
Message as MessageType,
|
||
NormalizedUserMessage,
|
||
} from 'src/types/message.js'
|
||
import { getQuerySourceForAgent } from 'src/utils/promptCategory.js'
|
||
import { z } from 'zod/v4'
|
||
import {
|
||
clearInvokedSkillsForAgent,
|
||
getSdkAgentProgressSummariesEnabled,
|
||
} from 'src/bootstrap/state.js'
|
||
import {
|
||
enhanceSystemPromptWithEnvDetails,
|
||
getSystemPrompt,
|
||
} from 'src/constants/prompts.js'
|
||
import { isCoordinatorMode } from 'src/coordinator/coordinatorMode.js'
|
||
import { startAgentSummarization } from 'src/services/AgentSummary/agentSummary.js'
|
||
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
|
||
import {
|
||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
logEvent,
|
||
} from 'src/services/analytics/index.js'
|
||
import { clearDumpState } from 'src/services/api/dumpPrompts.js'
|
||
import {
|
||
completeAgentTask as completeAsyncAgent,
|
||
createActivityDescriptionResolver,
|
||
createProgressTracker,
|
||
enqueueAgentNotification,
|
||
failAgentTask as failAsyncAgent,
|
||
getProgressUpdate,
|
||
getTokenCountFromTracker,
|
||
isLocalAgentTask,
|
||
killAsyncAgent,
|
||
registerAgentForeground,
|
||
registerAsyncAgent,
|
||
unregisterAgentForeground,
|
||
updateAgentProgress as updateAsyncAgentProgress,
|
||
updateProgressFromMessage,
|
||
} from 'src/tasks/LocalAgentTask/LocalAgentTask.js'
|
||
import {
|
||
checkRemoteAgentEligibility,
|
||
formatPreconditionError,
|
||
getRemoteTaskSessionUrl,
|
||
registerRemoteAgentTask,
|
||
type BackgroundRemoteSessionPrecondition,
|
||
} from 'src/tasks/RemoteAgentTask/RemoteAgentTask.js'
|
||
import { assembleToolPool } from 'src/tools.js'
|
||
import { asAgentId } from 'src/types/ids.js'
|
||
import { runWithAgentContext, type SubagentContext } from 'src/utils/agentContext.js'
|
||
import { isAgentSwarmsEnabled } from 'src/utils/agentSwarmsEnabled.js'
|
||
import { getCwd, runWithCwdOverride } from 'src/utils/cwd.js'
|
||
import { logForDebugging } from 'src/utils/debug.js'
|
||
import { isEnvTruthy } from 'src/utils/envUtils.js'
|
||
import { AbortError, errorMessage, toError } from 'src/utils/errors.js'
|
||
import type { CacheSafeParams } from 'src/utils/forkedAgent.js'
|
||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||
import {
|
||
createUserMessage,
|
||
extractTextContent,
|
||
isSyntheticMessage,
|
||
normalizeMessages,
|
||
} from 'src/utils/messages.js'
|
||
import { getAgentModel } from 'src/utils/model/agent.js'
|
||
import { permissionModeSchema } from 'src/utils/permissions/PermissionMode.js'
|
||
import type { PermissionResult } from 'src/utils/permissions/PermissionResult.js'
|
||
import {
|
||
filterDeniedAgents,
|
||
getDenyRuleForAgent,
|
||
} from 'src/utils/permissions/permissions.js'
|
||
import { enqueueSdkEvent } from 'src/utils/sdkEventQueue.js'
|
||
import { writeAgentMetadata } from 'src/utils/sessionStorage.js'
|
||
import { sleep } from 'src/utils/sleep.js'
|
||
import { buildEffectiveSystemPrompt } from 'src/utils/systemPrompt.js'
|
||
import { asSystemPrompt } from 'src/utils/systemPromptType.js'
|
||
import { getTaskOutputPath } from 'src/utils/task/diskOutput.js'
|
||
import { getParentSessionId, isTeammate } from 'src/utils/teammate.js'
|
||
import { isInProcessTeammate } from 'src/utils/teammateContext.js'
|
||
import { teleportToRemote } from 'src/utils/teleport.js'
|
||
import { getAssistantMessageContentLength } from 'src/utils/tokens.js'
|
||
import { createAgentId } from 'src/utils/uuid.js'
|
||
import {
|
||
createAgentWorktree,
|
||
hasWorktreeChanges,
|
||
removeAgentWorktree,
|
||
} from 'src/utils/worktree.js'
|
||
import { BASH_TOOL_NAME } from '../BashTool/toolName.js'
|
||
import { BackgroundHint } from '../BashTool/UI.js'
|
||
import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js'
|
||
import { spawnTeammate } from '../shared/spawnMultiAgent.js'
|
||
import { setAgentColor } from './agentColorManager.js'
|
||
import {
|
||
agentToolResultSchema,
|
||
classifyHandoffIfNeeded,
|
||
emitTaskProgress,
|
||
extractPartialResult,
|
||
finalizeAgentTool,
|
||
getLastToolUseName,
|
||
runAsyncAgentLifecycle,
|
||
} from './agentToolUtils.js'
|
||
import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js'
|
||
import {
|
||
AGENT_TOOL_NAME,
|
||
LEGACY_AGENT_TOOL_NAME,
|
||
ONE_SHOT_BUILTIN_AGENT_TYPES,
|
||
} from './constants.js'
|
||
import {
|
||
buildForkedMessages,
|
||
buildWorktreeNotice,
|
||
FORK_AGENT,
|
||
isForkSubagentEnabled,
|
||
isInForkChild,
|
||
} from './forkSubagent.js'
|
||
import type { AgentDefinition } from './loadAgentsDir.js'
|
||
import {
|
||
filterAgentsByMcpRequirements,
|
||
hasRequiredMcpServers,
|
||
isBuiltInAgent,
|
||
} from './loadAgentsDir.js'
|
||
import { getPrompt } from './prompt.js'
|
||
import { runAgent } from './runAgent.js'
|
||
import {
|
||
renderGroupedAgentToolUse,
|
||
renderToolResultMessage,
|
||
renderToolUseErrorMessage,
|
||
renderToolUseMessage,
|
||
renderToolUseProgressMessage,
|
||
renderToolUseRejectedMessage,
|
||
renderToolUseTag,
|
||
userFacingName,
|
||
userFacingNameBackgroundColor,
|
||
} from './UI.js'
|
||
|
||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||
const proactiveModule =
|
||
feature('PROACTIVE') || feature('KAIROS')
|
||
? (require('src/proactive/index.js') as typeof import('src/proactive/index.js'))
|
||
: null
|
||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||
|
||
// Progress display constants (for showing background hint)
|
||
const PROGRESS_THRESHOLD_MS = 2000 // Show background hint after 2 seconds
|
||
|
||
// Check if background tasks are disabled at module load time
|
||
const isBackgroundTasksDisabled =
|
||
// eslint-disable-next-line custom-rules/no-process-env-top-level -- Intentional: schema must be defined at module load
|
||
isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS)
|
||
|
||
// Auto-background agent tasks after this many ms (0 = disabled)
|
||
// Enabled by env var OR GrowthBook gate (checked lazily since GB may not be ready at module load)
|
||
function getAutoBackgroundMs(): number {
|
||
if (
|
||
isEnvTruthy(process.env.CLAUDE_AUTO_BACKGROUND_TASKS) ||
|
||
getFeatureValue_CACHED_MAY_BE_STALE('tengu_auto_background_agents', false)
|
||
) {
|
||
return 120_000
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// Multi-agent type constants are defined inline inside gated blocks to enable dead code elimination
|
||
|
||
// Base input schema without multi-agent parameters
|
||
const baseInputSchema = lazySchema(() =>
|
||
z.object({
|
||
description: z
|
||
.string()
|
||
.describe('A short (3-5 word) description of the task'),
|
||
prompt: z.string().describe('The task for the agent to perform'),
|
||
subagent_type: z
|
||
.string()
|
||
.optional()
|
||
.describe('The type of specialized agent to use for this task'),
|
||
model: z
|
||
.enum(['sonnet', 'opus', 'haiku'])
|
||
.optional()
|
||
.describe(
|
||
"Optional model override for this agent. Takes precedence over the agent definition's model frontmatter. If omitted, uses the agent definition's model, or inherits from the parent.",
|
||
),
|
||
run_in_background: z
|
||
.boolean()
|
||
.optional()
|
||
.describe(
|
||
'Set to true to run this agent in the background. You will be notified when it completes.',
|
||
),
|
||
}),
|
||
)
|
||
|
||
// Full schema combining base + multi-agent params + isolation
|
||
const fullInputSchema = lazySchema(() => {
|
||
// Multi-agent parameters
|
||
const multiAgentInputSchema = z.object({
|
||
name: z
|
||
.string()
|
||
.optional()
|
||
.describe(
|
||
'Name for the spawned agent. Makes it addressable via SendMessage({to: name}) while running.',
|
||
),
|
||
team_name: z
|
||
.string()
|
||
.optional()
|
||
.describe(
|
||
'Team name for spawning. Uses current team context if omitted.',
|
||
),
|
||
mode: permissionModeSchema()
|
||
.optional()
|
||
.describe(
|
||
'Permission mode for spawned teammate (e.g., "plan" to require plan approval).',
|
||
),
|
||
})
|
||
|
||
return baseInputSchema()
|
||
.merge(multiAgentInputSchema)
|
||
.extend({
|
||
isolation: (process.env.USER_TYPE === 'ant'
|
||
? z.enum(['worktree', 'remote'])
|
||
: z.enum(['worktree'])
|
||
)
|
||
.optional()
|
||
.describe(
|
||
process.env.USER_TYPE === 'ant'
|
||
? 'Isolation mode. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo. "remote" launches the agent in a remote CCR environment (always runs in background).'
|
||
: 'Isolation mode. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo.',
|
||
),
|
||
cwd: z
|
||
.string()
|
||
.optional()
|
||
.describe(
|
||
'Absolute path to run the agent in. Overrides the working directory for all filesystem and shell operations within this agent. Mutually exclusive with isolation: "worktree".',
|
||
),
|
||
})
|
||
})
|
||
|
||
// Strip optional fields from the schema when the backing feature is off so
|
||
// the model never sees them. Done via .omit() rather than conditional spread
|
||
// inside .extend() because the spread-ternary breaks Zod's type inference
|
||
// (field type collapses to `unknown`). The ternary return produces a union
|
||
// type, but call() destructures via the explicit AgentToolInput type below
|
||
// which always includes all optional fields.
|
||
export const inputSchema = lazySchema(() => {
|
||
const schema = feature('KAIROS')
|
||
? fullInputSchema()
|
||
: fullInputSchema().omit({ cwd: true })
|
||
|
||
// GrowthBook-in-lazySchema is acceptable here (unlike subagent_type, which
|
||
// was removed in 906da6c723): the divergence window is one-session-per-
|
||
// gate-flip via _CACHED_MAY_BE_STALE disk read, and worst case is either
|
||
// "schema shows a no-op param" (gate flips on mid-session: param ignored
|
||
// by forceAsync) or "schema hides a param that would've worked" (gate
|
||
// flips off mid-session: everything still runs async via memoized
|
||
// forceAsync). No Zod rejection, no crash — unlike required→optional.
|
||
return isBackgroundTasksDisabled || isForkSubagentEnabled()
|
||
? schema.omit({ run_in_background: true })
|
||
: schema
|
||
})
|
||
type InputSchema = ReturnType<typeof inputSchema>
|
||
|
||
// Explicit type widens the schema inference to always include all optional
|
||
// fields even when .omit() strips them for gating (cwd, run_in_background).
|
||
// subagent_type is optional; call() defaults it to general-purpose when the
|
||
// fork gate is off, or routes to the fork path when the gate is on.
|
||
type AgentToolInput = z.infer<ReturnType<typeof baseInputSchema>> & {
|
||
name?: string
|
||
team_name?: string
|
||
mode?: z.infer<ReturnType<typeof permissionModeSchema>>
|
||
isolation?: 'worktree' | 'remote'
|
||
cwd?: string
|
||
}
|
||
|
||
// Output schema - multi-agent spawned schema added dynamically at runtime when enabled
|
||
export const outputSchema = lazySchema(() => {
|
||
const syncOutputSchema = agentToolResultSchema().extend({
|
||
status: z.literal('completed'),
|
||
prompt: z.string(),
|
||
})
|
||
|
||
const asyncOutputSchema = z.object({
|
||
status: z.literal('async_launched'),
|
||
agentId: z.string().describe('The ID of the async agent'),
|
||
description: z.string().describe('The description of the task'),
|
||
prompt: z.string().describe('The prompt for the agent'),
|
||
outputFile: z
|
||
.string()
|
||
.describe('Path to the output file for checking agent progress'),
|
||
canReadOutputFile: z
|
||
.boolean()
|
||
.optional()
|
||
.describe(
|
||
'Whether the calling agent has Read/Bash tools to check progress',
|
||
),
|
||
})
|
||
|
||
return z.union([syncOutputSchema, asyncOutputSchema])
|
||
})
|
||
type OutputSchema = ReturnType<typeof outputSchema>
|
||
type Output = z.input<OutputSchema>
|
||
|
||
// Private type for teammate spawn results - excluded from exported schema for dead code elimination
|
||
// The 'teammate_spawned' status string is only included when ENABLE_AGENT_SWARMS is true
|
||
type TeammateSpawnedOutput = {
|
||
status: 'teammate_spawned'
|
||
prompt: string
|
||
teammate_id: string
|
||
agent_id: string
|
||
agent_type?: string
|
||
model?: string
|
||
name: string
|
||
color?: string
|
||
tmux_session_name: string
|
||
tmux_window_name: string
|
||
tmux_pane_id: string
|
||
team_name?: string
|
||
is_splitpane?: boolean
|
||
plan_mode_required?: boolean
|
||
}
|
||
|
||
// Combined output type including both public and internal types
|
||
// Note: TeammateSpawnedOutput type is fine - TypeScript types are erased at compile time
|
||
// Private type for remote-launched results — excluded from exported schema
|
||
// like TeammateSpawnedOutput for dead code elimination purposes. Exported
|
||
// for UI.tsx to do proper discriminated-union narrowing instead of ad-hoc casts.
|
||
export type RemoteLaunchedOutput = {
|
||
status: 'remote_launched'
|
||
taskId: string
|
||
sessionUrl: string
|
||
description: string
|
||
prompt: string
|
||
outputFile: string
|
||
}
|
||
|
||
type InternalOutput = Output | TeammateSpawnedOutput | RemoteLaunchedOutput
|
||
|
||
import type { AgentToolProgress, ShellProgress } from 'src/types/tools.js'
|
||
// AgentTool forwards both its own progress events and shell progress
|
||
// events from the sub-agent so the SDK receives tool_progress updates during bash/powershell runs.
|
||
export type Progress = AgentToolProgress | ShellProgress
|
||
|
||
export const AgentTool = buildTool({
|
||
async prompt({ agents, tools, getToolPermissionContext, allowedAgentTypes }) {
|
||
const toolPermissionContext = await getToolPermissionContext()
|
||
|
||
// Get MCP servers that have tools available
|
||
const mcpServersWithTools: string[] = []
|
||
for (const tool of tools) {
|
||
if (tool.name?.startsWith('mcp__')) {
|
||
const parts = tool.name.split('__')
|
||
const serverName = parts[1]
|
||
if (serverName && !mcpServersWithTools.includes(serverName)) {
|
||
mcpServersWithTools.push(serverName)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Filter agents: first by MCP requirements, then by permission rules
|
||
const agentsWithMcpRequirementsMet = filterAgentsByMcpRequirements(
|
||
agents,
|
||
mcpServersWithTools,
|
||
)
|
||
const filteredAgents = filterDeniedAgents(
|
||
agentsWithMcpRequirementsMet,
|
||
toolPermissionContext,
|
||
AGENT_TOOL_NAME,
|
||
)
|
||
|
||
// Use inline env check instead of coordinatorModule to avoid circular
|
||
// dependency issues during test module loading.
|
||
const isCoordinator = feature('COORDINATOR_MODE')
|
||
? isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE)
|
||
: false
|
||
return await getPrompt(filteredAgents, isCoordinator, allowedAgentTypes)
|
||
},
|
||
name: AGENT_TOOL_NAME,
|
||
searchHint: 'delegate work to a subagent',
|
||
aliases: [LEGACY_AGENT_TOOL_NAME],
|
||
maxResultSizeChars: 100_000,
|
||
async description() {
|
||
return 'Launch a new agent'
|
||
},
|
||
get inputSchema(): InputSchema {
|
||
return inputSchema()
|
||
},
|
||
get outputSchema(): OutputSchema {
|
||
return outputSchema()
|
||
},
|
||
async call(
|
||
{
|
||
prompt,
|
||
subagent_type,
|
||
description,
|
||
model: modelParam,
|
||
run_in_background,
|
||
name,
|
||
team_name,
|
||
mode: spawnMode,
|
||
isolation,
|
||
cwd,
|
||
}: AgentToolInput,
|
||
toolUseContext,
|
||
canUseTool,
|
||
assistantMessage,
|
||
onProgress?,
|
||
) {
|
||
const startTime = Date.now()
|
||
const model = isCoordinatorMode() ? undefined : modelParam
|
||
|
||
// Get app state for permission mode and agent filtering
|
||
const appState = toolUseContext.getAppState()
|
||
const permissionMode = appState.toolPermissionContext.mode
|
||
// In-process teammates get a no-op setAppState; setAppStateForTasks
|
||
// reaches the root store so task registration/progress/kill stay visible.
|
||
const rootSetAppState =
|
||
toolUseContext.setAppStateForTasks ?? toolUseContext.setAppState
|
||
|
||
// Check if user is trying to use agent teams without access
|
||
if (team_name && !isAgentSwarmsEnabled()) {
|
||
throw new Error('Agent Teams is not yet available on your plan.')
|
||
}
|
||
|
||
// Teammates (in-process or tmux) passing `name` would trigger spawnTeammate()
|
||
// below, but TeamFile.members is a flat array with one leadAgentId — nested
|
||
// teammates land in the roster with no provenance and confuse the lead.
|
||
const teamName = resolveTeamName({ team_name }, appState)
|
||
if (isTeammate() && teamName && name) {
|
||
throw new Error(
|
||
'Teammates cannot spawn other teammates — the team roster is flat. To spawn a subagent instead, omit the `name` parameter.',
|
||
)
|
||
}
|
||
// In-process teammates cannot spawn background agents (their lifecycle is
|
||
// tied to the leader's process). Tmux teammates are separate processes and
|
||
// can manage their own background agents.
|
||
if (isInProcessTeammate() && teamName && run_in_background === true) {
|
||
throw new Error(
|
||
'In-process teammates cannot spawn background agents. Use run_in_background=false for synchronous subagents.',
|
||
)
|
||
}
|
||
|
||
// Check if this is a multi-agent spawn request
|
||
// Spawn is triggered when team_name is set (from param or context) and name is provided
|
||
if (teamName && name) {
|
||
// Set agent definition color for grouped UI display before spawning
|
||
const agentDef = subagent_type
|
||
? toolUseContext.options.agentDefinitions.activeAgents.find(
|
||
a => a.agentType === subagent_type,
|
||
)
|
||
: undefined
|
||
if (agentDef?.color) {
|
||
setAgentColor(subagent_type!, agentDef.color)
|
||
}
|
||
const result = await spawnTeammate(
|
||
{
|
||
name,
|
||
prompt,
|
||
description,
|
||
team_name: teamName,
|
||
use_splitpane: true,
|
||
plan_mode_required: spawnMode === 'plan',
|
||
model: model ?? agentDef?.model,
|
||
agent_type: subagent_type,
|
||
invokingRequestId: assistantMessage?.requestId as string | undefined,
|
||
},
|
||
toolUseContext,
|
||
)
|
||
|
||
// Type assertion uses TeammateSpawnedOutput (defined above) instead of any.
|
||
// This type is excluded from the exported outputSchema for dead code elimination.
|
||
// Cast through unknown because TeammateSpawnedOutput is intentionally
|
||
// not part of the exported Output union (for dead code elimination purposes).
|
||
const spawnResult: TeammateSpawnedOutput = {
|
||
status: 'teammate_spawned' as const,
|
||
prompt,
|
||
...result.data,
|
||
}
|
||
return { data: spawnResult } as unknown as { data: Output }
|
||
}
|
||
|
||
// Fork subagent experiment routing:
|
||
// - subagent_type set: use it (explicit wins)
|
||
// - subagent_type omitted, gate on: fork path (undefined)
|
||
// - subagent_type omitted, gate off: default general-purpose
|
||
const effectiveType =
|
||
subagent_type ??
|
||
(isForkSubagentEnabled() ? undefined : GENERAL_PURPOSE_AGENT.agentType)
|
||
const isForkPath = effectiveType === undefined
|
||
|
||
let selectedAgent: AgentDefinition
|
||
if (isForkPath) {
|
||
// Recursive fork guard: fork children keep the Agent tool in their
|
||
// pool for cache-identical tool defs, so reject fork attempts at call
|
||
// time. Primary check is querySource (compaction-resistant — set on
|
||
// context.options at spawn time, survives autocompact's message
|
||
// rewrite). Message-scan fallback catches any path where querySource
|
||
// wasn't threaded.
|
||
if (
|
||
toolUseContext.options.querySource ===
|
||
`agent:builtin:${FORK_AGENT.agentType}` ||
|
||
isInForkChild(toolUseContext.messages)
|
||
) {
|
||
throw new Error(
|
||
'Fork is not available inside a forked worker. Complete your task directly using your tools.',
|
||
)
|
||
}
|
||
selectedAgent = FORK_AGENT
|
||
} else {
|
||
// Filter agents to exclude those denied via Agent(AgentName) syntax
|
||
const allAgents = toolUseContext.options.agentDefinitions.activeAgents
|
||
const { allowedAgentTypes } = toolUseContext.options.agentDefinitions
|
||
const agents = filterDeniedAgents(
|
||
// When allowedAgentTypes is set (from Agent(x,y) tool spec), restrict to those types
|
||
allowedAgentTypes
|
||
? allAgents.filter(a => allowedAgentTypes.includes(a.agentType))
|
||
: allAgents,
|
||
appState.toolPermissionContext,
|
||
AGENT_TOOL_NAME,
|
||
)
|
||
|
||
const found = agents.find(agent => agent.agentType === effectiveType)
|
||
if (!found) {
|
||
// Check if the agent exists but is denied by permission rules
|
||
const agentExistsButDenied = allAgents.find(
|
||
agent => agent.agentType === effectiveType,
|
||
)
|
||
if (agentExistsButDenied) {
|
||
const denyRule = getDenyRuleForAgent(
|
||
appState.toolPermissionContext,
|
||
AGENT_TOOL_NAME,
|
||
effectiveType,
|
||
)
|
||
throw new Error(
|
||
`Agent type '${effectiveType}' has been denied by permission rule '${AGENT_TOOL_NAME}(${effectiveType})' from ${denyRule?.source ?? 'settings'}.`,
|
||
)
|
||
}
|
||
throw new Error(
|
||
`Agent type '${effectiveType}' not found. Available agents: ${agents
|
||
.map(a => a.agentType)
|
||
.join(', ')}`,
|
||
)
|
||
}
|
||
selectedAgent = found
|
||
}
|
||
|
||
// Same lifecycle constraint as the run_in_background guard above, but for
|
||
// agent definitions that force background via `background: true`. Checked
|
||
// here because selectedAgent is only now resolved.
|
||
if (
|
||
isInProcessTeammate() &&
|
||
teamName &&
|
||
selectedAgent.background === true
|
||
) {
|
||
throw new Error(
|
||
`In-process teammates cannot spawn background agents. Agent '${selectedAgent.agentType}' has background: true in its definition.`,
|
||
)
|
||
}
|
||
|
||
// Capture for type narrowing — `let selectedAgent` prevents TS from
|
||
// narrowing property types across the if-else assignment above.
|
||
const requiredMcpServers = selectedAgent.requiredMcpServers
|
||
|
||
// Check if required MCP servers have tools available
|
||
// A server that's connected but not authenticated won't have any tools
|
||
if (requiredMcpServers?.length) {
|
||
// If any required servers are still pending (connecting), wait for them
|
||
// before checking tool availability. This avoids a race condition where
|
||
// the agent is invoked before MCP servers finish connecting.
|
||
const hasPendingRequiredServers = appState.mcp.clients.some(
|
||
c =>
|
||
c.type === 'pending' &&
|
||
requiredMcpServers.some(pattern =>
|
||
c.name.toLowerCase().includes(pattern.toLowerCase()),
|
||
),
|
||
)
|
||
|
||
let currentAppState = appState
|
||
if (hasPendingRequiredServers) {
|
||
const MAX_WAIT_MS = 30_000
|
||
const POLL_INTERVAL_MS = 500
|
||
const deadline = Date.now() + MAX_WAIT_MS
|
||
|
||
while (Date.now() < deadline) {
|
||
await sleep(POLL_INTERVAL_MS)
|
||
currentAppState = toolUseContext.getAppState()
|
||
|
||
// Early exit: if any required server has already failed, no point
|
||
// waiting for other pending servers — the check will fail regardless.
|
||
const hasFailedRequiredServer = currentAppState.mcp.clients.some(
|
||
c =>
|
||
c.type === 'failed' &&
|
||
requiredMcpServers.some(pattern =>
|
||
c.name.toLowerCase().includes(pattern.toLowerCase()),
|
||
),
|
||
)
|
||
if (hasFailedRequiredServer) break
|
||
|
||
const stillPending = currentAppState.mcp.clients.some(
|
||
c =>
|
||
c.type === 'pending' &&
|
||
requiredMcpServers.some(pattern =>
|
||
c.name.toLowerCase().includes(pattern.toLowerCase()),
|
||
),
|
||
)
|
||
if (!stillPending) break
|
||
}
|
||
}
|
||
|
||
// Get servers that actually have tools (meaning they're connected AND authenticated)
|
||
const serversWithTools: string[] = []
|
||
for (const tool of currentAppState.mcp.tools) {
|
||
if (tool.name?.startsWith('mcp__')) {
|
||
// Extract server name from tool name (format: mcp__serverName__toolName)
|
||
const parts = tool.name.split('__')
|
||
const serverName = parts[1]
|
||
if (serverName && !serversWithTools.includes(serverName)) {
|
||
serversWithTools.push(serverName)
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!hasRequiredMcpServers(selectedAgent, serversWithTools)) {
|
||
const missing = requiredMcpServers.filter(
|
||
pattern =>
|
||
!serversWithTools.some(server =>
|
||
server.toLowerCase().includes(pattern.toLowerCase()),
|
||
),
|
||
)
|
||
throw new Error(
|
||
`Agent '${selectedAgent.agentType}' requires MCP servers matching: ${missing.join(', ')}. ` +
|
||
`MCP servers with tools: ${serversWithTools.length > 0 ? serversWithTools.join(', ') : 'none'}. ` +
|
||
`Use /mcp to configure and authenticate the required MCP servers.`,
|
||
)
|
||
}
|
||
}
|
||
|
||
// Initialize the color for this agent if it has a predefined one
|
||
if (selectedAgent.color) {
|
||
setAgentColor(selectedAgent.agentType, selectedAgent.color)
|
||
}
|
||
|
||
// Resolve agent params for logging (these are already resolved in runAgent)
|
||
const resolvedAgentModel = getAgentModel(
|
||
selectedAgent.model,
|
||
toolUseContext.options.mainLoopModel,
|
||
isForkPath ? undefined : model,
|
||
permissionMode,
|
||
)
|
||
|
||
logEvent('tengu_agent_tool_selected', {
|
||
agent_type:
|
||
selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
model:
|
||
resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
source:
|
||
selectedAgent.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
color:
|
||
selectedAgent.color as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
is_built_in_agent: isBuiltInAgent(selectedAgent),
|
||
is_resume: false,
|
||
is_async:
|
||
(run_in_background === true || selectedAgent.background === true) &&
|
||
!isBackgroundTasksDisabled,
|
||
is_fork: isForkPath,
|
||
})
|
||
|
||
// Resolve effective isolation mode (explicit param overrides agent def)
|
||
const effectiveIsolation = isolation ?? selectedAgent.isolation
|
||
|
||
// Remote isolation: delegate to CCR. Gated ant-only — the guard enables
|
||
// dead code elimination of the entire block for external builds.
|
||
if (process.env.USER_TYPE === 'ant' && effectiveIsolation === 'remote') {
|
||
const eligibility = await checkRemoteAgentEligibility()
|
||
if (!eligibility.eligible) {
|
||
const reasons = (eligibility as { eligible: false; errors: BackgroundRemoteSessionPrecondition[] }).errors
|
||
.map(formatPreconditionError)
|
||
.join('\n')
|
||
throw new Error(`Cannot launch remote agent:\n${reasons}`)
|
||
}
|
||
|
||
let bundleFailHint: string | undefined
|
||
const session = await teleportToRemote({
|
||
initialMessage: prompt,
|
||
description,
|
||
signal: toolUseContext.abortController.signal,
|
||
onBundleFail: msg => {
|
||
bundleFailHint = msg
|
||
},
|
||
})
|
||
if (!session) {
|
||
throw new Error(bundleFailHint ?? 'Failed to create remote session')
|
||
}
|
||
|
||
const { taskId, sessionId } = registerRemoteAgentTask({
|
||
remoteTaskType: 'remote-agent',
|
||
session: { id: session.id, title: session.title || description },
|
||
command: prompt,
|
||
context: toolUseContext,
|
||
toolUseId: toolUseContext.toolUseId,
|
||
})
|
||
|
||
logEvent('tengu_agent_tool_remote_launched', {
|
||
agent_type:
|
||
selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
|
||
const remoteResult: RemoteLaunchedOutput = {
|
||
status: 'remote_launched',
|
||
taskId,
|
||
sessionUrl: getRemoteTaskSessionUrl(sessionId),
|
||
description,
|
||
prompt,
|
||
outputFile: getTaskOutputPath(taskId),
|
||
}
|
||
return { data: remoteResult } as unknown as { data: Output }
|
||
}
|
||
// System prompt + prompt messages: branch on fork path.
|
||
//
|
||
// Fork path: child inherits the PARENT's system prompt (not FORK_AGENT's)
|
||
// for cache-identical API request prefixes. Prompt messages are built via
|
||
// buildForkedMessages() which clones the parent's full assistant message
|
||
// (all tool_use blocks) + placeholder tool_results + per-child directive.
|
||
//
|
||
// Normal path: build the selected agent's own system prompt with env
|
||
// details, and use a simple user message for the prompt.
|
||
let enhancedSystemPrompt: string[] | undefined
|
||
let forkParentSystemPrompt:
|
||
| ReturnType<typeof buildEffectiveSystemPrompt>
|
||
| undefined
|
||
let promptMessages: MessageType[]
|
||
|
||
if (isForkPath) {
|
||
if (toolUseContext.renderedSystemPrompt) {
|
||
forkParentSystemPrompt = toolUseContext.renderedSystemPrompt
|
||
} else {
|
||
// Fallback: recompute. May diverge from parent's cached bytes if
|
||
// GrowthBook state changed between parent turn-start and fork spawn.
|
||
const mainThreadAgentDefinition = appState.agent
|
||
? appState.agentDefinitions.activeAgents.find(
|
||
a => a.agentType === appState.agent,
|
||
)
|
||
: undefined
|
||
const additionalWorkingDirectories = Array.from(
|
||
appState.toolPermissionContext.additionalWorkingDirectories.keys(),
|
||
)
|
||
const defaultSystemPrompt = await getSystemPrompt(
|
||
toolUseContext.options.tools,
|
||
toolUseContext.options.mainLoopModel,
|
||
additionalWorkingDirectories,
|
||
toolUseContext.options.mcpClients,
|
||
)
|
||
forkParentSystemPrompt = buildEffectiveSystemPrompt({
|
||
mainThreadAgentDefinition,
|
||
toolUseContext,
|
||
customSystemPrompt: toolUseContext.options.customSystemPrompt,
|
||
defaultSystemPrompt,
|
||
appendSystemPrompt: toolUseContext.options.appendSystemPrompt,
|
||
})
|
||
}
|
||
promptMessages = buildForkedMessages(prompt, assistantMessage)
|
||
} else {
|
||
try {
|
||
const additionalWorkingDirectories = Array.from(
|
||
appState.toolPermissionContext.additionalWorkingDirectories.keys(),
|
||
)
|
||
|
||
// All agents have getSystemPrompt - pass toolUseContext to all
|
||
const agentPrompt = selectedAgent.getSystemPrompt({ toolUseContext })
|
||
|
||
// Log agent memory loaded event for subagents
|
||
if (selectedAgent.memory) {
|
||
logEvent('tengu_agent_memory_loaded', {
|
||
...(process.env.USER_TYPE === 'ant' && {
|
||
agent_type:
|
||
selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
}),
|
||
scope:
|
||
selectedAgent.memory as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
source:
|
||
'subagent' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
}
|
||
|
||
// Apply environment details enhancement
|
||
enhancedSystemPrompt = await enhanceSystemPromptWithEnvDetails(
|
||
[agentPrompt],
|
||
resolvedAgentModel,
|
||
additionalWorkingDirectories,
|
||
)
|
||
} catch (error) {
|
||
logForDebugging(
|
||
`Failed to get system prompt for agent ${selectedAgent.agentType}: ${errorMessage(error)}`,
|
||
)
|
||
}
|
||
promptMessages = [createUserMessage({ content: prompt })]
|
||
}
|
||
|
||
const metadata = {
|
||
prompt,
|
||
resolvedAgentModel,
|
||
isBuiltInAgent: isBuiltInAgent(selectedAgent),
|
||
startTime,
|
||
agentType: selectedAgent.agentType,
|
||
isAsync:
|
||
(run_in_background === true || selectedAgent.background === true) &&
|
||
!isBackgroundTasksDisabled,
|
||
}
|
||
|
||
// Use inline env check instead of coordinatorModule to avoid circular
|
||
// dependency issues during test module loading.
|
||
const isCoordinator = feature('COORDINATOR_MODE')
|
||
? isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE)
|
||
: false
|
||
|
||
// Fork subagent experiment: force ALL spawns async for a unified
|
||
// <task-notification> interaction model (not just fork spawns — all of them).
|
||
const forceAsync = isForkSubagentEnabled()
|
||
|
||
// Assistant mode: force all agents async. Synchronous subagents hold the
|
||
// main loop's turn open until they complete — the daemon's inputQueue
|
||
// backs up, and the first overdue cron catch-up on spawn becomes N
|
||
// serial subagent turns blocking all user input. Same gate as
|
||
// executeForkedSlashCommand's fire-and-forget path; the
|
||
// <task-notification> re-entry there is handled by the else branch
|
||
// below (registerAsyncAgentTask + notifyOnCompletion).
|
||
const assistantForceAsync = feature('KAIROS')
|
||
? appState.kairosEnabled
|
||
: false
|
||
|
||
const shouldRunAsync =
|
||
(run_in_background === true ||
|
||
selectedAgent.background === true ||
|
||
isCoordinator ||
|
||
forceAsync ||
|
||
assistantForceAsync ||
|
||
(proactiveModule?.isProactiveActive() ?? false)) &&
|
||
!isBackgroundTasksDisabled
|
||
// Assemble the worker's tool pool independently of the parent's.
|
||
// Workers always get their tools from assembleToolPool with their own
|
||
// permission mode, so they aren't affected by the parent's tool
|
||
// restrictions. This is computed here so that runAgent doesn't need to
|
||
// import from tools.ts (which would create a circular dependency).
|
||
const workerPermissionContext = {
|
||
...appState.toolPermissionContext,
|
||
mode: selectedAgent.permissionMode ?? 'acceptEdits',
|
||
}
|
||
const workerTools = assembleToolPool(
|
||
workerPermissionContext,
|
||
appState.mcp.tools,
|
||
)
|
||
|
||
// Create a stable agent ID early so it can be used for worktree slug
|
||
const earlyAgentId = createAgentId()
|
||
|
||
// Set up worktree isolation if requested
|
||
let worktreeInfo: {
|
||
worktreePath: string
|
||
worktreeBranch?: string
|
||
headCommit?: string
|
||
gitRoot?: string
|
||
hookBased?: boolean
|
||
} | null = null
|
||
|
||
if (effectiveIsolation === 'worktree') {
|
||
const slug = `agent-${earlyAgentId.slice(0, 8)}`
|
||
worktreeInfo = await createAgentWorktree(slug)
|
||
}
|
||
|
||
// Fork + worktree: inject a notice telling the child to translate paths
|
||
// and re-read potentially stale files. Appended after the fork directive
|
||
// so it appears as the most recent guidance the child sees.
|
||
if (isForkPath && worktreeInfo) {
|
||
promptMessages.push(
|
||
createUserMessage({
|
||
content: buildWorktreeNotice(getCwd(), worktreeInfo.worktreePath),
|
||
}),
|
||
)
|
||
}
|
||
|
||
const runAgentParams: Parameters<typeof runAgent>[0] = {
|
||
agentDefinition: selectedAgent,
|
||
promptMessages,
|
||
toolUseContext,
|
||
canUseTool,
|
||
isAsync: shouldRunAsync,
|
||
querySource:
|
||
toolUseContext.options.querySource ??
|
||
getQuerySourceForAgent(
|
||
selectedAgent.agentType,
|
||
isBuiltInAgent(selectedAgent),
|
||
),
|
||
model: isForkPath ? undefined : model,
|
||
// Fork path: pass parent's system prompt AND parent's exact tool
|
||
// array (cache-identical prefix). workerTools is rebuilt under
|
||
// permissionMode 'bubble' which differs from the parent's mode, so
|
||
// its tool-def serialization diverges and breaks cache at the first
|
||
// differing tool. useExactTools also inherits the parent's
|
||
// thinkingConfig and isNonInteractiveSession (see runAgent.ts).
|
||
//
|
||
// Normal path: when a cwd override is in effect (worktree isolation
|
||
// or explicit cwd), skip the pre-built system prompt so runAgent's
|
||
// buildAgentSystemPrompt() runs inside wrapWithCwd where getCwd()
|
||
// returns the override path.
|
||
override: isForkPath
|
||
? { systemPrompt: forkParentSystemPrompt }
|
||
: enhancedSystemPrompt && !worktreeInfo && !cwd
|
||
? { systemPrompt: asSystemPrompt(enhancedSystemPrompt) }
|
||
: undefined,
|
||
availableTools: isForkPath ? toolUseContext.options.tools : workerTools,
|
||
// Pass parent conversation when the fork-subagent path needs full
|
||
// context. useExactTools inherits thinkingConfig (runAgent.ts:624).
|
||
forkContextMessages: isForkPath ? toolUseContext.messages : undefined,
|
||
...(isForkPath && { useExactTools: true }),
|
||
worktreePath: worktreeInfo?.worktreePath,
|
||
description,
|
||
}
|
||
|
||
// Helper to wrap execution with a cwd override: explicit cwd arg (KAIROS)
|
||
// takes precedence over worktree isolation path.
|
||
const cwdOverridePath = cwd ?? worktreeInfo?.worktreePath
|
||
const wrapWithCwd = <T,>(fn: () => T): T =>
|
||
cwdOverridePath ? runWithCwdOverride(cwdOverridePath, fn) : fn()
|
||
|
||
// Helper to clean up worktree after agent completes
|
||
const cleanupWorktreeIfNeeded = async (): Promise<{
|
||
worktreePath?: string
|
||
worktreeBranch?: string
|
||
}> => {
|
||
if (!worktreeInfo) return {}
|
||
const { worktreePath, worktreeBranch, headCommit, gitRoot, hookBased } =
|
||
worktreeInfo
|
||
// Null out to make idempotent — guards against double-call if code
|
||
// between cleanup and end of try throws into catch
|
||
worktreeInfo = null
|
||
if (hookBased) {
|
||
// Hook-based worktrees are always kept since we can't detect VCS changes
|
||
logForDebugging(`Hook-based agent worktree kept at: ${worktreePath}`)
|
||
return { worktreePath }
|
||
}
|
||
if (headCommit) {
|
||
const changed = await hasWorktreeChanges(worktreePath, headCommit)
|
||
if (!changed) {
|
||
await removeAgentWorktree(worktreePath, worktreeBranch, gitRoot)
|
||
// Clear worktreePath from metadata so resume doesn't try to use
|
||
// a deleted directory. Fire-and-forget to match runAgent's
|
||
// writeAgentMetadata handling.
|
||
void writeAgentMetadata(asAgentId(earlyAgentId), {
|
||
agentType: selectedAgent.agentType,
|
||
description,
|
||
}).catch(_err =>
|
||
logForDebugging(`Failed to clear worktree metadata: ${_err}`),
|
||
)
|
||
return {}
|
||
}
|
||
}
|
||
logForDebugging(`Agent worktree has changes, keeping: ${worktreePath}`)
|
||
return { worktreePath, worktreeBranch }
|
||
}
|
||
|
||
if (shouldRunAsync) {
|
||
const asyncAgentId = earlyAgentId
|
||
const agentBackgroundTask = registerAsyncAgent({
|
||
agentId: asyncAgentId,
|
||
description,
|
||
prompt,
|
||
selectedAgent,
|
||
setAppState: rootSetAppState,
|
||
// Don't link to parent's abort controller -- background agents should
|
||
// survive when the user presses ESC to cancel the main thread.
|
||
// They are killed explicitly via chat:killAgents.
|
||
toolUseId: toolUseContext.toolUseId,
|
||
})
|
||
|
||
// Register name → agentId for SendMessage routing. Post-registerAsyncAgent
|
||
// so we don't leave a stale entry if spawn fails. Sync agents skipped —
|
||
// coordinator is blocked, so SendMessage routing doesn't apply.
|
||
if (name) {
|
||
rootSetAppState(prev => {
|
||
const next = new Map(prev.agentNameRegistry)
|
||
next.set(name, asAgentId(asyncAgentId))
|
||
return { ...prev, agentNameRegistry: next }
|
||
})
|
||
}
|
||
|
||
// Wrap async agent execution in agent context for analytics attribution
|
||
const asyncAgentContext: SubagentContext = {
|
||
agentId: asyncAgentId,
|
||
// For subagents from teammates: use team lead's session
|
||
// For subagents from main REPL: undefined (no parent session)
|
||
parentSessionId: getParentSessionId(),
|
||
agentType: 'subagent' as const,
|
||
subagentName: selectedAgent.agentType,
|
||
isBuiltIn: isBuiltInAgent(selectedAgent),
|
||
invokingRequestId: assistantMessage?.requestId as string | undefined,
|
||
invocationKind: 'spawn' as const,
|
||
invocationEmitted: false,
|
||
}
|
||
|
||
// Workload propagation: handlePromptSubmit wraps the entire turn in
|
||
// runWithWorkload (AsyncLocalStorage). ALS context is captured at
|
||
// invocation time — when this `void` fires — and survives every await
|
||
// inside. No capture/restore needed; the detached closure sees the
|
||
// parent turn's workload automatically, isolated from its finally.
|
||
void runWithAgentContext(asyncAgentContext, () =>
|
||
wrapWithCwd(() =>
|
||
runAsyncAgentLifecycle({
|
||
taskId: agentBackgroundTask.agentId,
|
||
abortController: agentBackgroundTask.abortController!,
|
||
makeStream: onCacheSafeParams =>
|
||
runAgent({
|
||
...runAgentParams,
|
||
override: {
|
||
...runAgentParams.override,
|
||
agentId: asAgentId(agentBackgroundTask.agentId),
|
||
abortController: agentBackgroundTask.abortController!,
|
||
},
|
||
onCacheSafeParams,
|
||
}),
|
||
metadata,
|
||
description,
|
||
toolUseContext,
|
||
rootSetAppState,
|
||
agentIdForCleanup: asyncAgentId,
|
||
enableSummarization:
|
||
isCoordinator ||
|
||
isForkSubagentEnabled() ||
|
||
getSdkAgentProgressSummariesEnabled(),
|
||
getWorktreeResult: cleanupWorktreeIfNeeded,
|
||
}),
|
||
),
|
||
)
|
||
|
||
const canReadOutputFile = toolUseContext.options.tools.some(
|
||
t =>
|
||
toolMatchesName(t, FILE_READ_TOOL_NAME) ||
|
||
toolMatchesName(t, BASH_TOOL_NAME),
|
||
)
|
||
return {
|
||
data: {
|
||
isAsync: true as const,
|
||
status: 'async_launched' as const,
|
||
agentId: agentBackgroundTask.agentId,
|
||
description: description,
|
||
prompt: prompt,
|
||
outputFile: getTaskOutputPath(agentBackgroundTask.agentId),
|
||
canReadOutputFile,
|
||
},
|
||
}
|
||
} else {
|
||
// Create an explicit agentId for sync agents
|
||
const syncAgentId = asAgentId(earlyAgentId)
|
||
|
||
// Set up agent context for sync execution (for analytics attribution)
|
||
const syncAgentContext: SubagentContext = {
|
||
agentId: syncAgentId,
|
||
// For subagents from teammates: use team lead's session
|
||
// For subagents from main REPL: undefined (no parent session)
|
||
parentSessionId: getParentSessionId(),
|
||
agentType: 'subagent' as const,
|
||
subagentName: selectedAgent.agentType,
|
||
isBuiltIn: isBuiltInAgent(selectedAgent),
|
||
invokingRequestId: assistantMessage?.requestId as string | undefined,
|
||
invocationKind: 'spawn' as const,
|
||
invocationEmitted: false,
|
||
}
|
||
|
||
// Wrap entire sync agent execution in context for analytics attribution
|
||
// and optionally in a worktree cwd override for filesystem isolation
|
||
return runWithAgentContext(syncAgentContext, () =>
|
||
wrapWithCwd(async () => {
|
||
const agentMessages: MessageType[] = []
|
||
const agentStartTime = Date.now()
|
||
const syncTracker = createProgressTracker()
|
||
const syncResolveActivity = createActivityDescriptionResolver(
|
||
toolUseContext.options.tools,
|
||
)
|
||
|
||
// Yield initial progress message to carry metadata (prompt)
|
||
if (promptMessages.length > 0) {
|
||
const normalizedPromptMessages = normalizeMessages(promptMessages)
|
||
const normalizedFirstMessage = normalizedPromptMessages.find(
|
||
(m): m is NormalizedUserMessage => m.type === 'user',
|
||
)
|
||
if (
|
||
normalizedFirstMessage &&
|
||
normalizedFirstMessage.type === 'user' &&
|
||
onProgress
|
||
) {
|
||
onProgress({
|
||
toolUseID: `agent_${assistantMessage.message.id}`,
|
||
data: {
|
||
message: normalizedFirstMessage,
|
||
type: 'agent_progress',
|
||
prompt,
|
||
agentId: syncAgentId,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
// Register as foreground task immediately so it can be backgrounded at any time
|
||
// Skip registration if background tasks are disabled
|
||
let foregroundTaskId: string | undefined
|
||
// Create the background race promise once outside the loop — otherwise
|
||
// each iteration adds a new .then() reaction to the same pending
|
||
// promise, accumulating callbacks for the lifetime of the agent.
|
||
let backgroundPromise: Promise<{ type: 'background' }> | undefined
|
||
let cancelAutoBackground: (() => void) | undefined
|
||
if (!isBackgroundTasksDisabled) {
|
||
const registration = registerAgentForeground({
|
||
agentId: syncAgentId,
|
||
description,
|
||
prompt,
|
||
selectedAgent,
|
||
setAppState: rootSetAppState,
|
||
toolUseId: toolUseContext.toolUseId,
|
||
autoBackgroundMs: getAutoBackgroundMs() || undefined,
|
||
})
|
||
foregroundTaskId = registration.taskId
|
||
backgroundPromise = registration.backgroundSignal.then(() => ({
|
||
type: 'background' as const,
|
||
}))
|
||
cancelAutoBackground = registration.cancelAutoBackground
|
||
}
|
||
|
||
// Track if we've shown the background hint UI
|
||
let backgroundHintShown = false
|
||
// Track if the agent was backgrounded (cleanup handled by backgrounded finally)
|
||
let wasBackgrounded = false
|
||
// Per-scope stop function — NOT shared with the backgrounded closure.
|
||
// idempotent: startAgentSummarization's stop() checks `stopped` flag.
|
||
let stopForegroundSummarization: (() => void) | undefined
|
||
// const capture for sound type narrowing inside the callback below
|
||
const summaryTaskId = foregroundTaskId
|
||
|
||
// Get async iterator for the agent
|
||
const agentIterator = runAgent({
|
||
...runAgentParams,
|
||
override: {
|
||
...runAgentParams.override,
|
||
agentId: syncAgentId,
|
||
},
|
||
onCacheSafeParams:
|
||
summaryTaskId && getSdkAgentProgressSummariesEnabled()
|
||
? (params: CacheSafeParams) => {
|
||
const { stop } = startAgentSummarization(
|
||
summaryTaskId,
|
||
syncAgentId,
|
||
params,
|
||
rootSetAppState,
|
||
)
|
||
stopForegroundSummarization = stop
|
||
}
|
||
: undefined,
|
||
})[Symbol.asyncIterator]()
|
||
|
||
// Track if an error occurred during iteration
|
||
let syncAgentError: Error | undefined
|
||
let wasAborted = false
|
||
let worktreeResult: {
|
||
worktreePath?: string
|
||
worktreeBranch?: string
|
||
} = {}
|
||
|
||
try {
|
||
while (true) {
|
||
const elapsed = Date.now() - agentStartTime
|
||
|
||
// Show background hint after threshold (but task is already registered)
|
||
// Skip if background tasks are disabled
|
||
if (
|
||
!isBackgroundTasksDisabled &&
|
||
!backgroundHintShown &&
|
||
elapsed >= PROGRESS_THRESHOLD_MS &&
|
||
toolUseContext.setToolJSX
|
||
) {
|
||
backgroundHintShown = true
|
||
toolUseContext.setToolJSX({
|
||
jsx: <BackgroundHint />,
|
||
shouldHidePromptInput: false,
|
||
shouldContinueAnimation: true,
|
||
showSpinner: true,
|
||
})
|
||
}
|
||
|
||
// Race between next message and background signal
|
||
// If background tasks are disabled, just await the next message directly
|
||
const nextMessagePromise = agentIterator.next()
|
||
const raceResult = backgroundPromise
|
||
? await Promise.race([
|
||
nextMessagePromise.then(r => ({
|
||
type: 'message' as const,
|
||
result: r,
|
||
})),
|
||
backgroundPromise,
|
||
])
|
||
: {
|
||
type: 'message' as const,
|
||
result: await nextMessagePromise,
|
||
}
|
||
|
||
// Check if we were backgrounded via backgroundAll()
|
||
// foregroundTaskId is guaranteed to be defined if raceResult.type is 'background'
|
||
// because backgroundPromise is only defined when foregroundTaskId is defined
|
||
if (raceResult.type === 'background' && foregroundTaskId) {
|
||
const appState = toolUseContext.getAppState()
|
||
const task = appState.tasks[foregroundTaskId]
|
||
if (isLocalAgentTask(task) && task.isBackgrounded) {
|
||
// Capture the taskId for use in the async callback
|
||
const backgroundedTaskId = foregroundTaskId
|
||
wasBackgrounded = true
|
||
// Stop foreground summarization; the backgrounded closure
|
||
// below owns its own independent stop function.
|
||
stopForegroundSummarization?.()
|
||
|
||
// Workload: inherited via ALS at `void` invocation time,
|
||
// same as the async-from-start path above.
|
||
// Continue agent in background and return async result
|
||
void runWithAgentContext(syncAgentContext, async () => {
|
||
let stopBackgroundedSummarization: (() => void) | undefined
|
||
try {
|
||
// Clean up the foreground iterator so its finally block runs
|
||
// (releases MCP connections, session hooks, prompt cache tracking, etc.)
|
||
// Timeout prevents blocking if MCP server cleanup hangs.
|
||
// .catch() prevents unhandled rejection if timeout wins the race.
|
||
await Promise.race([
|
||
agentIterator.return(undefined).catch(() => {}),
|
||
sleep(1000),
|
||
])
|
||
// Initialize progress tracking from existing messages
|
||
const tracker = createProgressTracker()
|
||
const resolveActivity2 =
|
||
createActivityDescriptionResolver(
|
||
toolUseContext.options.tools,
|
||
)
|
||
for (const existingMsg of agentMessages) {
|
||
updateProgressFromMessage(
|
||
tracker,
|
||
existingMsg,
|
||
resolveActivity2,
|
||
toolUseContext.options.tools,
|
||
)
|
||
}
|
||
for await (const msg of runAgent({
|
||
...runAgentParams,
|
||
isAsync: true, // Agent is now running in background
|
||
override: {
|
||
...runAgentParams.override,
|
||
agentId: asAgentId(backgroundedTaskId),
|
||
abortController: task.abortController,
|
||
},
|
||
onCacheSafeParams: getSdkAgentProgressSummariesEnabled()
|
||
? (params: CacheSafeParams) => {
|
||
const { stop } = startAgentSummarization(
|
||
backgroundedTaskId,
|
||
asAgentId(backgroundedTaskId),
|
||
params,
|
||
rootSetAppState,
|
||
)
|
||
stopBackgroundedSummarization = stop
|
||
}
|
||
: undefined,
|
||
})) {
|
||
agentMessages.push(msg)
|
||
|
||
// Track progress for backgrounded agents
|
||
updateProgressFromMessage(
|
||
tracker,
|
||
msg,
|
||
resolveActivity2,
|
||
toolUseContext.options.tools,
|
||
)
|
||
updateAsyncAgentProgress(
|
||
backgroundedTaskId,
|
||
getProgressUpdate(tracker),
|
||
rootSetAppState,
|
||
)
|
||
|
||
const lastToolName = getLastToolUseName(msg)
|
||
if (lastToolName) {
|
||
emitTaskProgress(
|
||
tracker,
|
||
backgroundedTaskId,
|
||
toolUseContext.toolUseId,
|
||
description,
|
||
startTime,
|
||
lastToolName,
|
||
)
|
||
}
|
||
}
|
||
const agentResult = finalizeAgentTool(
|
||
agentMessages,
|
||
backgroundedTaskId,
|
||
metadata,
|
||
)
|
||
|
||
// Mark task completed FIRST so TaskOutput(block=true)
|
||
// unblocks immediately. classifyHandoffIfNeeded and
|
||
// cleanupWorktreeIfNeeded can hang — they must not gate
|
||
// the status transition (gh-20236).
|
||
completeAsyncAgent(agentResult, rootSetAppState)
|
||
|
||
// Extract text from agent result content for the notification
|
||
let finalMessage = extractTextContent(
|
||
agentResult.content,
|
||
'\n',
|
||
)
|
||
|
||
if (feature('TRANSCRIPT_CLASSIFIER')) {
|
||
const backgroundedAppState =
|
||
toolUseContext.getAppState()
|
||
const handoffWarning = await classifyHandoffIfNeeded({
|
||
agentMessages,
|
||
tools: toolUseContext.options.tools,
|
||
toolPermissionContext:
|
||
backgroundedAppState.toolPermissionContext,
|
||
abortSignal: task.abortController!.signal,
|
||
subagentType: selectedAgent.agentType,
|
||
totalToolUseCount: agentResult.totalToolUseCount,
|
||
})
|
||
if (handoffWarning) {
|
||
finalMessage = `${handoffWarning}\n\n${finalMessage}`
|
||
}
|
||
}
|
||
|
||
// Clean up worktree before notification so we can include it
|
||
const worktreeResult = await cleanupWorktreeIfNeeded()
|
||
|
||
enqueueAgentNotification({
|
||
taskId: backgroundedTaskId,
|
||
description,
|
||
status: 'completed',
|
||
setAppState: rootSetAppState,
|
||
finalMessage,
|
||
usage: {
|
||
totalTokens: getTokenCountFromTracker(tracker),
|
||
toolUses: agentResult.totalToolUseCount,
|
||
durationMs: agentResult.totalDurationMs,
|
||
},
|
||
toolUseId: toolUseContext.toolUseId,
|
||
...worktreeResult,
|
||
})
|
||
} catch (error) {
|
||
if (error instanceof AbortError) {
|
||
// Transition status BEFORE worktree cleanup so
|
||
// TaskOutput unblocks even if git hangs (gh-20236).
|
||
killAsyncAgent(backgroundedTaskId, 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_cancel_background' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
const worktreeResult = await cleanupWorktreeIfNeeded()
|
||
const partialResult =
|
||
extractPartialResult(agentMessages)
|
||
enqueueAgentNotification({
|
||
taskId: backgroundedTaskId,
|
||
description,
|
||
status: 'killed',
|
||
setAppState: rootSetAppState,
|
||
toolUseId: toolUseContext.toolUseId,
|
||
finalMessage: partialResult,
|
||
...worktreeResult,
|
||
})
|
||
return
|
||
}
|
||
const errMsg = errorMessage(error)
|
||
failAsyncAgent(
|
||
backgroundedTaskId,
|
||
errMsg,
|
||
rootSetAppState,
|
||
)
|
||
const worktreeResult = await cleanupWorktreeIfNeeded()
|
||
enqueueAgentNotification({
|
||
taskId: backgroundedTaskId,
|
||
description,
|
||
status: 'failed',
|
||
error: errMsg,
|
||
setAppState: rootSetAppState,
|
||
toolUseId: toolUseContext.toolUseId,
|
||
...worktreeResult,
|
||
})
|
||
} finally {
|
||
stopBackgroundedSummarization?.()
|
||
clearInvokedSkillsForAgent(syncAgentId)
|
||
clearDumpState(syncAgentId)
|
||
// Note: worktree cleanup is done before enqueueAgentNotification
|
||
// in both try and catch paths so we can include worktree info
|
||
}
|
||
})
|
||
|
||
// Return async_launched result immediately
|
||
const canReadOutputFile = toolUseContext.options.tools.some(
|
||
t =>
|
||
toolMatchesName(t, FILE_READ_TOOL_NAME) ||
|
||
toolMatchesName(t, BASH_TOOL_NAME),
|
||
)
|
||
return {
|
||
data: {
|
||
isAsync: true as const,
|
||
status: 'async_launched' as const,
|
||
agentId: backgroundedTaskId,
|
||
description: description,
|
||
prompt: prompt,
|
||
outputFile: getTaskOutputPath(backgroundedTaskId),
|
||
canReadOutputFile,
|
||
},
|
||
}
|
||
}
|
||
}
|
||
|
||
// Process the message from the race result
|
||
if (raceResult.type !== 'message') {
|
||
// This shouldn't happen - background case handled above
|
||
continue
|
||
}
|
||
const { result } = raceResult
|
||
if (result.done) break
|
||
const message = result.value as MessageType
|
||
|
||
agentMessages.push(message)
|
||
|
||
// Emit task_progress for the VS Code subagent panel
|
||
updateProgressFromMessage(
|
||
syncTracker,
|
||
message,
|
||
syncResolveActivity,
|
||
toolUseContext.options.tools,
|
||
)
|
||
if (foregroundTaskId) {
|
||
const lastToolName = getLastToolUseName(message)
|
||
if (lastToolName) {
|
||
emitTaskProgress(
|
||
syncTracker,
|
||
foregroundTaskId,
|
||
toolUseContext.toolUseId,
|
||
description,
|
||
agentStartTime,
|
||
lastToolName,
|
||
)
|
||
// Keep AppState task.progress in sync when SDK summaries are
|
||
// enabled, so updateAgentSummary reads correct token/tool counts
|
||
// instead of zeros.
|
||
if (getSdkAgentProgressSummariesEnabled()) {
|
||
updateAsyncAgentProgress(
|
||
foregroundTaskId,
|
||
getProgressUpdate(syncTracker),
|
||
rootSetAppState,
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Forward bash_progress events from sub-agent to parent so the SDK
|
||
// receives tool_progress events just as it does for the main agent.
|
||
if (
|
||
message.type === 'progress' &&
|
||
((message.data as { type: string })?.type === 'bash_progress' ||
|
||
(message.data as { type: string })?.type === 'powershell_progress') &&
|
||
onProgress
|
||
) {
|
||
onProgress({
|
||
toolUseID: message.toolUseID as string,
|
||
data: message.data,
|
||
})
|
||
}
|
||
|
||
if (message.type !== 'assistant' && message.type !== 'user') {
|
||
continue
|
||
}
|
||
|
||
// Increment token count in spinner for assistant messages
|
||
// Subagent streaming events are filtered out in runAgent.ts, so we
|
||
// need to count tokens from completed messages here
|
||
if (message.type === 'assistant') {
|
||
const contentLength = getAssistantMessageContentLength(message as AssistantMessage)
|
||
if (contentLength > 0) {
|
||
toolUseContext.setResponseLength(len => len + contentLength)
|
||
}
|
||
}
|
||
|
||
const normalizedNew = normalizeMessages([message])
|
||
for (const m of normalizedNew) {
|
||
for (const content of (m.message?.content ?? []) as readonly { readonly type: string }[]) {
|
||
if (
|
||
content.type !== 'tool_use' &&
|
||
content.type !== 'tool_result'
|
||
) {
|
||
continue
|
||
}
|
||
|
||
// Forward progress updates
|
||
if (onProgress) {
|
||
onProgress({
|
||
toolUseID: `agent_${assistantMessage.message.id}`,
|
||
data: {
|
||
message: m,
|
||
type: 'agent_progress',
|
||
// prompt only needed on first progress message (UI.tsx:624
|
||
// reads progressMessages[0]). Omit here to avoid duplication.
|
||
prompt: '',
|
||
agentId: syncAgentId,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
// Handle errors from the sync agent loop
|
||
// AbortError should be re-thrown for proper interruption handling
|
||
if (error instanceof AbortError) {
|
||
wasAborted = true
|
||
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: false,
|
||
is_built_in_agent: metadata.isBuiltInAgent,
|
||
reason:
|
||
'user_cancel_sync' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
throw error
|
||
}
|
||
|
||
// Log the error for debugging
|
||
logForDebugging(`Sync agent error: ${errorMessage(error)}`, {
|
||
level: 'error',
|
||
})
|
||
|
||
// Store the error to handle after cleanup
|
||
syncAgentError = toError(error)
|
||
} finally {
|
||
// Clear the background hint UI
|
||
if (toolUseContext.setToolJSX) {
|
||
toolUseContext.setToolJSX(null)
|
||
}
|
||
|
||
// Stop foreground summarization. Idempotent — if already stopped at
|
||
// the backgrounding transition, this is a no-op. The backgrounded
|
||
// closure owns a separate stop function (stopBackgroundedSummarization).
|
||
stopForegroundSummarization?.()
|
||
|
||
// Unregister foreground task if agent completed without being backgrounded
|
||
if (foregroundTaskId) {
|
||
unregisterAgentForeground(foregroundTaskId, rootSetAppState)
|
||
// Notify SDK consumers (e.g. VS Code subagent panel) that this
|
||
// foreground agent is done. Goes through drainSdkEvents() — does
|
||
// NOT trigger the print.ts XML task_notification parser or the LLM loop.
|
||
if (!wasBackgrounded) {
|
||
const progress = getProgressUpdate(syncTracker)
|
||
enqueueSdkEvent({
|
||
type: 'system',
|
||
subtype: 'task_notification',
|
||
task_id: foregroundTaskId,
|
||
tool_use_id: toolUseContext.toolUseId,
|
||
status: syncAgentError
|
||
? 'failed'
|
||
: wasAborted
|
||
? 'stopped'
|
||
: 'completed',
|
||
output_file: '',
|
||
summary: description,
|
||
usage: {
|
||
total_tokens: progress.tokenCount,
|
||
tool_uses: progress.toolUseCount,
|
||
duration_ms: Date.now() - agentStartTime,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
// Clean up scoped skills so they don't accumulate in the global map
|
||
clearInvokedSkillsForAgent(syncAgentId)
|
||
|
||
// Clean up dumpState entry for this agent to prevent unbounded growth
|
||
// Skip if backgrounded — the backgrounded agent's finally handles cleanup
|
||
if (!wasBackgrounded) {
|
||
clearDumpState(syncAgentId)
|
||
}
|
||
|
||
// Cancel auto-background timer if agent completed before it fired
|
||
cancelAutoBackground?.()
|
||
|
||
// Clean up worktree if applicable (in finally to handle abort/error paths)
|
||
// Skip if backgrounded — the background continuation is still running in it
|
||
if (!wasBackgrounded) {
|
||
worktreeResult = await cleanupWorktreeIfNeeded()
|
||
}
|
||
}
|
||
|
||
// Re-throw abort errors
|
||
// TODO: Find a cleaner way to express this
|
||
const lastMessage = agentMessages.findLast(
|
||
_ => _.type !== 'system' && _.type !== 'progress',
|
||
)
|
||
if (lastMessage && isSyntheticMessage(lastMessage)) {
|
||
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: false,
|
||
is_built_in_agent: metadata.isBuiltInAgent,
|
||
reason:
|
||
'user_cancel_sync' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
throw new AbortError()
|
||
}
|
||
|
||
// If an error occurred during iteration, try to return a result with
|
||
// whatever messages we have. If we have no assistant messages,
|
||
// re-throw the error so it's properly handled by the tool framework.
|
||
if (syncAgentError) {
|
||
// Check if we have any assistant messages to return
|
||
const hasAssistantMessages = agentMessages.some(
|
||
msg => msg.type === 'assistant',
|
||
)
|
||
|
||
if (!hasAssistantMessages) {
|
||
// No messages collected, re-throw the error
|
||
throw syncAgentError
|
||
}
|
||
|
||
// We have some messages, try to finalize and return them
|
||
// This allows the parent agent to see partial progress even after an error
|
||
logForDebugging(
|
||
`Sync agent recovering from error with ${agentMessages.length} messages`,
|
||
)
|
||
}
|
||
|
||
const agentResult = finalizeAgentTool(
|
||
agentMessages,
|
||
syncAgentId,
|
||
metadata,
|
||
)
|
||
|
||
if (feature('TRANSCRIPT_CLASSIFIER')) {
|
||
const currentAppState = toolUseContext.getAppState()
|
||
const handoffWarning = await classifyHandoffIfNeeded({
|
||
agentMessages,
|
||
tools: toolUseContext.options.tools,
|
||
toolPermissionContext: currentAppState.toolPermissionContext,
|
||
abortSignal: toolUseContext.abortController.signal,
|
||
subagentType: selectedAgent.agentType,
|
||
totalToolUseCount: agentResult.totalToolUseCount,
|
||
})
|
||
if (handoffWarning) {
|
||
agentResult.content = [
|
||
{ type: 'text' as const, text: handoffWarning },
|
||
...agentResult.content,
|
||
]
|
||
}
|
||
}
|
||
|
||
return {
|
||
data: {
|
||
status: 'completed' as const,
|
||
prompt,
|
||
...agentResult,
|
||
...worktreeResult,
|
||
},
|
||
}
|
||
}),
|
||
)
|
||
}
|
||
},
|
||
isReadOnly() {
|
||
return true // delegates permission checks to its underlying tools
|
||
},
|
||
toAutoClassifierInput(input) {
|
||
const i = input as AgentToolInput
|
||
const tags = [
|
||
i.subagent_type,
|
||
i.mode ? `mode=${i.mode}` : undefined,
|
||
].filter((t): t is string => t !== undefined)
|
||
const prefix = tags.length > 0 ? `(${tags.join(', ')}): ` : ': '
|
||
return `${prefix}${i.prompt}`
|
||
},
|
||
isConcurrencySafe() {
|
||
return true
|
||
},
|
||
userFacingName,
|
||
userFacingNameBackgroundColor,
|
||
getActivityDescription(input) {
|
||
return input?.description ?? 'Running task'
|
||
},
|
||
async checkPermissions(input, context): Promise<PermissionResult> {
|
||
const appState = context.getAppState()
|
||
|
||
// Only route through auto mode classifier when in auto mode
|
||
// In all other modes, auto-approve sub-agent generation
|
||
// Note: process.env.USER_TYPE === 'ant' guard enables dead code elimination for external builds
|
||
if (
|
||
process.env.USER_TYPE === 'ant' &&
|
||
appState.toolPermissionContext.mode === 'auto'
|
||
) {
|
||
return {
|
||
behavior: 'passthrough',
|
||
message: 'Agent tool requires permission to spawn sub-agents.',
|
||
}
|
||
}
|
||
|
||
return { behavior: 'allow', updatedInput: input }
|
||
},
|
||
mapToolResultToToolResultBlockParam(data, toolUseID) {
|
||
// Multi-agent spawn result
|
||
const internalData = data as InternalOutput
|
||
if (
|
||
typeof internalData === 'object' &&
|
||
internalData !== null &&
|
||
'status' in internalData &&
|
||
internalData.status === 'teammate_spawned'
|
||
) {
|
||
const spawnData = internalData as TeammateSpawnedOutput
|
||
return {
|
||
tool_use_id: toolUseID,
|
||
type: 'tool_result',
|
||
content: [
|
||
{
|
||
type: 'text',
|
||
text: `Spawned successfully.
|
||
agent_id: ${spawnData.teammate_id}
|
||
name: ${spawnData.name}
|
||
team_name: ${spawnData.team_name}
|
||
The agent is now running and will receive instructions via mailbox.`,
|
||
},
|
||
],
|
||
}
|
||
}
|
||
if ('status' in internalData && internalData.status === 'remote_launched') {
|
||
const r = internalData
|
||
return {
|
||
tool_use_id: toolUseID,
|
||
type: 'tool_result',
|
||
content: [
|
||
{
|
||
type: 'text',
|
||
text: `Remote agent launched in CCR.\ntaskId: ${r.taskId}\nsession_url: ${r.sessionUrl}\noutput_file: ${r.outputFile}\nThe agent is running remotely. You will be notified automatically when it completes.\nBriefly tell the user what you launched and end your response.`,
|
||
},
|
||
],
|
||
}
|
||
}
|
||
if (data.status === 'async_launched') {
|
||
const prefix = `Async agent launched successfully.\nagentId: ${data.agentId} (internal ID - do not mention to user. Use SendMessage with to: '${data.agentId}' to continue this agent.)\nThe agent is working in the background. You will be notified automatically when it completes.`
|
||
const instructions = data.canReadOutputFile
|
||
? `Do not duplicate this agent's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.\noutput_file: ${data.outputFile}\nIf asked, you can check progress before completion by using ${FILE_READ_TOOL_NAME} or ${BASH_TOOL_NAME} tail on the output file.`
|
||
: `Briefly tell the user what you launched and end your response. Do not generate any other text — agent results will arrive in a subsequent message.`
|
||
const text = `${prefix}\n${instructions}`
|
||
return {
|
||
tool_use_id: toolUseID,
|
||
type: 'tool_result',
|
||
content: [
|
||
{
|
||
type: 'text',
|
||
text,
|
||
},
|
||
],
|
||
}
|
||
}
|
||
if (data.status === 'completed') {
|
||
const worktreeData = data as Record<string, unknown>
|
||
const worktreeInfoText = worktreeData.worktreePath
|
||
? `\nworktreePath: ${worktreeData.worktreePath}\nworktreeBranch: ${worktreeData.worktreeBranch}`
|
||
: ''
|
||
// If the subagent completes with no content, the tool_result is just the
|
||
// agentId/usage trailer below — a metadata-only block at the prompt tail.
|
||
// Some models read that as "nothing to act on" and end their turn
|
||
// immediately. Say so explicitly so the parent has something to react to.
|
||
const contentOrMarker =
|
||
data.content.length > 0
|
||
? data.content
|
||
: [
|
||
{
|
||
type: 'text' as const,
|
||
text: '(Subagent completed but returned no output.)',
|
||
},
|
||
]
|
||
// One-shot built-ins (Explore, Plan) are never continued via SendMessage
|
||
// — the agentId hint and <usage> block are dead weight (~135 chars ×
|
||
// 34M Explore runs/week ≈ 1-2 Gtok/week). Telemetry doesn't parse this
|
||
// block (it uses logEvent in finalizeAgentTool), so dropping is safe.
|
||
// agentType is optional for resume compat — missing means show trailer.
|
||
if (
|
||
data.agentType &&
|
||
ONE_SHOT_BUILTIN_AGENT_TYPES.has(data.agentType) &&
|
||
!worktreeInfoText
|
||
) {
|
||
return {
|
||
tool_use_id: toolUseID,
|
||
type: 'tool_result',
|
||
content: contentOrMarker,
|
||
}
|
||
}
|
||
return {
|
||
tool_use_id: toolUseID,
|
||
type: 'tool_result',
|
||
content: [
|
||
...contentOrMarker,
|
||
{
|
||
type: 'text',
|
||
text: `agentId: ${data.agentId} (use SendMessage with to: '${data.agentId}' to continue this agent)${worktreeInfoText}
|
||
<usage>total_tokens: ${data.totalTokens}
|
||
tool_uses: ${data.totalToolUseCount}
|
||
duration_ms: ${data.totalDurationMs}</usage>`,
|
||
},
|
||
],
|
||
}
|
||
}
|
||
data satisfies never
|
||
throw new Error(
|
||
`Unexpected agent tool result status: ${(data as { status: string }).status}`,
|
||
)
|
||
},
|
||
renderToolResultMessage,
|
||
renderToolUseMessage,
|
||
renderToolUseTag,
|
||
renderToolUseProgressMessage,
|
||
renderToolUseRejectedMessage,
|
||
renderToolUseErrorMessage,
|
||
renderGroupedToolUse: renderGroupedAgentToolUse,
|
||
} satisfies ToolDef<InputSchema, Output, Progress>)
|
||
|
||
function resolveTeamName(
|
||
input: { team_name?: string },
|
||
appState: { teamContext?: { teamName: string } },
|
||
): string | undefined {
|
||
if (!isAgentSwarmsEnabled()) return undefined
|
||
return input.team_name || appState.teamContext?.teamName
|
||
}
|