init claude-code
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,66 @@
|
||||
import { getAgentColorMap } from '../../bootstrap/state.js'
|
||||
import type { Theme } from '../../utils/theme.js'
|
||||
|
||||
export type AgentColorName =
|
||||
| 'red'
|
||||
| 'blue'
|
||||
| 'green'
|
||||
| 'yellow'
|
||||
| 'purple'
|
||||
| 'orange'
|
||||
| 'pink'
|
||||
| 'cyan'
|
||||
|
||||
export const AGENT_COLORS: readonly AgentColorName[] = [
|
||||
'red',
|
||||
'blue',
|
||||
'green',
|
||||
'yellow',
|
||||
'purple',
|
||||
'orange',
|
||||
'pink',
|
||||
'cyan',
|
||||
] as const
|
||||
|
||||
export const AGENT_COLOR_TO_THEME_COLOR = {
|
||||
red: 'red_FOR_SUBAGENTS_ONLY',
|
||||
blue: 'blue_FOR_SUBAGENTS_ONLY',
|
||||
green: 'green_FOR_SUBAGENTS_ONLY',
|
||||
yellow: 'yellow_FOR_SUBAGENTS_ONLY',
|
||||
purple: 'purple_FOR_SUBAGENTS_ONLY',
|
||||
orange: 'orange_FOR_SUBAGENTS_ONLY',
|
||||
pink: 'pink_FOR_SUBAGENTS_ONLY',
|
||||
cyan: 'cyan_FOR_SUBAGENTS_ONLY',
|
||||
} as const satisfies Record<AgentColorName, keyof Theme>
|
||||
|
||||
export function getAgentColor(agentType: string): keyof Theme | undefined {
|
||||
if (agentType === 'general-purpose') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const agentColorMap = getAgentColorMap()
|
||||
|
||||
// Check if color already assigned
|
||||
const existingColor = agentColorMap.get(agentType)
|
||||
if (existingColor && AGENT_COLORS.includes(existingColor)) {
|
||||
return AGENT_COLOR_TO_THEME_COLOR[existingColor]
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function setAgentColor(
|
||||
agentType: string,
|
||||
color: AgentColorName | undefined,
|
||||
): void {
|
||||
const agentColorMap = getAgentColorMap()
|
||||
|
||||
if (!color) {
|
||||
agentColorMap.delete(agentType)
|
||||
return
|
||||
}
|
||||
|
||||
if (AGENT_COLORS.includes(color)) {
|
||||
agentColorMap.set(agentType, color)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Shared utilities for displaying agent information.
|
||||
* Used by both the CLI `claude agents` handler and the interactive `/agents` command.
|
||||
*/
|
||||
|
||||
import { getDefaultSubagentModel } from '../../utils/model/agent.js'
|
||||
import {
|
||||
getSourceDisplayName,
|
||||
type SettingSource,
|
||||
} from '../../utils/settings/constants.js'
|
||||
import type { AgentDefinition } from './loadAgentsDir.js'
|
||||
|
||||
type AgentSource = SettingSource | 'built-in' | 'plugin'
|
||||
|
||||
export type AgentSourceGroup = {
|
||||
label: string
|
||||
source: AgentSource
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered list of agent source groups for display.
|
||||
* Both the CLI and interactive UI should use this to ensure consistent ordering.
|
||||
*/
|
||||
export const AGENT_SOURCE_GROUPS: AgentSourceGroup[] = [
|
||||
{ label: 'User agents', source: 'userSettings' },
|
||||
{ label: 'Project agents', source: 'projectSettings' },
|
||||
{ label: 'Local agents', source: 'localSettings' },
|
||||
{ label: 'Managed agents', source: 'policySettings' },
|
||||
{ label: 'Plugin agents', source: 'plugin' },
|
||||
{ label: 'CLI arg agents', source: 'flagSettings' },
|
||||
{ label: 'Built-in agents', source: 'built-in' },
|
||||
]
|
||||
|
||||
export type ResolvedAgent = AgentDefinition & {
|
||||
overriddenBy?: AgentSource
|
||||
}
|
||||
|
||||
/**
|
||||
* Annotate agents with override information by comparing against the active
|
||||
* (winning) agent list. An agent is "overridden" when another agent with the
|
||||
* same type from a higher-priority source takes precedence.
|
||||
*
|
||||
* Also deduplicates by (agentType, source) to handle git worktree duplicates
|
||||
* where the same agent file is loaded from both the worktree and main repo.
|
||||
*/
|
||||
export function resolveAgentOverrides(
|
||||
allAgents: AgentDefinition[],
|
||||
activeAgents: AgentDefinition[],
|
||||
): ResolvedAgent[] {
|
||||
const activeMap = new Map<string, AgentDefinition>()
|
||||
for (const agent of activeAgents) {
|
||||
activeMap.set(agent.agentType, agent)
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
const resolved: ResolvedAgent[] = []
|
||||
|
||||
// Iterate allAgents, annotating each with override info from activeAgents.
|
||||
// Deduplicate by (agentType, source) to handle git worktree duplicates.
|
||||
for (const agent of allAgents) {
|
||||
const key = `${agent.agentType}:${agent.source}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
|
||||
const active = activeMap.get(agent.agentType)
|
||||
const overriddenBy =
|
||||
active && active.source !== agent.source ? active.source : undefined
|
||||
resolved.push({ ...agent, overriddenBy })
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the display model string for an agent.
|
||||
* Returns the model alias or 'inherit' for display purposes.
|
||||
*/
|
||||
export function resolveAgentModelDisplay(
|
||||
agent: AgentDefinition,
|
||||
): string | undefined {
|
||||
const model = agent.model || getDefaultSubagentModel()
|
||||
if (!model) return undefined
|
||||
return model === 'inherit' ? 'inherit' : model
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable label for the source that overrides an agent.
|
||||
* Returns lowercase, e.g. "user", "project", "managed".
|
||||
*/
|
||||
export function getOverrideSourceLabel(source: AgentSource): string {
|
||||
return getSourceDisplayName(source).toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare agents alphabetically by name (case-insensitive).
|
||||
*/
|
||||
export function compareAgentsByName(
|
||||
a: AgentDefinition,
|
||||
b: AgentDefinition,
|
||||
): number {
|
||||
return a.agentType.localeCompare(b.agentType, undefined, {
|
||||
sensitivity: 'base',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { join, normalize, sep } from 'path'
|
||||
import { getProjectRoot } from '../../bootstrap/state.js'
|
||||
import {
|
||||
buildMemoryPrompt,
|
||||
ensureMemoryDirExists,
|
||||
} from '../../memdir/memdir.js'
|
||||
import { getMemoryBaseDir } from '../../memdir/paths.js'
|
||||
import { getCwd } from '../../utils/cwd.js'
|
||||
import { findCanonicalGitRoot } from '../../utils/git.js'
|
||||
import { sanitizePath } from '../../utils/path.js'
|
||||
|
||||
// Persistent agent memory scope: 'user' (~/.claude/agent-memory/), 'project' (.claude/agent-memory/), or 'local' (.claude/agent-memory-local/)
|
||||
export type AgentMemoryScope = 'user' | 'project' | 'local'
|
||||
|
||||
/**
|
||||
* Sanitize an agent type name for use as a directory name.
|
||||
* Replaces colons (invalid on Windows, used in plugin-namespaced agent
|
||||
* types like "my-plugin:my-agent") with dashes.
|
||||
*/
|
||||
function sanitizeAgentTypeForPath(agentType: string): string {
|
||||
return agentType.replace(/:/g, '-')
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the local agent memory directory, which is project-specific and not checked into VCS.
|
||||
* When CLAUDE_CODE_REMOTE_MEMORY_DIR is set, persists to the mount with project namespacing.
|
||||
* Otherwise, uses <cwd>/.claude/agent-memory-local/<agentType>/.
|
||||
*/
|
||||
function getLocalAgentMemoryDir(dirName: string): string {
|
||||
if (process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR) {
|
||||
return (
|
||||
join(
|
||||
process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR,
|
||||
'projects',
|
||||
sanitizePath(
|
||||
findCanonicalGitRoot(getProjectRoot()) ?? getProjectRoot(),
|
||||
),
|
||||
'agent-memory-local',
|
||||
dirName,
|
||||
) + sep
|
||||
)
|
||||
}
|
||||
return join(getCwd(), '.claude', 'agent-memory-local', dirName) + sep
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the agent memory directory for a given agent type and scope.
|
||||
* - 'user' scope: <memoryBase>/agent-memory/<agentType>/
|
||||
* - 'project' scope: <cwd>/.claude/agent-memory/<agentType>/
|
||||
* - 'local' scope: see getLocalAgentMemoryDir()
|
||||
*/
|
||||
export function getAgentMemoryDir(
|
||||
agentType: string,
|
||||
scope: AgentMemoryScope,
|
||||
): string {
|
||||
const dirName = sanitizeAgentTypeForPath(agentType)
|
||||
switch (scope) {
|
||||
case 'project':
|
||||
return join(getCwd(), '.claude', 'agent-memory', dirName) + sep
|
||||
case 'local':
|
||||
return getLocalAgentMemoryDir(dirName)
|
||||
case 'user':
|
||||
return join(getMemoryBaseDir(), 'agent-memory', dirName) + sep
|
||||
}
|
||||
}
|
||||
|
||||
// Check if file is within an agent memory directory (any scope).
|
||||
export function isAgentMemoryPath(absolutePath: string): boolean {
|
||||
// SECURITY: Normalize to prevent path traversal bypasses via .. segments
|
||||
const normalizedPath = normalize(absolutePath)
|
||||
const memoryBase = getMemoryBaseDir()
|
||||
|
||||
// User scope: check memory base (may be custom dir or config home)
|
||||
if (normalizedPath.startsWith(join(memoryBase, 'agent-memory') + sep)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Project scope: always cwd-based (not redirected)
|
||||
if (
|
||||
normalizedPath.startsWith(join(getCwd(), '.claude', 'agent-memory') + sep)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Local scope: persisted to mount when CLAUDE_CODE_REMOTE_MEMORY_DIR is set, otherwise cwd-based
|
||||
if (process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR) {
|
||||
if (
|
||||
normalizedPath.includes(sep + 'agent-memory-local' + sep) &&
|
||||
normalizedPath.startsWith(
|
||||
join(process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR, 'projects') + sep,
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
} else if (
|
||||
normalizedPath.startsWith(
|
||||
join(getCwd(), '.claude', 'agent-memory-local') + sep,
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the agent memory file path for a given agent type and scope.
|
||||
*/
|
||||
export function getAgentMemoryEntrypoint(
|
||||
agentType: string,
|
||||
scope: AgentMemoryScope,
|
||||
): string {
|
||||
return join(getAgentMemoryDir(agentType, scope), 'MEMORY.md')
|
||||
}
|
||||
|
||||
export function getMemoryScopeDisplay(
|
||||
memory: AgentMemoryScope | undefined,
|
||||
): string {
|
||||
switch (memory) {
|
||||
case 'user':
|
||||
return `User (${join(getMemoryBaseDir(), 'agent-memory')}/)`
|
||||
case 'project':
|
||||
return 'Project (.claude/agent-memory/)'
|
||||
case 'local':
|
||||
return `Local (${getLocalAgentMemoryDir('...')})`
|
||||
default:
|
||||
return 'None'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load persistent memory for an agent with memory enabled.
|
||||
* Creates the memory directory if needed and returns a prompt with memory contents.
|
||||
*
|
||||
* @param agentType The agent's type name (used as directory name)
|
||||
* @param scope 'user' for ~/.claude/agent-memory/ or 'project' for .claude/agent-memory/
|
||||
*/
|
||||
export function loadAgentMemoryPrompt(
|
||||
agentType: string,
|
||||
scope: AgentMemoryScope,
|
||||
): string {
|
||||
let scopeNote: string
|
||||
switch (scope) {
|
||||
case 'user':
|
||||
scopeNote =
|
||||
'- Since this memory is user-scope, keep learnings general since they apply across all projects'
|
||||
break
|
||||
case 'project':
|
||||
scopeNote =
|
||||
'- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project'
|
||||
break
|
||||
case 'local':
|
||||
scopeNote =
|
||||
'- Since this memory is local-scope (not checked into version control), tailor your memories to this project and machine'
|
||||
break
|
||||
}
|
||||
|
||||
const memoryDir = getAgentMemoryDir(agentType, scope)
|
||||
|
||||
// Fire-and-forget: this runs at agent-spawn time inside a sync
|
||||
// getSystemPrompt() callback (called from React render in AgentDetail.tsx,
|
||||
// so it cannot be async). The spawned agent won't try to Write until after
|
||||
// a full API round-trip, by which time mkdir will have completed. Even if
|
||||
// it hasn't, FileWriteTool does its own mkdir of the parent directory.
|
||||
void ensureMemoryDirExists(memoryDir)
|
||||
|
||||
const coworkExtraGuidelines =
|
||||
process.env.CLAUDE_COWORK_MEMORY_EXTRA_GUIDELINES
|
||||
return buildMemoryPrompt({
|
||||
displayName: 'Persistent Agent Memory',
|
||||
memoryDir,
|
||||
extraGuidelines:
|
||||
coworkExtraGuidelines && coworkExtraGuidelines.trim().length > 0
|
||||
? [scopeNote, coworkExtraGuidelines]
|
||||
: [scopeNote],
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { mkdir, readdir, readFile, unlink, writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { z } from 'zod/v4'
|
||||
import { getCwd } from '../../utils/cwd.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { lazySchema } from '../../utils/lazySchema.js'
|
||||
import { jsonParse, jsonStringify } from '../../utils/slowOperations.js'
|
||||
import { type AgentMemoryScope, getAgentMemoryDir } from './agentMemory.js'
|
||||
|
||||
const SNAPSHOT_BASE = 'agent-memory-snapshots'
|
||||
const SNAPSHOT_JSON = 'snapshot.json'
|
||||
const SYNCED_JSON = '.snapshot-synced.json'
|
||||
|
||||
const snapshotMetaSchema = lazySchema(() =>
|
||||
z.object({
|
||||
updatedAt: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
|
||||
const syncedMetaSchema = lazySchema(() =>
|
||||
z.object({
|
||||
syncedFrom: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
type SyncedMeta = z.infer<ReturnType<typeof syncedMetaSchema>>
|
||||
|
||||
/**
|
||||
* Returns the path to the snapshot directory for an agent in the current project.
|
||||
* e.g., <cwd>/.claude/agent-memory-snapshots/<agentType>/
|
||||
*/
|
||||
export function getSnapshotDirForAgent(agentType: string): string {
|
||||
return join(getCwd(), '.claude', SNAPSHOT_BASE, agentType)
|
||||
}
|
||||
|
||||
function getSnapshotJsonPath(agentType: string): string {
|
||||
return join(getSnapshotDirForAgent(agentType), SNAPSHOT_JSON)
|
||||
}
|
||||
|
||||
function getSyncedJsonPath(agentType: string, scope: AgentMemoryScope): string {
|
||||
return join(getAgentMemoryDir(agentType, scope), SYNCED_JSON)
|
||||
}
|
||||
|
||||
async function readJsonFile<T>(
|
||||
path: string,
|
||||
schema: z.ZodType<T>,
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
const content = await readFile(path, { encoding: 'utf-8' })
|
||||
const result = schema.safeParse(jsonParse(content))
|
||||
return result.success ? result.data : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function copySnapshotToLocal(
|
||||
agentType: string,
|
||||
scope: AgentMemoryScope,
|
||||
): Promise<void> {
|
||||
const snapshotMemDir = getSnapshotDirForAgent(agentType)
|
||||
const localMemDir = getAgentMemoryDir(agentType, scope)
|
||||
|
||||
await mkdir(localMemDir, { recursive: true })
|
||||
|
||||
try {
|
||||
const files = await readdir(snapshotMemDir, { withFileTypes: true })
|
||||
for (const dirent of files) {
|
||||
if (!dirent.isFile() || dirent.name === SNAPSHOT_JSON) continue
|
||||
const content = await readFile(join(snapshotMemDir, dirent.name), {
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
await writeFile(join(localMemDir, dirent.name), content)
|
||||
}
|
||||
} catch (e) {
|
||||
logForDebugging(`Failed to copy snapshot to local agent memory: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSyncedMeta(
|
||||
agentType: string,
|
||||
scope: AgentMemoryScope,
|
||||
snapshotTimestamp: string,
|
||||
): Promise<void> {
|
||||
const syncedPath = getSyncedJsonPath(agentType, scope)
|
||||
const localMemDir = getAgentMemoryDir(agentType, scope)
|
||||
await mkdir(localMemDir, { recursive: true })
|
||||
const meta: SyncedMeta = { syncedFrom: snapshotTimestamp }
|
||||
try {
|
||||
await writeFile(syncedPath, jsonStringify(meta))
|
||||
} catch (e) {
|
||||
logForDebugging(`Failed to save snapshot sync metadata: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a snapshot exists and whether it's newer than what we last synced.
|
||||
*/
|
||||
export async function checkAgentMemorySnapshot(
|
||||
agentType: string,
|
||||
scope: AgentMemoryScope,
|
||||
): Promise<{
|
||||
action: 'none' | 'initialize' | 'prompt-update'
|
||||
snapshotTimestamp?: string
|
||||
}> {
|
||||
const snapshotMeta = await readJsonFile(
|
||||
getSnapshotJsonPath(agentType),
|
||||
snapshotMetaSchema(),
|
||||
)
|
||||
|
||||
if (!snapshotMeta) {
|
||||
return { action: 'none' }
|
||||
}
|
||||
|
||||
const localMemDir = getAgentMemoryDir(agentType, scope)
|
||||
|
||||
let hasLocalMemory = false
|
||||
try {
|
||||
const dirents = await readdir(localMemDir, { withFileTypes: true })
|
||||
hasLocalMemory = dirents.some(d => d.isFile() && d.name.endsWith('.md'))
|
||||
} catch {
|
||||
// Directory doesn't exist
|
||||
}
|
||||
|
||||
if (!hasLocalMemory) {
|
||||
return { action: 'initialize', snapshotTimestamp: snapshotMeta.updatedAt }
|
||||
}
|
||||
|
||||
const syncedMeta = await readJsonFile(
|
||||
getSyncedJsonPath(agentType, scope),
|
||||
syncedMetaSchema(),
|
||||
)
|
||||
|
||||
if (
|
||||
!syncedMeta ||
|
||||
new Date(snapshotMeta.updatedAt) > new Date(syncedMeta.syncedFrom)
|
||||
) {
|
||||
return {
|
||||
action: 'prompt-update',
|
||||
snapshotTimestamp: snapshotMeta.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
return { action: 'none' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize local agent memory from a snapshot (first-time setup).
|
||||
*/
|
||||
export async function initializeFromSnapshot(
|
||||
agentType: string,
|
||||
scope: AgentMemoryScope,
|
||||
snapshotTimestamp: string,
|
||||
): Promise<void> {
|
||||
logForDebugging(
|
||||
`Initializing agent memory for ${agentType} from project snapshot`,
|
||||
)
|
||||
await copySnapshotToLocal(agentType, scope)
|
||||
await saveSyncedMeta(agentType, scope, snapshotTimestamp)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace local agent memory with the snapshot.
|
||||
*/
|
||||
export async function replaceFromSnapshot(
|
||||
agentType: string,
|
||||
scope: AgentMemoryScope,
|
||||
snapshotTimestamp: string,
|
||||
): Promise<void> {
|
||||
logForDebugging(
|
||||
`Replacing agent memory for ${agentType} with project snapshot`,
|
||||
)
|
||||
// Remove existing .md files before copying to avoid orphans
|
||||
const localMemDir = getAgentMemoryDir(agentType, scope)
|
||||
try {
|
||||
const existing = await readdir(localMemDir, { withFileTypes: true })
|
||||
for (const dirent of existing) {
|
||||
if (dirent.isFile() && dirent.name.endsWith('.md')) {
|
||||
await unlink(join(localMemDir, dirent.name))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory may not exist yet
|
||||
}
|
||||
await copySnapshotToLocal(agentType, scope)
|
||||
await saveSyncedMeta(agentType, scope, snapshotTimestamp)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the current snapshot as synced without changing local memory.
|
||||
*/
|
||||
export async function markSnapshotSynced(
|
||||
agentType: string,
|
||||
scope: AgentMemoryScope,
|
||||
snapshotTimestamp: string,
|
||||
): Promise<void> {
|
||||
await saveSyncedMeta(agentType, scope, snapshotTimestamp)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { BASH_TOOL_NAME } from 'src/tools/BashTool/toolName.js'
|
||||
import { FILE_READ_TOOL_NAME } from 'src/tools/FileReadTool/prompt.js'
|
||||
import { GLOB_TOOL_NAME } from 'src/tools/GlobTool/prompt.js'
|
||||
import { GREP_TOOL_NAME } from 'src/tools/GrepTool/prompt.js'
|
||||
import { SEND_MESSAGE_TOOL_NAME } from 'src/tools/SendMessageTool/constants.js'
|
||||
import { WEB_FETCH_TOOL_NAME } from 'src/tools/WebFetchTool/prompt.js'
|
||||
import { WEB_SEARCH_TOOL_NAME } from 'src/tools/WebSearchTool/prompt.js'
|
||||
import { isUsing3PServices } from 'src/utils/auth.js'
|
||||
import { hasEmbeddedSearchTools } from 'src/utils/embeddedTools.js'
|
||||
import { getSettings_DEPRECATED } from 'src/utils/settings/settings.js'
|
||||
import { jsonStringify } from '../../../utils/slowOperations.js'
|
||||
import type {
|
||||
AgentDefinition,
|
||||
BuiltInAgentDefinition,
|
||||
} from '../loadAgentsDir.js'
|
||||
|
||||
const CLAUDE_CODE_DOCS_MAP_URL =
|
||||
'https://code.claude.com/docs/en/claude_code_docs_map.md'
|
||||
const CDP_DOCS_MAP_URL = 'https://platform.claude.com/llms.txt'
|
||||
|
||||
export const CLAUDE_CODE_GUIDE_AGENT_TYPE = 'claude-code-guide'
|
||||
|
||||
function getClaudeCodeGuideBasePrompt(): string {
|
||||
// Ant-native builds alias find/grep to embedded bfs/ugrep and remove the
|
||||
// dedicated Glob/Grep tools, so point at find/grep instead.
|
||||
const localSearchHint = hasEmbeddedSearchTools()
|
||||
? `${FILE_READ_TOOL_NAME}, \`find\`, and \`grep\``
|
||||
: `${FILE_READ_TOOL_NAME}, ${GLOB_TOOL_NAME}, and ${GREP_TOOL_NAME}`
|
||||
|
||||
return `You are the Claude guide agent. Your primary responsibility is helping users understand and use Claude Code, the Claude Agent SDK, and the Claude API (formerly the Anthropic API) effectively.
|
||||
|
||||
**Your expertise spans three domains:**
|
||||
|
||||
1. **Claude Code** (the CLI tool): Installation, configuration, hooks, skills, MCP servers, keyboard shortcuts, IDE integrations, settings, and workflows.
|
||||
|
||||
2. **Claude Agent SDK**: A framework for building custom AI agents based on Claude Code technology. Available for Node.js/TypeScript and Python.
|
||||
|
||||
3. **Claude API**: The Claude API (formerly known as the Anthropic API) for direct model interaction, tool use, and integrations.
|
||||
|
||||
**Documentation sources:**
|
||||
|
||||
- **Claude Code docs** (${CLAUDE_CODE_DOCS_MAP_URL}): Fetch this for questions about the Claude Code CLI tool, including:
|
||||
- Installation, setup, and getting started
|
||||
- Hooks (pre/post command execution)
|
||||
- Custom skills
|
||||
- MCP server configuration
|
||||
- IDE integrations (VS Code, JetBrains)
|
||||
- Settings files and configuration
|
||||
- Keyboard shortcuts and hotkeys
|
||||
- Subagents and plugins
|
||||
- Sandboxing and security
|
||||
|
||||
- **Claude Agent SDK docs** (${CDP_DOCS_MAP_URL}): Fetch this for questions about building agents with the SDK, including:
|
||||
- SDK overview and getting started (Python and TypeScript)
|
||||
- Agent configuration + custom tools
|
||||
- Session management and permissions
|
||||
- MCP integration in agents
|
||||
- Hosting and deployment
|
||||
- Cost tracking and context management
|
||||
Note: Agent SDK docs are part of the Claude API documentation at the same URL.
|
||||
|
||||
- **Claude API docs** (${CDP_DOCS_MAP_URL}): Fetch this for questions about the Claude API (formerly the Anthropic API), including:
|
||||
- Messages API and streaming
|
||||
- Tool use (function calling) and Anthropic-defined tools (computer use, code execution, web search, text editor, bash, programmatic tool calling, tool search tool, context editing, Files API, structured outputs)
|
||||
- Vision, PDF support, and citations
|
||||
- Extended thinking and structured outputs
|
||||
- MCP connector for remote MCP servers
|
||||
- Cloud provider integrations (Bedrock, Vertex AI, Foundry)
|
||||
|
||||
**Approach:**
|
||||
1. Determine which domain the user's question falls into
|
||||
2. Use ${WEB_FETCH_TOOL_NAME} to fetch the appropriate docs map
|
||||
3. Identify the most relevant documentation URLs from the map
|
||||
4. Fetch the specific documentation pages
|
||||
5. Provide clear, actionable guidance based on official documentation
|
||||
6. Use ${WEB_SEARCH_TOOL_NAME} if docs don't cover the topic
|
||||
7. Reference local project files (CLAUDE.md, .claude/ directory) when relevant using ${localSearchHint}
|
||||
|
||||
**Guidelines:**
|
||||
- Always prioritize official documentation over assumptions
|
||||
- Keep responses concise and actionable
|
||||
- Include specific examples or code snippets when helpful
|
||||
- Reference exact documentation URLs in your responses
|
||||
- Help users discover features by proactively suggesting related commands, shortcuts, or capabilities
|
||||
|
||||
Complete the user's request by providing accurate, documentation-based guidance.`
|
||||
}
|
||||
|
||||
function getFeedbackGuideline(): string {
|
||||
// For 3P services (Bedrock/Vertex/Foundry), /feedback command is disabled
|
||||
// Direct users to the appropriate feedback channel instead
|
||||
if (isUsing3PServices()) {
|
||||
return `- When you cannot find an answer or the feature doesn't exist, direct the user to ${MACRO.ISSUES_EXPLAINER}`
|
||||
}
|
||||
return "- When you cannot find an answer or the feature doesn't exist, direct the user to use /feedback to report a feature request or bug"
|
||||
}
|
||||
|
||||
export const CLAUDE_CODE_GUIDE_AGENT: BuiltInAgentDefinition = {
|
||||
agentType: CLAUDE_CODE_GUIDE_AGENT_TYPE,
|
||||
whenToUse: `Use this agent when the user asks questions ("Can Claude...", "Does Claude...", "How do I...") about: (1) Claude Code (the CLI tool) - features, hooks, slash commands, MCP servers, settings, IDE integrations, keyboard shortcuts; (2) Claude Agent SDK - building custom agents; (3) Claude API (formerly Anthropic API) - API usage, tool use, Anthropic SDK usage. **IMPORTANT:** Before spawning a new agent, check if there is already a running or recently completed claude-code-guide agent that you can continue via ${SEND_MESSAGE_TOOL_NAME}.`,
|
||||
// Ant-native builds: Glob/Grep tools are removed; use Bash (with embedded
|
||||
// bfs/ugrep via find/grep aliases) for local file search instead.
|
||||
tools: hasEmbeddedSearchTools()
|
||||
? [
|
||||
BASH_TOOL_NAME,
|
||||
FILE_READ_TOOL_NAME,
|
||||
WEB_FETCH_TOOL_NAME,
|
||||
WEB_SEARCH_TOOL_NAME,
|
||||
]
|
||||
: [
|
||||
GLOB_TOOL_NAME,
|
||||
GREP_TOOL_NAME,
|
||||
FILE_READ_TOOL_NAME,
|
||||
WEB_FETCH_TOOL_NAME,
|
||||
WEB_SEARCH_TOOL_NAME,
|
||||
],
|
||||
source: 'built-in',
|
||||
baseDir: 'built-in',
|
||||
model: 'haiku',
|
||||
permissionMode: 'dontAsk',
|
||||
getSystemPrompt({ toolUseContext }) {
|
||||
const commands = toolUseContext.options.commands
|
||||
|
||||
// Build context sections
|
||||
const contextSections: string[] = []
|
||||
|
||||
// 1. Custom skills
|
||||
const customCommands = commands.filter(cmd => cmd.type === 'prompt')
|
||||
if (customCommands.length > 0) {
|
||||
const commandList = customCommands
|
||||
.map(cmd => `- /${cmd.name}: ${cmd.description}`)
|
||||
.join('\n')
|
||||
contextSections.push(
|
||||
`**Available custom skills in this project:**\n${commandList}`,
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Custom agents from .claude/agents/
|
||||
const customAgents =
|
||||
toolUseContext.options.agentDefinitions.activeAgents.filter(
|
||||
(a: AgentDefinition) => a.source !== 'built-in',
|
||||
)
|
||||
if (customAgents.length > 0) {
|
||||
const agentList = customAgents
|
||||
.map((a: AgentDefinition) => `- ${a.agentType}: ${a.whenToUse}`)
|
||||
.join('\n')
|
||||
contextSections.push(
|
||||
`**Available custom agents configured:**\n${agentList}`,
|
||||
)
|
||||
}
|
||||
|
||||
// 3. MCP servers
|
||||
const mcpClients = toolUseContext.options.mcpClients
|
||||
if (mcpClients && mcpClients.length > 0) {
|
||||
const mcpList = mcpClients
|
||||
.map((client: { name: string }) => `- ${client.name}`)
|
||||
.join('\n')
|
||||
contextSections.push(`**Configured MCP servers:**\n${mcpList}`)
|
||||
}
|
||||
|
||||
// 4. Plugin commands
|
||||
const pluginCommands = commands.filter(
|
||||
cmd => cmd.type === 'prompt' && cmd.source === 'plugin',
|
||||
)
|
||||
if (pluginCommands.length > 0) {
|
||||
const pluginList = pluginCommands
|
||||
.map(cmd => `- /${cmd.name}: ${cmd.description}`)
|
||||
.join('\n')
|
||||
contextSections.push(`**Available plugin skills:**\n${pluginList}`)
|
||||
}
|
||||
|
||||
// 5. User settings
|
||||
const settings = getSettings_DEPRECATED()
|
||||
if (Object.keys(settings).length > 0) {
|
||||
// eslint-disable-next-line no-restricted-syntax -- human-facing UI, not tool_result
|
||||
const settingsJson = jsonStringify(settings, null, 2)
|
||||
contextSections.push(
|
||||
`**User's settings.json:**\n\`\`\`json\n${settingsJson}\n\`\`\``,
|
||||
)
|
||||
}
|
||||
|
||||
// Add the feedback guideline (conditional based on whether user is using 3P services)
|
||||
const feedbackGuideline = getFeedbackGuideline()
|
||||
const basePromptWithFeedback = `${getClaudeCodeGuideBasePrompt()}
|
||||
${feedbackGuideline}`
|
||||
|
||||
// If we have any context to add, append it to the base system prompt
|
||||
if (contextSections.length > 0) {
|
||||
return `${basePromptWithFeedback}
|
||||
|
||||
---
|
||||
|
||||
# User's Current Configuration
|
||||
|
||||
The user has the following custom setup in their environment:
|
||||
|
||||
${contextSections.join('\n\n')}
|
||||
|
||||
When answering questions, consider these configured features and proactively suggest them when relevant.`
|
||||
}
|
||||
|
||||
// Return the base prompt if no context to add
|
||||
return basePromptWithFeedback
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { BASH_TOOL_NAME } from 'src/tools/BashTool/toolName.js'
|
||||
import { EXIT_PLAN_MODE_TOOL_NAME } from 'src/tools/ExitPlanModeTool/constants.js'
|
||||
import { FILE_EDIT_TOOL_NAME } from 'src/tools/FileEditTool/constants.js'
|
||||
import { FILE_READ_TOOL_NAME } from 'src/tools/FileReadTool/prompt.js'
|
||||
import { FILE_WRITE_TOOL_NAME } from 'src/tools/FileWriteTool/prompt.js'
|
||||
import { GLOB_TOOL_NAME } from 'src/tools/GlobTool/prompt.js'
|
||||
import { GREP_TOOL_NAME } from 'src/tools/GrepTool/prompt.js'
|
||||
import { NOTEBOOK_EDIT_TOOL_NAME } from 'src/tools/NotebookEditTool/constants.js'
|
||||
import { hasEmbeddedSearchTools } from 'src/utils/embeddedTools.js'
|
||||
import { AGENT_TOOL_NAME } from '../constants.js'
|
||||
import type { BuiltInAgentDefinition } from '../loadAgentsDir.js'
|
||||
|
||||
function getExploreSystemPrompt(): string {
|
||||
// Ant-native builds alias find/grep to embedded bfs/ugrep and remove the
|
||||
// dedicated Glob/Grep tools, so point at find/grep via Bash instead.
|
||||
const embedded = hasEmbeddedSearchTools()
|
||||
const globGuidance = embedded
|
||||
? `- Use \`find\` via ${BASH_TOOL_NAME} for broad file pattern matching`
|
||||
: `- Use ${GLOB_TOOL_NAME} for broad file pattern matching`
|
||||
const grepGuidance = embedded
|
||||
? `- Use \`grep\` via ${BASH_TOOL_NAME} for searching file contents with regex`
|
||||
: `- Use ${GREP_TOOL_NAME} for searching file contents with regex`
|
||||
|
||||
return `You are a file search specialist for Claude Code, Anthropic's official CLI for Claude. You excel at thoroughly navigating and exploring codebases.
|
||||
|
||||
=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===
|
||||
This is a READ-ONLY exploration task. You are STRICTLY PROHIBITED from:
|
||||
- Creating new files (no Write, touch, or file creation of any kind)
|
||||
- Modifying existing files (no Edit operations)
|
||||
- Deleting files (no rm or deletion)
|
||||
- Moving or copying files (no mv or cp)
|
||||
- Creating temporary files anywhere, including /tmp
|
||||
- Using redirect operators (>, >>, |) or heredocs to write to files
|
||||
- Running ANY commands that change system state
|
||||
|
||||
Your role is EXCLUSIVELY to search and analyze existing code. You do NOT have access to file editing tools - attempting to edit files will fail.
|
||||
|
||||
Your strengths:
|
||||
- Rapidly finding files using glob patterns
|
||||
- Searching code and text with powerful regex patterns
|
||||
- Reading and analyzing file contents
|
||||
|
||||
Guidelines:
|
||||
${globGuidance}
|
||||
${grepGuidance}
|
||||
- Use ${FILE_READ_TOOL_NAME} when you know the specific file path you need to read
|
||||
- Use ${BASH_TOOL_NAME} ONLY for read-only operations (ls, git status, git log, git diff, find${embedded ? ', grep' : ''}, cat, head, tail)
|
||||
- NEVER use ${BASH_TOOL_NAME} for: mkdir, touch, rm, cp, mv, git add, git commit, npm install, pip install, or any file creation/modification
|
||||
- Adapt your search approach based on the thoroughness level specified by the caller
|
||||
- Communicate your final report directly as a regular message - do NOT attempt to create files
|
||||
|
||||
NOTE: You are meant to be a fast agent that returns output as quickly as possible. In order to achieve this you must:
|
||||
- Make efficient use of the tools that you have at your disposal: be smart about how you search for files and implementations
|
||||
- Wherever possible you should try to spawn multiple parallel tool calls for grepping and reading files
|
||||
|
||||
Complete the user's search request efficiently and report your findings clearly.`
|
||||
}
|
||||
|
||||
export const EXPLORE_AGENT_MIN_QUERIES = 3
|
||||
|
||||
const EXPLORE_WHEN_TO_USE =
|
||||
'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.'
|
||||
|
||||
export const EXPLORE_AGENT: BuiltInAgentDefinition = {
|
||||
agentType: 'Explore',
|
||||
whenToUse: EXPLORE_WHEN_TO_USE,
|
||||
disallowedTools: [
|
||||
AGENT_TOOL_NAME,
|
||||
EXIT_PLAN_MODE_TOOL_NAME,
|
||||
FILE_EDIT_TOOL_NAME,
|
||||
FILE_WRITE_TOOL_NAME,
|
||||
NOTEBOOK_EDIT_TOOL_NAME,
|
||||
],
|
||||
source: 'built-in',
|
||||
baseDir: 'built-in',
|
||||
// Ants get inherit to use the main agent's model; external users get haiku for speed
|
||||
// Note: For ants, getAgentModel() checks tengu_explore_agent GrowthBook flag at runtime
|
||||
model: process.env.USER_TYPE === 'ant' ? 'inherit' : 'haiku',
|
||||
// Explore is a fast read-only search agent — it doesn't need commit/PR/lint
|
||||
// rules from CLAUDE.md. The main agent has full context and interprets results.
|
||||
omitClaudeMd: true,
|
||||
getSystemPrompt: () => getExploreSystemPrompt(),
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { BuiltInAgentDefinition } from '../loadAgentsDir.js'
|
||||
|
||||
const SHARED_PREFIX = `You are an agent for Claude Code, Anthropic's official CLI for Claude. Given the user's message, you should use the tools available to complete the task. Complete the task fully—don't gold-plate, but don't leave it half-done.`
|
||||
|
||||
const SHARED_GUIDELINES = `Your strengths:
|
||||
- Searching for code, configurations, and patterns across large codebases
|
||||
- Analyzing multiple files to understand system architecture
|
||||
- Investigating complex questions that require exploring many files
|
||||
- Performing multi-step research tasks
|
||||
|
||||
Guidelines:
|
||||
- For file searches: search broadly when you don't know where something lives. Use Read when you know the specific file path.
|
||||
- For analysis: Start broad and narrow down. Use multiple search strategies if the first doesn't yield results.
|
||||
- Be thorough: Check multiple locations, consider different naming conventions, look for related files.
|
||||
- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one.
|
||||
- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested.`
|
||||
|
||||
// Note: absolute-path + emoji guidance is appended by enhanceSystemPromptWithEnvDetails.
|
||||
function getGeneralPurposeSystemPrompt(): string {
|
||||
return `${SHARED_PREFIX} When you complete the task, respond with a concise report covering what was done and any key findings — the caller will relay this to the user, so it only needs the essentials.
|
||||
|
||||
${SHARED_GUIDELINES}`
|
||||
}
|
||||
|
||||
export const GENERAL_PURPOSE_AGENT: BuiltInAgentDefinition = {
|
||||
agentType: 'general-purpose',
|
||||
whenToUse:
|
||||
'General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.',
|
||||
tools: ['*'],
|
||||
source: 'built-in',
|
||||
baseDir: 'built-in',
|
||||
// model is intentionally omitted - uses getDefaultSubagentModel().
|
||||
getSystemPrompt: getGeneralPurposeSystemPrompt,
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { BASH_TOOL_NAME } from 'src/tools/BashTool/toolName.js'
|
||||
import { EXIT_PLAN_MODE_TOOL_NAME } from 'src/tools/ExitPlanModeTool/constants.js'
|
||||
import { FILE_EDIT_TOOL_NAME } from 'src/tools/FileEditTool/constants.js'
|
||||
import { FILE_READ_TOOL_NAME } from 'src/tools/FileReadTool/prompt.js'
|
||||
import { FILE_WRITE_TOOL_NAME } from 'src/tools/FileWriteTool/prompt.js'
|
||||
import { GLOB_TOOL_NAME } from 'src/tools/GlobTool/prompt.js'
|
||||
import { GREP_TOOL_NAME } from 'src/tools/GrepTool/prompt.js'
|
||||
import { NOTEBOOK_EDIT_TOOL_NAME } from 'src/tools/NotebookEditTool/constants.js'
|
||||
import { hasEmbeddedSearchTools } from 'src/utils/embeddedTools.js'
|
||||
import { AGENT_TOOL_NAME } from '../constants.js'
|
||||
import type { BuiltInAgentDefinition } from '../loadAgentsDir.js'
|
||||
import { EXPLORE_AGENT } from './exploreAgent.js'
|
||||
|
||||
function getPlanV2SystemPrompt(): string {
|
||||
// Ant-native builds alias find/grep to embedded bfs/ugrep and remove the
|
||||
// dedicated Glob/Grep tools, so point at find/grep instead.
|
||||
const searchToolsHint = hasEmbeddedSearchTools()
|
||||
? `\`find\`, \`grep\`, and ${FILE_READ_TOOL_NAME}`
|
||||
: `${GLOB_TOOL_NAME}, ${GREP_TOOL_NAME}, and ${FILE_READ_TOOL_NAME}`
|
||||
|
||||
return `You are a software architect and planning specialist for Claude Code. Your role is to explore the codebase and design implementation plans.
|
||||
|
||||
=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===
|
||||
This is a READ-ONLY planning task. You are STRICTLY PROHIBITED from:
|
||||
- Creating new files (no Write, touch, or file creation of any kind)
|
||||
- Modifying existing files (no Edit operations)
|
||||
- Deleting files (no rm or deletion)
|
||||
- Moving or copying files (no mv or cp)
|
||||
- Creating temporary files anywhere, including /tmp
|
||||
- Using redirect operators (>, >>, |) or heredocs to write to files
|
||||
- Running ANY commands that change system state
|
||||
|
||||
Your role is EXCLUSIVELY to explore the codebase and design implementation plans. You do NOT have access to file editing tools - attempting to edit files will fail.
|
||||
|
||||
You will be provided with a set of requirements and optionally a perspective on how to approach the design process.
|
||||
|
||||
## Your Process
|
||||
|
||||
1. **Understand Requirements**: Focus on the requirements provided and apply your assigned perspective throughout the design process.
|
||||
|
||||
2. **Explore Thoroughly**:
|
||||
- Read any files provided to you in the initial prompt
|
||||
- Find existing patterns and conventions using ${searchToolsHint}
|
||||
- Understand the current architecture
|
||||
- Identify similar features as reference
|
||||
- Trace through relevant code paths
|
||||
- Use ${BASH_TOOL_NAME} ONLY for read-only operations (ls, git status, git log, git diff, find${hasEmbeddedSearchTools() ? ', grep' : ''}, cat, head, tail)
|
||||
- NEVER use ${BASH_TOOL_NAME} for: mkdir, touch, rm, cp, mv, git add, git commit, npm install, pip install, or any file creation/modification
|
||||
|
||||
3. **Design Solution**:
|
||||
- Create implementation approach based on your assigned perspective
|
||||
- Consider trade-offs and architectural decisions
|
||||
- Follow existing patterns where appropriate
|
||||
|
||||
4. **Detail the Plan**:
|
||||
- Provide step-by-step implementation strategy
|
||||
- Identify dependencies and sequencing
|
||||
- Anticipate potential challenges
|
||||
|
||||
## Required Output
|
||||
|
||||
End your response with:
|
||||
|
||||
### Critical Files for Implementation
|
||||
List 3-5 files most critical for implementing this plan:
|
||||
- path/to/file1.ts
|
||||
- path/to/file2.ts
|
||||
- path/to/file3.ts
|
||||
|
||||
REMEMBER: You can ONLY explore and plan. You CANNOT and MUST NOT write, edit, or modify any files. You do NOT have access to file editing tools.`
|
||||
}
|
||||
|
||||
export const PLAN_AGENT: BuiltInAgentDefinition = {
|
||||
agentType: 'Plan',
|
||||
whenToUse:
|
||||
'Software architect agent for designing implementation plans. Use this when you need to plan the implementation strategy for a task. Returns step-by-step plans, identifies critical files, and considers architectural trade-offs.',
|
||||
disallowedTools: [
|
||||
AGENT_TOOL_NAME,
|
||||
EXIT_PLAN_MODE_TOOL_NAME,
|
||||
FILE_EDIT_TOOL_NAME,
|
||||
FILE_WRITE_TOOL_NAME,
|
||||
NOTEBOOK_EDIT_TOOL_NAME,
|
||||
],
|
||||
source: 'built-in',
|
||||
tools: EXPLORE_AGENT.tools,
|
||||
baseDir: 'built-in',
|
||||
model: 'inherit',
|
||||
// Plan is read-only and can Read CLAUDE.md directly if it needs conventions.
|
||||
// Dropping it from context saves tokens without blocking access.
|
||||
omitClaudeMd: true,
|
||||
getSystemPrompt: () => getPlanV2SystemPrompt(),
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { BuiltInAgentDefinition } from '../loadAgentsDir.js'
|
||||
|
||||
const STATUSLINE_SYSTEM_PROMPT = `You are a status line setup agent for Claude Code. Your job is to create or update the statusLine command in the user's Claude Code settings.
|
||||
|
||||
When asked to convert the user's shell PS1 configuration, follow these steps:
|
||||
1. Read the user's shell configuration files in this order of preference:
|
||||
- ~/.zshrc
|
||||
- ~/.bashrc
|
||||
- ~/.bash_profile
|
||||
- ~/.profile
|
||||
|
||||
2. Extract the PS1 value using this regex pattern: /(?:^|\\n)\\s*(?:export\\s+)?PS1\\s*=\\s*["']([^"']+)["']/m
|
||||
|
||||
3. Convert PS1 escape sequences to shell commands:
|
||||
- \\u → $(whoami)
|
||||
- \\h → $(hostname -s)
|
||||
- \\H → $(hostname)
|
||||
- \\w → $(pwd)
|
||||
- \\W → $(basename "$(pwd)")
|
||||
- \\$ → $
|
||||
- \\n → \\n
|
||||
- \\t → $(date +%H:%M:%S)
|
||||
- \\d → $(date "+%a %b %d")
|
||||
- \\@ → $(date +%I:%M%p)
|
||||
- \\# → #
|
||||
- \\! → !
|
||||
|
||||
4. When using ANSI color codes, be sure to use \`printf\`. Do not remove colors. Note that the status line will be printed in a terminal using dimmed colors.
|
||||
|
||||
5. If the imported PS1 would have trailing "$" or ">" characters in the output, you MUST remove them.
|
||||
|
||||
6. If no PS1 is found and user did not provide other instructions, ask for further instructions.
|
||||
|
||||
How to use the statusLine command:
|
||||
1. The statusLine command will receive the following JSON input via stdin:
|
||||
{
|
||||
"session_id": "string", // Unique session ID
|
||||
"session_name": "string", // Optional: Human-readable session name set via /rename
|
||||
"transcript_path": "string", // Path to the conversation transcript
|
||||
"cwd": "string", // Current working directory
|
||||
"model": {
|
||||
"id": "string", // Model ID (e.g., "claude-3-5-sonnet-20241022")
|
||||
"display_name": "string" // Display name (e.g., "Claude 3.5 Sonnet")
|
||||
},
|
||||
"workspace": {
|
||||
"current_dir": "string", // Current working directory path
|
||||
"project_dir": "string", // Project root directory path
|
||||
"added_dirs": ["string"] // Directories added via /add-dir
|
||||
},
|
||||
"version": "string", // Claude Code app version (e.g., "1.0.71")
|
||||
"output_style": {
|
||||
"name": "string", // Output style name (e.g., "default", "Explanatory", "Learning")
|
||||
},
|
||||
"context_window": {
|
||||
"total_input_tokens": number, // Total input tokens used in session (cumulative)
|
||||
"total_output_tokens": number, // Total output tokens used in session (cumulative)
|
||||
"context_window_size": number, // Context window size for current model (e.g., 200000)
|
||||
"current_usage": { // Token usage from last API call (null if no messages yet)
|
||||
"input_tokens": number, // Input tokens for current context
|
||||
"output_tokens": number, // Output tokens generated
|
||||
"cache_creation_input_tokens": number, // Tokens written to cache
|
||||
"cache_read_input_tokens": number // Tokens read from cache
|
||||
} | null,
|
||||
"used_percentage": number | null, // Pre-calculated: % of context used (0-100), null if no messages yet
|
||||
"remaining_percentage": number | null // Pre-calculated: % of context remaining (0-100), null if no messages yet
|
||||
},
|
||||
"rate_limits": { // Optional: Claude.ai subscription usage limits. Only present for subscribers after first API response.
|
||||
"five_hour": { // Optional: 5-hour session limit (may be absent)
|
||||
"used_percentage": number, // Percentage of limit used (0-100)
|
||||
"resets_at": number // Unix epoch seconds when this window resets
|
||||
},
|
||||
"seven_day": { // Optional: 7-day weekly limit (may be absent)
|
||||
"used_percentage": number, // Percentage of limit used (0-100)
|
||||
"resets_at": number // Unix epoch seconds when this window resets
|
||||
}
|
||||
},
|
||||
"vim": { // Optional, only present when vim mode is enabled
|
||||
"mode": "INSERT" | "NORMAL" // Current vim editor mode
|
||||
},
|
||||
"agent": { // Optional, only present when Claude is started with --agent flag
|
||||
"name": "string", // Agent name (e.g., "code-architect", "test-runner")
|
||||
"type": "string" // Optional: Agent type identifier
|
||||
},
|
||||
"worktree": { // Optional, only present when in a --worktree session
|
||||
"name": "string", // Worktree name/slug (e.g., "my-feature")
|
||||
"path": "string", // Full path to the worktree directory
|
||||
"branch": "string", // Optional: Git branch name for the worktree
|
||||
"original_cwd": "string", // The directory Claude was in before entering the worktree
|
||||
"original_branch": "string" // Optional: Branch that was checked out before entering the worktree
|
||||
}
|
||||
}
|
||||
|
||||
You can use this JSON data in your command like:
|
||||
- $(cat | jq -r '.model.display_name')
|
||||
- $(cat | jq -r '.workspace.current_dir')
|
||||
- $(cat | jq -r '.output_style.name')
|
||||
|
||||
Or store it in a variable first:
|
||||
- input=$(cat); echo "$(echo "$input" | jq -r '.model.display_name') in $(echo "$input" | jq -r '.workspace.current_dir')"
|
||||
|
||||
To display context remaining percentage (simplest approach using pre-calculated field):
|
||||
- input=$(cat); remaining=$(echo "$input" | jq -r '.context_window.remaining_percentage // empty'); [ -n "$remaining" ] && echo "Context: $remaining% remaining"
|
||||
|
||||
Or to display context used percentage:
|
||||
- input=$(cat); used=$(echo "$input" | jq -r '.context_window.used_percentage // empty'); [ -n "$used" ] && echo "Context: $used% used"
|
||||
|
||||
To display Claude.ai subscription rate limit usage (5-hour session limit):
|
||||
- input=$(cat); pct=$(echo "$input" | jq -r '.rate_limits.five_hour.used_percentage // empty'); [ -n "$pct" ] && printf "5h: %.0f%%" "$pct"
|
||||
|
||||
To display both 5-hour and 7-day limits when available:
|
||||
- input=$(cat); five=$(echo "$input" | jq -r '.rate_limits.five_hour.used_percentage // empty'); week=$(echo "$input" | jq -r '.rate_limits.seven_day.used_percentage // empty'); out=""; [ -n "$five" ] && out="5h:$(printf '%.0f' "$five")%"; [ -n "$week" ] && out="$out 7d:$(printf '%.0f' "$week")%"; echo "$out"
|
||||
|
||||
2. For longer commands, you can save a new file in the user's ~/.claude directory, e.g.:
|
||||
- ~/.claude/statusline-command.sh and reference that file in the settings.
|
||||
|
||||
3. Update the user's ~/.claude/settings.json with:
|
||||
{
|
||||
"statusLine": {
|
||||
"type": "command",
|
||||
"command": "your_command_here"
|
||||
}
|
||||
}
|
||||
|
||||
4. If ~/.claude/settings.json is a symlink, update the target file instead.
|
||||
|
||||
Guidelines:
|
||||
- Preserve existing settings when updating
|
||||
- Return a summary of what was configured, including the name of the script file if used
|
||||
- If the script includes git commands, they should skip optional locks
|
||||
- IMPORTANT: At the end of your response, inform the parent agent that this "statusline-setup" agent must be used for further status line changes.
|
||||
Also ensure that the user is informed that they can ask Claude to continue to make changes to the status line.
|
||||
`
|
||||
|
||||
export const STATUSLINE_SETUP_AGENT: BuiltInAgentDefinition = {
|
||||
agentType: 'statusline-setup',
|
||||
whenToUse:
|
||||
"Use this agent to configure the user's Claude Code status line setting.",
|
||||
tools: ['Read', 'Edit'],
|
||||
source: 'built-in',
|
||||
baseDir: 'built-in',
|
||||
model: 'sonnet',
|
||||
color: 'orange',
|
||||
getSystemPrompt: () => STATUSLINE_SYSTEM_PROMPT,
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { BASH_TOOL_NAME } from 'src/tools/BashTool/toolName.js'
|
||||
import { EXIT_PLAN_MODE_TOOL_NAME } from 'src/tools/ExitPlanModeTool/constants.js'
|
||||
import { FILE_EDIT_TOOL_NAME } from 'src/tools/FileEditTool/constants.js'
|
||||
import { FILE_WRITE_TOOL_NAME } from 'src/tools/FileWriteTool/prompt.js'
|
||||
import { NOTEBOOK_EDIT_TOOL_NAME } from 'src/tools/NotebookEditTool/constants.js'
|
||||
import { WEB_FETCH_TOOL_NAME } from 'src/tools/WebFetchTool/prompt.js'
|
||||
import { AGENT_TOOL_NAME } from '../constants.js'
|
||||
import type { BuiltInAgentDefinition } from '../loadAgentsDir.js'
|
||||
|
||||
const VERIFICATION_SYSTEM_PROMPT = `You are a verification specialist. Your job is not to confirm the implementation works — it's to try to break it.
|
||||
|
||||
You have two documented failure patterns. First, verification avoidance: when faced with a check, you find reasons not to run it — you read code, narrate what you would test, write "PASS," and move on. Second, being seduced by the first 80%: you see a polished UI or a passing test suite and feel inclined to pass it, not noticing half the buttons do nothing, the state vanishes on refresh, or the backend crashes on bad input. The first 80% is the easy part. Your entire value is in finding the last 20%. The caller may spot-check your commands by re-running them — if a PASS step has no command output, or output that doesn't match re-execution, your report gets rejected.
|
||||
|
||||
=== CRITICAL: DO NOT MODIFY THE PROJECT ===
|
||||
You are STRICTLY PROHIBITED from:
|
||||
- Creating, modifying, or deleting any files IN THE PROJECT DIRECTORY
|
||||
- Installing dependencies or packages
|
||||
- Running git write operations (add, commit, push)
|
||||
|
||||
You MAY write ephemeral test scripts to a temp directory (/tmp or $TMPDIR) via ${BASH_TOOL_NAME} redirection when inline commands aren't sufficient — e.g., a multi-step race harness or a Playwright test. Clean up after yourself.
|
||||
|
||||
Check your ACTUAL available tools rather than assuming from this prompt. You may have browser automation (mcp__claude-in-chrome__*, mcp__playwright__*), ${WEB_FETCH_TOOL_NAME}, or other MCP tools depending on the session — do not skip capabilities you didn't think to check for.
|
||||
|
||||
=== WHAT YOU RECEIVE ===
|
||||
You will receive: the original task description, files changed, approach taken, and optionally a plan file path.
|
||||
|
||||
=== VERIFICATION STRATEGY ===
|
||||
Adapt your strategy based on what was changed:
|
||||
|
||||
**Frontend changes**: Start dev server → check your tools for browser automation (mcp__claude-in-chrome__*, mcp__playwright__*) and USE them to navigate, screenshot, click, and read console — do NOT say "needs a real browser" without attempting → curl a sample of page subresources (image-optimizer URLs like /_next/image, same-origin API routes, static assets) since HTML can serve 200 while everything it references fails → run frontend tests
|
||||
**Backend/API changes**: Start server → curl/fetch endpoints → verify response shapes against expected values (not just status codes) → test error handling → check edge cases
|
||||
**CLI/script changes**: Run with representative inputs → verify stdout/stderr/exit codes → test edge inputs (empty, malformed, boundary) → verify --help / usage output is accurate
|
||||
**Infrastructure/config changes**: Validate syntax → dry-run where possible (terraform plan, kubectl apply --dry-run=server, docker build, nginx -t) → check env vars / secrets are actually referenced, not just defined
|
||||
**Library/package changes**: Build → full test suite → import the library from a fresh context and exercise the public API as a consumer would → verify exported types match README/docs examples
|
||||
**Bug fixes**: Reproduce the original bug → verify fix → run regression tests → check related functionality for side effects
|
||||
**Mobile (iOS/Android)**: Clean build → install on simulator/emulator → dump accessibility/UI tree (idb ui describe-all / uiautomator dump), find elements by label, tap by tree coords, re-dump to verify; screenshots secondary → kill and relaunch to test persistence → check crash logs (logcat / device console)
|
||||
**Data/ML pipeline**: Run with sample input → verify output shape/schema/types → test empty input, single row, NaN/null handling → check for silent data loss (row counts in vs out)
|
||||
**Database migrations**: Run migration up → verify schema matches intent → run migration down (reversibility) → test against existing data, not just empty DB
|
||||
**Refactoring (no behavior change)**: Existing test suite MUST pass unchanged → diff the public API surface (no new/removed exports) → spot-check observable behavior is identical (same inputs → same outputs)
|
||||
**Other change types**: The pattern is always the same — (a) figure out how to exercise this change directly (run/call/invoke/deploy it), (b) check outputs against expectations, (c) try to break it with inputs/conditions the implementer didn't test. The strategies above are worked examples for common cases.
|
||||
|
||||
=== REQUIRED STEPS (universal baseline) ===
|
||||
1. Read the project's CLAUDE.md / README for build/test commands and conventions. Check package.json / Makefile / pyproject.toml for script names. If the implementer pointed you to a plan or spec file, read it — that's the success criteria.
|
||||
2. Run the build (if applicable). A broken build is an automatic FAIL.
|
||||
3. Run the project's test suite (if it has one). Failing tests are an automatic FAIL.
|
||||
4. Run linters/type-checkers if configured (eslint, tsc, mypy, etc.).
|
||||
5. Check for regressions in related code.
|
||||
|
||||
Then apply the type-specific strategy above. Match rigor to stakes: a one-off script doesn't need race-condition probes; production payments code needs everything.
|
||||
|
||||
Test suite results are context, not evidence. Run the suite, note pass/fail, then move on to your real verification. The implementer is an LLM too — its tests may be heavy on mocks, circular assertions, or happy-path coverage that proves nothing about whether the system actually works end-to-end.
|
||||
|
||||
=== RECOGNIZE YOUR OWN RATIONALIZATIONS ===
|
||||
You will feel the urge to skip checks. These are the exact excuses you reach for — recognize them and do the opposite:
|
||||
- "The code looks correct based on my reading" — reading is not verification. Run it.
|
||||
- "The implementer's tests already pass" — the implementer is an LLM. Verify independently.
|
||||
- "This is probably fine" — probably is not verified. Run it.
|
||||
- "Let me start the server and check the code" — no. Start the server and hit the endpoint.
|
||||
- "I don't have a browser" — did you actually check for mcp__claude-in-chrome__* / mcp__playwright__*? If present, use them. If an MCP tool fails, troubleshoot (server running? selector right?). The fallback exists so you don't invent your own "can't do this" story.
|
||||
- "This would take too long" — not your call.
|
||||
If you catch yourself writing an explanation instead of a command, stop. Run the command.
|
||||
|
||||
=== ADVERSARIAL PROBES (adapt to the change type) ===
|
||||
Functional tests confirm the happy path. Also try to break it:
|
||||
- **Concurrency** (servers/APIs): parallel requests to create-if-not-exists paths — duplicate sessions? lost writes?
|
||||
- **Boundary values**: 0, -1, empty string, very long strings, unicode, MAX_INT
|
||||
- **Idempotency**: same mutating request twice — duplicate created? error? correct no-op?
|
||||
- **Orphan operations**: delete/reference IDs that don't exist
|
||||
These are seeds, not a checklist — pick the ones that fit what you're verifying.
|
||||
|
||||
=== BEFORE ISSUING PASS ===
|
||||
Your report must include at least one adversarial probe you ran (concurrency, boundary, idempotency, orphan op, or similar) and its result — even if the result was "handled correctly." If all your checks are "returns 200" or "test suite passes," you have confirmed the happy path, not verified correctness. Go back and try to break something.
|
||||
|
||||
=== BEFORE ISSUING FAIL ===
|
||||
You found something that looks broken. Before reporting FAIL, check you haven't missed why it's actually fine:
|
||||
- **Already handled**: is there defensive code elsewhere (validation upstream, error recovery downstream) that prevents this?
|
||||
- **Intentional**: does CLAUDE.md / comments / commit message explain this as deliberate?
|
||||
- **Not actionable**: is this a real limitation but unfixable without breaking an external contract (stable API, protocol spec, backwards compat)? If so, note it as an observation, not a FAIL — a "bug" that can't be fixed isn't actionable.
|
||||
Don't use these as excuses to wave away real issues — but don't FAIL on intentional behavior either.
|
||||
|
||||
=== OUTPUT FORMAT (REQUIRED) ===
|
||||
Every check MUST follow this structure. A check without a Command run block is not a PASS — it's a skip.
|
||||
|
||||
\`\`\`
|
||||
### Check: [what you're verifying]
|
||||
**Command run:**
|
||||
[exact command you executed]
|
||||
**Output observed:**
|
||||
[actual terminal output — copy-paste, not paraphrased. Truncate if very long but keep the relevant part.]
|
||||
**Result: PASS** (or FAIL — with Expected vs Actual)
|
||||
\`\`\`
|
||||
|
||||
Bad (rejected):
|
||||
\`\`\`
|
||||
### Check: POST /api/register validation
|
||||
**Result: PASS**
|
||||
Evidence: Reviewed the route handler in routes/auth.py. The logic correctly validates
|
||||
email format and password length before DB insert.
|
||||
\`\`\`
|
||||
(No command run. Reading code is not verification.)
|
||||
|
||||
Good:
|
||||
\`\`\`
|
||||
### Check: POST /api/register rejects short password
|
||||
**Command run:**
|
||||
curl -s -X POST localhost:8000/api/register -H 'Content-Type: application/json' \\
|
||||
-d '{"email":"t@t.co","password":"short"}' | python3 -m json.tool
|
||||
**Output observed:**
|
||||
{
|
||||
"error": "password must be at least 8 characters"
|
||||
}
|
||||
(HTTP 400)
|
||||
**Expected vs Actual:** Expected 400 with password-length error. Got exactly that.
|
||||
**Result: PASS**
|
||||
\`\`\`
|
||||
|
||||
End with exactly this line (parsed by caller):
|
||||
|
||||
VERDICT: PASS
|
||||
or
|
||||
VERDICT: FAIL
|
||||
or
|
||||
VERDICT: PARTIAL
|
||||
|
||||
PARTIAL is for environmental limitations only (no test framework, tool unavailable, server can't start) — not for "I'm unsure whether this is a bug." If you can run the check, you must decide PASS or FAIL.
|
||||
|
||||
Use the literal string \`VERDICT: \` followed by exactly one of \`PASS\`, \`FAIL\`, \`PARTIAL\`. No markdown bold, no punctuation, no variation.
|
||||
- **FAIL**: include what failed, exact error output, reproduction steps.
|
||||
- **PARTIAL**: what was verified, what could not be and why (missing tool/env), what the implementer should know.`
|
||||
|
||||
const VERIFICATION_WHEN_TO_USE =
|
||||
'Use this agent to verify that implementation work is correct before reporting completion. Invoke after non-trivial tasks (3+ file edits, backend/API changes, infrastructure changes). Pass the ORIGINAL user task description, list of files changed, and approach taken. The agent runs builds, tests, linters, and checks to produce a PASS/FAIL/PARTIAL verdict with evidence.'
|
||||
|
||||
export const VERIFICATION_AGENT: BuiltInAgentDefinition = {
|
||||
agentType: 'verification',
|
||||
whenToUse: VERIFICATION_WHEN_TO_USE,
|
||||
color: 'red',
|
||||
background: true,
|
||||
disallowedTools: [
|
||||
AGENT_TOOL_NAME,
|
||||
EXIT_PLAN_MODE_TOOL_NAME,
|
||||
FILE_EDIT_TOOL_NAME,
|
||||
FILE_WRITE_TOOL_NAME,
|
||||
NOTEBOOK_EDIT_TOOL_NAME,
|
||||
],
|
||||
source: 'built-in',
|
||||
baseDir: 'built-in',
|
||||
model: 'inherit',
|
||||
getSystemPrompt: () => VERIFICATION_SYSTEM_PROMPT,
|
||||
criticalSystemReminder_EXPERIMENTAL:
|
||||
'CRITICAL: This is a VERIFICATION-ONLY task. You CANNOT edit, write, or create files IN THE PROJECT DIRECTORY (tmp is allowed for ephemeral test scripts). You MUST end with VERDICT: PASS, VERDICT: FAIL, or VERDICT: PARTIAL.',
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { getIsNonInteractiveSession } from '../../bootstrap/state.js'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||
import { CLAUDE_CODE_GUIDE_AGENT } from './built-in/claudeCodeGuideAgent.js'
|
||||
import { EXPLORE_AGENT } from './built-in/exploreAgent.js'
|
||||
import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js'
|
||||
import { PLAN_AGENT } from './built-in/planAgent.js'
|
||||
import { STATUSLINE_SETUP_AGENT } from './built-in/statuslineSetup.js'
|
||||
import { VERIFICATION_AGENT } from './built-in/verificationAgent.js'
|
||||
import type { AgentDefinition } from './loadAgentsDir.js'
|
||||
|
||||
export function areExplorePlanAgentsEnabled(): boolean {
|
||||
if (feature('BUILTIN_EXPLORE_PLAN_AGENTS')) {
|
||||
// 3P default: true — Bedrock/Vertex keep agents enabled (matches pre-experiment
|
||||
// external behavior). A/B test treatment sets false to measure impact of removal.
|
||||
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_stoat', true)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function getBuiltInAgents(): AgentDefinition[] {
|
||||
// Allow disabling all built-in agents via env var (useful for SDK users who want a blank slate)
|
||||
// Only applies in noninteractive mode (SDK/API usage)
|
||||
if (
|
||||
isEnvTruthy(process.env.CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS) &&
|
||||
getIsNonInteractiveSession()
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Use lazy require inside the function body to avoid circular dependency
|
||||
// issues at module init time. The coordinatorMode module depends on tools
|
||||
// which depend on AgentTool which imports this file.
|
||||
if (feature('COORDINATOR_MODE')) {
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE)) {
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const { getCoordinatorAgents } =
|
||||
require('../../coordinator/workerAgent.js') as typeof import('../../coordinator/workerAgent.js')
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
return getCoordinatorAgents()
|
||||
}
|
||||
}
|
||||
|
||||
const agents: AgentDefinition[] = [
|
||||
GENERAL_PURPOSE_AGENT,
|
||||
STATUSLINE_SETUP_AGENT,
|
||||
]
|
||||
|
||||
if (areExplorePlanAgentsEnabled()) {
|
||||
agents.push(EXPLORE_AGENT, PLAN_AGENT)
|
||||
}
|
||||
|
||||
// Include Code Guide agent for non-SDK entrypoints
|
||||
const isNonSdkEntrypoint =
|
||||
process.env.CLAUDE_CODE_ENTRYPOINT !== 'sdk-ts' &&
|
||||
process.env.CLAUDE_CODE_ENTRYPOINT !== 'sdk-py' &&
|
||||
process.env.CLAUDE_CODE_ENTRYPOINT !== 'sdk-cli'
|
||||
|
||||
if (isNonSdkEntrypoint) {
|
||||
agents.push(CLAUDE_CODE_GUIDE_AGENT)
|
||||
}
|
||||
|
||||
if (
|
||||
feature('VERIFICATION_AGENT') &&
|
||||
getFeatureValue_CACHED_MAY_BE_STALE('tengu_hive_evidence', false)
|
||||
) {
|
||||
agents.push(VERIFICATION_AGENT)
|
||||
}
|
||||
|
||||
return agents
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export const AGENT_TOOL_NAME = 'Agent'
|
||||
// Legacy wire name for backward compat (permission rules, hooks, resumed sessions)
|
||||
export const LEGACY_AGENT_TOOL_NAME = 'Task'
|
||||
export const VERIFICATION_AGENT_TYPE = 'verification'
|
||||
|
||||
// Built-in agents that run once and return a report — the parent never
|
||||
// SendMessages back to continue them. Skip the agentId/SendMessage/usage
|
||||
// trailer for these to save tokens (~135 chars × 34M Explore runs/week).
|
||||
export const ONE_SHOT_BUILTIN_AGENT_TYPES: ReadonlySet<string> = new Set([
|
||||
'Explore',
|
||||
'Plan',
|
||||
])
|
||||
@@ -0,0 +1,210 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import type { BetaToolUseBlock } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { getIsNonInteractiveSession } from '../../bootstrap/state.js'
|
||||
import {
|
||||
FORK_BOILERPLATE_TAG,
|
||||
FORK_DIRECTIVE_PREFIX,
|
||||
} from '../../constants/xml.js'
|
||||
import { isCoordinatorMode } from '../../coordinator/coordinatorMode.js'
|
||||
import type {
|
||||
AssistantMessage,
|
||||
Message as MessageType,
|
||||
} from '../../types/message.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { createUserMessage } from '../../utils/messages.js'
|
||||
import type { BuiltInAgentDefinition } from './loadAgentsDir.js'
|
||||
|
||||
/**
|
||||
* Fork subagent feature gate.
|
||||
*
|
||||
* When enabled:
|
||||
* - `subagent_type` becomes optional on the Agent tool schema
|
||||
* - Omitting `subagent_type` triggers an implicit fork: the child inherits
|
||||
* the parent's full conversation context and system prompt
|
||||
* - All agent spawns run in the background (async) for a unified
|
||||
* `<task-notification>` interaction model
|
||||
* - `/fork <directive>` slash command is available
|
||||
*
|
||||
* Mutually exclusive with coordinator mode — coordinator already owns the
|
||||
* orchestration role and has its own delegation model.
|
||||
*/
|
||||
export function isForkSubagentEnabled(): boolean {
|
||||
if (feature('FORK_SUBAGENT')) {
|
||||
if (isCoordinatorMode()) return false
|
||||
if (getIsNonInteractiveSession()) return false
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Synthetic agent type name used for analytics when the fork path fires. */
|
||||
export const FORK_SUBAGENT_TYPE = 'fork'
|
||||
|
||||
/**
|
||||
* Synthetic agent definition for the fork path.
|
||||
*
|
||||
* Not registered in builtInAgents — used only when `!subagent_type` and the
|
||||
* experiment is active. `tools: ['*']` with `useExactTools` means the fork
|
||||
* child receives the parent's exact tool pool (for cache-identical API
|
||||
* prefixes). `permissionMode: 'bubble'` surfaces permission prompts to the
|
||||
* parent terminal. `model: 'inherit'` keeps the parent's model for context
|
||||
* length parity.
|
||||
*
|
||||
* The getSystemPrompt here is unused: the fork path passes
|
||||
* `override.systemPrompt` with the parent's already-rendered system prompt
|
||||
* bytes, threaded via `toolUseContext.renderedSystemPrompt`. Reconstructing
|
||||
* by re-calling getSystemPrompt() can diverge (GrowthBook cold→warm) and
|
||||
* bust the prompt cache; threading the rendered bytes is byte-exact.
|
||||
*/
|
||||
export const FORK_AGENT = {
|
||||
agentType: FORK_SUBAGENT_TYPE,
|
||||
whenToUse:
|
||||
'Implicit fork — inherits full conversation context. Not selectable via subagent_type; triggered by omitting subagent_type when the fork experiment is active.',
|
||||
tools: ['*'],
|
||||
maxTurns: 200,
|
||||
model: 'inherit',
|
||||
permissionMode: 'bubble',
|
||||
source: 'built-in',
|
||||
baseDir: 'built-in',
|
||||
getSystemPrompt: () => '',
|
||||
} satisfies BuiltInAgentDefinition
|
||||
|
||||
/**
|
||||
* Guard against recursive forking. Fork children keep the Agent tool in their
|
||||
* tool pool for cache-identical tool definitions, so we reject fork attempts
|
||||
* at call time by detecting the fork boilerplate tag in conversation history.
|
||||
*/
|
||||
export function isInForkChild(messages: MessageType[]): boolean {
|
||||
return messages.some(m => {
|
||||
if (m.type !== 'user') return false
|
||||
const content = m.message.content
|
||||
if (!Array.isArray(content)) return false
|
||||
return content.some(
|
||||
block =>
|
||||
block.type === 'text' &&
|
||||
block.text.includes(`<${FORK_BOILERPLATE_TAG}>`),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/** Placeholder text used for all tool_result blocks in the fork prefix.
|
||||
* Must be identical across all fork children for prompt cache sharing. */
|
||||
const FORK_PLACEHOLDER_RESULT = 'Fork started — processing in background'
|
||||
|
||||
/**
|
||||
* Build the forked conversation messages for the child agent.
|
||||
*
|
||||
* For prompt cache sharing, all fork children must produce byte-identical
|
||||
* API request prefixes. This function:
|
||||
* 1. Keeps the full parent assistant message (all tool_use blocks, thinking, text)
|
||||
* 2. Builds a single user message with tool_results for every tool_use block
|
||||
* using an identical placeholder, then appends a per-child directive text block
|
||||
*
|
||||
* Result: [...history, assistant(all_tool_uses), user(placeholder_results..., directive)]
|
||||
* Only the final text block differs per child, maximizing cache hits.
|
||||
*/
|
||||
export function buildForkedMessages(
|
||||
directive: string,
|
||||
assistantMessage: AssistantMessage,
|
||||
): MessageType[] {
|
||||
// Clone the assistant message to avoid mutating the original, keeping all
|
||||
// content blocks (thinking, text, and every tool_use)
|
||||
const fullAssistantMessage: AssistantMessage = {
|
||||
...assistantMessage,
|
||||
uuid: randomUUID(),
|
||||
message: {
|
||||
...assistantMessage.message,
|
||||
content: [...assistantMessage.message.content],
|
||||
},
|
||||
}
|
||||
|
||||
// Collect all tool_use blocks from the assistant message
|
||||
const toolUseBlocks = assistantMessage.message.content.filter(
|
||||
(block): block is BetaToolUseBlock => block.type === 'tool_use',
|
||||
)
|
||||
|
||||
if (toolUseBlocks.length === 0) {
|
||||
logForDebugging(
|
||||
`No tool_use blocks found in assistant message for fork directive: ${directive.slice(0, 50)}...`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
return [
|
||||
createUserMessage({
|
||||
content: [
|
||||
{ type: 'text' as const, text: buildChildMessage(directive) },
|
||||
],
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
// Build tool_result blocks for every tool_use, all with identical placeholder text
|
||||
const toolResultBlocks = toolUseBlocks.map(block => ({
|
||||
type: 'tool_result' as const,
|
||||
tool_use_id: block.id,
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: FORK_PLACEHOLDER_RESULT,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
// Build a single user message: all placeholder tool_results + the per-child directive
|
||||
// TODO(smoosh): this text sibling creates a [tool_result, text] pattern on the wire
|
||||
// (renders as </function_results>\n\nHuman:<text>). One-off per-child construction,
|
||||
// not a repeated teacher, so low-priority. If we ever care, use smooshIntoToolResult
|
||||
// from src/utils/messages.ts to fold the directive into the last tool_result.content.
|
||||
const toolResultMessage = createUserMessage({
|
||||
content: [
|
||||
...toolResultBlocks,
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: buildChildMessage(directive),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
return [fullAssistantMessage, toolResultMessage]
|
||||
}
|
||||
|
||||
export function buildChildMessage(directive: string): string {
|
||||
return `<${FORK_BOILERPLATE_TAG}>
|
||||
STOP. READ THIS FIRST.
|
||||
|
||||
You are a forked worker process. You are NOT the main agent.
|
||||
|
||||
RULES (non-negotiable):
|
||||
1. Your system prompt says "default to forking." IGNORE IT \u2014 that's for the parent. You ARE the fork. Do NOT spawn sub-agents; execute directly.
|
||||
2. Do NOT converse, ask questions, or suggest next steps
|
||||
3. Do NOT editorialize or add meta-commentary
|
||||
4. USE your tools directly: Bash, Read, Write, etc.
|
||||
5. If you modify files, commit your changes before reporting. Include the commit hash in your report.
|
||||
6. Do NOT emit text between tool calls. Use tools silently, then report once at the end.
|
||||
7. Stay strictly within your directive's scope. If you discover related systems outside your scope, mention them in one sentence at most — other workers cover those areas.
|
||||
8. Keep your report under 500 words unless the directive specifies otherwise. Be factual and concise.
|
||||
9. Your response MUST begin with "Scope:". No preamble, no thinking-out-loud.
|
||||
10. REPORT structured facts, then stop
|
||||
|
||||
Output format (plain text labels, not markdown headers):
|
||||
Scope: <echo back your assigned scope in one sentence>
|
||||
Result: <the answer or key findings, limited to the scope above>
|
||||
Key files: <relevant file paths — include for research tasks>
|
||||
Files changed: <list with commit hash — include only if you modified files>
|
||||
Issues: <list — include only if there are issues to flag>
|
||||
</${FORK_BOILERPLATE_TAG}>
|
||||
|
||||
${FORK_DIRECTIVE_PREFIX}${directive}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Notice injected into fork children running in an isolated worktree.
|
||||
* Tells the child to translate paths from the inherited context, re-read
|
||||
* potentially stale files, and that its changes are isolated.
|
||||
*/
|
||||
export function buildWorktreeNotice(
|
||||
parentCwd: string,
|
||||
worktreeCwd: string,
|
||||
): string {
|
||||
return `You've inherited the conversation context above from a parent agent working in ${parentCwd}. You are operating in an isolated git worktree at ${worktreeCwd} — same repository, same relative file structure, separate working copy. Paths in the inherited context refer to the parent's working directory; translate them to your worktree root. Re-read files before editing if the parent may have modified them since they appear in the context. Your changes stay in this worktree and will not affect the parent's files.`
|
||||
}
|
||||
@@ -0,0 +1,755 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import memoize from 'lodash-es/memoize.js'
|
||||
import { basename } from 'path'
|
||||
import type { SettingSource } from 'src/utils/settings/constants.js'
|
||||
import { z } from 'zod/v4'
|
||||
import { isAutoMemoryEnabled } from '../../memdir/paths.js'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../../services/analytics/index.js'
|
||||
import {
|
||||
type McpServerConfig,
|
||||
McpServerConfigSchema,
|
||||
} from '../../services/mcp/types.js'
|
||||
import type { ToolUseContext } from '../../Tool.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import {
|
||||
EFFORT_LEVELS,
|
||||
type EffortValue,
|
||||
parseEffortValue,
|
||||
} from '../../utils/effort.js'
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||
import { parsePositiveIntFromFrontmatter } from '../../utils/frontmatterParser.js'
|
||||
import { lazySchema } from '../../utils/lazySchema.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import {
|
||||
loadMarkdownFilesForSubdir,
|
||||
parseAgentToolsFromFrontmatter,
|
||||
parseSlashCommandToolsFromFrontmatter,
|
||||
} from '../../utils/markdownConfigLoader.js'
|
||||
import {
|
||||
PERMISSION_MODES,
|
||||
type PermissionMode,
|
||||
} from '../../utils/permissions/PermissionMode.js'
|
||||
import {
|
||||
clearPluginAgentCache,
|
||||
loadPluginAgents,
|
||||
} from '../../utils/plugins/loadPluginAgents.js'
|
||||
import { HooksSchema, type HooksSettings } from '../../utils/settings/types.js'
|
||||
import { jsonStringify } from '../../utils/slowOperations.js'
|
||||
import { FILE_EDIT_TOOL_NAME } from '../FileEditTool/constants.js'
|
||||
import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js'
|
||||
import { FILE_WRITE_TOOL_NAME } from '../FileWriteTool/prompt.js'
|
||||
import {
|
||||
AGENT_COLORS,
|
||||
type AgentColorName,
|
||||
setAgentColor,
|
||||
} from './agentColorManager.js'
|
||||
import { type AgentMemoryScope, loadAgentMemoryPrompt } from './agentMemory.js'
|
||||
import {
|
||||
checkAgentMemorySnapshot,
|
||||
initializeFromSnapshot,
|
||||
} from './agentMemorySnapshot.js'
|
||||
import { getBuiltInAgents } from './builtInAgents.js'
|
||||
|
||||
// Type for MCP server specification in agent definitions
|
||||
// Can be either a reference to an existing server by name, or an inline definition as { [name]: config }
|
||||
export type AgentMcpServerSpec =
|
||||
| string // Reference to existing server by name (e.g., "slack")
|
||||
| { [name: string]: McpServerConfig } // Inline definition as { name: config }
|
||||
|
||||
// Zod schema for agent MCP server specs
|
||||
const AgentMcpServerSpecSchema = lazySchema(() =>
|
||||
z.union([
|
||||
z.string(), // Reference by name
|
||||
z.record(z.string(), McpServerConfigSchema()), // Inline as { name: config }
|
||||
]),
|
||||
)
|
||||
|
||||
// Zod schemas for JSON agent validation
|
||||
// Note: HooksSchema is lazy so the circular chain AppState -> loadAgentsDir -> settings/types
|
||||
// is broken at module load time
|
||||
const AgentJsonSchema = lazySchema(() =>
|
||||
z.object({
|
||||
description: z.string().min(1, 'Description cannot be empty'),
|
||||
tools: z.array(z.string()).optional(),
|
||||
disallowedTools: z.array(z.string()).optional(),
|
||||
prompt: z.string().min(1, 'Prompt cannot be empty'),
|
||||
model: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Model cannot be empty')
|
||||
.transform(m => (m.toLowerCase() === 'inherit' ? 'inherit' : m))
|
||||
.optional(),
|
||||
effort: z.union([z.enum(EFFORT_LEVELS), z.number().int()]).optional(),
|
||||
permissionMode: z.enum(PERMISSION_MODES).optional(),
|
||||
mcpServers: z.array(AgentMcpServerSpecSchema()).optional(),
|
||||
hooks: HooksSchema().optional(),
|
||||
maxTurns: z.number().int().positive().optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
initialPrompt: z.string().optional(),
|
||||
memory: z.enum(['user', 'project', 'local']).optional(),
|
||||
background: z.boolean().optional(),
|
||||
isolation: (process.env.USER_TYPE === 'ant'
|
||||
? z.enum(['worktree', 'remote'])
|
||||
: z.enum(['worktree'])
|
||||
).optional(),
|
||||
}),
|
||||
)
|
||||
|
||||
const AgentsJsonSchema = lazySchema(() =>
|
||||
z.record(z.string(), AgentJsonSchema()),
|
||||
)
|
||||
|
||||
// Base type with common fields for all agents
|
||||
export type BaseAgentDefinition = {
|
||||
agentType: string
|
||||
whenToUse: string
|
||||
tools?: string[]
|
||||
disallowedTools?: string[]
|
||||
skills?: string[] // Skill names to preload (parsed from comma-separated frontmatter)
|
||||
mcpServers?: AgentMcpServerSpec[] // MCP servers specific to this agent
|
||||
hooks?: HooksSettings // Session-scoped hooks registered when agent starts
|
||||
color?: AgentColorName
|
||||
model?: string
|
||||
effort?: EffortValue
|
||||
permissionMode?: PermissionMode
|
||||
maxTurns?: number // Maximum number of agentic turns before stopping
|
||||
filename?: string // Original filename without .md extension (for user/project/managed agents)
|
||||
baseDir?: string
|
||||
criticalSystemReminder_EXPERIMENTAL?: string // Short message re-injected at every user turn
|
||||
requiredMcpServers?: string[] // MCP server name patterns that must be configured for agent to be available
|
||||
background?: boolean // Always run as background task when spawned
|
||||
initialPrompt?: string // Prepended to the first user turn (slash commands work)
|
||||
memory?: AgentMemoryScope // Persistent memory scope
|
||||
isolation?: 'worktree' | 'remote' // Run in an isolated git worktree, or remotely in CCR (ant-only)
|
||||
pendingSnapshotUpdate?: { snapshotTimestamp: string }
|
||||
/** Omit CLAUDE.md hierarchy from the agent's userContext. Read-only agents
|
||||
* (Explore, Plan) don't need commit/PR/lint guidelines — the main agent has
|
||||
* full CLAUDE.md and interprets their output. Saves ~5-15 Gtok/week across
|
||||
* 34M+ Explore spawns. Kill-switch: tengu_slim_subagent_claudemd. */
|
||||
omitClaudeMd?: boolean
|
||||
}
|
||||
|
||||
// Built-in agents - dynamic prompts only, no static systemPrompt field
|
||||
export type BuiltInAgentDefinition = BaseAgentDefinition & {
|
||||
source: 'built-in'
|
||||
baseDir: 'built-in'
|
||||
callback?: () => void
|
||||
getSystemPrompt: (params: {
|
||||
toolUseContext: Pick<ToolUseContext, 'options'>
|
||||
}) => string
|
||||
}
|
||||
|
||||
// Custom agents from user/project/policy settings - prompt stored via closure
|
||||
export type CustomAgentDefinition = BaseAgentDefinition & {
|
||||
getSystemPrompt: () => string
|
||||
source: SettingSource
|
||||
filename?: string
|
||||
baseDir?: string
|
||||
}
|
||||
|
||||
// Plugin agents - similar to custom but with plugin metadata, prompt stored via closure
|
||||
export type PluginAgentDefinition = BaseAgentDefinition & {
|
||||
getSystemPrompt: () => string
|
||||
source: 'plugin'
|
||||
filename?: string
|
||||
plugin: string
|
||||
}
|
||||
|
||||
// Union type for all agent types
|
||||
export type AgentDefinition =
|
||||
| BuiltInAgentDefinition
|
||||
| CustomAgentDefinition
|
||||
| PluginAgentDefinition
|
||||
|
||||
// Type guards for runtime type checking
|
||||
export function isBuiltInAgent(
|
||||
agent: AgentDefinition,
|
||||
): agent is BuiltInAgentDefinition {
|
||||
return agent.source === 'built-in'
|
||||
}
|
||||
|
||||
export function isCustomAgent(
|
||||
agent: AgentDefinition,
|
||||
): agent is CustomAgentDefinition {
|
||||
return agent.source !== 'built-in' && agent.source !== 'plugin'
|
||||
}
|
||||
|
||||
export function isPluginAgent(
|
||||
agent: AgentDefinition,
|
||||
): agent is PluginAgentDefinition {
|
||||
return agent.source === 'plugin'
|
||||
}
|
||||
|
||||
export type AgentDefinitionsResult = {
|
||||
activeAgents: AgentDefinition[]
|
||||
allAgents: AgentDefinition[]
|
||||
failedFiles?: Array<{ path: string; error: string }>
|
||||
allowedAgentTypes?: string[]
|
||||
}
|
||||
|
||||
export function getActiveAgentsFromList(
|
||||
allAgents: AgentDefinition[],
|
||||
): AgentDefinition[] {
|
||||
const builtInAgents = allAgents.filter(a => a.source === 'built-in')
|
||||
const pluginAgents = allAgents.filter(a => a.source === 'plugin')
|
||||
const userAgents = allAgents.filter(a => a.source === 'userSettings')
|
||||
const projectAgents = allAgents.filter(a => a.source === 'projectSettings')
|
||||
const managedAgents = allAgents.filter(a => a.source === 'policySettings')
|
||||
const flagAgents = allAgents.filter(a => a.source === 'flagSettings')
|
||||
|
||||
const agentGroups = [
|
||||
builtInAgents,
|
||||
pluginAgents,
|
||||
userAgents,
|
||||
projectAgents,
|
||||
flagAgents,
|
||||
managedAgents,
|
||||
]
|
||||
|
||||
const agentMap = new Map<string, AgentDefinition>()
|
||||
|
||||
for (const agents of agentGroups) {
|
||||
for (const agent of agents) {
|
||||
agentMap.set(agent.agentType, agent)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(agentMap.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an agent's required MCP servers are available.
|
||||
* Returns true if no requirements or all requirements are met.
|
||||
* @param agent The agent to check
|
||||
* @param availableServers List of available MCP server names (e.g., from mcp.clients)
|
||||
*/
|
||||
export function hasRequiredMcpServers(
|
||||
agent: AgentDefinition,
|
||||
availableServers: string[],
|
||||
): boolean {
|
||||
if (!agent.requiredMcpServers || agent.requiredMcpServers.length === 0) {
|
||||
return true
|
||||
}
|
||||
// Each required pattern must match at least one available server (case-insensitive)
|
||||
return agent.requiredMcpServers.every(pattern =>
|
||||
availableServers.some(server =>
|
||||
server.toLowerCase().includes(pattern.toLowerCase()),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters agents based on MCP server requirements.
|
||||
* Only returns agents whose required MCP servers are available.
|
||||
* @param agents List of agents to filter
|
||||
* @param availableServers List of available MCP server names
|
||||
*/
|
||||
export function filterAgentsByMcpRequirements(
|
||||
agents: AgentDefinition[],
|
||||
availableServers: string[],
|
||||
): AgentDefinition[] {
|
||||
return agents.filter(agent => hasRequiredMcpServers(agent, availableServers))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for and initialize agent memory from project snapshots.
|
||||
* For agents with memory enabled, copies snapshot to local if no local memory exists.
|
||||
* For agents with newer snapshots, logs a debug message (user prompt TODO).
|
||||
*/
|
||||
async function initializeAgentMemorySnapshots(
|
||||
agents: CustomAgentDefinition[],
|
||||
): Promise<void> {
|
||||
await Promise.all(
|
||||
agents.map(async agent => {
|
||||
if (agent.memory !== 'user') return
|
||||
const result = await checkAgentMemorySnapshot(
|
||||
agent.agentType,
|
||||
agent.memory,
|
||||
)
|
||||
switch (result.action) {
|
||||
case 'initialize':
|
||||
logForDebugging(
|
||||
`Initializing ${agent.agentType} memory from project snapshot`,
|
||||
)
|
||||
await initializeFromSnapshot(
|
||||
agent.agentType,
|
||||
agent.memory,
|
||||
result.snapshotTimestamp!,
|
||||
)
|
||||
break
|
||||
case 'prompt-update':
|
||||
agent.pendingSnapshotUpdate = {
|
||||
snapshotTimestamp: result.snapshotTimestamp!,
|
||||
}
|
||||
logForDebugging(
|
||||
`Newer snapshot available for ${agent.agentType} memory (snapshot: ${result.snapshotTimestamp})`,
|
||||
)
|
||||
break
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const getAgentDefinitionsWithOverrides = memoize(
|
||||
async (cwd: string): Promise<AgentDefinitionsResult> => {
|
||||
// Simple mode: skip custom agents, only return built-ins
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) {
|
||||
const builtInAgents = getBuiltInAgents()
|
||||
return {
|
||||
activeAgents: builtInAgents,
|
||||
allAgents: builtInAgents,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const markdownFiles = await loadMarkdownFilesForSubdir('agents', cwd)
|
||||
|
||||
const failedFiles: Array<{ path: string; error: string }> = []
|
||||
const customAgents = markdownFiles
|
||||
.map(({ filePath, baseDir, frontmatter, content, source }) => {
|
||||
const agent = parseAgentFromMarkdown(
|
||||
filePath,
|
||||
baseDir,
|
||||
frontmatter,
|
||||
content,
|
||||
source,
|
||||
)
|
||||
if (!agent) {
|
||||
// Skip non-agent markdown files silently (e.g., reference docs
|
||||
// co-located with agent definitions). Only report errors for files
|
||||
// that look like agent attempts (have a 'name' field in frontmatter).
|
||||
if (!frontmatter['name']) {
|
||||
return null
|
||||
}
|
||||
const errorMsg = getParseError(frontmatter)
|
||||
failedFiles.push({ path: filePath, error: errorMsg })
|
||||
logForDebugging(
|
||||
`Failed to parse agent from ${filePath}: ${errorMsg}`,
|
||||
)
|
||||
logEvent('tengu_agent_parse_error', {
|
||||
error:
|
||||
errorMsg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
location:
|
||||
source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
return null
|
||||
}
|
||||
return agent
|
||||
})
|
||||
.filter(agent => agent !== null)
|
||||
|
||||
// Kick off plugin agent loading concurrently with memory snapshot init —
|
||||
// loadPluginAgents is memoized and takes no args, so it's independent.
|
||||
// Join both so neither becomes a floating promise if the other throws.
|
||||
let pluginAgentsPromise = loadPluginAgents()
|
||||
if (feature('AGENT_MEMORY_SNAPSHOT') && isAutoMemoryEnabled()) {
|
||||
const [pluginAgents_] = await Promise.all([
|
||||
pluginAgentsPromise,
|
||||
initializeAgentMemorySnapshots(customAgents),
|
||||
])
|
||||
pluginAgentsPromise = Promise.resolve(pluginAgents_)
|
||||
}
|
||||
const pluginAgents = await pluginAgentsPromise
|
||||
|
||||
const builtInAgents = getBuiltInAgents()
|
||||
|
||||
const allAgentsList: AgentDefinition[] = [
|
||||
...builtInAgents,
|
||||
...pluginAgents,
|
||||
...customAgents,
|
||||
]
|
||||
|
||||
const activeAgents = getActiveAgentsFromList(allAgentsList)
|
||||
|
||||
// Initialize colors for all active agents
|
||||
for (const agent of activeAgents) {
|
||||
if (agent.color) {
|
||||
setAgentColor(agent.agentType, agent.color)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeAgents,
|
||||
allAgents: allAgentsList,
|
||||
failedFiles: failedFiles.length > 0 ? failedFiles : undefined,
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
logForDebugging(`Error loading agent definitions: ${errorMessage}`)
|
||||
logError(error)
|
||||
// Even on error, return the built-in agents
|
||||
const builtInAgents = getBuiltInAgents()
|
||||
return {
|
||||
activeAgents: builtInAgents,
|
||||
allAgents: builtInAgents,
|
||||
failedFiles: [{ path: 'unknown', error: errorMessage }],
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export function clearAgentDefinitionsCache(): void {
|
||||
getAgentDefinitionsWithOverrides.cache.clear?.()
|
||||
clearPluginAgentCache()
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to determine the specific parsing error for an agent file
|
||||
*/
|
||||
function getParseError(frontmatter: Record<string, unknown>): string {
|
||||
const agentType = frontmatter['name']
|
||||
const description = frontmatter['description']
|
||||
|
||||
if (!agentType || typeof agentType !== 'string') {
|
||||
return 'Missing required "name" field in frontmatter'
|
||||
}
|
||||
|
||||
if (!description || typeof description !== 'string') {
|
||||
return 'Missing required "description" field in frontmatter'
|
||||
}
|
||||
|
||||
return 'Unknown parsing error'
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse hooks from frontmatter using the HooksSchema
|
||||
* @param frontmatter The frontmatter object containing potential hooks
|
||||
* @param agentType The agent type for logging purposes
|
||||
* @returns Parsed hooks settings or undefined if invalid/missing
|
||||
*/
|
||||
function parseHooksFromFrontmatter(
|
||||
frontmatter: Record<string, unknown>,
|
||||
agentType: string,
|
||||
): HooksSettings | undefined {
|
||||
if (!frontmatter.hooks) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const result = HooksSchema().safeParse(frontmatter.hooks)
|
||||
if (!result.success) {
|
||||
logForDebugging(
|
||||
`Invalid hooks in agent '${agentType}': ${result.error.message}`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses agent definition from JSON data
|
||||
*/
|
||||
export function parseAgentFromJson(
|
||||
name: string,
|
||||
definition: unknown,
|
||||
source: SettingSource = 'flagSettings',
|
||||
): CustomAgentDefinition | null {
|
||||
try {
|
||||
const parsed = AgentJsonSchema().parse(definition)
|
||||
|
||||
let tools = parseAgentToolsFromFrontmatter(parsed.tools)
|
||||
|
||||
// If memory is enabled, inject Write/Edit/Read tools for memory access
|
||||
if (isAutoMemoryEnabled() && parsed.memory && tools !== undefined) {
|
||||
const toolSet = new Set(tools)
|
||||
for (const tool of [
|
||||
FILE_WRITE_TOOL_NAME,
|
||||
FILE_EDIT_TOOL_NAME,
|
||||
FILE_READ_TOOL_NAME,
|
||||
]) {
|
||||
if (!toolSet.has(tool)) {
|
||||
tools = [...tools, tool]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const disallowedTools =
|
||||
parsed.disallowedTools !== undefined
|
||||
? parseAgentToolsFromFrontmatter(parsed.disallowedTools)
|
||||
: undefined
|
||||
|
||||
const systemPrompt = parsed.prompt
|
||||
|
||||
const agent: CustomAgentDefinition = {
|
||||
agentType: name,
|
||||
whenToUse: parsed.description,
|
||||
...(tools !== undefined ? { tools } : {}),
|
||||
...(disallowedTools !== undefined ? { disallowedTools } : {}),
|
||||
getSystemPrompt: () => {
|
||||
if (isAutoMemoryEnabled() && parsed.memory) {
|
||||
return (
|
||||
systemPrompt + '\n\n' + loadAgentMemoryPrompt(name, parsed.memory)
|
||||
)
|
||||
}
|
||||
return systemPrompt
|
||||
},
|
||||
source,
|
||||
...(parsed.model ? { model: parsed.model } : {}),
|
||||
...(parsed.effort !== undefined ? { effort: parsed.effort } : {}),
|
||||
...(parsed.permissionMode
|
||||
? { permissionMode: parsed.permissionMode }
|
||||
: {}),
|
||||
...(parsed.mcpServers && parsed.mcpServers.length > 0
|
||||
? { mcpServers: parsed.mcpServers }
|
||||
: {}),
|
||||
...(parsed.hooks ? { hooks: parsed.hooks } : {}),
|
||||
...(parsed.maxTurns !== undefined ? { maxTurns: parsed.maxTurns } : {}),
|
||||
...(parsed.skills && parsed.skills.length > 0
|
||||
? { skills: parsed.skills }
|
||||
: {}),
|
||||
...(parsed.initialPrompt ? { initialPrompt: parsed.initialPrompt } : {}),
|
||||
...(parsed.background ? { background: parsed.background } : {}),
|
||||
...(parsed.memory ? { memory: parsed.memory } : {}),
|
||||
...(parsed.isolation ? { isolation: parsed.isolation } : {}),
|
||||
}
|
||||
|
||||
return agent
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
logForDebugging(`Error parsing agent '${name}' from JSON: ${errorMessage}`)
|
||||
logError(error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses multiple agents from a JSON object
|
||||
*/
|
||||
export function parseAgentsFromJson(
|
||||
agentsJson: unknown,
|
||||
source: SettingSource = 'flagSettings',
|
||||
): AgentDefinition[] {
|
||||
try {
|
||||
const parsed = AgentsJsonSchema().parse(agentsJson)
|
||||
return Object.entries(parsed)
|
||||
.map(([name, def]) => parseAgentFromJson(name, def, source))
|
||||
.filter((agent): agent is CustomAgentDefinition => agent !== null)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
logForDebugging(`Error parsing agents from JSON: ${errorMessage}`)
|
||||
logError(error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses agent definition from markdown file data
|
||||
*/
|
||||
export function parseAgentFromMarkdown(
|
||||
filePath: string,
|
||||
baseDir: string,
|
||||
frontmatter: Record<string, unknown>,
|
||||
content: string,
|
||||
source: SettingSource,
|
||||
): CustomAgentDefinition | null {
|
||||
try {
|
||||
const agentType = frontmatter['name']
|
||||
let whenToUse = frontmatter['description'] as string
|
||||
|
||||
// Validate required fields — silently skip files without any agent
|
||||
// frontmatter (they're likely co-located reference documentation)
|
||||
if (!agentType || typeof agentType !== 'string') {
|
||||
return null
|
||||
}
|
||||
if (!whenToUse || typeof whenToUse !== 'string') {
|
||||
logForDebugging(
|
||||
`Agent file ${filePath} is missing required 'description' in frontmatter`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// Unescape newlines in whenToUse that were escaped for YAML parsing
|
||||
whenToUse = whenToUse.replace(/\\n/g, '\n')
|
||||
|
||||
const color = frontmatter['color'] as AgentColorName | undefined
|
||||
const modelRaw = frontmatter['model']
|
||||
let model: string | undefined
|
||||
if (typeof modelRaw === 'string' && modelRaw.trim().length > 0) {
|
||||
const trimmed = modelRaw.trim()
|
||||
model = trimmed.toLowerCase() === 'inherit' ? 'inherit' : trimmed
|
||||
}
|
||||
|
||||
// Parse background flag
|
||||
const backgroundRaw = frontmatter['background']
|
||||
|
||||
if (
|
||||
backgroundRaw !== undefined &&
|
||||
backgroundRaw !== 'true' &&
|
||||
backgroundRaw !== 'false' &&
|
||||
backgroundRaw !== true &&
|
||||
backgroundRaw !== false
|
||||
) {
|
||||
logForDebugging(
|
||||
`Agent file ${filePath} has invalid background value '${backgroundRaw}'. Must be 'true', 'false', or omitted.`,
|
||||
)
|
||||
}
|
||||
|
||||
const background =
|
||||
backgroundRaw === 'true' || backgroundRaw === true ? true : undefined
|
||||
|
||||
// Parse memory scope
|
||||
const VALID_MEMORY_SCOPES: AgentMemoryScope[] = ['user', 'project', 'local']
|
||||
const memoryRaw = frontmatter['memory'] as string | undefined
|
||||
let memory: AgentMemoryScope | undefined
|
||||
if (memoryRaw !== undefined) {
|
||||
if (VALID_MEMORY_SCOPES.includes(memoryRaw as AgentMemoryScope)) {
|
||||
memory = memoryRaw as AgentMemoryScope
|
||||
} else {
|
||||
logForDebugging(
|
||||
`Agent file ${filePath} has invalid memory value '${memoryRaw}'. Valid options: ${VALID_MEMORY_SCOPES.join(', ')}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse isolation mode. 'remote' is ant-only; external builds reject it at parse time.
|
||||
type IsolationMode = 'worktree' | 'remote'
|
||||
const VALID_ISOLATION_MODES: readonly IsolationMode[] =
|
||||
process.env.USER_TYPE === 'ant' ? ['worktree', 'remote'] : ['worktree']
|
||||
const isolationRaw = frontmatter['isolation'] as string | undefined
|
||||
let isolation: IsolationMode | undefined
|
||||
if (isolationRaw !== undefined) {
|
||||
if (VALID_ISOLATION_MODES.includes(isolationRaw as IsolationMode)) {
|
||||
isolation = isolationRaw as IsolationMode
|
||||
} else {
|
||||
logForDebugging(
|
||||
`Agent file ${filePath} has invalid isolation value '${isolationRaw}'. Valid options: ${VALID_ISOLATION_MODES.join(', ')}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse effort from frontmatter (supports string levels and integers)
|
||||
const effortRaw = frontmatter['effort']
|
||||
const parsedEffort =
|
||||
effortRaw !== undefined ? parseEffortValue(effortRaw) : undefined
|
||||
|
||||
if (effortRaw !== undefined && parsedEffort === undefined) {
|
||||
logForDebugging(
|
||||
`Agent file ${filePath} has invalid effort '${effortRaw}'. Valid options: ${EFFORT_LEVELS.join(', ')} or an integer`,
|
||||
)
|
||||
}
|
||||
|
||||
// Parse permissionMode from frontmatter
|
||||
const permissionModeRaw = frontmatter['permissionMode'] as
|
||||
| string
|
||||
| undefined
|
||||
const isValidPermissionMode =
|
||||
permissionModeRaw &&
|
||||
(PERMISSION_MODES as readonly string[]).includes(permissionModeRaw)
|
||||
|
||||
if (permissionModeRaw && !isValidPermissionMode) {
|
||||
const errorMsg = `Agent file ${filePath} has invalid permissionMode '${permissionModeRaw}'. Valid options: ${PERMISSION_MODES.join(', ')}`
|
||||
logForDebugging(errorMsg)
|
||||
}
|
||||
|
||||
// Parse maxTurns from frontmatter
|
||||
const maxTurnsRaw = frontmatter['maxTurns']
|
||||
const maxTurns = parsePositiveIntFromFrontmatter(maxTurnsRaw)
|
||||
if (maxTurnsRaw !== undefined && maxTurns === undefined) {
|
||||
logForDebugging(
|
||||
`Agent file ${filePath} has invalid maxTurns '${maxTurnsRaw}'. Must be a positive integer.`,
|
||||
)
|
||||
}
|
||||
|
||||
// Extract filename without extension
|
||||
const filename = basename(filePath, '.md')
|
||||
|
||||
// Parse tools from frontmatter
|
||||
let tools = parseAgentToolsFromFrontmatter(frontmatter['tools'])
|
||||
|
||||
// If memory is enabled, inject Write/Edit/Read tools for memory access
|
||||
if (isAutoMemoryEnabled() && memory && tools !== undefined) {
|
||||
const toolSet = new Set(tools)
|
||||
for (const tool of [
|
||||
FILE_WRITE_TOOL_NAME,
|
||||
FILE_EDIT_TOOL_NAME,
|
||||
FILE_READ_TOOL_NAME,
|
||||
]) {
|
||||
if (!toolSet.has(tool)) {
|
||||
tools = [...tools, tool]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse disallowedTools from frontmatter
|
||||
const disallowedToolsRaw = frontmatter['disallowedTools']
|
||||
const disallowedTools =
|
||||
disallowedToolsRaw !== undefined
|
||||
? parseAgentToolsFromFrontmatter(disallowedToolsRaw)
|
||||
: undefined
|
||||
|
||||
// Parse skills from frontmatter
|
||||
const skills = parseSlashCommandToolsFromFrontmatter(frontmatter['skills'])
|
||||
|
||||
const initialPromptRaw = frontmatter['initialPrompt']
|
||||
const initialPrompt =
|
||||
typeof initialPromptRaw === 'string' && initialPromptRaw.trim()
|
||||
? initialPromptRaw
|
||||
: undefined
|
||||
|
||||
// Parse mcpServers from frontmatter using same Zod validation as JSON agents
|
||||
const mcpServersRaw = frontmatter['mcpServers']
|
||||
let mcpServers: AgentMcpServerSpec[] | undefined
|
||||
if (Array.isArray(mcpServersRaw)) {
|
||||
mcpServers = mcpServersRaw
|
||||
.map(item => {
|
||||
const result = AgentMcpServerSpecSchema().safeParse(item)
|
||||
if (result.success) {
|
||||
return result.data
|
||||
}
|
||||
logForDebugging(
|
||||
`Agent file ${filePath} has invalid mcpServers item: ${jsonStringify(item)}. Error: ${result.error.message}`,
|
||||
)
|
||||
return null
|
||||
})
|
||||
.filter((item): item is AgentMcpServerSpec => item !== null)
|
||||
}
|
||||
|
||||
// Parse hooks from frontmatter
|
||||
const hooks = parseHooksFromFrontmatter(frontmatter, agentType)
|
||||
|
||||
const systemPrompt = content.trim()
|
||||
const agentDef: CustomAgentDefinition = {
|
||||
baseDir,
|
||||
agentType: agentType,
|
||||
whenToUse: whenToUse,
|
||||
...(tools !== undefined ? { tools } : {}),
|
||||
...(disallowedTools !== undefined ? { disallowedTools } : {}),
|
||||
...(skills !== undefined ? { skills } : {}),
|
||||
...(initialPrompt !== undefined ? { initialPrompt } : {}),
|
||||
...(mcpServers !== undefined && mcpServers.length > 0
|
||||
? { mcpServers }
|
||||
: {}),
|
||||
...(hooks !== undefined ? { hooks } : {}),
|
||||
getSystemPrompt: () => {
|
||||
if (isAutoMemoryEnabled() && memory) {
|
||||
const memoryPrompt = loadAgentMemoryPrompt(agentType, memory)
|
||||
return systemPrompt + '\n\n' + memoryPrompt
|
||||
}
|
||||
return systemPrompt
|
||||
},
|
||||
source,
|
||||
filename,
|
||||
...(color && typeof color === 'string' && AGENT_COLORS.includes(color)
|
||||
? { color }
|
||||
: {}),
|
||||
...(model !== undefined ? { model } : {}),
|
||||
...(parsedEffort !== undefined ? { effort: parsedEffort } : {}),
|
||||
...(isValidPermissionMode
|
||||
? { permissionMode: permissionModeRaw as PermissionMode }
|
||||
: {}),
|
||||
...(maxTurns !== undefined ? { maxTurns } : {}),
|
||||
...(background ? { background } : {}),
|
||||
...(memory ? { memory } : {}),
|
||||
...(isolation ? { isolation } : {}),
|
||||
}
|
||||
return agentDef
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
logForDebugging(`Error parsing agent from ${filePath}: ${errorMessage}`)
|
||||
logError(error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
|
||||
import { getSubscriptionType } from '../../utils/auth.js'
|
||||
import { hasEmbeddedSearchTools } from '../../utils/embeddedTools.js'
|
||||
import { isEnvDefinedFalsy, isEnvTruthy } from '../../utils/envUtils.js'
|
||||
import { isTeammate } from '../../utils/teammate.js'
|
||||
import { isInProcessTeammate } from '../../utils/teammateContext.js'
|
||||
import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js'
|
||||
import { FILE_WRITE_TOOL_NAME } from '../FileWriteTool/prompt.js'
|
||||
import { GLOB_TOOL_NAME } from '../GlobTool/prompt.js'
|
||||
import { SEND_MESSAGE_TOOL_NAME } from '../SendMessageTool/constants.js'
|
||||
import { AGENT_TOOL_NAME } from './constants.js'
|
||||
import { isForkSubagentEnabled } from './forkSubagent.js'
|
||||
import type { AgentDefinition } from './loadAgentsDir.js'
|
||||
|
||||
function getToolsDescription(agent: AgentDefinition): string {
|
||||
const { tools, disallowedTools } = agent
|
||||
const hasAllowlist = tools && tools.length > 0
|
||||
const hasDenylist = disallowedTools && disallowedTools.length > 0
|
||||
|
||||
if (hasAllowlist && hasDenylist) {
|
||||
// Both defined: filter allowlist by denylist to match runtime behavior
|
||||
const denySet = new Set(disallowedTools)
|
||||
const effectiveTools = tools.filter(t => !denySet.has(t))
|
||||
if (effectiveTools.length === 0) {
|
||||
return 'None'
|
||||
}
|
||||
return effectiveTools.join(', ')
|
||||
} else if (hasAllowlist) {
|
||||
// Allowlist only: show the specific tools available
|
||||
return tools.join(', ')
|
||||
} else if (hasDenylist) {
|
||||
// Denylist only: show "All tools except X, Y, Z"
|
||||
return `All tools except ${disallowedTools.join(', ')}`
|
||||
}
|
||||
// No restrictions
|
||||
return 'All tools'
|
||||
}
|
||||
|
||||
/**
|
||||
* Format one agent line for the agent_listing_delta attachment message:
|
||||
* `- type: whenToUse (Tools: ...)`.
|
||||
*/
|
||||
export function formatAgentLine(agent: AgentDefinition): string {
|
||||
const toolsDescription = getToolsDescription(agent)
|
||||
return `- ${agent.agentType}: ${agent.whenToUse} (Tools: ${toolsDescription})`
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the agent list should be injected as an attachment message instead
|
||||
* of embedded in the tool description. When true, getPrompt() returns a static
|
||||
* description and attachments.ts emits an agent_listing_delta attachment.
|
||||
*
|
||||
* The dynamic agent list was ~10.2% of fleet cache_creation tokens: MCP async
|
||||
* connect, /reload-plugins, or permission-mode changes mutate the list →
|
||||
* description changes → full tool-schema cache bust.
|
||||
*
|
||||
* Override with CLAUDE_CODE_AGENT_LIST_IN_MESSAGES=true/false for testing.
|
||||
*/
|
||||
export function shouldInjectAgentListInMessages(): boolean {
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_AGENT_LIST_IN_MESSAGES)) return true
|
||||
if (isEnvDefinedFalsy(process.env.CLAUDE_CODE_AGENT_LIST_IN_MESSAGES))
|
||||
return false
|
||||
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_agent_list_attach', false)
|
||||
}
|
||||
|
||||
export async function getPrompt(
|
||||
agentDefinitions: AgentDefinition[],
|
||||
isCoordinator?: boolean,
|
||||
allowedAgentTypes?: string[],
|
||||
): Promise<string> {
|
||||
// Filter agents by allowed types when Agent(x,y) restricts which agents can be spawned
|
||||
const effectiveAgents = allowedAgentTypes
|
||||
? agentDefinitions.filter(a => allowedAgentTypes.includes(a.agentType))
|
||||
: agentDefinitions
|
||||
|
||||
// Fork subagent feature: when enabled, insert the "When to fork" section
|
||||
// (fork semantics, directive-style prompts) and swap in fork-aware examples.
|
||||
const forkEnabled = isForkSubagentEnabled()
|
||||
|
||||
const whenToForkSection = forkEnabled
|
||||
? `
|
||||
|
||||
## When to fork
|
||||
|
||||
Fork yourself (omit \`subagent_type\`) when the intermediate tool output isn't worth keeping in your context. The criterion is qualitative \u2014 "will I need this output again" \u2014 not task size.
|
||||
- **Research**: fork open-ended questions. If research can be broken into independent questions, launch parallel forks in one message. A fork beats a fresh subagent for this \u2014 it inherits context and shares your cache.
|
||||
- **Implementation**: prefer to fork implementation work that requires more than a couple of edits. Do research before jumping to implementation.
|
||||
|
||||
Forks are cheap because they share your prompt cache. Don't set \`model\` on a fork \u2014 a different model can't reuse the parent's cache. Pass a short \`name\` (one or two words, lowercase) so the user can see the fork in the teams panel and steer it mid-run.
|
||||
|
||||
**Don't peek.** The tool result includes an \`output_file\` path — do not Read or tail it unless the user explicitly asks for a progress check. You get a completion notification; trust it. Reading the transcript mid-flight pulls the fork's tool noise into your context, which defeats the point of forking.
|
||||
|
||||
**Don't race.** After launching, you know nothing about what the fork found. Never fabricate or predict fork results in any format — not as prose, summary, or structured output. The notification arrives as a user-role message in a later turn; it is never something you write yourself. If the user asks a follow-up before the notification lands, tell them the fork is still running — give status, not a guess.
|
||||
|
||||
**Writing a fork prompt.** Since the fork inherits your context, the prompt is a *directive* — what to do, not what the situation is. Be specific about scope: what's in, what's out, what another agent is handling. Don't re-explain background.
|
||||
`
|
||||
: ''
|
||||
|
||||
const writingThePromptSection = `
|
||||
|
||||
## Writing the prompt
|
||||
|
||||
${forkEnabled ? 'When spawning a fresh agent (with a `subagent_type`), it starts with zero context. ' : ''}Brief the agent like a smart colleague who just walked into the room — it hasn't seen this conversation, doesn't know what you've tried, doesn't understand why this task matters.
|
||||
- Explain what you're trying to accomplish and why.
|
||||
- Describe what you've already learned or ruled out.
|
||||
- Give enough context about the surrounding problem that the agent can make judgment calls rather than just following a narrow instruction.
|
||||
- If you need a short response, say so ("report in under 200 words").
|
||||
- Lookups: hand over the exact command. Investigations: hand over the question — prescribed steps become dead weight when the premise is wrong.
|
||||
|
||||
${forkEnabled ? 'For fresh agents, terse' : 'Terse'} command-style prompts produce shallow, generic work.
|
||||
|
||||
**Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change.
|
||||
`
|
||||
|
||||
const forkExamples = `Example usage:
|
||||
|
||||
<example>
|
||||
user: "What's left on this branch before we can ship?"
|
||||
assistant: <thinking>Forking this \u2014 it's a survey question. I want the punch list, not the git output in my context.</thinking>
|
||||
${AGENT_TOOL_NAME}({
|
||||
name: "ship-audit",
|
||||
description: "Branch ship-readiness audit",
|
||||
prompt: "Audit what's left before this branch can ship. Check: uncommitted changes, commits ahead of main, whether tests exist, whether the GrowthBook gate is wired up, whether CI-relevant files changed. Report a punch list \u2014 done vs. missing. Under 200 words."
|
||||
})
|
||||
assistant: Ship-readiness audit running.
|
||||
<commentary>
|
||||
Turn ends here. The coordinator knows nothing about the findings yet. What follows is a SEPARATE turn \u2014 the notification arrives from outside, as a user-role message. It is not something the coordinator writes.
|
||||
</commentary>
|
||||
[later turn \u2014 notification arrives as user message]
|
||||
assistant: Audit's back. Three blockers: no tests for the new prompt path, GrowthBook gate wired but not in build_flags.yaml, and one uncommitted file.
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "so is the gate wired up or not"
|
||||
<commentary>
|
||||
User asks mid-wait. The audit fork was launched to answer exactly this, and it hasn't returned. The coordinator does not have this answer. Give status, not a fabricated result.
|
||||
</commentary>
|
||||
assistant: Still waiting on the audit \u2014 that's one of the things it's checking. Should land shortly.
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "Can you get a second opinion on whether this migration is safe?"
|
||||
assistant: <thinking>I'll ask the code-reviewer agent — it won't see my analysis, so it can give an independent read.</thinking>
|
||||
<commentary>
|
||||
A subagent_type is specified, so the agent starts fresh. It needs full context in the prompt. The briefing explains what to assess and why.
|
||||
</commentary>
|
||||
${AGENT_TOOL_NAME}({
|
||||
name: "migration-review",
|
||||
description: "Independent migration review",
|
||||
subagent_type: "code-reviewer",
|
||||
prompt: "Review migration 0042_user_schema.sql for safety. Context: we're adding a NOT NULL column to a 50M-row table. Existing rows get a backfill default. I want a second opinion on whether the backfill approach is safe under concurrent writes — I've checked locking behavior but want independent verification. Report: is this safe, and if not, what specifically breaks?"
|
||||
})
|
||||
</example>
|
||||
`
|
||||
|
||||
const currentExamples = `Example usage:
|
||||
|
||||
<example_agent_descriptions>
|
||||
"test-runner": use this agent after you are done writing code to run tests
|
||||
"greeting-responder": use this agent to respond to user greetings with a friendly joke
|
||||
</example_agent_descriptions>
|
||||
|
||||
<example>
|
||||
user: "Please write a function that checks if a number is prime"
|
||||
assistant: I'm going to use the ${FILE_WRITE_TOOL_NAME} tool to write the following code:
|
||||
<code>
|
||||
function isPrime(n) {
|
||||
if (n <= 1) return false
|
||||
for (let i = 2; i * i <= n; i++) {
|
||||
if (n % i === 0) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
</code>
|
||||
<commentary>
|
||||
Since a significant piece of code was written and the task was completed, now use the test-runner agent to run the tests
|
||||
</commentary>
|
||||
assistant: Uses the ${AGENT_TOOL_NAME} tool to launch the test-runner agent
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "Hello"
|
||||
<commentary>
|
||||
Since the user is greeting, use the greeting-responder agent to respond with a friendly joke
|
||||
</commentary>
|
||||
assistant: "I'm going to use the ${AGENT_TOOL_NAME} tool to launch the greeting-responder agent"
|
||||
</example>
|
||||
`
|
||||
|
||||
// When the gate is on, the agent list lives in an agent_listing_delta
|
||||
// attachment (see attachments.ts) instead of inline here. This keeps the
|
||||
// tool description static across MCP/plugin/permission changes so the
|
||||
// tools-block prompt cache doesn't bust every time an agent loads.
|
||||
const listViaAttachment = shouldInjectAgentListInMessages()
|
||||
|
||||
const agentListSection = listViaAttachment
|
||||
? `Available agent types are listed in <system-reminder> messages in the conversation.`
|
||||
: `Available agent types and the tools they have access to:
|
||||
${effectiveAgents.map(agent => formatAgentLine(agent)).join('\n')}`
|
||||
|
||||
// Shared core prompt used by both coordinator and non-coordinator modes
|
||||
const shared = `Launch a new agent to handle complex, multi-step tasks autonomously.
|
||||
|
||||
The ${AGENT_TOOL_NAME} tool launches specialized agents (subprocesses) that autonomously handle complex tasks. Each agent type has specific capabilities and tools available to it.
|
||||
|
||||
${agentListSection}
|
||||
|
||||
${
|
||||
forkEnabled
|
||||
? `When using the ${AGENT_TOOL_NAME} tool, specify a subagent_type to use a specialized agent, or omit it to fork yourself — a fork inherits your full conversation context.`
|
||||
: `When using the ${AGENT_TOOL_NAME} tool, specify a subagent_type parameter to select which agent type to use. If omitted, the general-purpose agent is used.`
|
||||
}`
|
||||
|
||||
// Coordinator mode gets the slim prompt -- the coordinator system prompt
|
||||
// already covers usage notes, examples, and when-not-to-use guidance.
|
||||
if (isCoordinator) {
|
||||
return shared
|
||||
}
|
||||
|
||||
// Ant-native builds alias find/grep to embedded bfs/ugrep and remove the
|
||||
// dedicated Glob/Grep tools, so point at find via Bash instead.
|
||||
const embedded = hasEmbeddedSearchTools()
|
||||
const fileSearchHint = embedded
|
||||
? '`find` via the Bash tool'
|
||||
: `the ${GLOB_TOOL_NAME} tool`
|
||||
// The "class Foo" example is about content search. Non-embedded stays Glob
|
||||
// (original intent: find-the-file-containing). Embedded gets grep because
|
||||
// find -name doesn't look at file contents.
|
||||
const contentSearchHint = embedded
|
||||
? '`grep` via the Bash tool'
|
||||
: `the ${GLOB_TOOL_NAME} tool`
|
||||
const whenNotToUseSection = forkEnabled
|
||||
? ''
|
||||
: `
|
||||
When NOT to use the ${AGENT_TOOL_NAME} tool:
|
||||
- If you want to read a specific file path, use the ${FILE_READ_TOOL_NAME} tool or ${fileSearchHint} instead of the ${AGENT_TOOL_NAME} tool, to find the match more quickly
|
||||
- If you are searching for a specific class definition like "class Foo", use ${contentSearchHint} instead, to find the match more quickly
|
||||
- If you are searching for code within a specific file or set of 2-3 files, use the ${FILE_READ_TOOL_NAME} tool instead of the ${AGENT_TOOL_NAME} tool, to find the match more quickly
|
||||
- Other tasks that are not related to the agent descriptions above
|
||||
`
|
||||
|
||||
// When listing via attachment, the "launch multiple agents" note is in the
|
||||
// attachment message (conditioned on subscription there). When inline, keep
|
||||
// the existing per-call getSubscriptionType() check.
|
||||
const concurrencyNote =
|
||||
!listViaAttachment && getSubscriptionType() !== 'pro'
|
||||
? `
|
||||
- Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses`
|
||||
: ''
|
||||
|
||||
// Non-coordinator gets the full prompt with all sections
|
||||
return `${shared}
|
||||
${whenNotToUseSection}
|
||||
|
||||
Usage notes:
|
||||
- Always include a short description (3-5 words) summarizing what the agent will do${concurrencyNote}
|
||||
- When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.${
|
||||
// eslint-disable-next-line custom-rules/no-process-env-top-level
|
||||
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS) &&
|
||||
!isInProcessTeammate() &&
|
||||
!forkEnabled
|
||||
? `
|
||||
- You can optionally run agents in the background using the run_in_background parameter. When an agent runs in the background, you will be automatically notified when it completes — do NOT sleep, poll, or proactively check on its progress. Continue with other work or respond to the user instead.
|
||||
- **Foreground vs background**: Use foreground (default) when you need the agent's results before you can proceed — e.g., research agents whose findings inform your next steps. Use background when you have genuinely independent work to do in parallel.`
|
||||
: ''
|
||||
}
|
||||
- To continue a previously spawned agent, use ${SEND_MESSAGE_TOOL_NAME} with the agent's ID or name as the \`to\` field. The agent resumes with its full context preserved. ${forkEnabled ? 'Each fresh Agent invocation with a subagent_type starts without context — provide a complete task description.' : 'Each Agent invocation starts fresh — provide a complete task description.'}
|
||||
- The agent's outputs should generally be trusted
|
||||
- Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.)${forkEnabled ? '' : ", since it is not aware of the user's intent"}
|
||||
- If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.
|
||||
- If the user specifies that they want you to run agents "in parallel", you MUST send a single message with multiple ${AGENT_TOOL_NAME} tool use content blocks. For example, if you need to launch both a build-validator agent and a test-runner agent in parallel, send a single message with both tool calls.
|
||||
- You can optionally set \`isolation: "worktree"\` to run the agent in a temporary git worktree, giving it an isolated copy of the repository. The worktree is automatically cleaned up if the agent makes no changes; if changes are made, the worktree path and branch are returned in the result.${
|
||||
process.env.USER_TYPE === 'ant'
|
||||
? `\n- You can set \`isolation: "remote"\` to run the agent in a remote CCR environment. This is always a background task; you'll be notified when it completes. Use for long-running tasks that need a fresh sandbox.`
|
||||
: ''
|
||||
}${
|
||||
isInProcessTeammate()
|
||||
? `
|
||||
- The run_in_background, name, team_name, and mode parameters are not available in this context. Only synchronous subagents are supported.`
|
||||
: isTeammate()
|
||||
? `
|
||||
- The name, team_name, and mode parameters are not available in this context — teammates cannot spawn other teammates. Omit them to spawn a subagent.`
|
||||
: ''
|
||||
}${whenToForkSection}${writingThePromptSection}
|
||||
|
||||
${forkEnabled ? forkExamples : currentExamples}`
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { promises as fsp } from 'fs'
|
||||
import { getSdkAgentProgressSummariesEnabled } from '../../bootstrap/state.js'
|
||||
import { getSystemPrompt } from '../../constants/prompts.js'
|
||||
import { isCoordinatorMode } from '../../coordinator/coordinatorMode.js'
|
||||
import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'
|
||||
import type { ToolUseContext } from '../../Tool.js'
|
||||
import { registerAsyncAgent } from '../../tasks/LocalAgentTask/LocalAgentTask.js'
|
||||
import { assembleToolPool } from '../../tools.js'
|
||||
import { asAgentId } from '../../types/ids.js'
|
||||
import { runWithAgentContext } from '../../utils/agentContext.js'
|
||||
import { runWithCwdOverride } from '../../utils/cwd.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import {
|
||||
createUserMessage,
|
||||
filterOrphanedThinkingOnlyMessages,
|
||||
filterUnresolvedToolUses,
|
||||
filterWhitespaceOnlyAssistantMessages,
|
||||
} from '../../utils/messages.js'
|
||||
import { getAgentModel } from '../../utils/model/agent.js'
|
||||
import { getQuerySourceForAgent } from '../../utils/promptCategory.js'
|
||||
import {
|
||||
getAgentTranscript,
|
||||
readAgentMetadata,
|
||||
} from '../../utils/sessionStorage.js'
|
||||
import { buildEffectiveSystemPrompt } from '../../utils/systemPrompt.js'
|
||||
import type { SystemPrompt } from '../../utils/systemPromptType.js'
|
||||
import { getTaskOutputPath } from '../../utils/task/diskOutput.js'
|
||||
import { getParentSessionId } from '../../utils/teammate.js'
|
||||
import { reconstructForSubagentResume } from '../../utils/toolResultStorage.js'
|
||||
import { runAsyncAgentLifecycle } from './agentToolUtils.js'
|
||||
import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js'
|
||||
import { FORK_AGENT, isForkSubagentEnabled } from './forkSubagent.js'
|
||||
import type { AgentDefinition } from './loadAgentsDir.js'
|
||||
import { isBuiltInAgent } from './loadAgentsDir.js'
|
||||
import { runAgent } from './runAgent.js'
|
||||
|
||||
export type ResumeAgentResult = {
|
||||
agentId: string
|
||||
description: string
|
||||
outputFile: string
|
||||
}
|
||||
export async function resumeAgentBackground({
|
||||
agentId,
|
||||
prompt,
|
||||
toolUseContext,
|
||||
canUseTool,
|
||||
invokingRequestId,
|
||||
}: {
|
||||
agentId: string
|
||||
prompt: string
|
||||
toolUseContext: ToolUseContext
|
||||
canUseTool: CanUseToolFn
|
||||
invokingRequestId?: string
|
||||
}): Promise<ResumeAgentResult> {
|
||||
const startTime = Date.now()
|
||||
const appState = toolUseContext.getAppState()
|
||||
// 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
|
||||
const permissionMode = appState.toolPermissionContext.mode
|
||||
|
||||
const [transcript, meta] = await Promise.all([
|
||||
getAgentTranscript(asAgentId(agentId)),
|
||||
readAgentMetadata(asAgentId(agentId)),
|
||||
])
|
||||
if (!transcript) {
|
||||
throw new Error(`No transcript found for agent ID: ${agentId}`)
|
||||
}
|
||||
const resumedMessages = filterWhitespaceOnlyAssistantMessages(
|
||||
filterOrphanedThinkingOnlyMessages(
|
||||
filterUnresolvedToolUses(transcript.messages),
|
||||
),
|
||||
)
|
||||
const resumedReplacementState = reconstructForSubagentResume(
|
||||
toolUseContext.contentReplacementState,
|
||||
resumedMessages,
|
||||
transcript.contentReplacements,
|
||||
)
|
||||
// Best-effort: if the original worktree was removed externally, fall back
|
||||
// to parent cwd rather than crashing on chdir later.
|
||||
const resumedWorktreePath = meta?.worktreePath
|
||||
? await fsp.stat(meta.worktreePath).then(
|
||||
s => (s.isDirectory() ? meta.worktreePath : undefined),
|
||||
() => {
|
||||
logForDebugging(
|
||||
`Resumed worktree ${meta.worktreePath} no longer exists; falling back to parent cwd`,
|
||||
)
|
||||
return undefined
|
||||
},
|
||||
)
|
||||
: undefined
|
||||
if (resumedWorktreePath) {
|
||||
// Bump mtime so stale-worktree cleanup doesn't delete a just-resumed worktree (#22355)
|
||||
const now = new Date()
|
||||
await fsp.utimes(resumedWorktreePath, now, now)
|
||||
}
|
||||
|
||||
// Skip filterDeniedAgents re-gating — original spawn already passed permission checks
|
||||
let selectedAgent: AgentDefinition
|
||||
let isResumedFork = false
|
||||
if (meta?.agentType === FORK_AGENT.agentType) {
|
||||
selectedAgent = FORK_AGENT
|
||||
isResumedFork = true
|
||||
} else if (meta?.agentType) {
|
||||
const found = toolUseContext.options.agentDefinitions.activeAgents.find(
|
||||
a => a.agentType === meta.agentType,
|
||||
)
|
||||
selectedAgent = found ?? GENERAL_PURPOSE_AGENT
|
||||
} else {
|
||||
selectedAgent = GENERAL_PURPOSE_AGENT
|
||||
}
|
||||
|
||||
const uiDescription = meta?.description ?? '(resumed)'
|
||||
|
||||
let forkParentSystemPrompt: SystemPrompt | undefined
|
||||
if (isResumedFork) {
|
||||
if (toolUseContext.renderedSystemPrompt) {
|
||||
forkParentSystemPrompt = toolUseContext.renderedSystemPrompt
|
||||
} else {
|
||||
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,
|
||||
})
|
||||
}
|
||||
if (!forkParentSystemPrompt) {
|
||||
throw new Error(
|
||||
'Cannot resume fork agent: unable to reconstruct parent system prompt',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve model for analytics metadata (runAgent resolves its own internally)
|
||||
const resolvedAgentModel = getAgentModel(
|
||||
selectedAgent.model,
|
||||
toolUseContext.options.mainLoopModel,
|
||||
undefined,
|
||||
permissionMode,
|
||||
)
|
||||
|
||||
const workerPermissionContext = {
|
||||
...appState.toolPermissionContext,
|
||||
mode: selectedAgent.permissionMode ?? 'acceptEdits',
|
||||
}
|
||||
const workerTools = isResumedFork
|
||||
? toolUseContext.options.tools
|
||||
: assembleToolPool(workerPermissionContext, appState.mcp.tools)
|
||||
|
||||
const runAgentParams: Parameters<typeof runAgent>[0] = {
|
||||
agentDefinition: selectedAgent,
|
||||
promptMessages: [
|
||||
...resumedMessages,
|
||||
createUserMessage({ content: prompt }),
|
||||
],
|
||||
toolUseContext,
|
||||
canUseTool,
|
||||
isAsync: true,
|
||||
querySource: getQuerySourceForAgent(
|
||||
selectedAgent.agentType,
|
||||
isBuiltInAgent(selectedAgent),
|
||||
),
|
||||
model: undefined,
|
||||
// Fork resume: pass parent's system prompt (cache-identical prefix).
|
||||
// Non-fork: undefined → runAgent recomputes under wrapWithCwd so
|
||||
// getCwd() sees resumedWorktreePath.
|
||||
override: isResumedFork
|
||||
? { systemPrompt: forkParentSystemPrompt }
|
||||
: undefined,
|
||||
availableTools: workerTools,
|
||||
// Transcript already contains the parent context slice from the
|
||||
// original fork. Re-supplying it would cause duplicate tool_use IDs.
|
||||
forkContextMessages: undefined,
|
||||
...(isResumedFork && { useExactTools: true }),
|
||||
// Re-persist so metadata survives runAgent's writeAgentMetadata overwrite
|
||||
worktreePath: resumedWorktreePath,
|
||||
description: meta?.description,
|
||||
contentReplacementState: resumedReplacementState,
|
||||
}
|
||||
|
||||
// Skip name-registry write — original entry persists from the initial spawn
|
||||
const agentBackgroundTask = registerAsyncAgent({
|
||||
agentId,
|
||||
description: uiDescription,
|
||||
prompt,
|
||||
selectedAgent,
|
||||
setAppState: rootSetAppState,
|
||||
toolUseId: toolUseContext.toolUseId,
|
||||
})
|
||||
|
||||
const metadata = {
|
||||
prompt,
|
||||
resolvedAgentModel,
|
||||
isBuiltInAgent: isBuiltInAgent(selectedAgent),
|
||||
startTime,
|
||||
agentType: selectedAgent.agentType,
|
||||
isAsync: true,
|
||||
}
|
||||
|
||||
const asyncAgentContext = {
|
||||
agentId,
|
||||
parentSessionId: getParentSessionId(),
|
||||
agentType: 'subagent' as const,
|
||||
subagentName: selectedAgent.agentType,
|
||||
isBuiltIn: isBuiltInAgent(selectedAgent),
|
||||
invokingRequestId,
|
||||
invocationKind: 'resume' as const,
|
||||
invocationEmitted: false,
|
||||
}
|
||||
|
||||
const wrapWithCwd = <T>(fn: () => T): T =>
|
||||
resumedWorktreePath ? runWithCwdOverride(resumedWorktreePath, fn) : fn()
|
||||
|
||||
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: uiDescription,
|
||||
toolUseContext,
|
||||
rootSetAppState,
|
||||
agentIdForCleanup: agentId,
|
||||
enableSummarization:
|
||||
isCoordinatorMode() ||
|
||||
isForkSubagentEnabled() ||
|
||||
getSdkAgentProgressSummariesEnabled(),
|
||||
getWorktreeResult: async () =>
|
||||
resumedWorktreePath ? { worktreePath: resumedWorktreePath } : {},
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
agentId,
|
||||
description: uiDescription,
|
||||
outputFile: getTaskOutputPath(agentId),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,973 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import type { UUID } from 'crypto'
|
||||
import { randomUUID } from 'crypto'
|
||||
import uniqBy from 'lodash-es/uniqBy.js'
|
||||
import { logForDebugging } from 'src/utils/debug.js'
|
||||
import { getProjectRoot, getSessionId } from '../../bootstrap/state.js'
|
||||
import { getCommand, getSkillToolCommands, hasCommand } from '../../commands.js'
|
||||
import {
|
||||
DEFAULT_AGENT_PROMPT,
|
||||
enhanceSystemPromptWithEnvDetails,
|
||||
} from '../../constants/prompts.js'
|
||||
import type { QuerySource } from '../../constants/querySource.js'
|
||||
import { getSystemContext, getUserContext } from '../../context.js'
|
||||
import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'
|
||||
import { query } from '../../query.js'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
|
||||
import { getDumpPromptsPath } from '../../services/api/dumpPrompts.js'
|
||||
import { cleanupAgentTracking } from '../../services/api/promptCacheBreakDetection.js'
|
||||
import {
|
||||
connectToServer,
|
||||
fetchToolsForClient,
|
||||
} from '../../services/mcp/client.js'
|
||||
import { getMcpConfigByName } from '../../services/mcp/config.js'
|
||||
import type {
|
||||
MCPServerConnection,
|
||||
ScopedMcpServerConfig,
|
||||
} from '../../services/mcp/types.js'
|
||||
import type { Tool, Tools, ToolUseContext } from '../../Tool.js'
|
||||
import { killShellTasksForAgent } from '../../tasks/LocalShellTask/killShellTasks.js'
|
||||
import type { Command } from '../../types/command.js'
|
||||
import type { AgentId } from '../../types/ids.js'
|
||||
import type {
|
||||
AssistantMessage,
|
||||
Message,
|
||||
ProgressMessage,
|
||||
RequestStartEvent,
|
||||
StreamEvent,
|
||||
SystemCompactBoundaryMessage,
|
||||
TombstoneMessage,
|
||||
ToolUseSummaryMessage,
|
||||
UserMessage,
|
||||
} from '../../types/message.js'
|
||||
import { createAttachmentMessage } from '../../utils/attachments.js'
|
||||
import { AbortError } from '../../utils/errors.js'
|
||||
import { getDisplayPath } from '../../utils/file.js'
|
||||
import {
|
||||
cloneFileStateCache,
|
||||
createFileStateCacheWithSizeLimit,
|
||||
READ_FILE_STATE_CACHE_SIZE,
|
||||
} from '../../utils/fileStateCache.js'
|
||||
import {
|
||||
type CacheSafeParams,
|
||||
createSubagentContext,
|
||||
} from '../../utils/forkedAgent.js'
|
||||
import { registerFrontmatterHooks } from '../../utils/hooks/registerFrontmatterHooks.js'
|
||||
import { clearSessionHooks } from '../../utils/hooks/sessionHooks.js'
|
||||
import { executeSubagentStartHooks } from '../../utils/hooks.js'
|
||||
import { createUserMessage } from '../../utils/messages.js'
|
||||
import { getAgentModel } from '../../utils/model/agent.js'
|
||||
import type { ModelAlias } from '../../utils/model/aliases.js'
|
||||
import {
|
||||
clearAgentTranscriptSubdir,
|
||||
recordSidechainTranscript,
|
||||
setAgentTranscriptSubdir,
|
||||
writeAgentMetadata,
|
||||
} from '../../utils/sessionStorage.js'
|
||||
import {
|
||||
isRestrictedToPluginOnly,
|
||||
isSourceAdminTrusted,
|
||||
} from '../../utils/settings/pluginOnlyPolicy.js'
|
||||
import {
|
||||
asSystemPrompt,
|
||||
type SystemPrompt,
|
||||
} from '../../utils/systemPromptType.js'
|
||||
import {
|
||||
isPerfettoTracingEnabled,
|
||||
registerAgent as registerPerfettoAgent,
|
||||
unregisterAgent as unregisterPerfettoAgent,
|
||||
} from '../../utils/telemetry/perfettoTracing.js'
|
||||
import type { ContentReplacementState } from '../../utils/toolResultStorage.js'
|
||||
import { createAgentId } from '../../utils/uuid.js'
|
||||
import { resolveAgentTools } from './agentToolUtils.js'
|
||||
import { type AgentDefinition, isBuiltInAgent } from './loadAgentsDir.js'
|
||||
|
||||
/**
|
||||
* Initialize agent-specific MCP servers
|
||||
* Agents can define their own MCP servers in their frontmatter that are additive
|
||||
* to the parent's MCP clients. These servers are connected when the agent starts
|
||||
* and cleaned up when the agent finishes.
|
||||
*
|
||||
* @param agentDefinition The agent definition with optional mcpServers
|
||||
* @param parentClients MCP clients inherited from parent context
|
||||
* @returns Merged clients (parent + agent-specific), agent MCP tools, and cleanup function
|
||||
*/
|
||||
async function initializeAgentMcpServers(
|
||||
agentDefinition: AgentDefinition,
|
||||
parentClients: MCPServerConnection[],
|
||||
): Promise<{
|
||||
clients: MCPServerConnection[]
|
||||
tools: Tools
|
||||
cleanup: () => Promise<void>
|
||||
}> {
|
||||
// If no agent-specific servers defined, return parent clients as-is
|
||||
if (!agentDefinition.mcpServers?.length) {
|
||||
return {
|
||||
clients: parentClients,
|
||||
tools: [],
|
||||
cleanup: async () => {},
|
||||
}
|
||||
}
|
||||
|
||||
// When MCP is locked to plugin-only, skip frontmatter MCP servers for
|
||||
// USER-CONTROLLED agents only. Plugin, built-in, and policySettings agents
|
||||
// are admin-trusted — their frontmatter MCP is part of the admin-approved
|
||||
// surface. Blocking them (as the first cut did) breaks plugin agents that
|
||||
// legitimately need MCP, contradicting "plugin-provided always loads."
|
||||
const agentIsAdminTrusted = isSourceAdminTrusted(agentDefinition.source)
|
||||
if (isRestrictedToPluginOnly('mcp') && !agentIsAdminTrusted) {
|
||||
logForDebugging(
|
||||
`[Agent: ${agentDefinition.agentType}] Skipping MCP servers: strictPluginOnlyCustomization locks MCP to plugin-only (agent source: ${agentDefinition.source})`,
|
||||
)
|
||||
return {
|
||||
clients: parentClients,
|
||||
tools: [],
|
||||
cleanup: async () => {},
|
||||
}
|
||||
}
|
||||
|
||||
const agentClients: MCPServerConnection[] = []
|
||||
// Track which clients were newly created (inline definitions) vs. shared from parent
|
||||
// Only newly created clients should be cleaned up when the agent finishes
|
||||
const newlyCreatedClients: MCPServerConnection[] = []
|
||||
const agentTools: Tool[] = []
|
||||
|
||||
for (const spec of agentDefinition.mcpServers) {
|
||||
let config: ScopedMcpServerConfig | null = null
|
||||
let name: string
|
||||
let isNewlyCreated = false
|
||||
|
||||
if (typeof spec === 'string') {
|
||||
// Reference by name - look up in existing MCP configs
|
||||
// This uses the memoized connectToServer, so we may get a shared client
|
||||
name = spec
|
||||
config = getMcpConfigByName(spec)
|
||||
if (!config) {
|
||||
logForDebugging(
|
||||
`[Agent: ${agentDefinition.agentType}] MCP server not found: ${spec}`,
|
||||
{ level: 'warn' },
|
||||
)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// Inline definition as { [name]: config }
|
||||
// These are agent-specific servers that should be cleaned up
|
||||
const entries = Object.entries(spec)
|
||||
if (entries.length !== 1) {
|
||||
logForDebugging(
|
||||
`[Agent: ${agentDefinition.agentType}] Invalid MCP server spec: expected exactly one key`,
|
||||
{ level: 'warn' },
|
||||
)
|
||||
continue
|
||||
}
|
||||
const [serverName, serverConfig] = entries[0]!
|
||||
name = serverName
|
||||
config = {
|
||||
...serverConfig,
|
||||
scope: 'dynamic' as const,
|
||||
} as ScopedMcpServerConfig
|
||||
isNewlyCreated = true
|
||||
}
|
||||
|
||||
// Connect to the server
|
||||
const client = await connectToServer(name, config)
|
||||
agentClients.push(client)
|
||||
if (isNewlyCreated) {
|
||||
newlyCreatedClients.push(client)
|
||||
}
|
||||
|
||||
// Fetch tools if connected
|
||||
if (client.type === 'connected') {
|
||||
const tools = await fetchToolsForClient(client)
|
||||
agentTools.push(...tools)
|
||||
logForDebugging(
|
||||
`[Agent: ${agentDefinition.agentType}] Connected to MCP server '${name}' with ${tools.length} tools`,
|
||||
)
|
||||
} else {
|
||||
logForDebugging(
|
||||
`[Agent: ${agentDefinition.agentType}] Failed to connect to MCP server '${name}': ${client.type}`,
|
||||
{ level: 'warn' },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Create cleanup function for agent-specific servers
|
||||
// Only clean up newly created clients (inline definitions), not shared/referenced ones
|
||||
// Shared clients (referenced by string name) are memoized and used by the parent context
|
||||
const cleanup = async () => {
|
||||
for (const client of newlyCreatedClients) {
|
||||
if (client.type === 'connected') {
|
||||
try {
|
||||
await client.cleanup()
|
||||
} catch (error) {
|
||||
logForDebugging(
|
||||
`[Agent: ${agentDefinition.agentType}] Error cleaning up MCP server '${client.name}': ${error}`,
|
||||
{ level: 'warn' },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return merged clients (parent + agent-specific) and agent tools
|
||||
return {
|
||||
clients: [...parentClients, ...agentClients],
|
||||
tools: agentTools,
|
||||
cleanup,
|
||||
}
|
||||
}
|
||||
|
||||
type QueryMessage =
|
||||
| StreamEvent
|
||||
| RequestStartEvent
|
||||
| Message
|
||||
| ToolUseSummaryMessage
|
||||
| TombstoneMessage
|
||||
|
||||
/**
|
||||
* Type guard to check if a message from query() is a recordable Message type.
|
||||
* Matches the types we want to record: assistant, user, progress, or system compact_boundary.
|
||||
*/
|
||||
function isRecordableMessage(
|
||||
msg: QueryMessage,
|
||||
): msg is
|
||||
| AssistantMessage
|
||||
| UserMessage
|
||||
| ProgressMessage
|
||||
| SystemCompactBoundaryMessage {
|
||||
return (
|
||||
msg.type === 'assistant' ||
|
||||
msg.type === 'user' ||
|
||||
msg.type === 'progress' ||
|
||||
(msg.type === 'system' &&
|
||||
'subtype' in msg &&
|
||||
msg.subtype === 'compact_boundary')
|
||||
)
|
||||
}
|
||||
|
||||
export async function* runAgent({
|
||||
agentDefinition,
|
||||
promptMessages,
|
||||
toolUseContext,
|
||||
canUseTool,
|
||||
isAsync,
|
||||
canShowPermissionPrompts,
|
||||
forkContextMessages,
|
||||
querySource,
|
||||
override,
|
||||
model,
|
||||
maxTurns,
|
||||
preserveToolUseResults,
|
||||
availableTools,
|
||||
allowedTools,
|
||||
onCacheSafeParams,
|
||||
contentReplacementState,
|
||||
useExactTools,
|
||||
worktreePath,
|
||||
description,
|
||||
transcriptSubdir,
|
||||
onQueryProgress,
|
||||
}: {
|
||||
agentDefinition: AgentDefinition
|
||||
promptMessages: Message[]
|
||||
toolUseContext: ToolUseContext
|
||||
canUseTool: CanUseToolFn
|
||||
isAsync: boolean
|
||||
/** Whether this agent can show permission prompts. Defaults to !isAsync.
|
||||
* Set to true for in-process teammates that run async but share the terminal. */
|
||||
canShowPermissionPrompts?: boolean
|
||||
forkContextMessages?: Message[]
|
||||
querySource: QuerySource
|
||||
override?: {
|
||||
userContext?: { [k: string]: string }
|
||||
systemContext?: { [k: string]: string }
|
||||
systemPrompt?: SystemPrompt
|
||||
abortController?: AbortController
|
||||
agentId?: AgentId
|
||||
}
|
||||
model?: ModelAlias
|
||||
maxTurns?: number
|
||||
/** Preserve toolUseResult on messages for subagents with viewable transcripts */
|
||||
preserveToolUseResults?: boolean
|
||||
/** Precomputed tool pool for the worker agent. Computed by the caller
|
||||
* (AgentTool.tsx) to avoid a circular dependency between runAgent and tools.ts.
|
||||
* Always contains the full tool pool assembled with the worker's own permission
|
||||
* mode, independent of the parent's tool restrictions. */
|
||||
availableTools: Tools
|
||||
/** Tool permission rules to add to the agent's session allow rules.
|
||||
* When provided, replaces ALL allow rules so the agent only has what's
|
||||
* explicitly listed (parent approvals don't leak through). */
|
||||
allowedTools?: string[]
|
||||
/** Optional callback invoked with CacheSafeParams after constructing the agent's
|
||||
* system prompt, context, and tools. Used by background summarization to fork
|
||||
* the agent's conversation for periodic progress summaries. */
|
||||
onCacheSafeParams?: (params: CacheSafeParams) => void
|
||||
/** Replacement state reconstructed from a resumed sidechain transcript so
|
||||
* the same tool results are re-replaced (prompt cache stability). When
|
||||
* omitted, createSubagentContext clones the parent's state. */
|
||||
contentReplacementState?: ContentReplacementState
|
||||
/** When true, use availableTools directly without filtering through
|
||||
* resolveAgentTools(). Also inherits the parent's thinkingConfig and
|
||||
* isNonInteractiveSession instead of overriding them. Used by the fork
|
||||
* subagent path to produce byte-identical API request prefixes for
|
||||
* prompt cache hits. */
|
||||
useExactTools?: boolean
|
||||
/** Worktree path if the agent was spawned with isolation: "worktree".
|
||||
* Persisted to metadata so resume can restore the correct cwd. */
|
||||
worktreePath?: string
|
||||
/** Original task description from AgentTool input. Persisted to metadata
|
||||
* so a resumed agent's notification can show the original description. */
|
||||
description?: string
|
||||
/** Optional subdirectory under subagents/ to group this agent's transcript
|
||||
* with related ones (e.g. workflows/<runId> for workflow subagents). */
|
||||
transcriptSubdir?: string
|
||||
/** Optional callback fired on every message yielded by query() — including
|
||||
* stream_event deltas that runAgent otherwise drops. Use to detect liveness
|
||||
* during long single-block streams (e.g. thinking) where no assistant
|
||||
* message is yielded for >60s. */
|
||||
onQueryProgress?: () => void
|
||||
}): AsyncGenerator<Message, void> {
|
||||
// Track subagent usage for feature discovery
|
||||
|
||||
const appState = toolUseContext.getAppState()
|
||||
const permissionMode = appState.toolPermissionContext.mode
|
||||
// Always-shared channel to the root AppState store. toolUseContext.setAppState
|
||||
// is a no-op when the *parent* is itself an async agent (nested async→async),
|
||||
// so session-scoped writes (hooks, bash tasks) must go through this instead.
|
||||
const rootSetAppState =
|
||||
toolUseContext.setAppStateForTasks ?? toolUseContext.setAppState
|
||||
|
||||
const resolvedAgentModel = getAgentModel(
|
||||
agentDefinition.model,
|
||||
toolUseContext.options.mainLoopModel,
|
||||
model,
|
||||
permissionMode,
|
||||
)
|
||||
|
||||
const agentId = override?.agentId ? override.agentId : createAgentId()
|
||||
|
||||
// Route this agent's transcript into a grouping subdirectory if requested
|
||||
// (e.g. workflow subagents write to subagents/workflows/<runId>/).
|
||||
if (transcriptSubdir) {
|
||||
setAgentTranscriptSubdir(agentId, transcriptSubdir)
|
||||
}
|
||||
|
||||
// Register agent in Perfetto trace for hierarchy visualization
|
||||
if (isPerfettoTracingEnabled()) {
|
||||
const parentId = toolUseContext.agentId ?? getSessionId()
|
||||
registerPerfettoAgent(agentId, agentDefinition.agentType, parentId)
|
||||
}
|
||||
|
||||
// Log API calls path for subagents (ant-only)
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
logForDebugging(
|
||||
`[Subagent ${agentDefinition.agentType}] API calls: ${getDisplayPath(getDumpPromptsPath(agentId))}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Handle message forking for context sharing
|
||||
// Filter out incomplete tool calls from parent messages to avoid API errors
|
||||
const contextMessages: Message[] = forkContextMessages
|
||||
? filterIncompleteToolCalls(forkContextMessages)
|
||||
: []
|
||||
const initialMessages: Message[] = [...contextMessages, ...promptMessages]
|
||||
|
||||
const agentReadFileState =
|
||||
forkContextMessages !== undefined
|
||||
? cloneFileStateCache(toolUseContext.readFileState)
|
||||
: createFileStateCacheWithSizeLimit(READ_FILE_STATE_CACHE_SIZE)
|
||||
|
||||
const [baseUserContext, baseSystemContext] = await Promise.all([
|
||||
override?.userContext ?? getUserContext(),
|
||||
override?.systemContext ?? getSystemContext(),
|
||||
])
|
||||
|
||||
// Read-only agents (Explore, Plan) don't act on commit/PR/lint rules from
|
||||
// CLAUDE.md — the main agent has full context and interprets their output.
|
||||
// Dropping claudeMd here saves ~5-15 Gtok/week across 34M+ Explore spawns.
|
||||
// Explicit override.userContext from callers is preserved untouched.
|
||||
// Kill-switch defaults true; flip tengu_slim_subagent_claudemd=false to revert.
|
||||
const shouldOmitClaudeMd =
|
||||
agentDefinition.omitClaudeMd &&
|
||||
!override?.userContext &&
|
||||
getFeatureValue_CACHED_MAY_BE_STALE('tengu_slim_subagent_claudemd', true)
|
||||
const { claudeMd: _omittedClaudeMd, ...userContextNoClaudeMd } =
|
||||
baseUserContext
|
||||
const resolvedUserContext = shouldOmitClaudeMd
|
||||
? userContextNoClaudeMd
|
||||
: baseUserContext
|
||||
|
||||
// Explore/Plan are read-only search agents — the parent-session-start
|
||||
// gitStatus (up to 40KB, explicitly labeled stale) is dead weight. If they
|
||||
// need git info they run `git status` themselves and get fresh data.
|
||||
// Saves ~1-3 Gtok/week fleet-wide.
|
||||
const { gitStatus: _omittedGitStatus, ...systemContextNoGit } =
|
||||
baseSystemContext
|
||||
const resolvedSystemContext =
|
||||
agentDefinition.agentType === 'Explore' ||
|
||||
agentDefinition.agentType === 'Plan'
|
||||
? systemContextNoGit
|
||||
: baseSystemContext
|
||||
|
||||
// Override permission mode if agent defines one
|
||||
// However, don't override if parent is in bypassPermissions or acceptEdits mode - those should always take precedence
|
||||
// For async agents, also set shouldAvoidPermissionPrompts since they can't show UI
|
||||
const agentPermissionMode = agentDefinition.permissionMode
|
||||
const agentGetAppState = () => {
|
||||
const state = toolUseContext.getAppState()
|
||||
let toolPermissionContext = state.toolPermissionContext
|
||||
|
||||
// Override permission mode if agent defines one (unless parent is bypassPermissions, acceptEdits, or auto)
|
||||
if (
|
||||
agentPermissionMode &&
|
||||
state.toolPermissionContext.mode !== 'bypassPermissions' &&
|
||||
state.toolPermissionContext.mode !== 'acceptEdits' &&
|
||||
!(
|
||||
feature('TRANSCRIPT_CLASSIFIER') &&
|
||||
state.toolPermissionContext.mode === 'auto'
|
||||
)
|
||||
) {
|
||||
toolPermissionContext = {
|
||||
...toolPermissionContext,
|
||||
mode: agentPermissionMode,
|
||||
}
|
||||
}
|
||||
|
||||
// Set flag to auto-deny prompts for agents that can't show UI
|
||||
// Use explicit canShowPermissionPrompts if provided, otherwise:
|
||||
// - bubble mode: always show prompts (bubbles to parent terminal)
|
||||
// - default: !isAsync (sync agents show prompts, async agents don't)
|
||||
const shouldAvoidPrompts =
|
||||
canShowPermissionPrompts !== undefined
|
||||
? !canShowPermissionPrompts
|
||||
: agentPermissionMode === 'bubble'
|
||||
? false
|
||||
: isAsync
|
||||
if (shouldAvoidPrompts) {
|
||||
toolPermissionContext = {
|
||||
...toolPermissionContext,
|
||||
shouldAvoidPermissionPrompts: true,
|
||||
}
|
||||
}
|
||||
|
||||
// For background agents that can show prompts, await automated checks
|
||||
// (classifier, permission hooks) before showing the permission dialog.
|
||||
// Since these are background agents, waiting is fine — the user should
|
||||
// only be interrupted when automated checks can't resolve the permission.
|
||||
// This applies to bubble mode (always) and explicit canShowPermissionPrompts.
|
||||
if (isAsync && !shouldAvoidPrompts) {
|
||||
toolPermissionContext = {
|
||||
...toolPermissionContext,
|
||||
awaitAutomatedChecksBeforeDialog: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Scope tool permissions: when allowedTools is provided, use them as session rules.
|
||||
// IMPORTANT: Preserve cliArg rules (from SDK's --allowedTools) since those are
|
||||
// explicit permissions from the SDK consumer that should apply to all agents.
|
||||
// Only clear session-level rules from the parent to prevent unintended leakage.
|
||||
if (allowedTools !== undefined) {
|
||||
toolPermissionContext = {
|
||||
...toolPermissionContext,
|
||||
alwaysAllowRules: {
|
||||
// Preserve SDK-level permissions from --allowedTools
|
||||
cliArg: state.toolPermissionContext.alwaysAllowRules.cliArg,
|
||||
// Use the provided allowedTools as session-level permissions
|
||||
session: [...allowedTools],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Override effort level if agent defines one
|
||||
const effortValue =
|
||||
agentDefinition.effort !== undefined
|
||||
? agentDefinition.effort
|
||||
: state.effortValue
|
||||
|
||||
if (
|
||||
toolPermissionContext === state.toolPermissionContext &&
|
||||
effortValue === state.effortValue
|
||||
) {
|
||||
return state
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toolPermissionContext,
|
||||
effortValue,
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedTools = useExactTools
|
||||
? availableTools
|
||||
: resolveAgentTools(agentDefinition, availableTools, isAsync).resolvedTools
|
||||
|
||||
const additionalWorkingDirectories = Array.from(
|
||||
appState.toolPermissionContext.additionalWorkingDirectories.keys(),
|
||||
)
|
||||
|
||||
const agentSystemPrompt = override?.systemPrompt
|
||||
? override.systemPrompt
|
||||
: asSystemPrompt(
|
||||
await getAgentSystemPrompt(
|
||||
agentDefinition,
|
||||
toolUseContext,
|
||||
resolvedAgentModel,
|
||||
additionalWorkingDirectories,
|
||||
resolvedTools,
|
||||
),
|
||||
)
|
||||
|
||||
// Determine abortController:
|
||||
// - Override takes precedence
|
||||
// - Async agents get a new unlinked controller (runs independently)
|
||||
// - Sync agents share parent's controller
|
||||
const agentAbortController = override?.abortController
|
||||
? override.abortController
|
||||
: isAsync
|
||||
? new AbortController()
|
||||
: toolUseContext.abortController
|
||||
|
||||
// Execute SubagentStart hooks and collect additional context
|
||||
const additionalContexts: string[] = []
|
||||
for await (const hookResult of executeSubagentStartHooks(
|
||||
agentId,
|
||||
agentDefinition.agentType,
|
||||
agentAbortController.signal,
|
||||
)) {
|
||||
if (
|
||||
hookResult.additionalContexts &&
|
||||
hookResult.additionalContexts.length > 0
|
||||
) {
|
||||
additionalContexts.push(...hookResult.additionalContexts)
|
||||
}
|
||||
}
|
||||
|
||||
// Add SubagentStart hook context as a user message (consistent with SessionStart/UserPromptSubmit)
|
||||
if (additionalContexts.length > 0) {
|
||||
const contextMessage = createAttachmentMessage({
|
||||
type: 'hook_additional_context',
|
||||
content: additionalContexts,
|
||||
hookName: 'SubagentStart',
|
||||
toolUseID: randomUUID(),
|
||||
hookEvent: 'SubagentStart',
|
||||
})
|
||||
initialMessages.push(contextMessage)
|
||||
}
|
||||
|
||||
// Register agent's frontmatter hooks (scoped to agent lifecycle)
|
||||
// Pass isAgent=true to convert Stop hooks to SubagentStop (since subagents trigger SubagentStop)
|
||||
// Same admin-trusted gate for frontmatter hooks: under ["hooks"] alone
|
||||
// (skills/agents not locked), user agents still load — block their
|
||||
// frontmatter-hook REGISTRATION here where source is known, rather than
|
||||
// blanket-blocking all session hooks at execution time (which would
|
||||
// also kill plugin agents' hooks).
|
||||
const hooksAllowedForThisAgent =
|
||||
!isRestrictedToPluginOnly('hooks') ||
|
||||
isSourceAdminTrusted(agentDefinition.source)
|
||||
if (agentDefinition.hooks && hooksAllowedForThisAgent) {
|
||||
registerFrontmatterHooks(
|
||||
rootSetAppState,
|
||||
agentId,
|
||||
agentDefinition.hooks,
|
||||
`agent '${agentDefinition.agentType}'`,
|
||||
true, // isAgent - converts Stop to SubagentStop
|
||||
)
|
||||
}
|
||||
|
||||
// Preload skills from agent frontmatter
|
||||
const skillsToPreload = agentDefinition.skills ?? []
|
||||
if (skillsToPreload.length > 0) {
|
||||
const allSkills = await getSkillToolCommands(getProjectRoot())
|
||||
|
||||
// Filter valid skills and warn about missing ones
|
||||
const validSkills: Array<{
|
||||
skillName: string
|
||||
skill: (typeof allSkills)[0] & { type: 'prompt' }
|
||||
}> = []
|
||||
|
||||
for (const skillName of skillsToPreload) {
|
||||
// Resolve the skill name, trying multiple strategies:
|
||||
// 1. Exact match (hasCommand checks name, userFacingName, aliases)
|
||||
// 2. Fully-qualified with agent's plugin prefix (e.g., "my-skill" → "plugin:my-skill")
|
||||
// 3. Suffix match on ":skillName" for plugin-namespaced skills
|
||||
const resolvedName = resolveSkillName(
|
||||
skillName,
|
||||
allSkills,
|
||||
agentDefinition,
|
||||
)
|
||||
if (!resolvedName) {
|
||||
logForDebugging(
|
||||
`[Agent: ${agentDefinition.agentType}] Warning: Skill '${skillName}' specified in frontmatter was not found`,
|
||||
{ level: 'warn' },
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const skill = getCommand(resolvedName, allSkills)
|
||||
if (skill.type !== 'prompt') {
|
||||
logForDebugging(
|
||||
`[Agent: ${agentDefinition.agentType}] Warning: Skill '${skillName}' is not a prompt-based skill`,
|
||||
{ level: 'warn' },
|
||||
)
|
||||
continue
|
||||
}
|
||||
validSkills.push({ skillName, skill })
|
||||
}
|
||||
|
||||
// Load all skill contents concurrently and add to initial messages
|
||||
const { formatSkillLoadingMetadata } = await import(
|
||||
'../../utils/processUserInput/processSlashCommand.js'
|
||||
)
|
||||
const loaded = await Promise.all(
|
||||
validSkills.map(async ({ skillName, skill }) => ({
|
||||
skillName,
|
||||
skill,
|
||||
content: await skill.getPromptForCommand('', toolUseContext),
|
||||
})),
|
||||
)
|
||||
for (const { skillName, skill, content } of loaded) {
|
||||
logForDebugging(
|
||||
`[Agent: ${agentDefinition.agentType}] Preloaded skill '${skillName}'`,
|
||||
)
|
||||
|
||||
// Add command-message metadata so the UI shows which skill is loading
|
||||
const metadata = formatSkillLoadingMetadata(
|
||||
skillName,
|
||||
skill.progressMessage,
|
||||
)
|
||||
|
||||
initialMessages.push(
|
||||
createUserMessage({
|
||||
content: [{ type: 'text', text: metadata }, ...content],
|
||||
isMeta: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize agent-specific MCP servers (additive to parent's servers)
|
||||
const {
|
||||
clients: mergedMcpClients,
|
||||
tools: agentMcpTools,
|
||||
cleanup: mcpCleanup,
|
||||
} = await initializeAgentMcpServers(
|
||||
agentDefinition,
|
||||
toolUseContext.options.mcpClients,
|
||||
)
|
||||
|
||||
// Merge agent MCP tools with resolved agent tools, deduplicating by name.
|
||||
// resolvedTools is already deduplicated (see resolveAgentTools), so skip
|
||||
// the spread + uniqBy overhead when there are no agent-specific MCP tools.
|
||||
const allTools =
|
||||
agentMcpTools.length > 0
|
||||
? uniqBy([...resolvedTools, ...agentMcpTools], 'name')
|
||||
: resolvedTools
|
||||
|
||||
// Build agent-specific options
|
||||
const agentOptions: ToolUseContext['options'] = {
|
||||
isNonInteractiveSession: useExactTools
|
||||
? toolUseContext.options.isNonInteractiveSession
|
||||
: isAsync
|
||||
? true
|
||||
: (toolUseContext.options.isNonInteractiveSession ?? false),
|
||||
appendSystemPrompt: toolUseContext.options.appendSystemPrompt,
|
||||
tools: allTools,
|
||||
commands: [],
|
||||
debug: toolUseContext.options.debug,
|
||||
verbose: toolUseContext.options.verbose,
|
||||
mainLoopModel: resolvedAgentModel,
|
||||
// For fork children (useExactTools), inherit thinking config to match the
|
||||
// parent's API request prefix for prompt cache hits. For regular
|
||||
// sub-agents, disable thinking to control output token costs.
|
||||
thinkingConfig: useExactTools
|
||||
? toolUseContext.options.thinkingConfig
|
||||
: { type: 'disabled' as const },
|
||||
mcpClients: mergedMcpClients,
|
||||
mcpResources: toolUseContext.options.mcpResources,
|
||||
agentDefinitions: toolUseContext.options.agentDefinitions,
|
||||
// Fork children (useExactTools path) need querySource on context.options
|
||||
// for the recursive-fork guard at AgentTool.tsx call() — it checks
|
||||
// options.querySource === 'agent:builtin:fork'. This survives autocompact
|
||||
// (which rewrites messages, not context.options). Without this, the guard
|
||||
// reads undefined and only the message-scan fallback fires — which
|
||||
// autocompact defeats by replacing the fork-boilerplate message.
|
||||
...(useExactTools && { querySource }),
|
||||
}
|
||||
|
||||
// Create subagent context using shared helper
|
||||
// - Sync agents share setAppState, setResponseLength, abortController with parent
|
||||
// - Async agents are fully isolated (but with explicit unlinked abortController)
|
||||
const agentToolUseContext = createSubagentContext(toolUseContext, {
|
||||
options: agentOptions,
|
||||
agentId,
|
||||
agentType: agentDefinition.agentType,
|
||||
messages: initialMessages,
|
||||
readFileState: agentReadFileState,
|
||||
abortController: agentAbortController,
|
||||
getAppState: agentGetAppState,
|
||||
// Sync agents share these callbacks with parent
|
||||
shareSetAppState: !isAsync,
|
||||
shareSetResponseLength: true, // Both sync and async contribute to response metrics
|
||||
criticalSystemReminder_EXPERIMENTAL:
|
||||
agentDefinition.criticalSystemReminder_EXPERIMENTAL,
|
||||
contentReplacementState,
|
||||
})
|
||||
|
||||
// Preserve tool use results for subagents with viewable transcripts (in-process teammates)
|
||||
if (preserveToolUseResults) {
|
||||
agentToolUseContext.preserveToolUseResults = true
|
||||
}
|
||||
|
||||
// Expose cache-safe params for background summarization (prompt cache sharing)
|
||||
if (onCacheSafeParams) {
|
||||
onCacheSafeParams({
|
||||
systemPrompt: agentSystemPrompt,
|
||||
userContext: resolvedUserContext,
|
||||
systemContext: resolvedSystemContext,
|
||||
toolUseContext: agentToolUseContext,
|
||||
forkContextMessages: initialMessages,
|
||||
})
|
||||
}
|
||||
|
||||
// Record initial messages before the query loop starts, plus the agentType
|
||||
// so resume can route correctly when subagent_type is omitted. Both writes
|
||||
// are fire-and-forget — persistence failure shouldn't block the agent.
|
||||
void recordSidechainTranscript(initialMessages, agentId).catch(_err =>
|
||||
logForDebugging(`Failed to record sidechain transcript: ${_err}`),
|
||||
)
|
||||
void writeAgentMetadata(agentId, {
|
||||
agentType: agentDefinition.agentType,
|
||||
...(worktreePath && { worktreePath }),
|
||||
...(description && { description }),
|
||||
}).catch(_err => logForDebugging(`Failed to write agent metadata: ${_err}`))
|
||||
|
||||
// Track the last recorded message UUID for parent chain continuity
|
||||
let lastRecordedUuid: UUID | null = initialMessages.at(-1)?.uuid ?? null
|
||||
|
||||
try {
|
||||
for await (const message of query({
|
||||
messages: initialMessages,
|
||||
systemPrompt: agentSystemPrompt,
|
||||
userContext: resolvedUserContext,
|
||||
systemContext: resolvedSystemContext,
|
||||
canUseTool,
|
||||
toolUseContext: agentToolUseContext,
|
||||
querySource,
|
||||
maxTurns: maxTurns ?? agentDefinition.maxTurns,
|
||||
})) {
|
||||
onQueryProgress?.()
|
||||
// Forward subagent API request starts to parent's metrics display
|
||||
// so TTFT/OTPS update during subagent execution.
|
||||
if (
|
||||
message.type === 'stream_event' &&
|
||||
message.event.type === 'message_start' &&
|
||||
message.ttftMs != null
|
||||
) {
|
||||
toolUseContext.pushApiMetricsEntry?.(message.ttftMs)
|
||||
continue
|
||||
}
|
||||
|
||||
// Yield attachment messages (e.g., structured_output) without recording them
|
||||
if (message.type === 'attachment') {
|
||||
// Handle max turns reached signal from query.ts
|
||||
if (message.attachment.type === 'max_turns_reached') {
|
||||
logForDebugging(
|
||||
`[Agent
|
||||
: $
|
||||
{
|
||||
agentDefinition.agentType
|
||||
}
|
||||
] Reached max turns limit ($
|
||||
{
|
||||
message.attachment.maxTurns
|
||||
}
|
||||
)`,
|
||||
)
|
||||
break
|
||||
}
|
||||
yield message
|
||||
continue
|
||||
}
|
||||
|
||||
if (isRecordableMessage(message)) {
|
||||
// Record only the new message with correct parent (O(1) per message)
|
||||
await recordSidechainTranscript(
|
||||
[message],
|
||||
agentId,
|
||||
lastRecordedUuid,
|
||||
).catch(err =>
|
||||
logForDebugging(`Failed to record sidechain transcript: ${err}`),
|
||||
)
|
||||
if (message.type !== 'progress') {
|
||||
lastRecordedUuid = message.uuid
|
||||
}
|
||||
yield message
|
||||
}
|
||||
}
|
||||
|
||||
if (agentAbortController.signal.aborted) {
|
||||
throw new AbortError()
|
||||
}
|
||||
|
||||
// Run callback if provided (only built-in agents have callbacks)
|
||||
if (isBuiltInAgent(agentDefinition) && agentDefinition.callback) {
|
||||
agentDefinition.callback()
|
||||
}
|
||||
} finally {
|
||||
// Clean up agent-specific MCP servers (runs on normal completion, abort, or error)
|
||||
await mcpCleanup()
|
||||
// Clean up agent's session hooks
|
||||
if (agentDefinition.hooks) {
|
||||
clearSessionHooks(rootSetAppState, agentId)
|
||||
}
|
||||
// Clean up prompt cache tracking state for this agent
|
||||
if (feature('PROMPT_CACHE_BREAK_DETECTION')) {
|
||||
cleanupAgentTracking(agentId)
|
||||
}
|
||||
// Release cloned file state cache memory
|
||||
agentToolUseContext.readFileState.clear()
|
||||
// Release the cloned fork context messages
|
||||
initialMessages.length = 0
|
||||
// Release perfetto agent registry entry
|
||||
unregisterPerfettoAgent(agentId)
|
||||
// Release transcript subdir mapping
|
||||
clearAgentTranscriptSubdir(agentId)
|
||||
// Release this agent's todos entry. Without this, every subagent that
|
||||
// called TodoWrite leaves a key in AppState.todos forever (even after all
|
||||
// items complete, the value is [] but the key stays). Whale sessions
|
||||
// spawn hundreds of agents; each orphaned key is a small leak that adds up.
|
||||
rootSetAppState(prev => {
|
||||
if (!(agentId in prev.todos)) return prev
|
||||
const { [agentId]: _removed, ...todos } = prev.todos
|
||||
return { ...prev, todos }
|
||||
})
|
||||
// Kill any background bash tasks this agent spawned. Without this, a
|
||||
// `run_in_background` shell loop (e.g. test fixture fake-logs.sh) outlives
|
||||
// the agent as a PPID=1 zombie once the main session eventually exits.
|
||||
killShellTasksForAgent(agentId, toolUseContext.getAppState, rootSetAppState)
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
if (feature('MONITOR_TOOL')) {
|
||||
const mcpMod =
|
||||
require('../../tasks/MonitorMcpTask/MonitorMcpTask.js') as typeof import('../../tasks/MonitorMcpTask/MonitorMcpTask.js')
|
||||
mcpMod.killMonitorMcpTasksForAgent(
|
||||
agentId,
|
||||
toolUseContext.getAppState,
|
||||
rootSetAppState,
|
||||
)
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out assistant messages with incomplete tool calls (tool uses without results).
|
||||
* This prevents API errors when sending messages with orphaned tool calls.
|
||||
*/
|
||||
export function filterIncompleteToolCalls(messages: Message[]): Message[] {
|
||||
// Build a set of tool use IDs that have results
|
||||
const toolUseIdsWithResults = new Set<string>()
|
||||
|
||||
for (const message of messages) {
|
||||
if (message?.type === 'user') {
|
||||
const userMessage = message as UserMessage
|
||||
const content = userMessage.message.content
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block.type === 'tool_result' && block.tool_use_id) {
|
||||
toolUseIdsWithResults.add(block.tool_use_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out assistant messages that contain tool calls without results
|
||||
return messages.filter(message => {
|
||||
if (message?.type === 'assistant') {
|
||||
const assistantMessage = message as AssistantMessage
|
||||
const content = assistantMessage.message.content
|
||||
if (Array.isArray(content)) {
|
||||
// Check if this assistant message has any tool uses without results
|
||||
const hasIncompleteToolCall = content.some(
|
||||
block =>
|
||||
block.type === 'tool_use' &&
|
||||
block.id &&
|
||||
!toolUseIdsWithResults.has(block.id),
|
||||
)
|
||||
// Exclude messages with incomplete tool calls
|
||||
return !hasIncompleteToolCall
|
||||
}
|
||||
}
|
||||
// Keep all non-assistant messages and assistant messages without tool calls
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
async function getAgentSystemPrompt(
|
||||
agentDefinition: AgentDefinition,
|
||||
toolUseContext: Pick<ToolUseContext, 'options'>,
|
||||
resolvedAgentModel: string,
|
||||
additionalWorkingDirectories: string[],
|
||||
resolvedTools: readonly Tool[],
|
||||
): Promise<string[]> {
|
||||
const enabledToolNames = new Set(resolvedTools.map(t => t.name))
|
||||
try {
|
||||
const agentPrompt = agentDefinition.getSystemPrompt({ toolUseContext })
|
||||
const prompts = [agentPrompt]
|
||||
|
||||
return await enhanceSystemPromptWithEnvDetails(
|
||||
prompts,
|
||||
resolvedAgentModel,
|
||||
additionalWorkingDirectories,
|
||||
enabledToolNames,
|
||||
)
|
||||
} catch (_error) {
|
||||
return enhanceSystemPromptWithEnvDetails(
|
||||
[DEFAULT_AGENT_PROMPT],
|
||||
resolvedAgentModel,
|
||||
additionalWorkingDirectories,
|
||||
enabledToolNames,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a skill name from agent frontmatter to a registered command name.
|
||||
*
|
||||
* Plugin skills are registered with namespaced names (e.g., "my-plugin:my-skill")
|
||||
* but agents reference them with bare names (e.g., "my-skill"). This function
|
||||
* tries multiple resolution strategies:
|
||||
*
|
||||
* 1. Exact match via hasCommand (name, userFacingName, aliases)
|
||||
* 2. Prefix with agent's plugin name (e.g., "my-skill" → "my-plugin:my-skill")
|
||||
* 3. Suffix match — find any command whose name ends with ":skillName"
|
||||
*/
|
||||
function resolveSkillName(
|
||||
skillName: string,
|
||||
allSkills: Command[],
|
||||
agentDefinition: AgentDefinition,
|
||||
): string | null {
|
||||
// 1. Direct match
|
||||
if (hasCommand(skillName, allSkills)) {
|
||||
return skillName
|
||||
}
|
||||
|
||||
// 2. Try prefixing with the agent's plugin name
|
||||
// Plugin agents have agentType like "pluginName:agentName"
|
||||
const pluginPrefix = agentDefinition.agentType.split(':')[0]
|
||||
if (pluginPrefix) {
|
||||
const qualifiedName = `${pluginPrefix}:${skillName}`
|
||||
if (hasCommand(qualifiedName, allSkills)) {
|
||||
return qualifiedName
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Suffix match — find a skill whose name ends with ":skillName"
|
||||
const suffix = `:${skillName}`
|
||||
const match = allSkills.find(cmd => cmd.name.endsWith(suffix))
|
||||
if (match) {
|
||||
return match.name
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user